Compare commits

..
Author SHA1 Message Date
Mark Tolmacs 42acd426a3 fix: Direct binding manipulation 2025-08-21 14:46:02 +02:00
Mark Tolmacs a2cd3f0e77 Fix missing babel transformer 2025-08-21 14:43:49 +02:00
Mark Tolmacs 0c5e420812 Add custom binding check as plugin 2025-08-21 14:27:46 +02:00
54 changed files with 2410 additions and 5417 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
{ {
"extends": ["@excalidraw/eslint-config", "react-app"], "extends": ["@excalidraw/eslint-config", "react-app"],
"plugins": ["excalidraw"],
"rules": { "rules": {
"import/order": [ "import/order": [
"warn", "warn",
@@ -38,6 +39,7 @@
{ {
"allowReferrer": true "allowReferrer": true
} }
] ],
"excalidraw/no-binding-direct-mod": "error"
} }
} }
@@ -615,52 +615,6 @@ export default function ExampleApp({
const renderMenu = () => { const renderMenu = () => {
return ( return (
<MainMenu> <MainMenu>
<MainMenu.Sub>
<MainMenu.Sub.Trigger
title="Custom trigger"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zm-7.518-.267A8.25 8.25 0 1120.25 10.5M8.288 14.212A5.25 5.25 0 1117.25 10.5"
/>
</svg>
}
>
Submenu trigger
</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
/>
</svg>
}
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export /> <MainMenu.DefaultItems.Export />
<MainMenu.Separator /> <MainMenu.Separator />
@@ -668,57 +622,10 @@ export default function ExampleApp({
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onSelect={() => window.alert("You clicked on collab button")} onSelect={() => window.alert("You clicked on collab button")}
/> />
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Group title="Excalidraw links"> <MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials /> <MainMenu.DefaultItems.Socials />
</MainMenu.Group> </MainMenu.Group>
{/* <MainMenu.Separator /> */} <MainMenu.Separator />
<MainMenu.Sub>
<MainMenu.Sub.Trigger className="custom-classname">
Another submenu trigger
</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content className="custom-classname-for-content">
<MainMenu.Sub.Item
title="Sub item"
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger me</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger me inside</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
onSelect={() => {
alert("wow, nested submenus!");
}}
>
Item wow
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Sub.Item
onSelect={() => {
alert("wow, nested submenus! very cool");
}}
>
Another one
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.ItemCustom> <MainMenu.ItemCustom>
<button <button
style={{ height: "2rem" }} style={{ height: "2rem" }}
+7
View File
@@ -20,6 +20,7 @@ import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME, THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -498,6 +499,11 @@ const ExcalidrawWrapper = () => {
} }
}; };
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => { const syncData = debounce(() => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
@@ -588,6 +594,7 @@ const ExcalidrawWrapper = () => {
visibilityChange, visibilityChange,
false, false,
); );
clearTimeout(titleTimeout);
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
@@ -39,7 +39,6 @@ export const AppMainMenu: React.FC<{
<MainMenu.DefaultItems.SearchMenu /> <MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.DefaultItems.Preferences />
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.ItemLink <MainMenu.ItemLink
icon={ExcalLogo} icon={ExcalLogo}
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Excalidraw Whiteboard</title> <title>
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
</title>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
+2
View File
@@ -8,6 +8,7 @@
"examples/*" "examples/*"
], ],
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-explicit-resource-management": "7.28.0",
"@babel/preset-env": "7.26.9", "@babel/preset-env": "7.26.9",
"@excalidraw/eslint-config": "1.0.3", "@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
@@ -24,6 +25,7 @@
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-config-react-app": "7.0.1", "eslint-config-react-app": "7.0.1",
"eslint-plugin-eslint": "file:packages/eslint",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1", "http-server": "14.1.1",
+4 -4
View File
@@ -28,9 +28,11 @@ export const isBrave = () =>
export const isMobile = export const isMobile =
isIOS || isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test( /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent, navigator.userAgent.toLowerCase(),
) || ) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform); /android|ios|ipod|blackberry|windows phone/i.test(
navigator.platform.toLowerCase(),
);
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@@ -522,5 +524,3 @@ export enum UserIdleState {
* the start and end points) * the start and end points)
*/ */
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
+35 -15
View File
@@ -1111,16 +1111,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => }: Delta<ElementPartial>) =>
!!( !!(
deleted.version &&
inserted.version &&
// versions are required integers // versions are required integers
( Number.isInteger(deleted.version) &&
Number.isInteger(deleted.version) && Number.isInteger(inserted.version) &&
Number.isInteger(inserted.version) && // versions should be positive, zero included
// versions should be positive, zero included deleted.version >= 0 &&
deleted.version! >= 0 && inserted.version >= 0 &&
inserted.version! >= 0 && // versions should never be the same
// versions should never be the same deleted.version !== inserted.version
deleted.version !== inserted.version
)
); );
private static satisfiesUniqueInvariants = ( private static satisfiesUniqueInvariants = (
@@ -1191,10 +1191,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element
if (!prevElement.isDeleted) { if (!prevElement.isDeleted) {
removed[prevElement.id] = delta; removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
} }
} }
} }
@@ -1222,8 +1221,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
// ignore updates which would "delete" already deleted element // ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) { if (!nextElement.isDeleted) {
added[nextElement.id] = delta; added[nextElement.id] = delta;
} else {
updated[nextElement.id] = delta;
} }
continue; continue;
@@ -1253,7 +1250,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
updated[nextElement.id] = delta; const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted);
const strippedInserted = ElementsDelta.stripVersionProps(
delta.inserted,
);
// making sure there are at least some changes and only changed version & versionNonce does not count!
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) {
updated[nextElement.id] = delta;
}
} }
} }
@@ -1367,8 +1372,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
latestDelta = delta; latestDelta = delta;
} }
const strippedDeleted = ElementsDelta.stripVersionProps(
latestDelta.deleted,
);
const strippedInserted = ElementsDelta.stripVersionProps(
latestDelta.inserted,
);
// it might happen that after applying latest changes the delta itself does not contain any changes // it might happen that after applying latest changes the delta itself does not contain any changes
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) { if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) {
modifiedDeltas[id] = latestDelta; modifiedDeltas[id] = latestDelta;
} }
} }
@@ -2063,4 +2075,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return strippedPartial; return strippedPartial;
} }
private static stripVersionProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}
} }
+17 -30
View File
@@ -359,12 +359,6 @@ const handleSegmentRelease = (
null, null,
); );
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = []; const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points // First part of the arrow are the old points
@@ -712,7 +706,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint, endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
): ElementUpdate<ExcalidrawElbowArrowElement> => { ) => {
let startIsSpecial = arrow.startIsSpecial ?? null; let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) => const globalUpdatedPoints = updatedPoints.map((p, i) =>
@@ -747,15 +741,8 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point // Calculate the moving second point connection and add the start point
{ {
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1); const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2); const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
const startIsHorizontal = headingIsHorizontal(startHeading); const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal( const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@@ -814,19 +801,10 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
const secondToLastPoint = globalUpdatedPoints.at( const secondToLastPoint =
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2), globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
); const thirdToLastPoint =
const thirdToLastPoint = globalUpdatedPoints.at( globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
const endIsHorizontal = headingIsHorizontal(endHeading); const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal( const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint, thirdToLastPoint,
@@ -2093,7 +2071,16 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): ElementUpdate<ExcalidrawElbowArrowElement> => { ): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
let points = global.map((p) => let points = global.map((p) =>
+3 -19
View File
@@ -35,7 +35,6 @@ import {
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement"; } from "./textElement";
import { import {
getMinTextElementWidth, getMinTextElementWidth,
@@ -226,16 +225,7 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) { if (textElement && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition( scene.mutateElement(textElement, { angle });
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
} }
} }
}; };
@@ -426,15 +416,9 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) { if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, { scene.mutateElement(boundText, {
x, x: boundText.x + (rotatedCX - cx),
y, y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}); });
} }
+2 -22
View File
@@ -10,12 +10,12 @@ import {
invariant, invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types"; import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
@@ -254,26 +254,6 @@ export const computeBoundTextPosition = (
x = x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
} }
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
const contentCenter = pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
);
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
return {
x: rx - boundTextElement.width / 2,
y: ry - boundTextElement.height / 2,
};
}
return { x, y }; return { x, y };
}; };
+10 -27
View File
@@ -8,7 +8,7 @@ import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => { describe("ElementsDelta", () => {
describe("elements delta calculation", () => { describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => { it("should not create removed delta when element gets removed but was already deleted", () => {
const element = API.createElement({ const element = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@@ -19,12 +19,12 @@ describe("ElementsDelta", () => {
const prevElements = new Map([[element.id, element]]); const prevElements = new Map([[element.id, element]]);
const nextElements = new Map(); const nextElements = new Map();
expect(() => const delta = ElementsDelta.calculate(prevElements, nextElements);
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow(); expect(delta.isEmpty()).toBeTruthy();
}); });
it("should not throw when adding element as already deleted", () => { it("should not create added delta when adding element as already deleted", () => {
const element = API.createElement({ const element = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@@ -35,12 +35,12 @@ describe("ElementsDelta", () => {
const prevElements = new Map(); const prevElements = new Map();
const nextElements = new Map([[element.id, element]]); const nextElements = new Map([[element.id, element]]);
expect(() => const delta = ElementsDelta.calculate(prevElements, nextElements);
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow(); expect(delta.isEmpty()).toBeTruthy();
}); });
it("should create updated delta even when there is only version and versionNonce change", () => { it("should not create updated delta when there is only version and versionNonce change", () => {
const baseElement = API.createElement({ const baseElement = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@@ -65,24 +65,7 @@ describe("ElementsDelta", () => {
nextElements as SceneElementsMap, nextElements as SceneElementsMap,
); );
expect(delta).toEqual( expect(delta.isEmpty()).toBeTruthy();
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
}); });
}); });
+1 -171
View File
@@ -1,14 +1,13 @@
import { getLineHeight } from "@excalidraw/common"; import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common"; import { FONT_FAMILY } from "@excalidraw/common";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement"; } from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements"; import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@@ -208,172 +207,3 @@ describe("Test getDefaultLineHeight", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
}); });
}); });
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
rules: {
"no-binding-direct-mod": require("./no-binding-direct-mod"),
},
};
+84
View File
@@ -0,0 +1,84 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: "problem",
docs: {
description:
"disallow direct mutation of startBinding or endBinding via mutateElement",
category: "Best Practices",
recommended: false,
},
fixable: null,
schema: [],
messages: {
noDirectBindingMutation:
"Direct mutation of {{ property }} via mutateElement() is not allowed. Use proper binding update functions instead.",
},
},
create(context) {
return {
CallExpression(node) {
// Check if this is a call to mutateElement (direct call or method call)
let isMutateElementCall = false;
if (
node.callee.type === "Identifier" &&
node.callee.name === "mutateElement"
) {
// Direct call: mutateElement()
isMutateElementCall = true;
} else if (
node.callee.type === "MemberExpression" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "mutateElement"
) {
// Method call: something.mutateElement() or this.scene.mutateElement()
isMutateElementCall = true;
}
if (isMutateElementCall) {
// mutateElement can have different argument patterns:
// 1. mutateElement(element, updates) - 2 args
// 2. mutateElement(element, elementsMap, updates) - 3 args
// 3. mutateElement(element, updates, options) - 3 args
let updatesArg = null;
if (node.arguments.length >= 2) {
// Try second argument first (most common pattern)
const secondArg = node.arguments[1];
if (secondArg.type === "ObjectExpression") {
updatesArg = secondArg;
} else if (node.arguments.length >= 3) {
// If second arg is not an object, try third argument
const thirdArg = node.arguments[2];
if (thirdArg.type === "ObjectExpression") {
updatesArg = thirdArg;
}
}
}
if (updatesArg) {
// Look for startBinding or endBinding properties
for (const property of updatesArg.properties) {
if (
property.type === "Property" &&
property.key.type === "Identifier" &&
(property.key.name === "startBinding" ||
property.key.name === "endBinding")
) {
context.report({
node: property,
messageId: "noDirectBindingMutation",
data: {
property: property.key.name,
},
});
}
}
}
}
},
};
},
};
+11
View File
@@ -0,0 +1,11 @@
{
"name": "eslint-plugin-excalidraw",
"version": "0.1.0",
"main": "index.js",
"scripts": {
"lint": "eslint ."
},
"devDependencies": {
"eslint": "^7.32.0"
}
}
+11 -21
View File
@@ -7,6 +7,7 @@ import {
getFontString, getFontString,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
bindOrUnbindLinearElement,
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
@@ -36,6 +37,7 @@ import { newElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { import type {
ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextContainer, ExcalidrawTextContainer,
@@ -270,7 +272,7 @@ export const actionWrapTextInContainer = register({
), ),
groupIds: textElement.groupIds, groupIds: textElement.groupIds,
frameId: textElement.frameId, frameId: textElement.frameId,
}); }) as ExcalidrawBindableElement;
// update bindings // update bindings
if (textElement.boundElements?.length) { if (textElement.boundElements?.length) {
@@ -281,26 +283,14 @@ export const actionWrapTextInContainer = register({
linearElementIds.includes(ele.id), linearElementIds.includes(ele.id),
) as ExcalidrawLinearElement[]; ) as ExcalidrawLinearElement[];
linearElements.forEach((ele) => { linearElements.forEach((ele) => {
let startBinding = ele.startBinding; bindOrUnbindLinearElement(
let endBinding = ele.endBinding; ele,
ele.startBinding?.elementId === textElement.id
if (startBinding?.elementId === textElement.id) { ? container
startBinding = { : "keep",
...startBinding, ele.endBinding?.elementId === textElement.id ? container : "keep",
elementId: container.id, app.scene,
}; );
}
if (endBinding?.elementId === textElement.id) {
endBinding = { ...endBinding, elementId: container.id };
}
if (startBinding || endBinding) {
app.scene.mutateElement(ele, {
startBinding,
endBinding,
});
}
}); });
} }
@@ -1,6 +1,9 @@
import { KEYS, updateActiveTool } from "@excalidraw/common"; import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import {
bindOrUnbindLinearElement,
getNonDeletedElements,
} from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element"; import { fixBindingsAfterDeletion } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
@@ -92,14 +95,14 @@ const deleteSelectedElements = (
el.boundElements.forEach((candidate) => { el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id); const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) { if (bound && isElbowArrow(bound)) {
app.scene.mutateElement(bound, { if (el.id === bound.startBinding?.elementId) {
startBinding: bindOrUnbindLinearElement(
el.id === bound.startBinding?.elementId bound,
? null el.id === bound.startBinding?.elementId ? null : "keep",
: bound.startBinding, el.id === bound.endBinding?.elementId ? null : "keep",
endBinding: app.scene,
el.id === bound.endBinding?.elementId ? null : bound.endBinding, );
}); }
} }
}); });
} }
+1 -3
View File
@@ -54,8 +54,7 @@ export type ShortcutName =
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
| "commandPalette" | "commandPalette"
| "searchMenu" | "searchMenu";
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -117,7 +116,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")], searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [], wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+1 -2
View File
@@ -397,7 +397,6 @@ export const ShapesSwitcher = ({
onClickOutside={() => setIsExtraToolsMenuOpen(false)} onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)} onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown" className="App-toolbar__extra-tools-dropdown"
align="end"
> >
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })} onSelect={() => app.setActiveTool({ type: "frame" })}
@@ -451,10 +450,10 @@ export const ShapesSwitcher = ({
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon} icon={MagicIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
</> </>
)} )}
+7 -31
View File
@@ -102,7 +102,6 @@ import {
Emitter, Emitter,
isMobile, isMobile,
MINIMUM_ARROW_SIZE, MINIMUM_ARROW_SIZE,
DOUBLE_TAP_POSITION_THRESHOLD,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -532,7 +531,6 @@ export const useExcalidrawActionManager = () =>
let didTapTwice: boolean = false; let didTapTwice: boolean = false;
let tappedTwiceTimer = 0; let tappedTwiceTimer = 0;
let firstTapPosition: { x: number; y: number } | null = null;
let isHoldingSpace: boolean = false; let isHoldingSpace: boolean = false;
let isPanning: boolean = false; let isPanning: boolean = false;
let isDraggingScrollBar: boolean = false; let isDraggingScrollBar: boolean = false;
@@ -2991,7 +2989,6 @@ class App extends React.Component<AppProps, AppState> {
private static resetTapTwice() { private static resetTapTwice() {
didTapTwice = false; didTapTwice = false;
firstTapPosition = null;
} }
private onTouchStart = (event: TouchEvent) => { private onTouchStart = (event: TouchEvent) => {
@@ -3002,13 +2999,6 @@ class App extends React.Component<AppProps, AppState> {
if (!didTapTwice) { if (!didTapTwice) {
didTapTwice = true; didTapTwice = true;
if (event.touches.length === 1) {
firstTapPosition = {
x: event.touches[0].clientX,
y: event.touches[0].clientY,
};
}
clearTimeout(tappedTwiceTimer); clearTimeout(tappedTwiceTimer);
tappedTwiceTimer = window.setTimeout( tappedTwiceTimer = window.setTimeout(
App.resetTapTwice, App.resetTapTwice,
@@ -3016,29 +3006,15 @@ class App extends React.Component<AppProps, AppState> {
); );
return; return;
} }
// insert text only if we tapped twice with a single finger
// insert text only if we tapped twice with a single finger at approximately the same position
// event.touches.length === 1 will also prevent inserting text when user's zooming // event.touches.length === 1 will also prevent inserting text when user's zooming
if (didTapTwice && event.touches.length === 1 && firstTapPosition) { if (didTapTwice && event.touches.length === 1) {
const touch = event.touches[0]; const touch = event.touches[0];
const distance = pointDistance( // @ts-ignore
pointFrom(touch.clientX, touch.clientY), this.handleCanvasDoubleClick({
pointFrom(firstTapPosition.x, firstTapPosition.y), clientX: touch.clientX,
); clientY: touch.clientY,
});
// only create text if the second tap is within the threshold of the first tap
// this prevents accidental text creation during dragging/selection
if (distance <= DOUBLE_TAP_POSITION_THRESHOLD) {
// end lasso trail and deselect elements just in case
this.lassoTrail.endPath();
this.deselectElements();
// @ts-ignore
this.handleCanvasDoubleClick({
clientX: touch.clientX,
clientY: touch.clientY,
});
}
didTapTwice = false; didTapTwice = false;
clearTimeout(tappedTwiceTimer); clearTimeout(tappedTwiceTimer);
} }
+2 -2
View File
@@ -11,7 +11,7 @@ interface ButtonProps
HTMLButtonElement HTMLButtonElement
> { > {
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => any; onSelect: () => any;
/** whether button is in active state */ /** whether button is in active state */
selected?: boolean; selected?: boolean;
children: React.ReactNode; children: React.ReactNode;
@@ -34,7 +34,7 @@ export const Button = ({
return ( return (
<button <button
onClick={composeEventHandlers(rest.onClick, (event) => { onClick={composeEventHandlers(rest.onClick, (event) => {
onSelect(event); onSelect();
})} })}
type={type} type={type}
className={clsx("excalidraw-button", className, { selected })} className={clsx("excalidraw-button", className, { selected })}
@@ -30,18 +30,6 @@
align-items: center; align-items: center;
} }
#canvas-bg-color-picker-container {
.color-picker__top-picks {
gap: 0.5rem;
}
.color-picker-container {
@include isMobile {
max-width: none;
}
}
}
.color-picker__button { .color-picker__button {
--radius: 4px; --radius: 4px;
--size: 1.375rem; --size: 1.375rem;
@@ -25,6 +25,10 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch"; import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList"; import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import { import {
FontFamilyCodeIcon, FontFamilyCodeIcon,
FontFamilyHeadingIcon, FontFamilyHeadingIcon,
@@ -32,15 +36,8 @@ import {
FreedrawIcon, FreedrawIcon,
} from "../icons"; } from "../icons";
import { Ellipsify } from "../Ellipsify";
import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import {
FontPickerListItem,
FontPickerListItemBadgeType,
} from "./FontPickerListItem";
import type { JSX } from "react"; import type { JSX } from "react";
export interface FontDescriptor { export interface FontDescriptor {
@@ -49,7 +46,7 @@ export interface FontDescriptor {
text: string; text: string;
deprecated?: true; deprecated?: true;
badge?: { badge?: {
type: ValueOf<typeof FontPickerListItemBadgeType>; type: ValueOf<typeof DropDownMenuItemBadgeType>;
placeholder: string; placeholder: string;
}; };
} }
@@ -115,7 +112,7 @@ export const FontPickerList = React.memo(
Object.assign(fontDescriptor, { Object.assign(fontDescriptor, {
deprecated: metadata.deprecated, deprecated: metadata.deprecated,
badge: { badge: {
type: FontPickerListItemBadgeType.RED, type: DropDownMenuItemBadgeType.RED,
placeholder: t("fontList.badge.old"), placeholder: t("fontList.badge.old"),
}, },
}); });
@@ -230,7 +227,7 @@ export const FontPickerList = React.memo(
); );
const renderFont = (font: FontDescriptor, index: number) => ( const renderFont = (font: FontDescriptor, index: number) => (
<FontPickerListItem <DropdownMenuItem
key={font.value} key={font.value}
icon={font.icon} icon={font.icon}
value={font.value} value={font.value}
@@ -242,8 +239,8 @@ export const FontPickerList = React.memo(
selected={font.value === selectedFontFamily} selected={font.value === selectedFontFamily}
// allow to tab between search and selected font // allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1} tabIndex={font.value === selectedFontFamily ? 0 : -1}
onSelect={() => { onClick={(e) => {
onSelect(font.value); onSelect(Number(e.currentTarget.value));
}} }}
onMouseMove={() => { onMouseMove={() => {
if (hoveredFont?.value !== font.value) { if (hoveredFont?.value !== font.value) {
@@ -251,13 +248,13 @@ export const FontPickerList = React.memo(
} }
}} }}
> >
<Ellipsify>{font.text}</Ellipsify> {font.text}
{font.badge && ( {font.badge && (
<FontPickerListItem.Badge type={font.badge.type}> <DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder} {font.badge.placeholder}
</FontPickerListItem.Badge> </DropDownMenuItemBadge>
)} )}
</FontPickerListItem> </DropdownMenuItem>
); );
const groups = []; const groups = [];
@@ -1,151 +0,0 @@
import React, { useEffect, useRef } from "react";
import { THEME } from "@excalidraw/common";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { Button } from "../Button";
import { useExcalidrawAppState } from "../App";
import { useDevice } from "../App";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import type { JSX } from "react";
const MenuItemContent = ({
textStyle,
icon,
shortcut,
children,
}: {
icon?: React.ReactNode;
shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
{children}
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export const FontPickerListItem = ({
icon,
value,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
...rest
}: {
icon?: JSX.Element;
value?: string | number | undefined;
order?: number;
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return (
<div className="radix-menu-item">
<Button
{...rest}
ref={ref}
onSelect={onSelect}
className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</div>
);
};
FontPickerListItem.displayName = "FontPickerListItem";
export const FontPickerListItemBadgeType = {
GREEN: "green",
RED: "red",
BLUE: "blue",
} as const;
export const FontPickerListItemBadge = ({
type = FontPickerListItemBadgeType.BLUE,
children,
}: {
type?: ValueOf<typeof FontPickerListItemBadgeType>;
children: React.ReactNode;
}) => {
const { theme } = useExcalidrawAppState();
const style = {
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
};
switch (type) {
case FontPickerListItemBadgeType.GREEN:
Object.assign(style, {
backgroundColor: "var(--background-color-badge)",
color: "var(--color-badge)",
});
break;
case FontPickerListItemBadgeType.RED:
Object.assign(style, {
backgroundColor: "pink",
color: "darkred",
});
break;
case FontPickerListItemBadgeType.BLUE:
default:
Object.assign(style, {
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
});
}
return (
<div className="DropDownMenuItemBadge" style={style}>
{children}
</div>
);
};
FontPickerListItemBadge.displayName = "DropdownMenuItemBadge";
FontPickerListItem.Badge = FontPickerListItemBadge;
@@ -238,10 +238,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]} shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true} isOr={true}
/> />
<Shortcut <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
label={t("toolBar.lock")}
shortcuts={[getShortcutFromShortcutName("toolLock")]}
/>
<Shortcut <Shortcut
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]} shortcuts={[getShortcutKey("CtrlOrCmd")]}
@@ -194,7 +194,6 @@ export const LibraryDropdownMenuButton: React.FC<{
<DropdownMenu open={isLibraryMenuOpen}> <DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)} onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
aria-label="Library menu"
> >
{DotsIcon} {DotsIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -202,7 +201,6 @@ export const LibraryDropdownMenuButton: React.FC<{
onClickOutside={() => setIsLibraryMenuOpen(false)} onClickOutside={() => setIsLibraryMenuOpen(false)}
onSelect={() => setIsLibraryMenuOpen(false)} onSelect={() => setIsLibraryMenuOpen(false)}
className="library-menu" className="library-menu"
align="end"
> >
{!itemsSelected && ( {!itemsSelected && (
<DropdownMenu.Item <DropdownMenu.Item
@@ -26,9 +26,9 @@ export const TTDDialogTrigger = ({
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } }); setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}} }}
icon={icon ?? brainIcon} icon={icon ?? brainIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
> >
{children ?? t("labels.textToDiagram")} {children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
</TTDDialogTriggerTunnel.In> </TTDDialogTriggerTunnel.In>
); );
@@ -1,45 +1,20 @@
@import "../../css/variables.module"; @import "../../css/variables.module.scss";
.excalidraw { .excalidraw {
[data-dropdown-menu-trigger] + [data-radix-popper-content-wrapper] {
z-index: 2 !important;
}
.dropdown-menu { .dropdown-menu {
max-width: 16rem; position: absolute;
margin-top: 0.25rem; top: 100%;
margin-top: 0.5rem;
&__submenu-trigger {
&[aria-expanded="true"] {
.dropdown-menu-item {
background-color: var(--button-hover-bg);
}
}
}
&__submenu-trigger-icon {
margin-left: auto;
opacity: 0.5;
}
.radix-menu-item {
&:focus-visible {
outline: none;
}
}
.dropdown-submenu {
margin-left: -0.75rem;
min-width: 16rem;
max-width: 20rem;
}
&--mobile { &--mobile {
left: 0;
width: 100%;
row-gap: 0.75rem;
.dropdown-menu-container { .dropdown-menu-container {
grid-template-columns: minmax(0, 1fr);
padding: 8px 8px; padding: 8px 8px;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--island-bg-color); // background-color: var(--island-bg-color);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
position: relative; position: relative;
@@ -55,14 +30,13 @@
.dropdown-menu-container { .dropdown-menu-container {
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
max-height: var(--radix-popper-available-height); max-height: calc(100vh - 150px);
overflow-y: auto; overflow-y: auto;
--gap: 2; --gap: 2;
} }
.dropdown-menu-item-base { .dropdown-menu-item-base {
display: flex; display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem; column-gap: 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-on-surface); color: var(--color-on-surface);
@@ -70,7 +44,6 @@
box-sizing: border-box; box-sizing: border-box;
font-weight: 400; font-weight: 400;
font-family: inherit; font-family: inherit;
justify-content: flex-start;
} }
&.manual-hover { &.manual-hover {
@@ -1,7 +1,5 @@
import React from "react"; import React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import DropdownMenuContent from "./DropdownMenuContent"; import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuGroup from "./DropdownMenuGroup"; import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuItem from "./DropdownMenuItem"; import DropdownMenuItem from "./DropdownMenuItem";
@@ -25,12 +23,11 @@ const DropdownMenu = ({
}) => { }) => {
const MenuTriggerComp = getMenuTriggerComponent(children); const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children); const MenuContentComp = getMenuContentComponent(children);
return ( return (
<DropdownMenuPrimitive.Root open={open} modal={false}> <>
{MenuTriggerComp} {MenuTriggerComp}
{MenuContentComp} {open && MenuContentComp}
</DropdownMenuPrimitive.Root> </>
); );
}; };
@@ -3,8 +3,6 @@ import React, { useEffect, useRef } from "react";
import { EVENT, KEYS } from "@excalidraw/common"; import { EVENT, KEYS } from "@excalidraw/common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useOutsideClick } from "../../hooks/useOutsideClick"; import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useStable } from "../../hooks/useStable"; import { useStable } from "../../hooks/useStable";
import { useDevice } from "../App"; import { useDevice } from "../App";
@@ -19,9 +17,6 @@ const MenuContent = ({
className = "", className = "",
onSelect, onSelect,
style, style,
sideOffset = 4,
align = "start",
collisionPadding,
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
onClickOutside?: () => void; onClickOutside?: () => void;
@@ -31,11 +26,6 @@ const MenuContent = ({
*/ */
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
sideOffset?: number;
align?: "start" | "center" | "end";
collisionPadding?:
| number
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
}) => { }) => {
const device = useDevice(); const device = useDevice();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -72,15 +62,11 @@ const MenuContent = ({
return ( return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}> <DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<DropdownMenuPrimitive.Content <div
ref={menuRef} ref={menuRef}
className={classNames} className={classNames}
style={style} style={style}
data-testid="dropdown-menu" data-testid="dropdown-menu"
side="bottom"
sideOffset={sideOffset}
align={align}
collisionPadding={collisionPadding}
> >
{/* the zIndex ensures this menu has higher stacking order, {/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */} see https://github.com/excalidraw/excalidraw/pull/1445 */}
@@ -95,7 +81,7 @@ const MenuContent = ({
{children} {children}
</Island> </Island>
)} )}
</DropdownMenuPrimitive.Content> </div>
</DropdownMenuContentPropsContext.Provider> </DropdownMenuContentPropsContext.Provider>
); );
}; };
@@ -1,17 +1,12 @@
import React, { useRef } from "react"; import React, { useEffect, useRef } from "react";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import type { ValueOf } from "@excalidraw/common/utility-types"; import type { ValueOf } from "@excalidraw/common/utility-types";
import { Button } from "../Button";
import { useExcalidrawAppState } from "../App"; import { useExcalidrawAppState } from "../App";
import MenuItemContent from "./DropdownMenuItemContent"; import MenuItemContent from "./DropdownMenuItemContent";
import { import {
getDropdownMenuItemClassName, getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick, useHandleDropdownMenuItemClick,
@@ -22,45 +17,55 @@ import type { JSX } from "react";
const DropdownMenuItem = ({ const DropdownMenuItem = ({
icon, icon,
value, value,
badge,
order, order,
children, children,
shortcut, shortcut,
className, className,
hovered,
selected, selected,
textStyle,
onSelect, onSelect,
onClick, onClick,
...rest ...rest
}: { }: {
icon?: JSX.Element; icon?: JSX.Element;
badge?: React.ReactNode;
value?: string | number | undefined; value?: string | number | undefined;
order?: number; order?: number;
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
hovered?: boolean;
selected?: boolean; selected?: boolean;
textStyle?: React.CSSProperties;
className?: string; className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => { } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect); const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
const ref = useRef<HTMLButtonElement>(null); const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return ( return (
<DropdownMenuPrimitive.Item className="radix-menu-item"> <button
<Button {...rest}
{...rest} ref={ref}
ref={ref} value={value}
onSelect={handleClick} onClick={handleClick}
className={getDropdownMenuItemClassName(className)} className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}
> >
<MenuItemContent icon={icon} shortcut={shortcut} badge={badge}> <MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children} {children}
</MenuItemContent> </MenuItemContent>
</Button> </button>
</DropdownMenuPrimitive.Item>
); );
}; };
DropdownMenuItem.displayName = "DropdownMenuItem"; DropdownMenuItem.displayName = "DropdownMenuItem";
@@ -2,24 +2,25 @@ import { useDevice } from "../App";
import { Ellipsify } from "../Ellipsify"; import { Ellipsify } from "../Ellipsify";
import type { JSX } from "react";
const MenuItemContent = ({ const MenuItemContent = ({
textStyle,
icon, icon,
badge,
shortcut, shortcut,
children, children,
}: { }: {
icon?: React.ReactNode; icon?: JSX.Element;
shortcut?: string; shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode; children: React.ReactNode;
badge?: React.ReactNode;
}) => { }) => {
const device = useDevice(); const device = useDevice();
return ( return (
<> <>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>} {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div className="dropdown-menu-item__text"> <div style={textStyle} className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify> <Ellipsify>{children}</Ellipsify>
{badge}
</div> </div>
{shortcut && !device.editor.isMobile && ( {shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div> <div className="dropdown-menu-item__shortcut">{shortcut}</div>
@@ -1,27 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
getSubMenuContentComponent,
getSubMenuTriggerComponent,
} from "./dropdownMenuUtils";
import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger";
import DropdownMenuSubContent from "./DropdownMenuSubContent";
import DropdownMenuSubItem from "./DropdownMenuSubItem";
const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => {
const MenuTriggerComp = getSubMenuTriggerComponent(children);
const MenuContentComp = getSubMenuContentComponent(children);
return (
<DropdownMenuPrimitive.Sub>
{MenuTriggerComp}
{MenuContentComp}
</DropdownMenuPrimitive.Sub>
);
};
DropdownMenuSub.Trigger = DropdownMenuSubTrigger;
DropdownMenuSub.Content = DropdownMenuSubContent;
DropdownMenuSub.Item = DropdownMenuSubItem;
export default DropdownMenuSub;
DropdownMenuSub.displayName = "DropdownMenuSub";
@@ -1,44 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import clsx from "clsx";
import { useDevice } from "../App";
import Stack from "../Stack";
import { Island } from "../Island";
const DropdownMenuSubContent = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const device = useDevice();
const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, {
"dropdown-menu--mobile": device.editor.isMobile,
}).trim();
return (
<DropdownMenuPrimitive.SubContent
className={classNames}
sideOffset={8}
alignOffset={-4}
>
{device.editor.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={1}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</DropdownMenuPrimitive.SubContent>
);
};
export default DropdownMenuSubContent;
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";
@@ -1,45 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Button } from "../Button";
import MenuItemContent from "./DropdownMenuItemContent";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
const DropdownMenuSubItem = ({
icon,
onSelect,
children,
shortcut,
className,
...rest
}: {
icon?: React.ReactNode;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<DropdownMenuPrimitive.Item className="radix-menu-item">
<Button
{...rest}
onSelect={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</DropdownMenuPrimitive.Item>
);
};
export default DropdownMenuSubItem;
DropdownMenuSubItem.displayName = "DropdownMenuSubItem";
@@ -1,39 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import React from "react";
import { ChevronRight } from "../icons";
import MenuItemContent from "./DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "./common";
import type { JSX } from "react";
const DropdownMenuSubTrigger = ({
children,
icon,
className,
...rest
}: {
children: React.ReactNode;
icon?: JSX.Element;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<DropdownMenuPrimitive.SubTrigger className="radix-menu-item dropdown-menu__submenu-trigger">
<div
{...rest}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon}>{children}</MenuItemContent>
<div className="dropdown-menu__submenu-trigger-icon">
{ChevronRight}
</div>
</div>
</DropdownMenuPrimitive.SubTrigger>
);
};
export default DropdownMenuSubTrigger;
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";
@@ -1,7 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useDevice } from "../App"; import { useDevice } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
@@ -25,8 +23,7 @@ const MenuTrigger = ({
}, },
).trim(); ).trim();
return ( return (
<DropdownMenuPrimitive.Trigger <button
data-dropdown-menu-trigger
data-prevent-outside-click data-prevent-outside-click
className={classNames} className={classNames}
onClick={onToggle} onClick={onToggle}
@@ -36,7 +33,7 @@ const MenuTrigger = ({
{...rest} {...rest}
> >
{children} {children}
</DropdownMenuPrimitive.Trigger> </button>
); );
}; };
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
const getMenuComponent = (component: string) => (children: React.ReactNode) => { export const getMenuTriggerComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find( const comp = React.Children.toArray(children).find(
(child) => (child) =>
React.isValidElement(child) && React.isValidElement(child) &&
@@ -8,7 +8,7 @@ const getMenuComponent = (component: string) => (children: React.ReactNode) => {
//@ts-ignore //@ts-ignore
child?.type.displayName && child?.type.displayName &&
//@ts-ignore //@ts-ignore
child.type.displayName === component, child.type.displayName === "DropdownMenuTrigger",
); );
if (!comp) { if (!comp) {
return null; return null;
@@ -17,11 +17,19 @@ const getMenuComponent = (component: string) => (children: React.ReactNode) => {
return comp; return comp;
}; };
export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger"); export const getMenuContentComponent = (children: React.ReactNode) => {
export const getMenuContentComponent = getMenuComponent("DropdownMenuContent"); const comp = React.Children.toArray(children).find(
export const getSubMenuTriggerComponent = getMenuComponent( (child) =>
"DropdownMenuSubTrigger", React.isValidElement(child) &&
); typeof child.type !== "string" &&
export const getSubMenuContentComponent = getMenuComponent( //@ts-ignore
"DropdownMenuSubContent", child?.type.displayName &&
); //@ts-ignore
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
-27
View File
@@ -72,15 +72,6 @@ const modifiedTablerIconProps: Opts = {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
//tabler-icons: chevron-right
export const ChevronRight = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="9 6 15 12 9 18" />
</g>,
tablerIconProps,
);
// tabler-icons: present // tabler-icons: present
export const PlusPromoIcon = createIcon( export const PlusPromoIcon = createIcon(
<g strokeWidth="1.5"> <g strokeWidth="1.5">
@@ -2278,21 +2269,3 @@ export const elementLinkIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const settingsIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" />
<path d="M16 6l4 0" />
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 12l2 0" />
<path d="M10 12l10 0" />
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 18l11 0" />
<path d="M19 18l1 0" />
</g>,
tablerIconProps,
);
export const emptyIcon = <div style={{ width: "1rem", height: "1rem" }} />;
@@ -9,11 +9,8 @@ import {
actionLoadScene, actionLoadScene,
actionSaveToActiveFile, actionSaveToActiveFile,
actionShortcuts, actionShortcuts,
actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleSearchMenu, actionToggleSearchMenu,
actionToggleTheme, actionToggleTheme,
actionToggleZenMode,
} from "../../actions"; } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
@@ -26,23 +23,13 @@ import {
useExcalidrawActionManager, useExcalidrawActionManager,
useExcalidrawElements, useExcalidrawElements,
useAppProps, useAppProps,
useApp,
} from "../App"; } from "../App";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState"; import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans"; import Trans from "../Trans";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem"; import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
import { actionToggleViewMode } from "../../actions/actionToggleViewMode";
import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
checkIcon,
emptyIcon,
} from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
@@ -326,10 +313,7 @@ export const ChangeCanvasBackground = () => {
> >
{t("labels.canvasBackground")} {t("labels.canvasBackground")}
</div> </div>
<div <div style={{ padding: "0 0.625rem" }}>
style={{ padding: "0 0.625rem" }}
id="canvas-bg-color-picker-container"
>
{actionManager.renderAction("changeViewBackgroundColor")} {actionManager.renderAction("changeViewBackgroundColor")}
</div> </div>
</div> </div>
@@ -409,73 +393,3 @@ export const LiveCollaborationTrigger = ({
}; };
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger"; LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
export const Preferences = ({ children }: { children?: React.ReactNode }) => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
const app = useApp();
return (
<DropdownMenuSub>
<DropdownMenuSub.Trigger icon={settingsIcon}>
{t("labels.preferences")}
</DropdownMenuSub.Trigger>
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
<DropdownMenuSub.Item
icon={appState.activeTool.locked ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("toolLock")}
onSelect={(event) => {
app.toggleLock();
event.preventDefault();
}}
>
{t("labels.preferences_toolLock")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.objectsSnapModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("objectsSnapMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleObjectsSnapMode);
event.preventDefault();
}}
>
{t("buttons.objectsSnapMode")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.gridModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("gridMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleGridMode);
event.preventDefault();
}}
>
{t("labels.toggleGrid")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.zenModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("zenMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleZenMode);
event.preventDefault();
}}
>
{t("buttons.zenMode")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.viewModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("viewMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleViewMode);
event.preventDefault();
}}
>
{t("labels.viewMode")}
</DropdownMenuSub.Item>
{children}
</DropdownMenuSub.Content>
</DropdownMenuSub>
);
};
Preferences.displayName = "Preferences";
@@ -2,12 +2,8 @@ import React from "react";
import { composeEventHandlers } from "@excalidraw/common"; import { composeEventHandlers } from "@excalidraw/common";
import * as Portal from "@radix-ui/react-portal";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useDevice, useExcalidrawSetAppState } from "../App"; import { useDevice, useExcalidrawSetAppState } from "../App";
import { UserList } from "../UserList"; import { UserList } from "../UserList";
@@ -40,17 +36,6 @@ const MainMenu = Object.assign(
return ( return (
<MainMenuTunnel.In> <MainMenuTunnel.In>
{appState.openMenu === "canvas" && device.editor.isMobile && (
<Portal.Root
style={{
backgroundColor: "rgba(18, 18, 18, 0.2)",
position: "fixed",
inset: "0px",
// zIndex: "var(--zIndex-layerUI)",
}}
onClick={() => setAppState({ openMenu: null })}
/>
)}
<DropdownMenu open={appState.openMenu === "canvas"}> <DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
onToggle={() => { onToggle={() => {
@@ -59,27 +44,15 @@ const MainMenu = Object.assign(
}); });
}} }}
data-testid="main-menu-trigger" data-testid="main-menu-trigger"
aria-label="Main menu"
className="main-menu-trigger" className="main-menu-trigger"
> >
{HamburgerMenuIcon} {HamburgerMenuIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
sideOffset={device.editor.isMobile ? 20 : undefined}
className="main-menu-content"
onClickOutside={onClickOutside} onClickOutside={onClickOutside}
onSelect={composeEventHandlers(onSelect, () => { onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
})} })}
collisionPadding={
// accounting for
// - editor footer on desktop
// - toolbar on mobile
// we probably don't want the menu to overlay these elements
!device.editor.isMobile
? { bottom: 90, top: 10 }
: { top: 90, bottom: 10 }
}
> >
{children} {children}
{device.editor.isMobile && appState.collaborators.size > 0 && ( {device.editor.isMobile && appState.collaborators.size > 0 && (
@@ -105,7 +78,6 @@ const MainMenu = Object.assign(
ItemCustom: DropdownMenu.ItemCustom, ItemCustom: DropdownMenu.ItemCustom,
Group: DropdownMenu.Group, Group: DropdownMenu.Group,
Separator: DropdownMenu.Separator, Separator: DropdownMenu.Separator,
Sub: DropdownMenuSub,
DefaultItems, DefaultItems,
}, },
); );
-1
View File
@@ -144,7 +144,6 @@
--color-logo-icon: var(--color-primary); --color-logo-icon: var(--color-primary);
--color-logo-text: #190064; --color-logo-text: #190064;
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem; --border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem; --border-radius-lg: 0.5rem;
+1 -4
View File
@@ -387,10 +387,7 @@ export const restoreElement = (
elbowed: true, elbowed: true,
startBinding: repairBinding(element, element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding), endBinding: repairBinding(element, element.endBinding),
fixedSegments: fixedSegments: element.fixedSegments,
element.fixedSegments?.length && base.points.length >= 4
? element.fixedSegments
: null,
startIsSpecial: element.startIsSpecial, startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial, endIsSpecial: element.endIsSpecial,
}) })
+1 -3
View File
@@ -171,9 +171,7 @@
"linkToElement": "Link to object", "linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame", "wrapSelectionInFrame": "Wrap selection in frame",
"tab": "Tab", "tab": "Tab",
"shapeSwitch": "Switch shape", "shapeSwitch": "Switch shape"
"preferences": "Preferences",
"preferences_toolLock": "Tool lock"
}, },
"elementLink": { "elementLink": {
"title": "Link to object", "title": "Link to object",
+3 -5
View File
@@ -81,13 +81,11 @@
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-popover": "1.1.6", "@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-tabs": "1.1.3", "@radix-ui/react-tabs": "1.1.3",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
@@ -99,8 +97,8 @@
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "2.11.0", "jotai": "2.11.0",
"jotai-scope": "0.7.2", "jotai-scope": "0.7.2",
"lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"lodash.debounce": "4.0.8",
"nanoid": "3.3.3", "nanoid": "3.3.3",
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "2.0.3", "pako": "2.0.3",
@@ -282,14 +282,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {
"id0": {
"deleted": {
"version": 12,
},
"inserted": {
"version": 11,
},
},
"id1": { "id1": {
"deleted": { "deleted": {
"boundElements": [], "boundElements": [],
@@ -404,14 +396,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"version": 12, "version": 12,
}, },
}, },
"id15": {
"deleted": {
"version": 10,
},
"inserted": {
"version": 9,
},
},
"id4": { "id4": {
"deleted": { "deleted": {
"height": "99.19972", "height": "99.19972",
@@ -853,14 +837,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {
"id0": {
"deleted": {
"version": 13,
},
"inserted": {
"version": 12,
},
},
"id1": { "id1": {
"deleted": { "deleted": {
"boundElements": [], "boundElements": [],
@@ -2656,7 +2632,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"height": 100, "height": 100,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": true,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -2705,7 +2681,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"textAlign": "left", "textAlign": "left",
"type": "text", "type": "text",
"updated": 1, "updated": 1,
"version": 8, "version": 6,
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 15, "x": 15,
@@ -2719,7 +2695,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id0", "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -2766,12 +2742,10 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
}, },
}, },
"elements": { "elements": {
"added": {}, "added": {
"removed": {},
"updated": {
"id0": { "id0": {
"deleted": { "deleted": {
"isDeleted": false, "isDeleted": true,
"version": 9, "version": 9,
}, },
"inserted": { "inserted": {
@@ -2800,21 +2774,16 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"y": 10, "y": 10,
}, },
}, },
"id1": { },
"deleted": { "removed": {},
"containerId": null, "updated": {
"version": 8,
},
"inserted": {
"containerId": null,
"version": 7,
},
},
"id5": { "id5": {
"deleted": { "deleted": {
"containerId": null,
"version": 7, "version": 7,
}, },
"inserted": { "inserted": {
"containerId": "id0",
"version": 6, "version": 6,
}, },
}, },
@@ -3127,14 +3096,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"version": 8, "version": 8,
}, },
}, },
"id5": {
"deleted": {
"version": 7,
},
"inserted": {
"version": 6,
},
},
}, },
}, },
"id": "id9", "id": "id9",
@@ -4684,15 +4645,15 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"id1": { "id1": {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"version": 8, "version": 4,
"x": 15, "x": 15,
"y": 15, "y": 15,
}, },
"inserted": { "inserted": {
"angle": 0, "angle": 90,
"version": 7, "version": 3,
"x": 15, "x": 205,
"y": 15, "y": 205,
}, },
}, },
}, },
@@ -4886,8 +4847,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"version": 6, "version": 6,
"verticalAlign": "top", "verticalAlign": "top",
"width": 80, "width": 80,
"x": "241.29526", "x": 205,
"y": "247.59241", "y": 205,
} }
`; `;
@@ -5671,12 +5632,12 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"updated": { "updated": {
"id1": { "id1": {
"deleted": { "deleted": {
"frameId": null, "frameId": "id0",
"version": 9, "version": 5,
}, },
"inserted": { "inserted": {
"frameId": null, "frameId": null,
"version": 8, "version": 6,
}, },
}, },
}, },
@@ -5823,7 +5784,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 6, "version": 5,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -5855,7 +5816,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 4,
"width": 100, "width": 100,
"x": 100, "x": 100,
"y": 100, "y": 100,
@@ -5891,74 +5852,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {},
"id0": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"A",
],
"height": 100,
"index": "a0",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 5,
"width": 100,
"x": 0,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 4,
},
},
"id1": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"A",
],
"height": 100,
"index": "a1",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 5,
"width": 100,
"x": 100,
"y": 100,
},
"inserted": {
"isDeleted": true,
"version": 4,
},
},
},
}, },
"id": "id13", "id": "id13",
}, },
@@ -6178,7 +6072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 9, "version": 8,
"width": 10, "width": 10,
"x": 20, "x": 20,
"y": 0, "y": 0,
@@ -6208,7 +6102,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 9, "version": 8,
"width": 10, "width": 10,
"x": 50, "x": 50,
"y": 50, "y": 50,
@@ -6293,39 +6187,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {},
"id3": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"index": "a1",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 8,
"width": 10,
"x": 20,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 7,
},
},
},
}, },
"id": "id18", "id": "id18",
}, },
@@ -6343,11 +6205,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id3": { "id3": {
"deleted": { "deleted": {
"backgroundColor": "#ffc9c9", "backgroundColor": "#ffc9c9",
"version": 9, "version": 8,
}, },
"inserted": { "inserted": {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"version": 8, "version": 7,
}, },
}, },
}, },
@@ -6372,39 +6234,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {},
"id8": {
"deleted": {
"angle": 0,
"backgroundColor": "#ffc9c9",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"index": "a2",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 8,
"width": 10,
"x": 30,
"y": 30,
},
"inserted": {
"isDeleted": true,
"version": 7,
},
},
},
}, },
"id": "id20", "id": "id20",
}, },
@@ -6421,12 +6251,12 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"updated": { "updated": {
"id8": { "id8": {
"deleted": { "deleted": {
"version": 9, "version": 8,
"x": 50, "x": 50,
"y": 50, "y": 50,
}, },
"inserted": { "inserted": {
"version": 8, "version": 7,
"x": 30, "x": 30,
"y": 30, "y": 30,
}, },
@@ -7274,7 +7104,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 9, "version": 8,
"width": 10, "width": 10,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -7305,60 +7135,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {},
"id0": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"index": "a0",
"isDeleted": true,
"lastCommittedPoint": [
10,
10,
],
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0,
0,
],
[
10,
10,
],
],
"roughness": 1,
"roundness": {
"type": 2,
},
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"version": 9,
"width": 10,
"x": 0,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 8,
},
},
},
}, },
"id": "id13", "id": "id13",
}, },
@@ -7567,7 +7344,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 9, "version": 8,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": 0,
@@ -7598,39 +7375,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {},
"id0": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"index": "a0",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 8,
"width": 10,
"x": 10,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 7,
},
},
},
}, },
"id": "id7", "id": "id7",
}, },
@@ -7648,11 +7393,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id0": { "id0": {
"deleted": { "deleted": {
"backgroundColor": "#ffec99", "backgroundColor": "#ffec99",
"version": 9, "version": 8,
}, },
"inserted": { "inserted": {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"version": 8, "version": 7,
}, },
}, },
}, },
@@ -10581,7 +10326,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 9, "version": 8,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": 0,
@@ -10664,18 +10409,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {},
"id0": {
"deleted": {
"isDeleted": false,
"version": 9,
},
"inserted": {
"isDeleted": false,
"version": 8,
},
},
},
}, },
"id": "id8", "id": "id8",
}, },
@@ -16041,14 +15775,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 5, "version": 5,
}, },
}, },
"id1": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 4,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@@ -17010,14 +16736,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 5, "version": 5,
}, },
}, },
"id1": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 5,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@@ -17643,14 +17361,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 9, "version": 9,
}, },
}, },
"id1": {
"deleted": {
"version": 10,
},
"inserted": {
"version": 9,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@@ -18012,14 +17722,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 7, "version": 7,
}, },
}, },
"id2": {
"deleted": {
"version": 4,
},
"inserted": {
"version": 3,
},
},
}, },
}, },
"id": "id21", "id": "id21",
@@ -2216,16 +2216,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo
}, },
}, },
}, },
"updated": { "updated": {},
"id0": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 3,
},
},
},
}, },
"id": "id6", "id": "id6",
}, },
@@ -10901,32 +10892,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s
}, },
}, },
}, },
"updated": { "updated": {},
"id0": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id3": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id6": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
},
}, },
"id": "id21", "id": "id21",
}, },
+22 -27
View File
@@ -1,18 +1,13 @@
import { queryByText, queryByTestId } from "@testing-library/react"; import { queryByText, queryByTestId } from "@testing-library/react";
import React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
import { t } from "../i18n"; import { t } from "../i18n";
import { Excalidraw, Footer } from ".."; import { Excalidraw, Footer, MainMenu } from "../index";
import MainMenu from "../components/main-menu/MainMenu";
import { import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils";
render,
togglePopover,
fireEvent,
GlobalTestState,
} from "./test-utils";
const { h } = window; const { h } = window;
@@ -20,7 +15,7 @@ describe("<Excalidraw/>", () => {
afterEach(() => { afterEach(() => {
const menu = document.querySelector(".dropdown-menu"); const menu = document.querySelector(".dropdown-menu");
if (menu) { if (menu) {
togglePopover("Main menu"); toggleMenu(document.querySelector(".excalidraw")!);
} }
}); });
@@ -141,7 +136,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={undefined} />, <Excalidraw UIOptions={undefined} />,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot(); expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
}); });
@@ -150,7 +145,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />, <Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "clear-canvas-button")).toBeNull(); expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
}); });
@@ -159,7 +154,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { export: false } }} />, <Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "json-export-button")).toBeNull(); expect(queryByTestId(container, "json-export-button")).toBeNull();
}); });
@@ -168,7 +163,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />, <Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "image-export-button")).toBeNull(); expect(queryByTestId(container, "image-export-button")).toBeNull();
}); });
@@ -187,7 +182,7 @@ describe("<Excalidraw/>", () => {
/>, />,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "save-as-button")).toBeNull(); expect(queryByTestId(container, "save-as-button")).toBeNull();
}); });
@@ -198,7 +193,7 @@ describe("<Excalidraw/>", () => {
/>, />,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "save-button")).toBeNull(); expect(queryByTestId(container, "save-button")).toBeNull();
}); });
@@ -209,7 +204,7 @@ describe("<Excalidraw/>", () => {
/>, />,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "canvas-background-label")).toBeNull(); expect(queryByTestId(container, "canvas-background-label")).toBeNull();
expect(queryByTestId(container, "canvas-background-picker")).toBeNull(); expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
}); });
@@ -225,7 +220,7 @@ describe("<Excalidraw/>", () => {
</Excalidraw>, </Excalidraw>,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "canvas-background-label")).toBeNull(); expect(queryByTestId(container, "canvas-background-label")).toBeNull();
expect(queryByTestId(container, "canvas-background-picker")).toBeNull(); expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
}); });
@@ -235,7 +230,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />, <Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "toggle-dark-mode")).toBeNull(); expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
}); });
@@ -256,8 +251,8 @@ describe("<Excalidraw/>", () => {
</Excalidraw>, </Excalidraw>,
); );
//open menu //open menu
toggleMenu(container);
// load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false` // load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false`
togglePopover("Main menu");
expect(queryByTestId(container, "load-button")).toBeNull(); expect(queryByTestId(container, "load-button")).toBeNull();
}); });
}); });
@@ -268,7 +263,7 @@ describe("<Excalidraw/>", () => {
const { container } = await render(<Excalidraw />); const { container } = await render(<Excalidraw />);
expect(h.state.theme).toBe(THEME.LIGHT); expect(h.state.theme).toBe(THEME.LIGHT);
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy(); expect(darkModeToggle).toBeTruthy();
}); });
@@ -278,7 +273,7 @@ describe("<Excalidraw/>", () => {
expect(h.state.theme).toBe(THEME.DARK); expect(h.state.theme).toBe(THEME.DARK);
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "toggle-dark-mode")).toBe(null); expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
}); });
@@ -291,7 +286,7 @@ describe("<Excalidraw/>", () => {
); );
expect(h.state.theme).toBe(THEME.DARK); expect(h.state.theme).toBe(THEME.DARK);
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy(); expect(darkModeToggle).toBeTruthy();
}); });
@@ -305,7 +300,7 @@ describe("<Excalidraw/>", () => {
); );
expect(h.state.theme).toBe(THEME.DARK); expect(h.state.theme).toBe(THEME.DARK);
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBe(null); expect(darkModeToggle).toBe(null);
}); });
@@ -315,7 +310,7 @@ describe("<Excalidraw/>", () => {
it("should allow editing name", async () => { it("should allow editing name", async () => {
const { container } = await render(<Excalidraw />); const { container } = await render(<Excalidraw />);
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
fireEvent.click(queryByTestId(container, "image-export-button")!); fireEvent.click(queryByTestId(container, "image-export-button")!);
const textInput: HTMLInputElement | null = document.querySelector( const textInput: HTMLInputElement | null = document.querySelector(
".ImageExportModal .ImageExportModal__preview__filename .TextInput", ".ImageExportModal .ImageExportModal__preview__filename .TextInput",
@@ -328,7 +323,7 @@ describe("<Excalidraw/>", () => {
const name = "test"; const name = "test";
const { container } = await render(<Excalidraw name={name} />); const { container } = await render(<Excalidraw name={name} />);
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
await fireEvent.click(queryByTestId(container, "image-export-button")!); await fireEvent.click(queryByTestId(container, "image-export-button")!);
const textInput = document.querySelector( const textInput = document.querySelector(
".ImageExportModal .ImageExportModal__preview__filename .TextInput", ".ImageExportModal .ImageExportModal__preview__filename .TextInput",
@@ -380,7 +375,7 @@ describe("<Excalidraw/>", () => {
</Excalidraw>, </Excalidraw>,
); );
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot(); expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
}); });
@@ -399,7 +394,7 @@ describe("<Excalidraw/>", () => {
const { container } = await render(<CustomExcalidraw />); const { container } = await render(<CustomExcalidraw />);
//open menu //open menu
togglePopover("Main menu"); toggleMenu(container);
expect(h.state.theme).toBe(THEME.LIGHT); expect(h.state.theme).toBe(THEME.LIGHT);
+7 -6
View File
@@ -4055,7 +4055,7 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: container.id, id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }], boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false, isDeleted: true,
}), }),
expect.objectContaining({ expect.objectContaining({
id: text.id, id: text.id,
@@ -4064,7 +4064,8 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: remoteText.id, id: remoteText.id,
containerId: container.id, // unbound
containerId: null,
isDeleted: false, isDeleted: false,
}), }),
]); ]);
@@ -4354,8 +4355,8 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
...textProps, ...textProps,
// text element got redrawn! // text element got redrawn!
x: 241.295259647664, x: 205,
y: 247.59240920619527, y: 205,
angle: 90, angle: 90,
id: text.id, id: text.id,
containerId: container.id, containerId: container.id,
@@ -4398,8 +4399,8 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
...textProps, ...textProps,
x: 241.295259647664, x: 205,
y: 247.59240920619527, y: 205,
angle: 90, angle: 90,
id: text.id, id: text.id,
containerId: container.id, containerId: container.id,
+9 -12
View File
@@ -1,5 +1,5 @@
import { queryByTestId } from "@testing-library/react"; import { act, queryByTestId } from "@testing-library/react";
import { act } from "@testing-library/react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { MIME_TYPES, ORIG_ID } from "@excalidraw/common"; import { MIME_TYPES, ORIG_ID } from "@excalidraw/common";
@@ -13,11 +13,9 @@ import { serializeLibraryAsJSON } from "../data/json";
import { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { fireEvent, render, togglePopover, waitFor } from "./test-utils";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { UI } from "./helpers/ui"; import { UI } from "./helpers/ui";
import { getCloneByOrigId } from "./test-utils"; import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
import type { LibraryItem, LibraryItems } from "../types"; import type { LibraryItem, LibraryItems } from "../types";
@@ -217,13 +215,12 @@ describe("library menu", () => {
const libraryButton = container.querySelector(".sidebar-trigger"); const libraryButton = container.querySelector(".sidebar-trigger");
fireEvent.click(libraryButton!); fireEvent.click(libraryButton!);
togglePopover("Library menu"); fireEvent.click(
// fireEvent.click( queryByTestId(
// queryByTestId( container.querySelector(".layer-ui__library")!,
// container.querySelector(".layer-ui__library")!, "dropdown-menu-button",
// "dropdown-menu-button", )!,
// )!, );
// );
fireEvent.click(queryByTestId(container, "lib-dropdown--load")!); fireEvent.click(queryByTestId(container, "lib-dropdown--load")!);
const libraryItems = parseLibraryJSON(await libraryJSONPromise); const libraryItems = parseLibraryJSON(await libraryJSONPromise);
@@ -2,46 +2,26 @@
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = ` exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
<div <div
aria-labelledby="radix-:r65:" class="dropdown-menu"
aria-orientation="vertical"
class="dropdown-menu main-menu-content"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="bottom"
data-state="open"
data-testid="dropdown-menu" data-testid="dropdown-menu"
dir="ltr"
id="radix-:r66:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
> >
<div <div
class="Island dropdown-menu-container" class="Island dropdown-menu-container"
style="--padding: 1; z-index: 2;" style="--padding: 2; z-index: 2;"
> >
<div <button
class="radix-menu-item" class="dropdown-menu-item dropdown-menu-item-base"
data-orientation="vertical" type="button"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
> >
<button <div
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base" class="dropdown-menu-item__icon"
type="button" />
<div
class="dropdown-menu-item__text"
> >
<div Click me
class="dropdown-menu-item__icon" </div>
/> </button>
<div
class="dropdown-menu-item__text"
>
Click me
</div>
</button>
</div>
<a <a
class="dropdown-menu-item dropdown-menu-item-base" class="dropdown-menu-item dropdown-menu-item-base"
href="https://plus.excalidraw.com/blog" href="https://plus.excalidraw.com/blog"
@@ -66,361 +46,301 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
custom menu item custom menu item
</button> </button>
</div> </div>
<div <button
class="radix-menu-item" aria-label="Help"
data-orientation="vertical" class="dropdown-menu-item dropdown-menu-item-base"
data-radix-collection-item="" data-testid="help-menu-item"
role="menuitem" title="Help"
tabindex="-1" type="button"
> >
<button <div
aria-label="Help" class="dropdown-menu-item__icon"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
> >
<div <svg
class="dropdown-menu-item__icon" aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
> >
<svg <g
aria-hidden="true" stroke-width="1.5"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
> >
<g <path
stroke-width="1.5" d="M0 0h24v24H0z"
> fill="none"
<path stroke="none"
d="M0 0h24v24H0z" />
fill="none" <circle
stroke="none" cx="12"
/> cy="12"
<circle r="9"
cx="12" />
cy="12" <line
r="9" x1="12"
/> x2="12"
<line y1="17"
x1="12" y2="17.01"
x2="12" />
y1="17" <path
y2="17.01" d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
/> />
<path </g>
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4" </svg>
/> </div>
</g> <div
</svg> class="dropdown-menu-item__text"
</div> >
<div Help
class="dropdown-menu-item__text" </div>
> <div
Help class="dropdown-menu-item__shortcut"
</div> >
<div ?
class="dropdown-menu-item__shortcut" </div>
> </button>
?
</div>
</button>
</div>
</div> </div>
</div> </div>
`; `;
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = ` exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
<div <div
aria-labelledby="radix-:rq:" class="dropdown-menu"
aria-orientation="vertical"
class="dropdown-menu main-menu-content"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="bottom"
data-state="open"
data-testid="dropdown-menu" data-testid="dropdown-menu"
dir="ltr"
id="radix-:rr:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
> >
<div <div
class="Island dropdown-menu-container" class="Island dropdown-menu-container"
style="--padding: 1; z-index: 2;" style="--padding: 2; z-index: 2;"
> >
<div <button
class="radix-menu-item" aria-label="Open"
data-orientation="vertical" class="dropdown-menu-item dropdown-menu-item-base"
data-radix-collection-item="" data-testid="load-button"
role="menuitem" title="Open"
tabindex="-1" type="button"
> >
<button <div
aria-label="Open" class="dropdown-menu-item__icon"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="load-button"
title="Open"
type="button"
> >
<div <svg
class="dropdown-menu-item__icon" aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
> >
<svg <path
aria-hidden="true" d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
class="" stroke-width="1.25"
fill="none" />
focusable="false" </svg>
role="img" </div>
stroke="currentColor" <div
stroke-linecap="round" class="dropdown-menu-item__text"
stroke-linejoin="round" >
viewBox="0 0 20 20" Open
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+O
</div>
</button>
<button
aria-label="Save to..."
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="json-export-button"
title="Save to..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
stroke-width="1.25"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Save to...
</div>
</button>
<button
aria-label="Export image..."
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="image-export-button"
title="Export image..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.25"
> >
<path <path
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z" d="M0 0h24v24H0z"
stroke-width="1.25" fill="none"
stroke="none"
/> />
</svg> <path
</div> d="M15 8h.01"
<div />
class="dropdown-menu-item__text" <path
> d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
Open />
</div> <path
<div d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
class="dropdown-menu-item__shortcut" />
> <path
Ctrl+O d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
</div> />
</button> <path
</div> d="M19 16v6"
<div />
class="radix-menu-item" <path
data-orientation="vertical" d="M22 19l-3 3l-3 -3"
data-radix-collection-item="" />
role="menuitem" </g>
tabindex="-1" </svg>
> </div>
<button <div
aria-label="Save to..." class="dropdown-menu-item__text"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="json-export-button"
title="Save to..."
type="button"
> >
<div Export image...
class="dropdown-menu-item__icon" </div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+Shift+E
</div>
</button>
<button
aria-label="Help"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
> >
<svg <g
aria-hidden="true" stroke-width="1.5"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
> >
<path <path
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10" d="M0 0h24v24H0z"
stroke-width="1.25" fill="none"
stroke="none"
/>
<circle
cx="12"
cy="12"
r="9"
/>
<line
x1="12"
x2="12"
y1="17"
y2="17.01"
/> />
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Save to...
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Export image..."
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="image-export-button"
title="Export image..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.25"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M15 8h.01"
/>
<path
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
/>
<path
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
/>
<path
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
/>
<path
d="M19 16v6"
/>
<path
d="M22 19l-3 3l-3 -3"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Export image...
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+Shift+E
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Help"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<circle
cx="12"
cy="12"
r="9"
/>
<line
x1="12"
x2="12"
y1="17"
y2="17.01"
/>
<path
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
>
?
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Reset the canvas"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path <path
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5" d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
stroke-width="1.25"
/> />
</svg> </g>
</div> </svg>
<div </div>
class="dropdown-menu-item__text" <div
class="dropdown-menu-item__text"
>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
>
?
</div>
</button>
<button
aria-label="Reset the canvas"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
> >
Reset the canvas <path
</div> d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
</button> stroke-width="1.25"
</div> />
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Reset the canvas
</div>
</button>
<div <div
style="height: 1px; margin: .5rem 0px;" style="height: 1px; margin: .5rem 0px;"
/> />
@@ -553,53 +473,45 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
style="height: 1px; margin: .5rem 0px;" style="height: 1px; margin: .5rem 0px;"
/> />
<div <button
class="radix-menu-item" aria-label="Dark mode"
data-orientation="vertical" class="dropdown-menu-item dropdown-menu-item-base"
data-radix-collection-item="" data-testid="toggle-dark-mode"
role="menuitem" title="Dark mode"
tabindex="-1" type="button"
> >
<button <div
aria-label="Dark mode" class="dropdown-menu-item__icon"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="toggle-dark-mode"
title="Dark mode"
type="button"
> >
<div <svg
class="dropdown-menu-item__icon" aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
> >
<svg <path
aria-hidden="true" clip-rule="evenodd"
class="" d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
fill="none"
focusable="false"
role="img"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" />
stroke-linejoin="round" </svg>
viewBox="0 0 20 20" </div>
> <div
<path class="dropdown-menu-item__text"
clip-rule="evenodd" >
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z" Dark mode
stroke="currentColor" </div>
/> <div
</svg> class="dropdown-menu-item__shortcut"
</div> >
<div Shift+Alt+D
class="dropdown-menu-item__text" </div>
> </button>
Dark mode
</div>
<div
class="dropdown-menu-item__shortcut"
>
Shift+Alt+D
</div>
</button>
</div>
<div <div
style="margin-top: 0.5rem;" style="margin-top: 0.5rem;"
> >
@@ -610,7 +522,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
Canvas background Canvas background
</div> </div>
<div <div
id="canvas-bg-color-picker-container"
style="padding: 0px 0.625rem;" style="padding: 0px 0.625rem;"
> >
<div> <div>
@@ -682,7 +593,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
style="width: 1px; height: 100%; margin: 0px auto;" style="width: 1px; height: 100%; margin: 0px auto;"
/> />
<button <button
aria-controls="radix-:r12:" aria-controls="radix-:r0:"
aria-expanded="false" aria-expanded="false"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-label="Canvas background" aria-label="Canvas background"
+1 -2
View File
@@ -215,12 +215,11 @@ export const textWysiwyg = ({
); );
app.scene.mutateElement(container, { height: targetContainerHeight }); app.scene.mutateElement(container, { height: targetContainerHeight });
} else { } else {
const { x, y } = computeBoundTextPosition( const { y } = computeBoundTextPosition(
container, container,
updatedTextElement as ExcalidrawTextElementWithContainer, updatedTextElement as ExcalidrawTextElementWithContainer,
elementsMap, elementsMap,
); );
coordX = x;
coordY = y; coordY = y;
} }
} }
+1702 -3541
View File
File diff suppressed because it is too large Load Diff