Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle c9f62f20d5 debuggs 2026-03-09 22:04:43 +01:00
32 changed files with 685 additions and 1389 deletions
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow", type: "arrow",
x: 450, x: 450,
y: 20, y: 20,
startArrowhead: "circle", startArrowhead: "dot",
endArrowhead: "triangle", endArrowhead: "triangle",
strokeColor: "#1971c2", strokeColor: "#1971c2",
strokeWidth: 2, strokeWidth: 2,
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform"; import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types"; import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [ const elements: ExcalidrawElementSkeleton[] = [
-32
View File
@@ -1,32 +0,0 @@
import type { Arrowhead, AnyArrowhead } from "./types";
export const normalizeArrowhead = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
switch (arrowhead) {
case undefined:
case null:
return null;
case "dot":
return "circle";
case "crowfoot_one":
return "cardinality_one";
case "crowfoot_many":
return "cardinality_many";
case "crowfoot_one_or_many":
return "cardinality_one_or_many";
default:
return arrowhead;
}
};
export const getArrowheadForPicker = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
const normalizedArrowhead = normalizeArrowhead(arrowhead);
if (normalizedArrowhead === null) {
return null;
}
return normalizedArrowhead;
};
+26 -39
View File
@@ -709,9 +709,6 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
}; };
const CARDINALITY_MARKER_SIZE = 20;
const CROWFOOT_ARROWHEAD_SIZE = 15;
/** @returns number in pixels */ /** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => { export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) { switch (arrowhead) {
@@ -720,14 +717,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "cardinality_many": case "crowfoot_many":
case "cardinality_one_or_many": case "crowfoot_one":
case "cardinality_zero_or_many": case "crowfoot_one_or_many":
return CROWFOOT_ARROWHEAD_SIZE; return 20;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default: default:
return 15; return 15;
} }
@@ -750,12 +743,7 @@ export const getArrowheadPoints = (
shape: Drawable[], shape: Drawable[],
position: "start" | "end", position: "start" | "end",
arrowhead: Arrowhead, arrowhead: Arrowhead,
offsetMultiplier = 0,
) => { ) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) { if (shape.length < 1) {
return null; return null;
} }
@@ -836,30 +824,29 @@ export const getArrowheadPoints = (
const lengthMultiplier = const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier); const minSize = Math.min(size, length * lengthMultiplier);
const tx = x2 - nx * minSize * offsetMultiplier; const xs = x2 - nx * minSize;
const ty = y2 - ny * minSize * offsetMultiplier; const ys = y2 - ny * minSize;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if (arrowhead === "circle" || arrowhead === "circle_outline") { if (
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2; arrowhead === "dot" ||
return [tx, ty, diameter]; arrowhead === "circle" ||
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
} }
const angle = getArrowheadAngle(arrowhead); const angle = getArrowheadAngle(arrowhead);
if ( if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
arrowhead === "cardinality_many" ||
arrowhead === "cardinality_one_or_many"
) {
// swap (xs, ys) with (x2, y2) // swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(tx, ty), pointFrom(x2, y2),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(-angle as Degrees), degreesToRadians(-angle as Degrees),
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(tx, ty), pointFrom(x2, y2),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -869,12 +856,12 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(tx, ty), pointFrom(x2, y2),
((-angle * Math.PI) / 180) as Radians, ((-angle * Math.PI) / 180) as Radians,
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(tx, ty), pointFrom(x2, y2),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -887,9 +874,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(tx + minSize * 2, ty), pointFrom(x2 + minSize * 2, y2),
pointFrom(tx, ty), pointFrom(x2, y2),
Math.atan2(py - ty, px - tx) as Radians, Math.atan2(py - y2, px - x2) as Radians,
); );
} else { } else {
const [px, py] = const [px, py] =
@@ -898,16 +885,16 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(tx - minSize * 2, ty), pointFrom(x2 - minSize * 2, y2),
pointFrom(tx, ty), pointFrom(x2, y2),
Math.atan2(ty - py, tx - px) as Radians, Math.atan2(y2 - py, x2 - px) as Radians,
); );
} }
return [tx, ty, x3, y3, ox, oy, x4, y4]; return [x2, y2, x3, y3, ox, oy, x4, y4];
} }
return [tx, ty, x3, y3, x4, y4]; return [x2, y2, x3, y3, x4, y4];
}; };
// TODO reuse shape.ts // TODO reuse shape.ts
-13
View File
@@ -872,19 +872,6 @@ export const shouldApplyFrameClip = (
return true; return true;
} }
// Elements that belong to a frame should still render through that frame's
// clip, even when fully outside the frame bounds (e.g. generated content).
if (
!appState.selectedElementsAreBeingDragged &&
element.frameId === frame.id
) {
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, true);
}
return true;
}
// if an element is outside the frame, but is part of a group that has some elements // if an element is outside the frame, but is part of a group that has some elements
// "in" the frame, we should clip the element // "in" the frame, we should clip the element
if ( if (
-1
View File
@@ -99,4 +99,3 @@ export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers"; export * from "./arrows/helpers";
export * from "./arrowheads";
+86 -223
View File
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ElementsMap, ElementsMap,
ExcalidrawLineElement, ExcalidrawLineElement,
Arrowhead,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
@@ -296,82 +296,6 @@ const modifyIframeLikeForRoughOptions = (
return element; return element;
}; };
const generateArrowheadCardinalityOne = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, lineOptions)];
};
const generateArrowheadLinesToTip = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
return [
generator.line(x3, y3, x2, y2, lineOptions),
generator.line(x4, y4, x2, y2, lineOptions),
];
};
const getArrowheadLineOptions = (
element: ExcalidrawLinearElement,
options: Options,
) => {
const lineOptions = { ...options };
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete lineOptions.strokeLineDash;
}
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
return lineOptions;
};
const generateArrowheadOutlineCircle = (
generator: RoughGenerator,
options: Options,
strokeColor: string,
arrowheadPoints: number[] | null,
fill: string,
diameterScale = 1,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x, y, diameter] = arrowheadPoints;
const circleOptions = {
...options,
fill,
fillStyle: "solid" as const,
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
};
delete circleOptions.strokeLineDash;
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
};
const getArrowheadShapes = ( const getArrowheadShapes = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
shape: Drawable[], shape: Drawable[],
@@ -382,54 +306,63 @@ const getArrowheadShapes = (
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean, isDarkMode: boolean,
) => { ) => {
if (arrowhead === null) { const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return []; return [];
} }
const generateCrowfootOne = (
arrowheadPoints: number[] | null,
options: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
const strokeColor = isDarkMode const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor) ? applyDarkModeFilter(element.strokeColor)
: element.strokeColor; : element.strokeColor;
const backgroundFillColor = isDarkMode
? applyDarkModeFilter(canvasBackgroundColor)
: canvasBackgroundColor;
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
switch (arrowhead) { switch (arrowhead) {
case "dot":
case "circle": case "circle":
case "circle_outline": { case "circle_outline": {
return generateArrowheadOutlineCircle( const [x, y, diameter] = arrowheadPoints;
generator,
options, // always use solid stroke for arrowhead
strokeColor, delete options.strokeLineDash;
getArrowheadPoints(element, shape, position, arrowhead),
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor, return [
); generator.circle(x, y, diameter, {
...options,
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
} }
case "triangle": case "triangle":
case "triangle_outline": { case "triangle_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3] = arrowheadPoints; const [x, y, x2, y2, x3, y3] = arrowheadPoints;
const triangleOptions = {
...options,
fill:
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead // always use solid stroke for arrowhead
delete triangleOptions.strokeLineDash; delete options.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -439,34 +372,24 @@ const getArrowheadShapes = (
[x3, y3], [x3, y3],
[x, y], [x, y],
], ],
triangleOptions, {
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
), ),
]; ];
} }
case "diamond": case "diamond":
case "diamond_outline": { case "diamond_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints; const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
const diamondOptions = {
...options,
fill:
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead // always use solid stroke for arrowhead
delete diamondOptions.strokeLineDash; delete options.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -477,106 +400,46 @@ const getArrowheadShapes = (
[x4, y4], [x4, y4],
[x, y], [x, y],
], ],
diamondOptions, {
), ...options,
]; fill:
} arrowhead === "diamond_outline"
case "cardinality_one": ? canvasBackgroundColor
return generateArrowheadCardinalityOne( : strokeColor,
generator, fillStyle: "solid",
getArrowheadPoints(element, shape, position, arrowhead), roughness: Math.min(1, options.roughness || 0),
getArrowheadLineOptions(element, options), },
);
case "cardinality_many":
return generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
case "cardinality_one_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(
element,
shape,
position,
"cardinality_one",
cardinalityOneOrManyOffset,
),
lineOptions,
),
];
}
case "cardinality_exactly_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one"),
lineOptions,
),
];
}
case "cardinality_zero_or_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
];
}
case "cardinality_zero_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
), ),
]; ];
} }
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar": case "bar":
case "arrow": case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: { default: {
return generateArrowheadLinesToTip( const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
generator,
getArrowheadPoints(element, shape, position, arrowhead), if (element.strokeStyle === "dotted") {
getArrowheadLineOptions(element, options), // for dotted arrows caps, reduce gap to make it more legible
); const dash = getDashArrayDotted(element.strokeWidth - 1);
options.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash;
}
options.roughness = Math.min(1, options.roughness || 0);
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
];
} }
} }
}; };
+4 -17
View File
@@ -303,32 +303,19 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean } { point: LocalPoint; isDragging?: boolean }
>; >;
export type CardinalityArrowhead =
| "cardinality_one"
| "cardinality_many"
| "cardinality_one_or_many"
| "cardinality_exactly_one"
| "cardinality_zero_or_one"
| "cardinality_zero_or_many";
export type ArrowheadLegacy =
| "dot"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
| "dot" // legacy. Do not use for new elements.
| "circle" | "circle"
| "circle_outline" | "circle_outline"
| "triangle" | "triangle"
| "triangle_outline" | "triangle_outline"
| "diamond" | "diamond"
| "diamond_outline" | "diamond_outline"
| CardinalityArrowhead; | "crowfoot_one"
| "crowfoot_many"
export type AnyArrowhead = Arrowhead | ArrowheadLegacy; | "crowfoot_one_or_many";
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
-78
View File
@@ -2,7 +2,6 @@ import {
convertToExcalidrawElements, convertToExcalidrawElements,
Excalidraw, Excalidraw,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
@@ -11,8 +10,6 @@ import {
render, render,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import { shouldApplyFrameClip } from "../src/frame";
import type { ExcalidrawElement } from "../src/types"; import type { ExcalidrawElement } from "../src/types";
const { h } = window; const { h } = window;
@@ -564,78 +561,3 @@ describe("adding elements to frames", () => {
}); });
}); });
}); });
describe("frame clipping", () => {
const getAppStateForFrameClip = () =>
({
frameRendering: {
enabled: true,
clip: true,
},
selectedElementsAreBeingDragged: false,
selectedElementIds: {},
frameToHighlight: null,
editingGroupId: null,
} as any);
it("clips a frame child even when fully outside the frame bounds", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const outsideChild = API.createElement({
type: "rectangle",
id: "outside-child",
x: 250,
y: 250,
width: 50,
height: 50,
frameId: frame.id,
});
const elementsMap = arrayToMap([outsideChild, frame]);
expect(
shouldApplyFrameClip(
outsideChild,
frame,
getAppStateForFrameClip(),
elementsMap,
),
).toBe(true);
});
it("does not clip an outside element that does not belong to the frame", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const outsideElement = API.createElement({
type: "rectangle",
id: "outside",
x: 250,
y: 250,
width: 50,
height: 50,
});
const elementsMap = arrayToMap([outsideElement, frame]);
expect(
shouldApplyFrameClip(
outsideElement,
frame,
getAppStateForFrameClip(),
elementsMap,
),
).toBe(false);
});
});
+14 -9
View File
@@ -18,13 +18,22 @@ Please add the latest change on the top under the correct section.
### Breaking changes ### Breaking changes
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`. - Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
### Features ### Features
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code. - Added `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded.
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting. ```tsx
<Excalidraw
onMount={({ excalidrawAPI, container }) => {
console.log(container);
excalidrawAPI.scrollToContent();
}}
onInitialize={(api) => {
api.refresh();
}}
/>
```
- Same events are also accessible imperatively through `api.onEvent(...)`. - Same events are also accessible imperatively through `api.onEvent(...)`.
@@ -32,6 +41,7 @@ Please add the latest change on the top under the correct section.
<Excalidraw <Excalidraw
onExcalidrawAPI={(api) => { onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => { api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
excalidrawAPI.scrollToContent();
console.log(container); console.log(container);
}); });
@@ -44,9 +54,7 @@ Please add the latest change on the top under the correct section.
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`. Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`. - Exported `ExcalidrawAPIProvider`, `useExcalidrawAPI`, and `useAppStateValue` from the package entrypoint. The imperative API also now exposes `onStateChange`.
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
```tsx ```tsx
<ExcalidrawAPIProvider> <ExcalidrawAPIProvider>
@@ -55,9 +63,6 @@ Please add the latest change on the top under the correct section.
</ExcalidrawAPIProvider>; </ExcalidrawAPIProvider>;
function Logger() { function Logger() {
// initially null before the ExcalidrawAPIProvider initializes ater
// <Excalidraw/> renders
// When <Excalidraw/> unmounts, is reset back to null
const api = useExcalidrawAPI(); const api = useExcalidrawAPI();
useAppStateValue("viewModeEnabled", (viewModeEnabled) => { useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
+14 -111
View File
@@ -1,10 +1,10 @@
# Excalidraw # Excalidraw
**Excalidraw** is exported as a React component that you can embed directly in your app. **Excalidraw** is exported as a component to be directly embedded in your project.
## Installation ## Installation
Install the package together with its React peer dependencies. Use `npm` or `yarn` to install the package.
```bash ```bash
npm install react react-dom @excalidraw/excalidraw npm install react react-dom @excalidraw/excalidraw
@@ -12,131 +12,34 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw yarn add react react-dom @excalidraw/excalidraw
``` ```
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`. > **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
## Quick start #### Self-hosting fonts
The minimum working setup has two easy-to-miss requirements: By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
1. Import the package CSS: For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
```ts ```js
import "@excalidraw/excalidraw/index.css"; <script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
``` ```
2. Render Excalidraw inside a container with a non-zero height. ### Dimensions of Excalidraw
```tsx Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
## Next.js / SSR frameworks
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
```tsx
// app/components/ExcalidrawClient.tsx
"use client";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function ExcalidrawClient() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
```tsx
// app/page.tsx
import dynamic from "next/dynamic";
const ExcalidrawClient = dynamic(
() => import("./components/ExcalidrawClient"),
{ ssr: false },
);
export default function Page() {
return <ExcalidrawClient />;
}
```
See the local examples for complete setups:
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
## LLM / agent tips
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
## Migrating to `@excalidraw/excalidraw@0.18.x`
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
| Old path | New path |
| --- | --- |
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
For example:
```ts
import { exportToSvg } from "@excalidraw/excalidraw";
```
## Self-hosting fonts
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
```html
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
</script>
```
## Demo ## Demo
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser). Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
## Integration ## Integration
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API ## API
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing ## Contributing
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
+1 -1
View File
@@ -399,7 +399,7 @@ export const actionSaveFileToDisk = register({
appState: { appState: {
openDialog: null, openDialog: null,
fileHandle: savedFileHandle, fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved"), duration: 3000 }, toast: { message: t("toast.fileSaved") },
}, },
}; };
} catch (error: any) { } catch (error: any) {
+84 -132
View File
@@ -36,7 +36,6 @@ import {
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import { import {
getBoundTextElement, getBoundTextElement,
@@ -125,12 +124,9 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCardinalityExactlyOneIcon, ArrowheadCrowfootIcon,
ArrowheadCardinalityManyIcon, ArrowheadCrowfootOneIcon,
ArrowheadCardinalityOneIcon, ArrowheadCrowfootOneOrManyIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -1554,117 +1550,80 @@ export const actionChangeRoundness = register<"sharp" | "round">({
}); });
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return { return [
visibleSections: [ {
{ value: null,
name: "default", text: t("labels.arrowhead_none"),
options: [ keyBinding: "q",
{ icon: <ArrowheadNoneIcon flip={flip} />,
value: null, },
text: t("labels.arrowhead_none"), {
keyBinding: "q", value: "arrow",
icon: <ArrowheadNoneIcon flip={flip} />, text: t("labels.arrowhead_arrow"),
}, keyBinding: "w",
{ icon: <ArrowheadArrowIcon flip={flip} />,
value: "arrow", },
text: t("labels.arrowhead_arrow"), {
keyBinding: "w", value: "triangle",
icon: <ArrowheadArrowIcon flip={flip} />, text: t("labels.arrowhead_triangle"),
}, icon: <ArrowheadTriangleIcon flip={flip} />,
{ keyBinding: "e",
value: "triangle", },
text: t("labels.arrowhead_triangle"), {
icon: <ArrowheadTriangleIcon flip={flip} />, value: "triangle_outline",
keyBinding: "e", text: t("labels.arrowhead_triangle_outline"),
}, icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
{ keyBinding: "r",
value: "triangle_outline", },
text: t("labels.arrowhead_triangle_outline"), {
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, value: "circle",
keyBinding: "r", text: t("labels.arrowhead_circle"),
}, keyBinding: "a",
], icon: <ArrowheadCircleIcon flip={flip} />,
}, },
], {
hiddenSections: [ value: "circle_outline",
{ text: t("labels.arrowhead_circle_outline"),
name: "default", keyBinding: "s",
options: [ icon: <ArrowheadCircleOutlineIcon flip={flip} />,
{ },
value: "circle", {
text: t("labels.arrowhead_circle"), value: "diamond",
keyBinding: "a", text: t("labels.arrowhead_diamond"),
icon: <ArrowheadCircleIcon flip={flip} />, icon: <ArrowheadDiamondIcon flip={flip} />,
}, keyBinding: "d",
{ },
value: "circle_outline", {
text: t("labels.arrowhead_circle_outline"), value: "diamond_outline",
keyBinding: "s", text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadCircleOutlineIcon flip={flip} />, icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
}, keyBinding: "f",
{ },
value: "diamond", {
text: t("labels.arrowhead_diamond"), value: "bar",
icon: <ArrowheadDiamondIcon flip={flip} />, text: t("labels.arrowhead_bar"),
keyBinding: "d", keyBinding: "z",
}, icon: <ArrowheadBarIcon flip={flip} />,
{ },
value: "diamond_outline", {
text: t("labels.arrowhead_diamond_outline"), value: "crowfoot_one",
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, text: t("labels.arrowhead_crowfoot_one"),
keyBinding: "f", icon: <ArrowheadCrowfootOneIcon flip={flip} />,
}, keyBinding: "x",
{ },
value: "bar", {
text: t("labels.arrowhead_bar"), value: "crowfoot_many",
keyBinding: "z", text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadBarIcon flip={flip} />, icon: <ArrowheadCrowfootIcon flip={flip} />,
}, keyBinding: "c",
], },
}, {
{ value: "crowfoot_one_or_many",
name: t("labels.cardinality"), text: t("labels.arrowhead_crowfoot_one_or_many"),
options: [ icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
{ keyBinding: "v",
value: "cardinality_one", },
text: t("labels.arrowhead_cardinality_one"), ] as const;
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "cardinality_many",
text: t("labels.arrowhead_cardinality_many"),
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
keyBinding: "c",
},
{
value: "cardinality_one_or_many",
text: t("labels.arrowhead_cardinality_one_or_many"),
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
{
value: "cardinality_exactly_one",
text: t("labels.arrowhead_cardinality_exactly_one"),
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_one",
text: t("labels.arrowhead_cardinality_zero_or_one"),
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_many",
text: t("labels.arrowhead_cardinality_zero_or_many"),
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
keyBinding: null,
},
],
},
],
} as const;
}; };
export const actionChangeArrowhead = register<{ export const actionChangeArrowhead = register<{
@@ -1708,52 +1667,45 @@ export const actionChangeArrowhead = register<{
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start" label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? getArrowheadForPicker(element.startArrowhead) ? element.startArrowhead
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead, hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end" label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? getArrowheadForPicker(element.endArrowhead) ? element.endArrowhead
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead, hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
+210 -79
View File
@@ -517,7 +517,6 @@ EditorInterfaceContext.displayName = "EditorInterfaceContext";
const editorLifecycleEventBehavior = { const editorLifecycleEventBehavior = {
"editor:mount": { cardinality: "once", replay: "last" }, "editor:mount": { cardinality: "once", replay: "last" },
"editor:initialize": { cardinality: "once", replay: "last" }, "editor:initialize": { cardinality: "once", replay: "last" },
"editor:unmount": { cardinality: "once", replay: "last" },
} as const; } as const;
export const ExcalidrawContainerContext = React.createContext<{ export const ExcalidrawContainerContext = React.createContext<{
@@ -557,7 +556,7 @@ export const ExcalidrawAPIContext =
ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext"; ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext";
export const ExcalidrawAPISetContext = React.createContext< export const ExcalidrawAPISetContext = React.createContext<
((api: ExcalidrawImperativeAPI | null) => void) | null ((api: ExcalidrawImperativeAPI) => void) | null
>(null); >(null);
ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext"; ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext";
@@ -685,6 +684,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null; null;
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
_diagPointerMoveLogged = false;
/** current frame pointer cords */ /** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null; lastPointerMoveCoords: { x: number; y: number } | null = null;
/** previous frame pointer coords */ /** previous frame pointer coords */
@@ -732,50 +732,6 @@ class App extends React.Component<AppProps, AppState> {
api: ExcalidrawImperativeAPI; api: ExcalidrawImperativeAPI;
private createExcalidrawAPI(): ExcalidrawImperativeAPI {
const api: ExcalidrawImperativeAPI = {
isDestroyed: false,
updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
getSceneElementsMapIncludingDeleted:
this.getSceneElementsMapIncludingDeleted,
history: {
clear: this.resetHistory,
},
scrollToContent: this.scrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
getName: this.getName,
registerAction: (action: Action) => {
this.actionManager.registerAction(action);
},
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
onStateChange: this.onStateChange,
onEvent: this.onEvent,
};
return api;
}
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
@@ -836,11 +792,47 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history)); this.actionManager.registerAction(createRedoAction(this.history));
// in case internal editor APIs call this early, otherwise we need this.api = {
// to construct this in componentDidMount because componentWillUnmount updateScene: this.updateScene,
// will invalidate it (so in StrictMode, doing this in constructor alone applyDeltas: this.applyDeltas,
// would be a problem) mutateElement: this.mutateElement,
this.api = this.createExcalidrawAPI(); updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
getSceneElementsMapIncludingDeleted:
this.getSceneElementsMapIncludingDeleted,
history: {
clear: this.resetHistory,
},
scrollToContent: this.scrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
getName: this.getName,
registerAction: (action: Action) => {
this.actionManager.registerAction(action);
},
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
onStateChange: this.onStateChange,
onEvent: this.onEvent,
} as const;
props.onExcalidrawAPI?.(this.api);
} }
updateEditorAtom = <Value, Args extends unknown[], Result>( updateEditorAtom = <Value, Args extends unknown[], Result>(
@@ -3012,11 +3004,80 @@ class App extends React.Component<AppProps, AppState> {
public async componentDidMount() { public async componentDidMount() {
this.unmounted = false; this.unmounted = false;
this.api = this.createExcalidrawAPI();
this.excalidrawContainerValue.container = this.excalidrawContainerValue.container =
this.excalidrawContainerRef.current; this.excalidrawContainerRef.current;
// [DIAG] Capture-phase listener to detect if pointerdown is intercepted
// before reaching our React handler. Patches stopPropagation/
// stopImmediatePropagation so we can detect if another listener kills
// the event before it reaches the canvas.
document.addEventListener(
"pointerdown",
(e) => {
const target = e.target as HTMLElement | null;
if (
target?.closest(".excalidraw") &&
(target.tagName === "CANVAS" || target.closest("canvas"))
) {
console.info(
"[DIAG] document capture pointerdown on canvas, tool:",
this.state.activeTool?.type,
{
defaultPrevented: e.defaultPrevented,
cancelable: e.cancelable,
isTrusted: e.isTrusted,
target: target.tagName,
pointerType: e.pointerType,
button: e.button,
},
);
// Patch to detect stopPropagation/stopImmediatePropagation
const origStop = e.stopPropagation.bind(e);
const origStopImm = e.stopImmediatePropagation.bind(e);
const origPD = e.preventDefault.bind(e);
e.stopPropagation = () => {
console.warn(
"[DIAG] capture: pointerdown stopPropagation called!",
new Error().stack,
);
origStop();
};
e.stopImmediatePropagation = () => {
console.warn(
"[DIAG] capture: pointerdown stopImmediatePropagation called!",
new Error().stack,
);
origStopImm();
};
e.preventDefault = () => {
console.warn(
"[DIAG] capture: pointerdown preventDefault called!",
new Error().stack,
);
origPD();
};
}
},
true, // capture phase
);
// [DIAG] Bubble-phase listener to confirm event wasn't stopped
document.addEventListener(
"pointerdown",
(e) => {
const target = e.target as HTMLElement | null;
if (
target?.closest(".excalidraw") &&
(target.tagName === "CANVAS" || target.closest("canvas"))
) {
console.info(
"[DIAG] document bubble pointerdown reached (not stopped)",
);
}
},
false, // bubble phase
);
if (isTestEnv() || isDevEnv()) { if (isTestEnv() || isDevEnv()) {
const setState = this.setState.bind(this); const setState = this.setState.bind(this);
Object.defineProperties(window.h, { Object.defineProperties(window.h, {
@@ -3100,35 +3161,10 @@ class App extends React.Component<AppProps, AppState> {
this.editorLifecycleEvents.emit("editor:mount", mountPayload); this.editorLifecycleEvents.emit("editor:mount", mountPayload);
this.props.onMount?.(mountPayload); this.props.onMount?.(mountPayload);
this.props.onExcalidrawAPI?.(this.api);
} }
public componentWillUnmount() { public componentWillUnmount() {
// we're recreating the api object reference so that the
// <ExcalidrawAPIContext.Provider/> picks up on it
this.api = { ...this.api, isDestroyed: true };
for (const key of Object.keys(this.api) as (keyof typeof this.api)[]) {
if (
(key.startsWith("get") ||
key === "onStateChange" ||
key === "onEvent") &&
typeof this.api[key] === "function"
) {
(this.api as any)[key] = () => {
throw new Error(
"ExcalidrawAPI is no longer usable after the editor has been unmounted and will return invalid/empty data. You should check for `ExcalidrawAPI.isDestroyed` before calling get* methods on subscribing to state/event changes.",
);
};
}
}
this.editorLifecycleEvents.emit("editor:unmount");
this.props.onUnmount?.();
this.props.onExcalidrawAPI?.(null);
(window as any).launchQueue?.setConsumer(() => {}); (window as any).launchQueue?.setConsumer(() => {});
this.renderer.destroy(); this.renderer.destroy();
this.scene.destroy(); this.scene.destroy();
this.scene = new Scene(); this.scene = new Scene();
@@ -3536,8 +3572,14 @@ class App extends React.Component<AppProps, AppState> {
} }
private onTouchStart = (event: TouchEvent) => { private onTouchStart = (event: TouchEvent) => {
console.info("[DIAG] touchStart", {
touches: event.touches.length,
defaultPrevented: event.defaultPrevented,
isIOS,
});
// fix for Apple Pencil Scribble (do not prevent for other devices) // fix for Apple Pencil Scribble (do not prevent for other devices)
if (isIOS) { if (isIOS) {
console.info("[DIAG] touchStart: calling preventDefault (isIOS)");
event.preventDefault(); event.preventDefault();
} }
@@ -3703,7 +3745,7 @@ class App extends React.Component<AppProps, AppState> {
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) { if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw"); const api = await import("@excalidraw/mermaid-to-excalidraw");
try { try {
const { elements: skeletonElements, files = {} } = const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text); await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, { const elements = convertToExcalidrawElements(skeletonElements, {
@@ -6452,6 +6494,20 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerMove = ( private handleCanvasPointerMove = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
) => { ) => {
if (!this._diagPointerMoveLogged) {
console.info("[DIAG] pointerMove (first)", {
pointerType: event.pointerType,
isPanning,
isHoldingSpace,
viewModeEnabled: this.state.viewModeEnabled,
activeTool: this.state.activeTool.type,
cursorButton: this.state.cursorButton,
});
this._diagPointerMoveLogged = true;
setTimeout(() => {
this._diagPointerMoveLogged = false;
}, 2000);
}
this.savePointer(event.clientX, event.clientY, this.state.cursorButton); this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
this.lastPointerMoveEvent = event.nativeEvent; this.lastPointerMoveEvent = event.nativeEvent;
const scenePointer = viewportCoordsToSceneCoords(event, this.state); const scenePointer = viewportCoordsToSceneCoords(event, this.state);
@@ -7232,6 +7288,56 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = ( private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
console.info("[DIAG] pointerDown", {
pointerType: event.pointerType,
button: event.button,
isPanning,
isHoldingSpace,
viewModeEnabled: this.state.viewModeEnabled,
penMode: this.state.penMode,
activeTool: this.state.activeTool.type,
editingTextElement: !!this.state.editingTextElement,
openDialog: this.state.openDialog?.name ?? null,
gesturePointers: gesture.pointers.size,
newElement: this.state.newElement?.type ?? null,
target: (event.target as HTMLElement)?.tagName,
defaultPrevented: event.nativeEvent.defaultPrevented,
cancelable: event.nativeEvent.cancelable,
isTrusted: event.nativeEvent.isTrusted,
hasSetPointerCapture: "setPointerCapture" in HTMLElement.prototype,
composedPath: event.nativeEvent
.composedPath()
.slice(0, 3)
.map((el) => (el as HTMLElement).tagName || el.constructor?.name),
});
// Patch nativeEvent to detect if something calls preventDefault/stopPropagation
const nativeEvt = event.nativeEvent;
const origPreventDefault = nativeEvt.preventDefault.bind(nativeEvt);
const origStopProp = nativeEvt.stopPropagation.bind(nativeEvt);
const origStopImmediate =
nativeEvt.stopImmediatePropagation.bind(nativeEvt);
nativeEvt.preventDefault = () => {
console.warn(
"[DIAG] pointerDown preventDefault called!",
new Error().stack,
);
origPreventDefault();
};
nativeEvt.stopPropagation = () => {
console.warn(
"[DIAG] pointerDown stopPropagation called!",
new Error().stack,
);
origStopProp();
};
nativeEvt.stopImmediatePropagation = () => {
console.warn(
"[DIAG] pointerDown stopImmediatePropagation called!",
new Error().stack,
);
origStopImmediate();
};
// If Ctrl is not held, ensure isBindingEnabled reflects the user preference. // If Ctrl is not held, ensure isBindingEnabled reflects the user preference.
if (!event.ctrlKey) { if (!event.ctrlKey) {
const preferenceEnabled = this.state.bindingPreference === "enabled"; const preferenceEnabled = this.state.bindingPreference === "enabled";
@@ -7366,6 +7472,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (isPanning) { if (isPanning) {
console.info("[DIAG] pointerDown EXIT: isPanning");
return; return;
} }
@@ -7375,6 +7482,7 @@ class App extends React.Component<AppProps, AppState> {
// else it will send pointer state & laser pointer events in collab when // else it will send pointer state & laser pointer events in collab when
// panning // panning
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) { if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
console.info("[DIAG] pointerDown EXIT: panUsingWheelOrSpaceDrag");
return; return;
} }
@@ -7439,11 +7547,16 @@ class App extends React.Component<AppProps, AppState> {
event.button !== POINTER_BUTTON.TOUCH && event.button !== POINTER_BUTTON.TOUCH &&
event.button !== POINTER_BUTTON.ERASER event.button !== POINTER_BUTTON.ERASER
) { ) {
console.info("[DIAG] pointerDown EXIT: unhandled button", event.button);
return; return;
} }
// don't select while panning // don't select while panning
if (gesture.pointers.size > 1) { if (gesture.pointers.size > 1) {
console.info(
"[DIAG] pointerDown EXIT: multi-pointer gesture",
gesture.pointers.size,
);
return; return;
} }
@@ -7456,12 +7569,16 @@ class App extends React.Component<AppProps, AppState> {
}); });
if (this.handleDraggingScrollBar(event, pointerDownState)) { if (this.handleDraggingScrollBar(event, pointerDownState)) {
console.info("[DIAG] pointerDown EXIT: dragging scrollbar");
return; return;
} }
this.clearSelectionIfNotUsingSelection(); this.clearSelectionIfNotUsingSelection();
if (this.handleSelectionOnPointerDown(event, pointerDownState)) { if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
console.info(
"[DIAG] pointerDown EXIT: handled by selectionOnPointerDown",
);
return; return;
} }
@@ -7474,6 +7591,11 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type === "image"; this.state.activeTool.type === "image";
if (!allowOnPointerDown) { if (!allowOnPointerDown) {
console.info("[DIAG] pointerDown EXIT: penMode blocking touch", {
penMode: this.state.penMode,
pointerType: event.pointerType,
activeTool: this.state.activeTool.type,
});
return; return;
} }
@@ -7661,6 +7783,7 @@ class App extends React.Component<AppProps, AppState> {
); );
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") { if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
console.info("[DIAG] pointerDown: registering move/up listeners");
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp); window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown); window.addEventListener(EVENT.KEYDOWN, onKeyDown);
@@ -7669,12 +7792,20 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.eventListeners.onUp = onPointerUp; pointerDownState.eventListeners.onUp = onPointerUp;
pointerDownState.eventListeners.onKeyUp = onKeyUp; pointerDownState.eventListeners.onKeyUp = onKeyUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown; pointerDownState.eventListeners.onKeyDown = onKeyDown;
} else {
console.info(
"[DIAG] pointerDown: NOT registering listeners (viewMode + non-laser)",
);
} }
}; };
private handleCanvasPointerUp = ( private handleCanvasPointerUp = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
) => { ) => {
console.info("[DIAG] pointerUp", {
pointerType: event.pointerType,
button: event.button,
});
if (getFeatureFlag("COMPLEX_BINDINGS")) { if (getFeatureFlag("COMPLEX_BINDINGS")) {
this.resetDelayedBindMode(); this.resetDelayedBindMode();
} }
+3 -15
View File
@@ -6,20 +6,14 @@
padding: 0.5rem; padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island-stronger); box-shadow: var(--shadow-island);
border-radius: 4px; border-radius: 4px;
position: absolute;
:root[dir="rtl"] & { :root[dir="rtl"] & {
padding: 0.4rem; padding: 0.4rem;
} }
} }
.picker-sections,
.picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-container button, .picker-container button,
.picker button { .picker button {
position: relative; position: relative;
@@ -68,13 +62,7 @@
.picker-collapsible { .picker-collapsible {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0; padding: 0.5rem 0;
color: var(--text-primary-color);
}
.picker-section-label {
font-size: 0.75rem;
color: var(--text-primary-color);
} }
.picker-keybinding { .picker-keybinding {
+67 -157
View File
@@ -1,6 +1,6 @@
import { Popover } from "radix-ui"; import { Popover } from "radix-ui";
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect, useMemo } from "react"; import React, { useEffect } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common"; import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -8,15 +8,13 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { useExcalidrawContainer } from "./App"; import { useEditorInterface, useExcalidrawContainer } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
import type { JSX } from "react"; import type { JSX } from "react";
const moreOptionsAtom = atom(false); const moreOptionsAtom = atom(false);
const PICKER_COLUMNS = 4;
const DEFAULT_SECTION_NAME = "default";
type Option<T> = { type Option<T> = {
value: T; value: T;
@@ -25,73 +23,28 @@ type Option<T> = {
keyBinding: string | null; keyBinding: string | null;
}; };
type PickerSection<T> = {
name: string;
options: readonly Option<T>[];
};
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) => section.options);
const findOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => {
for (const section of sections) {
const option = section.options.find(predicate);
if (option) {
return option;
}
}
return null;
};
const hasOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => sections.some((section) => section.options.some(predicate));
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) =>
Array.from(
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
(_, index) =>
section.options.slice(
index * PICKER_COLUMNS,
index * PICKER_COLUMNS + PICKER_COLUMNS,
),
),
);
function Picker<T>({ function Picker<T>({
visibleSections, options,
hiddenSections = [],
value, value,
label, label,
onChange, onChange,
onClose, onClose,
numberOfOptionsToAlwaysShow = options.length,
}: { }: {
label: string; label: string;
value: T; value: T;
visibleSections: readonly PickerSection<T>[]; options: readonly Option<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void; onChange: (value: T) => void;
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) { }) {
const editorInterface = useEditorInterface();
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const allSections = [...visibleSections, ...hiddenSections];
const allOptions = flattenOptions(allSections);
const navigationRows = getNavigationRows([
...visibleSections,
...(showMoreOptions ? hiddenSections : []),
]);
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = allOptions.find( const pressedOption = options.find(
(option) => option.keyBinding === event.key.toLowerCase(), (option) => option.keyBinding === event.key.toLowerCase(),
); )!;
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation // Keybinding navigation
@@ -99,17 +52,17 @@ function Picker<T>({
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.TAB) { } else if (event.key === KEYS.TAB) {
const index = allOptions.findIndex((option) => option.value === value); const index = options.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey const nextIndex = event.shiftKey
? (allOptions.length + index - 1) % allOptions.length ? (options.length + index - 1) % options.length
: (index + 1) % allOptions.length; : (index + 1) % options.length;
onChange(allOptions[nextIndex].value); onChange(options[nextIndex].value);
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
// Arrow navigation // Arrow navigation
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const index = allOptions.findIndex((option) => option.value === value); const index = options.findIndex((option) => option.value === value);
if (index !== -1) { if (index !== -1) {
const length = allOptions.length; const length = options.length;
let nextIndex = index; let nextIndex = index;
switch (event.key) { switch (event.key) {
@@ -123,60 +76,18 @@ function Picker<T>({
break; break;
// Go the next row // Go the next row
case KEYS.ARROW_DOWN: { case KEYS.ARROW_DOWN: {
const currentRowIndex = navigationRows.findIndex((row) => nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const nextRow =
navigationRows[(currentRowIndex + 1) % navigationRows.length];
const nextOption =
nextRow[Math.min(column, nextRow.length - 1)] ??
allOptions[index];
onChange(nextOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break; break;
} }
// Go the previous row // Go the previous row
case KEYS.ARROW_UP: { case KEYS.ARROW_UP: {
const currentRowIndex = navigationRows.findIndex((row) => nextIndex =
row.some((option) => option.value === value), (length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const previousRow =
navigationRows[
(navigationRows.length + currentRowIndex - 1) %
navigationRows.length
];
const previousOption =
previousRow[Math.min(column, previousRow.length - 1)] ??
allOptions[index];
onChange(previousOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break; break;
} }
} }
onChange(allOptions[nextIndex].value); onChange(options[nextIndex].value);
} }
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -188,29 +99,38 @@ function Picker<T>({
event.stopPropagation(); event.stopPropagation();
}; };
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => { useEffect(() => {
if (hasOption(hiddenSections, (option) => option.value === value)) { if (!alwaysVisibleOptions.some((option) => option.value === value)) {
setShowMoreOptions(true); setShowMoreOptions(true);
} }
}, [value, hiddenSections, setShowMoreOptions]); }, [value, alwaysVisibleOptions, setShowMoreOptions]);
const renderOptions = (options: readonly Option<T>[]) => { const renderOptions = (options: Option<T>[]) => {
return ( return (
<div className="picker-content"> <div className="picker-content">
{options.map((option) => ( {options.map((option, i) => (
<button <button
type="button" type="button"
className={clsx("picker-option", { className={clsx("picker-option", {
active: value === option.value, active: value === option.value,
})} })}
onClick={() => { onClick={(event) => {
onChange(option.value); onChange(option.value);
}} }}
title={ title={`${option.text} ${
option.keyBinding option.keyBinding && `${option.keyBinding.toUpperCase()}`
? `${option.text}${option.keyBinding.toUpperCase()}` }`}
: option.text
}
aria-label={option.text || "none"} aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined} aria-keyshortcuts={option.keyBinding || undefined}
key={option.text} key={option.text}
@@ -233,38 +153,26 @@ function Picker<T>({
); );
}; };
const renderSections = (sections: readonly PickerSection<T>[]) => const isMobile = editorInterface.formFactor === "phone";
sections.map((section, index) =>
section.name === DEFAULT_SECTION_NAME ? (
<React.Fragment key={`${section.name}-${index}`}>
{renderOptions(section.options)}
</React.Fragment>
) : (
<div className="picker-section" key={`${section.name}-${index}`}>
<div className="picker-section-label">{section.name}</div>
{renderOptions(section.options)}
</div>
),
);
return ( return (
<Popover.Content <Popover.Content
className="picker" side={isMobile ? "right" : "bottom"}
role="dialog"
aria-modal="true"
aria-label={label}
side={"bottom"}
align="start" align="start"
sideOffset={12} sideOffset={isMobile ? 8 : 12}
alignOffset={12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }} style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined} collisionBoundary={container ?? undefined}
> >
<div className="picker-sections"> <div
{renderSections(visibleSections)} className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{hiddenSections.length > 0 && ( {moreOptions.length > 0 && (
<Collapsible <Collapsible
label={t("labels.more_options")} label={t("labels.more_options")}
open={showMoreOptions} open={showMoreOptions}
@@ -273,9 +181,7 @@ function Picker<T>({
}} }}
className="picker-collapsible" className="picker-collapsible"
> >
<div className="picker-sections"> {renderOptions(moreOptions)}
{renderSections(hiddenSections)}
</div>
</Collapsible> </Collapsible>
)} )}
</div> </div>
@@ -286,45 +192,49 @@ function Picker<T>({
export function IconPicker<T>({ export function IconPicker<T>({
value, value,
label, label,
visibleSections, options,
hiddenSections,
onChange, onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: { }: {
label: string; label: string;
value: T; value: T;
visibleSections: readonly PickerSection<T>[]; options: readonly {
hiddenSections?: readonly PickerSection<T>[]; value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
onChange: (value: T) => void; onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) { }) {
const [isActive, setActive] = React.useState(false); const [isActive, setActive] = React.useState(false);
const selectedOption = useMemo( const rPickerButton = React.useRef<any>(null);
() =>
findOption(visibleSections, (option) => option.value === value) ??
findOption(hiddenSections ?? [], (option) => option.value === value),
[visibleSections, hiddenSections, value],
);
return ( return (
<div> <div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}> <Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger <Popover.Trigger
name={group}
type="button" type="button"
aria-label={label} aria-label={label}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""} className={isActive ? "active" : ""}
> >
{selectedOption?.icon} {options.find((option) => option.value === value)?.icon}
</Popover.Trigger> </Popover.Trigger>
{isActive && ( {isActive && (
<Picker <Picker
visibleSections={visibleSections} options={options}
hiddenSections={hiddenSections}
value={value} value={value}
label={label} label={label}
onChange={onChange} onChange={onChange}
onClose={() => { onClose={() => {
setActive(false); setActive(false);
}} }}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/> />
)} )}
</Popover.Root> </Popover.Root>
@@ -236,40 +236,15 @@ const MermaidToExcalidraw = ({
<Trans <Trans
i18nKey="mermaid.description" i18nKey="mermaid.description"
flowchartLink={(el) => ( flowchartLink={(el) => (
<a <a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
sequenceLink={(el) => ( sequenceLink={(el) => (
<a <a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
{el} {el}
</a> </a>
)} )}
classLink={(el) => ( classLink={(el) => (
<a <a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a>
href="https://mermaid.js.org/syntax/classDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
erdLink={(el) => (
<a
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
/> />
</div> </div>
@@ -94,7 +94,7 @@ export const convertMermaidToExcalidraw = async ({
} }
} }
const { elements, files = {} } = ret; const { elements, files } = ret;
setError(null); setError(null);
data.current = { data.current = {
+78 -137
View File
@@ -69,11 +69,6 @@ const modifiedTablerIconProps: Opts = {
strokeLinejoin: "round", strokeLinejoin: "round",
} as const; } as const;
const arrowheadPreviewIconProps: Opts = {
width: 40,
height: 20,
} as const;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// tabler-icons: present // tabler-icons: present
@@ -1296,17 +1291,16 @@ export const ArrowheadNoneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(24, 0) scale(-1, 1)" : ""}
stroke="currentColor" stroke="currentColor"
opacity={0.3} opacity={0.3}
strokeWidth={2} strokeWidth={2}
fill="none"
strokeLinecap="round"
> >
<path d="M7,11 H19" /> <path d="M12 12l-9 0" />
<path d="M25,6 L33,16 M33,6 L25,16" /> <path d="M21 9l-6 6" />
<path d="M21 15l-6 -6" />
</g>, </g>,
arrowheadPreviewIconProps, tablerIconProps,
), ),
); );
@@ -1318,46 +1312,11 @@ export const ArrowheadArrowIcon = React.memo(
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
strokeLinecap="round"
strokeLinejoin="round"
> >
<path d="M7,11 H33 M23,5 L33,11 L23,17" /> <path d="M34 10H6M34 10L27 5M34 10L27 15" />
<path d="M27.5 5L34.5 10L27.5 15" />
</g>, </g>,
arrowheadPreviewIconProps, { width: 40, height: 20 },
),
);
export const ArrowheadTriangleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
>
<path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
<path d="M23,5 L35,11 L23,17 Z" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadTriangleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
>
<path d="M7,11 H23" />
<path d="M23,5 L35,11 L23,17 Z" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
@@ -1369,10 +1328,10 @@ export const ArrowheadCircleIcon = React.memo(
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
> >
<path d="M7,11 H25" strokeWidth={2} strokeLinecap="round" /> <path d="M32 10L6 10" strokeWidth={2} />
<circle cx="29" cy="11" r="4" /> <circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>, </g>,
arrowheadPreviewIconProps, { width: 40, height: 20 },
), ),
); );
@@ -1385,10 +1344,58 @@ export const ArrowheadCircleOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2} strokeWidth={2}
> >
<path d="M7,11 H25" strokeLinecap="round" /> <path d="M26 10L6 10" />
<circle cx="29" cy="11" r="4" /> <circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>, </g>,
arrowheadPreviewIconProps, { width: 40, height: 20 },
),
);
export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
stroke="currentColor"
strokeWidth={2}
fill="none"
/>
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadTriangleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadTriangleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
strokeLinejoin="round"
>
<path d="M6,9.5H27" />
<path d="M27,5L34,10L27,14Z" fill="none" />
</g>,
{ width: 40, height: 20 },
), ),
); );
@@ -1400,11 +1407,12 @@ export const ArrowheadDiamondIcon = React.memo(
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2}
> >
<path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" /> <path d="M6,9.5H20" />
<path d="M21,11 L28,5 L35,11 L28,17 Z" /> <path d="M27,5L34,10L27,14L20,9.5Z" />
</g>, </g>,
arrowheadPreviewIconProps, { width: 40, height: 20 },
), ),
); );
@@ -1417,32 +1425,15 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
strokeLinecap="round"
> >
<path d="M7,11 H21" /> <path d="M6,9.5H20" />
<path d="M21,11 L28,5 L35,11 L28,17 Z" /> <path d="M27,5L34,10L27,14L20,9.5Z" />
</g>, </g>,
arrowheadPreviewIconProps, { width: 40, height: 20 },
), ),
); );
export const ArrowheadBarIcon = React.memo( export const ArrowheadCrowfootIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
>
<path d="M11,11 H31 M31,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1452,13 +1443,13 @@ export const ArrowheadCardinalityOneIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M35,11 H7 M15,5 V17" /> <path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
</g>, </g>,
arrowheadPreviewIconProps, { width: 40, height: 20 },
), ),
); );
export const ArrowheadCardinalityManyIcon = React.memo( export const ArrowheadCrowfootOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1468,13 +1459,13 @@ export const ArrowheadCardinalityManyIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" /> <path d="M34,10 H6 M15,10 L15,15 L15,5" />
</g>, </g>,
arrowheadPreviewIconProps, { width: 40, height: 20 },
), ),
); );
export const ArrowheadCardinalityOneOrManyIcon = React.memo( export const ArrowheadCrowfootOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1484,59 +1475,9 @@ export const ArrowheadCardinalityOneOrManyIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" /> <path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
</g>, </g>,
arrowheadPreviewIconProps, { width: 40, height: 20 },
),
);
export const ArrowheadCardinalityExactlyOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H7 M15,5 V17 M7,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H19 M11,11 H7 M7,5 V17" />
<circle cx="15" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H27 M19,11 H7 M15,11 L7,5 M15,11 L7,17" />
<circle cx="23" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
+6 -4
View File
@@ -34,10 +34,9 @@
--popup-text-color: #000; --popup-text-color: #000;
--popup-text-inverted-color: #fff; --popup-text-inverted-color: #fff;
--select-highlight-color: #{$color-blue-5}; --select-highlight-color: #{$color-blue-5};
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17), --shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05); 0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgb(0 0 0 / 18%);
--button-hover-bg: var(--color-surface-high); --button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high); --button-active-bg: var(--color-surface-high);
@@ -211,6 +210,9 @@
--popup-text-color: #{$color-gray-4}; --popup-text-color: #{$color-gray-4};
--popup-text-inverted-color: #2c2c2c; --popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$color-blue-4}; --select-highlight-color: #{$color-blue-4};
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07), --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198), 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
+4
View File
@@ -20,6 +20,10 @@ export const resaveAsImageWithScene = async (
) => { ) => {
const fileHandleType = getFileHandleType(fileHandle); const fileHandleType = getFileHandleType(fileHandle);
if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) { if (!isImageFileHandleType(fileHandleType)) {
throw new Error( throw new Error(
"fileHandle should exist and should be of type svg or png when resaving", "fileHandle should exist and should be of type svg or png when resaving",
+2 -8
View File
@@ -22,7 +22,6 @@ import {
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements, getNonDeletedElements,
normalizeArrowhead,
isPointInElement, isPointInElement,
isValidPolygon, isValidPolygon,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -427,8 +426,7 @@ export const restoreElement = (
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough // eslint-disable-next-line no-fallthrough
case "draw": case "draw":
const startArrowhead = normalizeArrowhead(element.startArrowhead); const { startArrowhead = null, endArrowhead = null } = element;
const endArrowhead = normalizeArrowhead(element.endArrowhead);
let x = element.x; let x = element.x;
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
@@ -460,11 +458,7 @@ export const restoreElement = (
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": { case "arrow": {
const startArrowhead = normalizeArrowhead(element.startArrowhead); const { startArrowhead = null, endArrowhead = "arrow" } = element;
const endArrowhead =
element.endArrowhead === undefined
? "arrow"
: normalizeArrowhead(element.endArrowhead);
const x: number | undefined = element.x; const x: number | undefined = element.x;
const y: number | undefined = element.y; const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
+47 -109
View File
@@ -6,51 +6,6 @@ import { getDefaultAppState } from "../appState";
import type { AppState } from "../types"; import type { AppState } from "../types";
type AppStateSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
const getSelectedValue = (appState: AppState, selector: AppStateSelector) => {
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
const getLatestValue = (
api: ReturnType<typeof useExcalidrawAPI>,
selector: AppStateSelector,
_internal: boolean,
) => {
if (api?.isDestroyed) {
return;
}
let appState = api?.getAppState();
if (!appState) {
if (!_internal) {
return undefined;
}
console.warn(
"useAppStateValue: excalidrawAPI not defined yet for internal component while it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
);
// fall back in case there's a bug so we don't break the app
// (internal components using this internal useAppStateValue expect
// non-undefined values on init)
appState = Object.assign(
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
getDefaultAppState(),
);
}
return getSelectedValue(appState, selector);
};
/** /**
* Subscribes to specific appState changes. The component re-renders * Subscribes to specific appState changes. The component re-renders
* only when the specified prop(s) change not on every appState update. * only when the specified prop(s) change not on every appState update.
@@ -64,109 +19,92 @@ const getLatestValue = (
* - `(keyof AppState)[]` whole `AppState` * - `(keyof AppState)[]` whole `AppState`
* - selector function selector's return type `T` * - selector function selector's return type `T`
* *
* Calls the optional callback with the latest value on every change (not called
* on initial render).
*
* If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest * If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest
* value once available. * value once available.
*/ */
export function useAppStateValue<K extends keyof AppState>( export function useAppStateValue<K extends keyof AppState>(
prop: K, prop: K,
callback?: (value: AppState[K], appState: AppState) => void,
_internal?: boolean, _internal?: boolean,
): AppState[K]; ): AppState[K];
export function useAppStateValue( export function useAppStateValue(
props: (keyof AppState)[], props: (keyof AppState)[],
callback?: (props: AppState, appState: AppState) => void,
_internal?: boolean, _internal?: boolean,
): AppState; ): AppState;
export function useAppStateValue<T>( export function useAppStateValue<T>(
selector: (appState: AppState) => T, selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
_internal?: boolean, _internal?: boolean,
): T; ): T;
export function useAppStateValue( export function useAppStateValue(
selector: AppStateSelector, selector:
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown),
callback?: (value: any, appState: AppState) => void,
_internal: boolean = true, _internal: boolean = true,
): unknown { ): unknown {
const api = useExcalidrawAPI(); const api = useExcalidrawAPI();
const [, rerender] = useState(0);
const stateRef = useRef<{ const getLatestValue = () => {
selector: AppStateSelector; let appState = api?.getAppState();
isInitialized: boolean; if (!appState) {
latestValue: unknown; if (!_internal) {
} | null>(null); return undefined;
if (!stateRef.current) { }
stateRef.current = { console.warn(
selector, "useAppStateValue: excalidrawAPI not defined yet for internal component in which case it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
isInitialized: !!api, );
latestValue: getLatestValue(api, selector, _internal), // fall back in case there's a bug so we don't break the app
}; // (internal components using this internal useAppStateValue expect
} // non-undefined values on init)
stateRef.current.selector = selector; appState = Object.assign(
if (!stateRef.current.isInitialized && api && !api.isDestroyed) { { width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
stateRef.current.isInitialized = true; getDefaultAppState(),
stateRef.current.latestValue = getLatestValue(api, selector, _internal); );
}
useEffect(() => {
const currentStateRef = stateRef.current;
if (!api || api.isDestroyed || !currentStateRef) {
return;
} }
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
return api.onStateChange(currentStateRef.selector, (newValue: any) => { const [value, setValue] = useState<unknown>(getLatestValue);
currentStateRef.latestValue = newValue;
rerender((value) => value + 1);
});
}, [api]);
return stateRef.current.latestValue;
}
/**
* Subscribes to specific appState changes without causing component rerenders.
*
* The callback is called on every matching change, but also on initial render
* so you can initialize your state.
*/
export function useOnAppStateChange<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
props: (keyof AppState)[],
callback: (props: AppState, appState: AppState) => void,
): undefined;
export function useOnAppStateChange<T>(
selector: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
selector: AppStateSelector,
callback: (value: any, appState: AppState) => void,
): undefined {
const api = useExcalidrawAPI();
const stateRef = useRef({ const stateRef = useRef({
selector, selector,
callback, callback,
isInitialized: !!api,
latestValue: value,
}); });
stateRef.current.selector = selector; stateRef.current.selector = selector;
stateRef.current.callback = callback; stateRef.current.callback = callback;
if (!stateRef.current.isInitialized && api) {
stateRef.current.isInitialized = true;
stateRef.current.latestValue = getLatestValue();
}
useEffect(() => { useEffect(() => {
if (!api || api.isDestroyed) { if (!api) {
return; return;
} }
stateRef.current.callback(
getLatestValue(api, stateRef.current.selector, true),
api.getAppState(),
);
return api.onStateChange( return api.onStateChange(
stateRef.current.selector, stateRef.current.selector,
(newValue: any, state: AppState) => { (newValue: any, state: AppState) => {
stateRef.current.callback(newValue, state); stateRef.current.latestValue = newValue;
stateRef.current.callback?.(newValue, state);
setValue(newValue);
}, },
); );
}, [api]); }, [api]);
return undefined; return stateRef.current.latestValue;
} }
+17 -30
View File
@@ -1,10 +1,4 @@
import React, { import React, { useCallback, useContext, useEffect, useState } from "react";
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common"; import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
@@ -18,10 +12,7 @@ import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollab
import MainMenu from "./components/main-menu/MainMenu"; import MainMenu from "./components/main-menu/MainMenu";
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen"; import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
import { defaultLang } from "./i18n"; import { defaultLang } from "./i18n";
import { import { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue";
useAppStateValue as _useAppStateValue,
useOnAppStateChange as _useOnAppStateChange,
} from "./hooks/useAppStateValue";
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai"; import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
import polyfill from "./polyfill"; import polyfill from "./polyfill";
@@ -40,8 +31,8 @@ polyfill();
/** /**
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built * Stateless provider that allows `useExcalidrawAPI()` (and hooks built
* on it, such as `useAppStateValue()` and `useOnAppStateChange()`) to work * on it, such as `useAppStateValue()`) to work outside the <Excalidraw>
* outside the <Excalidraw> component tree. * component tree.
*/ */
export const ExcalidrawAPIProvider = ({ export const ExcalidrawAPIProvider = ({
children, children,
@@ -66,7 +57,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
initialData, initialData,
onExcalidrawAPI, onExcalidrawAPI,
onMount, onMount,
onUnmount,
onInitialize, onInitialize,
isCollaborating = false, isCollaborating = false,
onPointerUpdate, onPointerUpdate,
@@ -129,16 +119,12 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
} }
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext); const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
const onExcalidrawAPIRef = useRef(onExcalidrawAPI);
onExcalidrawAPIRef.current = onExcalidrawAPI;
const handleExcalidrawAPI = useCallback( const handleExcalidrawAPI = useCallback(
(api: ExcalidrawImperativeAPI | null) => { (api: ExcalidrawImperativeAPI) => {
setExcalidrawAPI?.(api); setExcalidrawAPI?.(api);
onExcalidrawAPIRef.current?.(api); onExcalidrawAPI?.(api);
}, },
[setExcalidrawAPI], [onExcalidrawAPI, setExcalidrawAPI],
); );
useEffect(() => { useEffect(() => {
@@ -176,7 +162,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
initialData={initialData} initialData={initialData}
onExcalidrawAPI={handleExcalidrawAPI} onExcalidrawAPI={handleExcalidrawAPI}
onMount={onMount} onMount={onMount}
onUnmount={onUnmount}
onInitialize={onInitialize} onInitialize={onInitialize}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate} onPointerUpdate={onPointerUpdate}
@@ -393,7 +378,7 @@ export {
} from "./charts"; } from "./charts";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// useExcalidrawStateValue() wrapper for host apps for the return type to reflect the // useAppStateValue() wrapper for host apps for the return type to reflect the
// the potentially `undefined` value for initial render before the excalidrawAPI // the potentially `undefined` value for initial render before the excalidrawAPI
// is ready. // is ready.
// //
@@ -403,23 +388,25 @@ export {
* @param prop - appState prop(s) to subscribe to, or a selector function. * @param prop - appState prop(s) to subscribe to, or a selector function.
* NOTE `prop/selector` is memoized and will not change after initial render * NOTE `prop/selector` is memoized and will not change after initial render
*/ */
export function useExcalidrawStateValue<K extends keyof AppState>( export function useAppStateValue<K extends keyof AppState>(
prop: K, prop: K,
callback?: (value: AppState[K], appState: AppState) => void,
): AppState[K] | undefined; ): AppState[K] | undefined;
export function useExcalidrawStateValue<T extends keyof AppState>( export function useAppStateValue<T extends keyof AppState>(
props: T[], props: T[],
callback?: (values: AppState, appState: AppState) => void,
): AppState | undefined; ): AppState | undefined;
export function useExcalidrawStateValue<T>( export function useAppStateValue<T>(
selector: (appState: AppState) => T, selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
): T | undefined; ): T | undefined;
export function useExcalidrawStateValue( export function useAppStateValue(
selector: selector:
| keyof AppState | keyof AppState
| (keyof AppState)[] | (keyof AppState)[]
| ((appState: AppState) => unknown), | ((appState: AppState) => unknown),
callback?: (value: any, appState: AppState) => void,
) { ) {
return _useAppStateValue(selector as any, false); return _useAppStateValue(selector as any, callback, false);
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export { _useOnAppStateChange as useOnExcalidrawStateChange };
+2 -9
View File
@@ -53,14 +53,7 @@
"arrowhead_crowfoot_many": "Crow's foot (many)", "arrowhead_crowfoot_many": "Crow's foot (many)",
"arrowhead_crowfoot_one": "Crow's foot (one)", "arrowhead_crowfoot_one": "Crow's foot (one)",
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)", "arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
"arrowhead_cardinality_one": "Cardinality (one)",
"arrowhead_cardinality_many": "Cardinality (many)",
"arrowhead_cardinality_one_or_many": "Cardinality (one or many)",
"arrowhead_cardinality_exactly_one": "Cardinality (exactly one)",
"arrowhead_cardinality_zero_or_one": "Cardinality (zero or one)",
"arrowhead_cardinality_zero_or_many": "Cardinality (zero or many)",
"more_options": "More options", "more_options": "More options",
"cardinality": "Cardinality",
"arrowtypes": "Arrow type", "arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow", "arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow", "arrowtype_round": "Curved arrow",
@@ -631,7 +624,7 @@
"mermaid": { "mermaid": {
"title": "Mermaid to Excalidraw", "title": "Mermaid to Excalidraw",
"button": "Insert", "button": "Insert",
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>, <sequenceLink>Sequence</sequenceLink>, <classLink>Class</classLink>, and <erdLink>Entity Relationship</erdLink> Diagrams are supported. The other types will be rendered as image in Excalidraw.", "description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax", "syntax": "Mermaid Syntax",
"preview": "Preview", "preview": "Preview",
"label": "Mermaid", "label": "Mermaid",
@@ -661,7 +654,7 @@
"placeholder": { "placeholder": {
"title": "Let's design your diagram", "title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.", "description": "Describe the diagram you want to create, and we'll generate it for you.",
"hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams." "hint": "At the moment we know Flowchart, Sequence, and Class diagrams."
}, },
"preview": "Preview", "preview": "Preview",
"insert": "Insert", "insert": "Insert",
+1 -1
View File
@@ -88,7 +88,7 @@
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.1.1", "@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"browser-fs-access": "0.38.0", "browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`; exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = ` exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD "flowchart TD
@@ -1,111 +0,0 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { getDefaultAppState } from "../appState";
import { ExcalidrawAPIContext } from "../components/App";
import { AppStateObserver } from "../components/AppStateObserver";
import {
useAppStateValue,
useOnAppStateChange,
} from "../hooks/useAppStateValue";
import type { AppState, ExcalidrawImperativeAPI } from "../types";
const createAppState = (): AppState => ({
...getDefaultAppState(),
width: 0,
height: 0,
offsetLeft: 0,
offsetTop: 0,
});
const createMockAPI = (initialState: AppState) => {
let state = initialState;
const observer = new AppStateObserver(() => state);
return {
api: {
isDestroyed: false,
getAppState: () => state,
onStateChange: observer.onStateChange,
} as Pick<
ExcalidrawImperativeAPI,
"isDestroyed" | "getAppState" | "onStateChange"
> as ExcalidrawImperativeAPI,
updateAppState: (partial: Partial<AppState>) => {
const prevState = state;
state = { ...state, ...partial };
observer.flush(prevState);
},
};
};
describe("app state hooks", () => {
afterEach(() => {
cleanup();
});
it("useAppStateValue rerenders when the selected value changes", () => {
const renderSpy = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ValueConsumer = () => {
const value = useAppStateValue("viewModeEnabled");
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ValueConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("false");
expect(renderSpy).toHaveBeenCalledTimes(1);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(screen.getByTestId("value").textContent).toBe("true");
expect(renderSpy).toHaveBeenCalledTimes(2);
});
it("useOnAppStateChange notifies without rerendering", () => {
const renderSpy = vi.fn();
const callback = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ChangeConsumer = () => {
const value = useOnAppStateChange("viewModeEnabled", callback);
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ChangeConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("undefined");
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
false,
expect.objectContaining({ viewModeEnabled: false }),
);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ viewModeEnabled: true }),
);
});
});
@@ -200,26 +200,6 @@ describe("restoreElements", () => {
}); });
}); });
it("should normalize legacy crowfoot arrowheads on restore", () => {
const arrowElement = API.createElement({
type: "arrow",
});
const restoredArrow = restore.restoreElements(
[
{
...arrowElement,
startArrowhead: "crowfoot_one",
endArrowhead: "crowfoot_one_or_many",
} as any,
],
null,
)[0] as ExcalidrawLinearElement;
expect(restoredArrow.startArrowhead).toBe("cardinality_one");
expect(restoredArrow.endArrowhead).toBe("cardinality_one_or_many");
});
it("should strip element if restore fails", () => { it("should strip element if restore fails", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -41,15 +41,13 @@ describe("event callbacks", () => {
await render( await render(
<Excalidraw <Excalidraw
onExcalidrawAPI={(api) => { onExcalidrawAPI={(api) =>
if (api) { lifecyclePromise.resolve({
lifecyclePromise.resolve({ api: api as ExcalidrawImperativeAPI,
api, mount: api.onEvent("editor:mount"),
mount: api.onEvent("editor:mount"), initialize: api.onEvent("editor:initialize"),
initialize: api.onEvent("editor:initialize"), })
}); }
}
}}
/>, />,
); );
+1 -8
View File
@@ -573,15 +573,11 @@ export interface ExcalidrawProps {
* Invoked as soon as the Excalidraw API is available * Invoked as soon as the Excalidraw API is available
* NOTE editor is not yet mounted, and state is not yet initialized * NOTE editor is not yet mounted, and state is not yet initialized
*/ */
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI | null) => void; onExcalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
/** /**
* Invoked once the editor root is mounted. * Invoked once the editor root is mounted.
*/ */
onMount?: (payload: ExcalidrawMountPayload) => void; onMount?: (payload: ExcalidrawMountPayload) => void;
/**
* Invoked when the editor root is unmounted.
*/
onUnmount?: () => void;
/** /**
* Invoked once the initial scene is loaded. * Invoked once the initial scene is loaded.
*/ */
@@ -911,12 +907,9 @@ export type ExcalidrawMountPayload = {
export type ExcalidrawImperativeAPIEventMap = { export type ExcalidrawImperativeAPIEventMap = {
"editor:mount": [payload: ExcalidrawMountPayload]; "editor:mount": [payload: ExcalidrawMountPayload];
"editor:initialize": [api: ExcalidrawImperativeAPI]; "editor:initialize": [api: ExcalidrawImperativeAPI];
"editor:unmount": [];
}; };
export interface ExcalidrawImperativeAPI { export interface ExcalidrawImperativeAPI {
/** Whether the editor has been unmounted and the API is no longer usable. */
isDestroyed: boolean;
updateScene: InstanceType<typeof App>["updateScene"]; updateScene: InstanceType<typeof App>["updateScene"];
applyDeltas: InstanceType<typeof App>["applyDeltas"]; applyDeltas: InstanceType<typeof App>["applyDeltas"];
mutateElement: InstanceType<typeof App>["mutateElement"]; mutateElement: InstanceType<typeof App>["mutateElement"];
+4 -4
View File
@@ -1531,10 +1531,10 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.1.0": "@excalidraw/mermaid-to-excalidraw@2.0.0-rc4":
version "2.1.0" version "2.0.0-rc4"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03"
integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw== integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw==
dependencies: dependencies:
"@excalidraw/markdown-to-text" "0.1.2" "@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3" "@mermaid-js/parser" "^0.6.3"