Compare commits

...

6 Commits

Author SHA1 Message Date
dwelle 1625edeb07 wip 2026-03-14 12:38:38 +01:00
David Luzar c9ba7f839c chore(editor): bump @excalidraw/mermaid-to-excalidraw@2.1.1 (#10944) 2026-03-12 17:32:13 +01:00
David Luzar b4ce7c713b fix(editor): arrowhead picker overflowing viewport (#10943) 2026-03-12 16:08:46 +01:00
David Luzar 816c81c12e feat(editor): ERD arrowheads and diagrams (#10940) 2026-03-11 22:00:31 +01:00
David Luzar 92d25446d6 feat(packages/excalidraw): tweak and expose more API around state and lifecycle (#10939) 2026-03-11 16:51:03 +01:00
David Luzar e73a5b0116 docs(packages/excalidraw): improve readme (#10932) 2026-03-11 09:49:12 +01:00
45 changed files with 2420 additions and 1151 deletions
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow", type: "arrow",
x: 450, x: 450,
y: 20, y: 20,
startArrowhead: "dot", startArrowhead: "circle",
endArrowhead: "triangle", endArrowhead: "triangle",
strokeColor: "#1971c2", strokeColor: "#1971c2",
strokeWidth: 2, strokeWidth: 2,
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform"; import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types"; import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [ const elements: ExcalidrawElementSkeleton[] = [
+12 -8
View File
@@ -25,15 +25,19 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState(); const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({ const blob = await exportToBlob({
elements: children, data: {
appState: { elements: children,
...appState, appState: {
exportBackground: true, ...appState,
viewBackgroundColor: appState.viewBackgroundColor, exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
}, },
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
}); });
const dataURL = await getDataURL(blob); const dataURL = await getDataURL(blob);
+32
View File
@@ -0,0 +1,32 @@
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;
};
+39 -26
View File
@@ -709,6 +709,9 @@ 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) {
@@ -717,10 +720,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "crowfoot_many": case "cardinality_many":
case "crowfoot_one": case "cardinality_one_or_many":
case "crowfoot_one_or_many": case "cardinality_zero_or_many":
return 20; return CROWFOOT_ARROWHEAD_SIZE;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default: default:
return 15; return 15;
} }
@@ -743,7 +750,12 @@ 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;
} }
@@ -824,29 +836,30 @@ 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 xs = x2 - nx * minSize; const tx = x2 - nx * minSize * offsetMultiplier;
const ys = y2 - ny * minSize; const ty = y2 - ny * minSize * offsetMultiplier;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if ( if (arrowhead === "circle" || arrowhead === "circle_outline") {
arrowhead === "dot" || const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
arrowhead === "circle" || return [tx, ty, diameter];
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 (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") { if (
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(x2, y2), pointFrom(tx, ty),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(-angle as Degrees), degreesToRadians(-angle as Degrees),
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(x2, y2), pointFrom(tx, ty),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -856,12 +869,12 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(x2, y2), pointFrom(tx, ty),
((-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(x2, y2), pointFrom(tx, ty),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -874,9 +887,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(x2 + minSize * 2, y2), pointFrom(tx + minSize * 2, ty),
pointFrom(x2, y2), pointFrom(tx, ty),
Math.atan2(py - y2, px - x2) as Radians, Math.atan2(py - ty, px - tx) as Radians,
); );
} else { } else {
const [px, py] = const [px, py] =
@@ -885,16 +898,16 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2), pointFrom(tx - minSize * 2, ty),
pointFrom(x2, y2), pointFrom(tx, ty),
Math.atan2(y2 - py, x2 - px) as Radians, Math.atan2(ty - py, tx - px) as Radians,
); );
} }
return [x2, y2, x3, y3, ox, oy, x4, y4]; return [tx, ty, x3, y3, ox, oy, x4, y4];
} }
return [x2, y2, x3, y3, x4, y4]; return [tx, ty, x3, y3, x4, y4];
}; };
// TODO reuse shape.ts // TODO reuse shape.ts
+1
View File
@@ -99,3 +99,4 @@ 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";
+223 -86
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,6 +296,82 @@ 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[],
@@ -306,63 +382,54 @@ const getArrowheadShapes = (
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean, isDarkMode: boolean,
) => { ) => {
const arrowheadPoints = getArrowheadPoints( if (arrowhead === null) {
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": {
const [x, y, diameter] = arrowheadPoints; return generateArrowheadOutlineCircle(
generator,
// always use solid stroke for arrowhead options,
delete options.strokeLineDash; strokeColor,
getArrowheadPoints(element, shape, position, arrowhead),
return [ arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
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 options.strokeLineDash; delete triangleOptions.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -372,24 +439,34 @@ 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 options.strokeLineDash; delete diamondOptions.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -400,46 +477,106 @@ const getArrowheadShapes = (
[x4, y4], [x4, y4],
[x, y], [x, y],
], ],
{ diamondOptions,
...options, ),
fill: ];
arrowhead === "diamond_outline" }
? canvasBackgroundColor case "cardinality_one":
: strokeColor, return generateArrowheadCardinalityOne(
fillStyle: "solid", generator,
roughness: Math.min(1, options.roughness || 0), getArrowheadPoints(element, shape, position, arrowhead),
}, 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: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; return generateArrowheadLinesToTip(
generator,
if (element.strokeStyle === "dotted") { getArrowheadPoints(element, shape, position, arrowhead),
// for dotted arrows caps, reduce gap to make it more legible getArrowheadLineOptions(element, options),
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,
)
: []),
];
} }
} }
}; };
+17 -4
View File
@@ -303,19 +303,32 @@ 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"
| "crowfoot_one" | CardinalityArrowhead;
| "crowfoot_many"
| "crowfoot_one_or_many"; export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
+18 -10
View File
@@ -330,22 +330,30 @@ describe("Cropping and other features", async () => {
const widthToHeightRatio = image.width / image.height; const widthToHeightRatio = image.width / image.height;
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: [image], data: {
// @ts-ignore elements: [image],
appState: h.state, // @ts-ignore
files: h.app.files, appState: h.state,
exportPadding: 0, files: h.app.files,
},
config: {
padding: 0,
},
}); });
const exportedCanvasRatio = canvas.width / canvas.height; const exportedCanvasRatio = canvas.width / canvas.height;
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio); expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [image], data: {
// @ts-ignore elements: [image],
appState: h.state, // @ts-ignore
files: h.app.files, appState: h.state,
exportPadding: 0, files: h.app.files,
},
config: {
padding: 0,
},
}); });
const svgWidth = svg.getAttribute("width"); const svgWidth = svg.getAttribute("width");
const svgHeight = svg.getAttribute("height"); const svgHeight = svg.getAttribute("height");
+9 -14
View File
@@ -18,22 +18,13 @@ 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 `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded. - 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.
```tsx - 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.
<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(...)`.
@@ -41,7 +32,6 @@ 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);
}); });
@@ -54,7 +44,9 @@ 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)`.
- Exported `ExcalidrawAPIProvider`, `useExcalidrawAPI`, and `useAppStateValue` from the package entrypoint. The imperative API also now exposes `onStateChange`. - Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
- 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>
@@ -63,6 +55,9 @@ 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) => {
+111 -14
View File
@@ -1,10 +1,10 @@
# Excalidraw # Excalidraw
**Excalidraw** is exported as a component to be directly embedded in your project. **Excalidraw** is exported as a React component that you can embed directly in your app.
## Installation ## Installation
Use `npm` or `yarn` to install the package. Install the package together with its React peer dependencies.
```bash ```bash
npm install react react-dom @excalidraw/excalidraw npm install react react-dom @excalidraw/excalidraw
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw yarn add react react-dom @excalidraw/excalidraw
``` ```
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`. > **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
#### Self-hosting fonts ## Quick start
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod). The minimum working setup has two easy-to-miss requirements:
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: 1. Import the package CSS:
```js ```ts
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script> import "@excalidraw/excalidraw/index.css";
``` ```
### Dimensions of Excalidraw 2. Render Excalidraw inside a container with a non-zero height.
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. ```tsx
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
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example. Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
## Integration ## Integration
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration). Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API ## API
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api). Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing ## Contributing
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing). Read the [contributing 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") }, toast: { message: t("toast.fileSaved"), duration: 3000 },
}, },
}; };
} catch (error: any) { } catch (error: any) {
+132 -84
View File
@@ -36,6 +36,7 @@ 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,
@@ -124,9 +125,12 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCrowfootIcon, ArrowheadCardinalityExactlyOneIcon,
ArrowheadCrowfootOneIcon, ArrowheadCardinalityManyIcon,
ArrowheadCrowfootOneOrManyIcon, ArrowheadCardinalityOneIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -1550,80 +1554,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
}); });
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return [ return {
{ visibleSections: [
value: null, {
text: t("labels.arrowhead_none"), name: "default",
keyBinding: "q", options: [
icon: <ArrowheadNoneIcon flip={flip} />, {
}, value: null,
{ text: t("labels.arrowhead_none"),
value: "arrow", keyBinding: "q",
text: t("labels.arrowhead_arrow"), icon: <ArrowheadNoneIcon flip={flip} />,
keyBinding: "w", },
icon: <ArrowheadArrowIcon flip={flip} />, {
}, value: "arrow",
{ text: t("labels.arrowhead_arrow"),
value: "triangle", keyBinding: "w",
text: t("labels.arrowhead_triangle"), icon: <ArrowheadArrowIcon flip={flip} />,
icon: <ArrowheadTriangleIcon flip={flip} />, },
keyBinding: "e", {
}, value: "triangle",
{ text: t("labels.arrowhead_triangle"),
value: "triangle_outline", icon: <ArrowheadTriangleIcon flip={flip} />,
text: t("labels.arrowhead_triangle_outline"), keyBinding: "e",
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, },
keyBinding: "r", {
}, value: "triangle_outline",
{ text: t("labels.arrowhead_triangle_outline"),
value: "circle", icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
text: t("labels.arrowhead_circle"), keyBinding: "r",
keyBinding: "a", },
icon: <ArrowheadCircleIcon flip={flip} />, ],
}, },
{ ],
value: "circle_outline", hiddenSections: [
text: t("labels.arrowhead_circle_outline"), {
keyBinding: "s", name: "default",
icon: <ArrowheadCircleOutlineIcon flip={flip} />, options: [
}, {
{ value: "circle",
value: "diamond", text: t("labels.arrowhead_circle"),
text: t("labels.arrowhead_diamond"), keyBinding: "a",
icon: <ArrowheadDiamondIcon flip={flip} />, icon: <ArrowheadCircleIcon flip={flip} />,
keyBinding: "d", },
}, {
{ value: "circle_outline",
value: "diamond_outline", text: t("labels.arrowhead_circle_outline"),
text: t("labels.arrowhead_diamond_outline"), keyBinding: "s",
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, icon: <ArrowheadCircleOutlineIcon flip={flip} />,
keyBinding: "f", },
}, {
{ value: "diamond",
value: "bar", text: t("labels.arrowhead_diamond"),
text: t("labels.arrowhead_bar"), icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "z", keyBinding: "d",
icon: <ArrowheadBarIcon flip={flip} />, },
}, {
{ value: "diamond_outline",
value: "crowfoot_one", text: t("labels.arrowhead_diamond_outline"),
text: t("labels.arrowhead_crowfoot_one"), icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
icon: <ArrowheadCrowfootOneIcon flip={flip} />, keyBinding: "f",
keyBinding: "x", },
}, {
{ value: "bar",
value: "crowfoot_many", text: t("labels.arrowhead_bar"),
text: t("labels.arrowhead_crowfoot_many"), keyBinding: "z",
icon: <ArrowheadCrowfootIcon flip={flip} />, icon: <ArrowheadBarIcon flip={flip} />,
keyBinding: "c", },
}, ],
{ },
value: "crowfoot_one_or_many", {
text: t("labels.arrowhead_crowfoot_one_or_many"), name: t("labels.cardinality"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />, options: [
keyBinding: "v", {
}, value: "cardinality_one",
] as const; text: t("labels.arrowhead_cardinality_one"),
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<{
@@ -1667,45 +1708,52 @@ 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)
? element.startArrowhead ? getArrowheadForPicker(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)
? element.endArrowhead ? getArrowheadForPicker(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>
+79 -43
View File
@@ -517,6 +517,7 @@ 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<{
@@ -556,7 +557,7 @@ export const ExcalidrawAPIContext =
ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext"; ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext";
export const ExcalidrawAPISetContext = React.createContext< export const ExcalidrawAPISetContext = React.createContext<
((api: ExcalidrawImperativeAPI) => void) | null ((api: ExcalidrawImperativeAPI | null) => void) | null
>(null); >(null);
ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext"; ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext";
@@ -731,6 +732,50 @@ 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();
@@ -791,47 +836,11 @@ 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));
this.api = { // in case internal editor APIs call this early, otherwise we need
updateScene: this.updateScene, // to construct this in componentDidMount because componentWillUnmount
applyDeltas: this.applyDeltas, // will invalidate it (so in StrictMode, doing this in constructor alone
mutateElement: this.mutateElement, // would be a problem)
updateLibrary: this.library.updateLibrary, this.api = this.createExcalidrawAPI();
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>(
@@ -3003,6 +3012,8 @@ 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;
@@ -3089,10 +3100,35 @@ 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();
@@ -3667,7 +3703,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, {
+15 -3
View File
@@ -6,14 +6,20 @@
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); box-shadow: var(--shadow-island-stronger);
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;
@@ -62,7 +68,13 @@
.picker-collapsible { .picker-collapsible {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.5rem 0; padding: 0;
color: var(--text-primary-color);
}
.picker-section-label {
font-size: 0.75rem;
color: var(--text-primary-color);
} }
.picker-keybinding { .picker-keybinding {
+157 -67
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 } from "react"; import React, { useEffect, useMemo } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common"; import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -8,13 +8,15 @@ 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 { useEditorInterface, useExcalidrawContainer } from "./App"; import { 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;
@@ -23,28 +25,73 @@ 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>({
options, visibleSections,
hiddenSections = [],
value, value,
label, label,
onChange, onChange,
onClose, onClose,
numberOfOptionsToAlwaysShow = options.length,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly Option<T>[]; visibleSections: readonly PickerSection<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 = options.find( const pressedOption = allOptions.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
@@ -52,17 +99,17 @@ function Picker<T>({
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.TAB) { } else if (event.key === KEYS.TAB) {
const index = options.findIndex((option) => option.value === value); const index = allOptions.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length ? (allOptions.length + index - 1) % allOptions.length
: (index + 1) % options.length; : (index + 1) % allOptions.length;
onChange(options[nextIndex].value); onChange(allOptions[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 = options.findIndex((option) => option.value === value); const index = allOptions.findIndex((option) => option.value === value);
if (index !== -1) { if (index !== -1) {
const length = options.length; const length = allOptions.length;
let nextIndex = index; let nextIndex = index;
switch (event.key) { switch (event.key) {
@@ -76,18 +123,60 @@ function Picker<T>({
break; break;
// Go the next row // Go the next row
case KEYS.ARROW_DOWN: { case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length; const currentRowIndex = navigationRows.findIndex((row) =>
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: {
nextIndex = const currentRowIndex = navigationRows.findIndex((row) =>
(length + 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 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(options[nextIndex].value); onChange(allOptions[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) {
@@ -99,38 +188,29 @@ 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 (!alwaysVisibleOptions.some((option) => option.value === value)) { if (hasOption(hiddenSections, (option) => option.value === value)) {
setShowMoreOptions(true); setShowMoreOptions(true);
} }
}, [value, alwaysVisibleOptions, setShowMoreOptions]); }, [value, hiddenSections, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => { const renderOptions = (options: readonly Option<T>[]) => {
return ( return (
<div className="picker-content"> <div className="picker-content">
{options.map((option, i) => ( {options.map((option) => (
<button <button
type="button" type="button"
className={clsx("picker-option", { className={clsx("picker-option", {
active: value === option.value, active: value === option.value,
})} })}
onClick={(event) => { onClick={() => {
onChange(option.value); onChange(option.value);
}} }}
title={`${option.text} ${ title={
option.keyBinding && `${option.keyBinding.toUpperCase()}` option.keyBinding
}`} ? `${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}
@@ -153,26 +233,38 @@ function Picker<T>({
); );
}; };
const isMobile = editorInterface.formFactor === "phone"; const renderSections = (sections: readonly PickerSection<T>[]) =>
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
side={isMobile ? "right" : "bottom"} className="picker"
role="dialog"
aria-modal="true"
aria-label={label}
side={"bottom"}
align="start" align="start"
sideOffset={isMobile ? 8 : 12} sideOffset={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 <div className="picker-sections">
className={`picker`} {renderSections(visibleSections)}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{moreOptions.length > 0 && ( {hiddenSections.length > 0 && (
<Collapsible <Collapsible
label={t("labels.more_options")} label={t("labels.more_options")}
open={showMoreOptions} open={showMoreOptions}
@@ -181,7 +273,9 @@ function Picker<T>({
}} }}
className="picker-collapsible" className="picker-collapsible"
> >
{renderOptions(moreOptions)} <div className="picker-sections">
{renderSections(hiddenSections)}
</div>
</Collapsible> </Collapsible>
)} )}
</div> </div>
@@ -192,49 +286,45 @@ function Picker<T>({
export function IconPicker<T>({ export function IconPicker<T>({
value, value,
label, label,
options, visibleSections,
hiddenSections,
onChange, onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly { visibleSections: readonly PickerSection<T>[];
value: T; hiddenSections?: readonly PickerSection<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 rPickerButton = React.useRef<any>(null); const selectedOption = useMemo(
() =>
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" : ""}
> >
{options.find((option) => option.value === value)?.icon} {selectedOption?.icon}
</Popover.Trigger> </Popover.Trigger>
{isActive && ( {isActive && (
<Picker <Picker
options={options} visibleSections={visibleSections}
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>
@@ -1,4 +1,3 @@
import { exportToCanvas } from "@excalidraw/utils/export";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { import {
@@ -6,6 +5,7 @@ import {
EXPORT_IMAGE_TYPES, EXPORT_IMAGE_TYPES,
isFirefox, isFirefox,
EXPORT_SCALES, EXPORT_SCALES,
THEME,
cloneJSON, cloneJSON,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -26,6 +26,7 @@ import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -128,19 +129,26 @@ const ImageExportModal = ({
}; };
exportToCanvas({ exportToCanvas({
elements: exportedElements, data: {
appState: { elements: exportedElements,
...appStateSnapshot, appState: {
name: projectName, ...appStateSnapshot,
exportBackground: exportWithBackground, name: projectName,
exportWithDarkMode, exportBackground: exportWithBackground,
exportScale, exportScale,
exportEmbedScene: embedScene, exportEmbedScene: embedScene,
},
files,
},
config: {
padding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
canvasBackgroundColor: exportWithBackground
? appStateSnapshot.viewBackgroundColor
: false,
}, },
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
}) })
.then(async (canvas) => { .then(async (canvas) => {
if (isStaleRequest()) { if (isStaleRequest()) {
@@ -72,18 +72,20 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
svg = await exportToSvg( svg = await exportToSvg({
elements, data: {
{ elements,
exportBackground: false, appState: {
viewBackgroundColor: "#fff", exportBackground: false,
exportWithDarkMode: theme === "dark", viewBackgroundColor: "#fff",
},
files: null,
}, },
null, // files config: {
{
skipInliningFonts: true, skipInliningFonts: true,
theme,
}, },
); });
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -134,18 +136,20 @@ const PlainTextPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
const svg = await exportToSvg( const svg = await exportToSvg({
[textElement], data: {
{ elements: [textElement],
exportBackground: false, appState: {
viewBackgroundColor: "#fff", exportBackground: false,
exportWithDarkMode: theme === "dark", viewBackgroundColor: "#fff",
},
files: null,
}, },
null, config: {
{
skipInliningFonts: true, skipInliningFonts: true,
theme,
}, },
); });
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -1,4 +1,3 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
@@ -13,6 +12,7 @@ import {
import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { EditorLocalStorage } from "../data/EditorLocalStorage";
import { canvasToBlob, resizeImageFile } from "../data/blob"; import { canvasToBlob, resizeImageFile } from "../data/blob";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
@@ -63,9 +63,14 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) { for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({ const itemCanvas = await exportToCanvas({
elements: item.elements, data: {
files: null, elements: item.elements,
maxWidthOrHeight: BOX_SIZE, files: null,
appState: {},
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
}); });
const { width, height } = itemCanvas; const { width, height } = itemCanvas;
@@ -127,14 +132,18 @@ const SingleLibraryItem = ({
} }
(async () => { (async () => {
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: libItem.elements, data: {
appState: { elements: libItem.elements,
...appState, appState: {
viewBackgroundColor: "#fff", ...appState,
exportBackground: true, viewBackgroundColor: "#fff",
exportBackground: true,
},
files: null,
},
config: {
skipInliningFonts: true,
}, },
files: null,
skipInliningFonts: true,
}); });
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
@@ -236,15 +236,40 @@ const MermaidToExcalidraw = ({
<Trans <Trans
i18nKey="mermaid.description" i18nKey="mermaid.description"
flowchartLink={(el) => ( flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a> <a
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
sequenceLink={(el) => ( sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> <a
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
{el} {el}
</a> </a>
)} )}
classLink={(el) => ( classLink={(el) => (
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a> <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>
@@ -1,8 +1,4 @@
import { import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element"; import { convertToExcalidrawElements } from "@excalidraw/element";
@@ -94,7 +90,7 @@ export const convertMermaidToExcalidraw = async ({
} }
} }
const { elements, files } = ret; const { elements, files = {} } = ret;
setError(null); setError(null);
data.current = { data.current = {
@@ -105,14 +101,16 @@ export const convertMermaidToExcalidraw = async ({
}; };
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: data.current.elements, data: {
files: data.current.files, elements: data.current.elements,
exportPadding: DEFAULT_EXPORT_PADDING, files: data.current.files,
maxWidthOrHeight: },
Math.max(parent.offsetWidth, parent.offsetHeight) * config: {
window.devicePixelRatio, padding: DEFAULT_EXPORT_PADDING,
appState: { maxWidthOrHeight:
exportWithDarkMode: theme === THEME.DARK, Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
theme,
}, },
}); });
+135 -76
View File
@@ -69,6 +69,11 @@ 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
@@ -1291,16 +1296,17 @@ export const ArrowheadNoneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
transform={flip ? "translate(24, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor" stroke="currentColor"
opacity={0.3} opacity={0.3}
strokeWidth={2} strokeWidth={2}
fill="none"
strokeLinecap="round"
> >
<path d="M12 12l-9 0" /> <path d="M7,11 H19" />
<path d="M21 9l-6 6" /> <path d="M25,6 L33,16 M33,6 L25,16" />
<path d="M21 15l-6 -6" />
</g>, </g>,
tablerIconProps, arrowheadPreviewIconProps,
), ),
); );
@@ -1312,57 +1318,12 @@ export const ArrowheadArrowIcon = React.memo(
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
strokeLinecap="round"
strokeLinejoin="round"
> >
<path d="M34 10H6M34 10L27 5M34 10L27 15" /> <path d="M7,11 H33 M23,5 L33,11 L23,17" />
<path d="M27.5 5L34.5 10L27.5 15" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleIcon = 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} />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M26 10L6 10" />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ 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 },
), ),
); );
@@ -1373,11 +1334,12 @@ export const ArrowheadTriangleIcon = React.memo(
stroke="currentColor" stroke="currentColor"
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
> >
<path d="M32 10L6 10" strokeWidth={2} /> <path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" /> <path d="M23,5 L35,11 L23,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
@@ -1390,12 +1352,43 @@ export const ArrowheadTriangleOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2} strokeWidth={2}
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round"
> >
<path d="M6,9.5H27" /> <path d="M7,11 H23" />
<path d="M27,5L34,10L27,14Z" fill="none" /> <path d="M23,5 L35,11 L23,17 Z" />
</g>, </g>,
arrowheadPreviewIconProps,
),
);
{ width: 40, height: 20 }, export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M7,11 H25" strokeWidth={2} strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M7,11 H25" strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
@@ -1407,12 +1400,11 @@ 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="M6,9.5H20" /> <path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" />
<path d="M27,5L34,10L27,14L20,9.5Z" /> <path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
@@ -1425,15 +1417,32 @@ 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="M6,9.5H20" /> <path d="M7,11 H21" />
<path d="M27,5L34,10L27,14L20,9.5Z" /> <path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootIcon = React.memo( export const ArrowheadBarIcon = 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
@@ -1443,13 +1452,13 @@ export const ArrowheadCrowfootIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" /> <path d="M35,11 H7 M15,5 V17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootOneIcon = React.memo( export const ArrowheadCardinalityManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1459,13 +1468,13 @@ export const ArrowheadCrowfootOneIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L15,15 L15,5" /> <path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootOneOrManyIcon = React.memo( export const ArrowheadCardinalityOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1475,9 +1484,59 @@ export const ArrowheadCrowfootOneOrManyIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" /> <path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
),
);
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,
), ),
); );
+4 -6
View File
@@ -34,9 +34,10 @@
--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 0.9310142993927002px 0px rgba(0, 0, 0, 0.17), --shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08), 0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
0px 7px 14px 0px rgba(0, 0, 0, 0.05); --shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
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);
@@ -210,9 +211,6 @@
--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),
+35 -17
View File
@@ -4,6 +4,7 @@ import {
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
isFirefox, isFirefox,
MIME_TYPES, MIME_TYPES,
THEME,
cloneJSON, cloneJSON,
SVG_DOCUMENT_PREAMBLE, SVG_DOCUMENT_PREAMBLE,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -115,20 +116,29 @@ export const exportCanvas = async (
if (elements.length === 0) { if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas")); throw new Error(t("alerts.cannotExportEmptyCanvas"));
} }
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
if (type === "svg" || type === "clipboard-svg") { if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg( const svgPromise = exportToSvg({
elements, data: {
{ elements,
exportBackground, appState: {
exportWithDarkMode: appState.exportWithDarkMode, ...appState,
viewBackgroundColor, exportBackground,
exportPadding, exportEmbedScene: appState.exportEmbedScene && type === "svg",
exportScale: appState.exportScale, },
exportEmbedScene: appState.exportEmbedScene && type === "svg", files,
}, },
files, config: {
{ exportingFrame }, padding: exportPadding,
); exportingFrame,
theme,
canvasBackgroundColor: exportBackground
? viewBackgroundColor
: "transparent",
},
});
if (type === "svg") { if (type === "svg") {
return fileSave( return fileSave(
@@ -158,11 +168,19 @@ export const exportCanvas = async (
} }
} }
const tempCanvas = exportToCanvas(elements, appState, files, { const tempCanvas = exportToCanvas({
exportBackground, data: {
viewBackgroundColor, elements,
exportPadding, appState,
exportingFrame, files,
},
config: {
canvasBackgroundColor: exportBackground ? viewBackgroundColor : false,
padding: exportPadding,
theme,
scale: appState.exportScale,
exportingFrame,
},
}); });
if (type === "png") { if (type === "png") {
+8 -2
View File
@@ -22,6 +22,7 @@ import {
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements, getNonDeletedElements,
normalizeArrowhead,
isPointInElement, isPointInElement,
isValidPolygon, isValidPolygon,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -426,7 +427,8 @@ 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 = null, endArrowhead = null } = element; const startArrowhead = normalizeArrowhead(element.startArrowhead);
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
@@ -458,7 +460,11 @@ export const restoreElement = (
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": { case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element; const startArrowhead = normalizeArrowhead(element.startArrowhead);
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
+119 -57
View File
@@ -6,6 +6,51 @@ 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.
@@ -19,92 +64,109 @@ import type { AppState } from "../types";
* - `(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: selector: AppStateSelector,
| 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 getLatestValue = () => { const stateRef = useRef<{
let appState = api?.getAppState(); selector: AppStateSelector;
if (!appState) { isInitialized: boolean;
if (!_internal) { latestValue: unknown;
return undefined; } | null>(null);
} if (!stateRef.current) {
console.warn( stateRef.current = {
"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?", selector,
); isInitialized: !!api,
// fall back in case there's a bug so we don't break the app latestValue: getLatestValue(api, selector, _internal),
// (internal components using this internal useAppStateValue expect };
// non-undefined values on init) }
appState = Object.assign(
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
getDefaultAppState(),
);
}
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
const [value, setValue] = useState<unknown>(getLatestValue);
const stateRef = useRef({
selector,
callback,
isInitialized: !!api,
latestValue: value,
});
stateRef.current.selector = selector; stateRef.current.selector = selector;
stateRef.current.callback = callback; if (!stateRef.current.isInitialized && api && !api.isDestroyed) {
if (!stateRef.current.isInitialized && api) {
stateRef.current.isInitialized = true; stateRef.current.isInitialized = true;
stateRef.current.latestValue = getLatestValue(); stateRef.current.latestValue = getLatestValue(api, selector, _internal);
} }
useEffect(() => { useEffect(() => {
if (!api) { const currentStateRef = stateRef.current;
if (!api || api.isDestroyed || !currentStateRef) {
return; return;
} }
return api.onStateChange( return api.onStateChange(currentStateRef.selector, (newValue: any) => {
stateRef.current.selector, currentStateRef.latestValue = newValue;
(newValue: any, state: AppState) => { rerender((value) => value + 1);
stateRef.current.latestValue = newValue; });
stateRef.current.callback?.(newValue, state);
setValue(newValue);
},
);
}, [api]); }, [api]);
return stateRef.current.latestValue; 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({
selector,
callback,
});
stateRef.current.selector = selector;
stateRef.current.callback = callback;
useEffect(() => {
if (!api || api.isDestroyed) {
return;
}
stateRef.current.callback(
getLatestValue(api, stateRef.current.selector, true),
api.getAppState(),
);
return api.onStateChange(
stateRef.current.selector,
(newValue: any, state: AppState) => {
stateRef.current.callback(newValue, state);
},
);
}, [api]);
return undefined;
}
+12 -10
View File
@@ -1,8 +1,9 @@
import { exportToSvg } from "@excalidraw/utils/export";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "@excalidraw/common"; import { COLOR_PALETTE } from "@excalidraw/common";
import { exportToSvg } from "../scene/export";
import { atom, useAtom } from "../editor-jotai"; import { atom, useAtom } from "../editor-jotai";
import type { LibraryItem } from "../types"; import type { LibraryItem } from "../types";
@@ -12,17 +13,18 @@ export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
export const libraryItemSvgsCache = atom<SvgCache>(new Map()); export const libraryItemSvgsCache = atom<SvgCache>(new Map());
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
// TODO should pass theme (appState.exportWithDark) - we're still using
// CSS filter here
return await exportToSvg({ return await exportToSvg({
elements, data: {
appState: { elements,
exportBackground: false, appState: {
viewBackgroundColor: COLOR_PALETTE.white, exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
},
config: {
skipInliningFonts: true,
}, },
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
}); });
}; };
+19 -23
View File
@@ -1,4 +1,3 @@
import { getDefaultAppState } from "./appState";
import { exportToCanvas } from "./scene/export"; import { exportToCanvas } from "./scene/export";
const fs = require("fs"); const fs = require("fs");
@@ -59,26 +58,23 @@ const elements = [
registerFont("./public/Virgil.woff2", { family: "Virgil" }); registerFont("./public/Virgil.woff2", { family: "Virgil" });
registerFont("./public/Cascadia.woff2", { family: "Cascadia" }); registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
const canvas = exportToCanvas( (async () => {
elements as any, const canvas = await exportToCanvas({
{ data: {
...getDefaultAppState(), elements: elements as any,
offsetTop: 0, appState: {},
offsetLeft: 0, files: {},
width: 0, },
height: 0, config: {
}, canvasBackgroundColor: "#ffffff",
{}, // files createCanvas,
{ },
exportBackground: true, });
viewBackgroundColor: "#ffffff",
},
createCanvas,
);
const out = fs.createWriteStream("test.png"); const out = fs.createWriteStream("test.png");
const stream = (canvas as any).createPNGStream(); const stream = (canvas as any).createPNGStream();
stream.pipe(out); stream.pipe(out);
out.on("finish", () => { out.on("finish", () => {
console.info("test.png was created."); console.info("test.png was created.");
}); });
})();
+33 -18
View File
@@ -1,4 +1,10 @@
import React, { useCallback, useContext, useEffect, useState } from "react"; import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common"; import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
@@ -12,7 +18,10 @@ 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 { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue"; import {
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";
@@ -31,8 +40,8 @@ polyfill();
/** /**
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built * Stateless provider that allows `useExcalidrawAPI()` (and hooks built
* on it, such as `useAppStateValue()`) to work outside the <Excalidraw> * on it, such as `useAppStateValue()` and `useOnAppStateChange()`) to work
* component tree. * outside the <Excalidraw> component tree.
*/ */
export const ExcalidrawAPIProvider = ({ export const ExcalidrawAPIProvider = ({
children, children,
@@ -57,6 +66,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
initialData, initialData,
onExcalidrawAPI, onExcalidrawAPI,
onMount, onMount,
onUnmount,
onInitialize, onInitialize,
isCollaborating = false, isCollaborating = false,
onPointerUpdate, onPointerUpdate,
@@ -119,12 +129,16 @@ 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) => { (api: ExcalidrawImperativeAPI | null) => {
setExcalidrawAPI?.(api); setExcalidrawAPI?.(api);
onExcalidrawAPI?.(api); onExcalidrawAPIRef.current?.(api);
}, },
[onExcalidrawAPI, setExcalidrawAPI], [setExcalidrawAPI],
); );
useEffect(() => { useEffect(() => {
@@ -162,6 +176,7 @@ 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}
@@ -289,7 +304,9 @@ export {
exportToBlob, exportToBlob,
exportToSvg, exportToSvg,
exportToClipboard, exportToClipboard,
} from "@excalidraw/utils/export"; } from "./scene/export";
export type { ExportSceneData, ExportSceneConfig } from "./scene/export";
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
export { export {
@@ -378,7 +395,7 @@ export {
} from "./charts"; } from "./charts";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// useAppStateValue() wrapper for host apps for the return type to reflect the // useExcalidrawStateValue() 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.
// //
@@ -388,25 +405,23 @@ 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 useAppStateValue<K extends keyof AppState>( export function useExcalidrawStateValue<K extends keyof AppState>(
prop: K, prop: K,
callback?: (value: AppState[K], appState: AppState) => void,
): AppState[K] | undefined; ): AppState[K] | undefined;
export function useAppStateValue<T extends keyof AppState>( export function useExcalidrawStateValue<T extends keyof AppState>(
props: T[], props: T[],
callback?: (values: AppState, appState: AppState) => void,
): AppState | undefined; ): AppState | undefined;
export function useAppStateValue<T>( export function useExcalidrawStateValue<T>(
selector: (appState: AppState) => T, selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
): T | undefined; ): T | undefined;
export function useAppStateValue( export function useExcalidrawStateValue(
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, callback, false); return _useAppStateValue(selector as any, false);
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export { _useOnAppStateChange as useOnExcalidrawStateChange };
+9 -2
View File
@@ -53,7 +53,14 @@
"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",
@@ -624,7 +631,7 @@
"mermaid": { "mermaid": {
"title": "Mermaid to Excalidraw", "title": "Mermaid to Excalidraw",
"button": "Insert", "button": "Insert",
"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.", "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.",
"syntax": "Mermaid Syntax", "syntax": "Mermaid Syntax",
"preview": "Preview", "preview": "Preview",
"label": "Mermaid", "label": "Mermaid",
@@ -654,7 +661,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, and Class diagrams." "hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship 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.0.0-rc4", "@excalidraw/mermaid-to-excalidraw": "2.1.1",
"@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",
+672 -123
View File
@@ -1,13 +1,13 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
DEFAULT_EXPORT_PADDING,
FRAME_STYLE, FRAME_STYLE,
FONT_FAMILY, FONT_FAMILY,
SVG_NS, SVG_NS,
THEME, THEME,
MIME_TYPES, MIME_TYPES,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
COLOR_WHITE,
arrayToMap, arrayToMap,
distance, distance,
getFontString, getFontString,
@@ -47,20 +47,33 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
Theme,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode"; import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
import { restoreAppState } from "../data/restore";
import { encodePngMetadata } from "../data/image";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { renderStaticScene } from "../renderer/staticScene"; import { renderStaticScene } from "../renderer/staticScene";
import { renderSceneToSvg } from "../renderer/staticSvgScene"; import { renderSceneToSvg } from "../renderer/staticSvgScene";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "../clipboard";
import type { RenderableElementsMap } from "./types"; import type { RenderableElementsMap } from "./types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
// Default minimum export size in pixels
const DEFAULT_SMALLEST_EXPORT_SIZE = 20;
const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue;
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) { if (element.width <= maxWidth) {
@@ -169,36 +182,205 @@ const prepareElementsForRender = ({
return nextElements; return nextElements;
}; };
export const exportToCanvas = async ( // ---------------------------------------------------------------------------
elements: readonly NonDeletedExcalidrawElement[], // Types for the new API
appState: AppState, // ---------------------------------------------------------------------------
files: BinaryFiles,
{ export type ExportSceneData = {
exportBackground, elements: readonly NonDeletedExcalidrawElement[];
exportPadding = DEFAULT_EXPORT_PADDING, appState?: Partial<
viewBackgroundColor, Omit<AppState, "offsetTop" | "offsetLeft" | "exportWithDarkMode">
exportingFrame, >;
}: { files: BinaryFiles | null;
exportBackground: boolean; };
exportPadding?: number;
viewBackgroundColor: string; export type ExportSceneConfig = {
exportingFrame?: ExcalidrawFrameLikeElement | null; theme?: Theme;
}, /**
createCanvas: ( * Canvas background. Valid values are:
*
* - `undefined` - the background of "appState.viewBackgroundColor" is used.
* - `false` - no background is used (set to "transparent").
* - `string` - should be a valid CSS color.
*
* @default undefined
*/
canvasBackgroundColor?: string | false;
/**
* Canvas padding in pixels. Affected by `scale`.
*
* When `fit` is set to `none`, padding is added to the content bounding box
* (including if you set `width` or `height` or `maxWidthOrHeight` or
* `widthOrHeight`).
*
* When `fit` set to `contain`, padding is subtracted from the content
* bounding box (ensuring the size doesn't exceed the supplied values, with
* the exeception of using alongside `scale` as noted above), and the padding
* serves as a minimum distance between the content and the canvas edges, as
* it may exceed the supplied padding value from one side or the other in
* order to maintain the aspect ratio. It is recommended to set `position`
* to `center` when using `fit=contain`.
*
* When `fit` is set to `none` and either `width` or `height` or
* `maxWidthOrHeight` is set, padding is simply adding to the bounding box
* and the content may overflow the canvas, thus right or bottom padding
* may be ignored.
*
* @default 0
*/
padding?: number;
// -------------------------------------------------------------------------
/**
* Makes sure the canvas content fits into a frame of width/height no larger
* than this value, while maintaining the aspect ratio.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
maxWidthOrHeight?: number;
/**
* Scale the canvas content to be excatly this many pixels wide/tall,
* maintaining the aspect ratio.
*
* Cannot be used in conjunction with `maxWidthOrHeight`.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
widthOrHeight?: number;
// -------------------------------------------------------------------------
/**
* Width of the frame. Supply `x` or `y` if you want to ofsset the canvas
* content.
*
* If `width` omitted but `height` supplied, `width` is calculated from the
* the content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box width when both `width` and `height`
* are omitted.
*/
width?: number;
/**
* Height of the frame.
*
* If `height` omitted but `width` supplied, `height` is calculated from the
* content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box height when both `width` and `height`
* are omitted.
*/
height?: number;
/**
* Left canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `x` postion of the content bounding box.
*/
x?: number;
/**
* Top canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `y` postion of the content bounding box.
*/
y?: number;
/**
* Indicates the coordinate system of the `x` and `y` values.
*
* - `canvas` - `x` and `y` are relative to the canvas [0, 0] position.
* - `content` - `x` and `y` are relative to the content bounding box.
*
* @default "canvas"
*/
origin?: "canvas" | "content";
/**
* If dimensions specified and `x` and `y` are not specified, this indicates
* how the canvas should be scaled.
*
* Behavior aligns with the `object-fit` CSS property.
*
* - `none` - no scaling.
* - `contain` - scale to fit the frame. Includes `padding`.
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `fit` is ignored.
*
* @default "contain" unless `width`, `height`, `maxWidthOrHeight`, or
* `widthOrHeight` is specified in which case `none` is the default (can be
* changed). If `x` or `y` are specified, `none` is forced.
*/
fit?: "none" | "contain";
/**
* When either `x` or `y` are not specified, indicates how the canvas should
* be aligned on the respective axis.
*
* - `none` - canvas aligned to top left.
* - `center` - canvas is centered on the axis which is not specified
* (or both).
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `position` is ignored.
*
* @default "center"
*/
position?: "center" | "topLeft";
// -------------------------------------------------------------------------
/**
* A multiplier to increase/decrease the frame dimensions
* (content resolution).
*
* For example, if your canvas is 300x150 and you set scale to 2, the
* resulting size will be 600x300.
*
* @default 1
*/
scale?: number;
/**
* If you need to suply your own canvas, e.g. in test environments or in
* Node.js.
*
* Do not set `canvas.width/height` or modify the canvas context as that's
* handled by Excalidraw.
*
* Defaults to `document.createElement("canvas")`.
*/
createCanvas?: () => HTMLCanvasElement;
/**
* If you want to supply `width`/`height` dynamically (or derive from the
* content bounding box), you can use this function.
*
* Ignored if `maxWidthOrHeight`, `width`, or `height` is set.
*/
getDimensions?: (
width: number, width: number,
height: number, height: number,
) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => { ) => { width: number; height: number; scale?: number };
const canvas = document.createElement("canvas");
canvas.width = width * appState.exportScale; exportingFrame?: ExcalidrawFrameLikeElement | null;
canvas.height = height * appState.exportScale;
return { canvas, scale: appState.exportScale }; loadFonts?: () => Promise<void>;
}, };
loadFonts: () => Promise<void> = async () => {
await Fonts.loadElementsFonts(elements); // ---------------------------------------------------------------------------
}, // Internal helper to configure export dimensions
) => { // ---------------------------------------------------------------------------
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
await loadFonts(); const configExportDimension = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
// clone
const cfg = Object.assign({}, config);
const { exportingFrame } = cfg;
const elements = data.elements;
// initialize defaults
// ---------------------------------------------------------------------------
const appState = restoreAppState(data.appState, null);
const frameRendering = getFrameRenderingConfig( const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null, exportingFrame ?? null,
@@ -218,26 +400,255 @@ export const exportToCanvas = async (
}); });
if (exportingFrame) { if (exportingFrame) {
exportPadding = 0; cfg.padding = 0;
} }
const [minX, minY, width, height] = getCanvasSize( cfg.fit =
cfg.fit ??
(cfg.width != null ||
cfg.height != null ||
cfg.maxWidthOrHeight != null ||
cfg.widthOrHeight != null
? "contain"
: "none");
cfg.padding = cfg.padding ?? 0;
cfg.scale = cfg.scale ?? 1;
cfg.origin = cfg.origin ?? "canvas";
cfg.position = cfg.position ?? "center";
if (cfg.maxWidthOrHeight != null && cfg.widthOrHeight != null) {
if (!import.meta.env.PROD) {
console.warn("`maxWidthOrHeight` is ignored when `widthOrHeight` is set");
}
cfg.maxWidthOrHeight = undefined;
}
if (
(cfg.maxWidthOrHeight != null || cfg.width != null || cfg.height != null) &&
cfg.getDimensions
) {
if (!import.meta.env.PROD) {
console.warn(
"`getDimensions` is ignored when `width`, `height`, or `maxWidthOrHeight` is set",
);
}
cfg.getDimensions = undefined;
}
// ---------------------------------------------------------------------------
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
if (cfg.loadFonts) {
await cfg.loadFonts();
} else {
await Fonts.loadElementsFonts(elements);
}
// value used to scale the canvas context. By default, we use this to
// make the canvas fit into the frame (e.g. for `cfg.fit` set to `contain`).
// If `cfg.scale` is set, we multiply the resulting canvasScale by it to
// scale the output further.
let exportScale = 1;
const origCanvasSize = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
); );
const { canvas, scale = 1 } = createCanvas(width, height); // variables for original content bounding box
const [origX, origY, origWidth, origHeight] = origCanvasSize;
// variables for target bounding box
let [x, y, width, height] = origCanvasSize;
const defaultAppState = getDefaultAppState(); x = cfg.x ?? x;
y = cfg.y ?? y;
width = cfg.width ?? width;
height = cfg.height ?? height;
if (cfg.fit === "contain" || cfg.widthOrHeight || cfg.maxWidthOrHeight) {
cfg.padding =
cfg.padding && cfg.padding > 0
? Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
)
: 0;
if (cfg.getDimensions != null) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.padding = Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.widthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.widthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.maxWidthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.maxWidthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
}
}
if (cfg.maxWidthOrHeight != null || cfg.widthOrHeight != null) {
if (cfg.padding) {
if (cfg.maxWidthOrHeight != null) {
cfg.maxWidthOrHeight -= cfg.padding * 2;
} else if (cfg.widthOrHeight != null) {
cfg.widthOrHeight -= cfg.padding * 2;
}
}
const max = Math.max(width, height);
if (cfg.widthOrHeight != null) {
// calculate by how much do we need to scale the canvas to fit into the
// target dimension (e.g. target: max 50px, actual: 70x100px => scale: 0.5)
exportScale = cfg.widthOrHeight / max;
} else if (cfg.maxWidthOrHeight != null) {
exportScale = cfg.maxWidthOrHeight < max ? cfg.maxWidthOrHeight / max : 1;
}
width *= exportScale;
height *= exportScale;
} else if (cfg.getDimensions) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.scale = ret.scale ?? cfg.scale;
} else if (cfg.fit === "contain") {
width -= cfg.padding * 2;
height -= cfg.padding * 2;
const wRatio = width / origWidth;
const hRatio = height / origHeight;
// scale the orig canvas to fit in the target region
exportScale = Math.min(wRatio, hRatio);
}
x = cfg.x ?? origX;
y = cfg.y ?? origY;
// if we switch to "content" coords, we need to offset cfg-supplied
// coords by the x/y of content bounding box
if (cfg.origin === "content") {
if (cfg.x != null) {
x += origX;
}
if (cfg.y != null) {
y += origY;
}
}
// Centering the content to the frame.
// We divide width/height by canvasScale so that we calculate in the original
// aspect ratio dimensions.
if (cfg.position === "center") {
x -=
width / exportScale / 2 -
(cfg.x == null ? origWidth : width + cfg.padding * 2) / 2;
y -=
height / exportScale / 2 -
(cfg.y == null ? origHeight : height + cfg.padding * 2) / 2;
}
// rescale padding based on current canvasScale factor so that the resulting
// padding is kept the same as supplied by user (with the exception of
// `cfg.scale` being set, which also scales the padding)
const normalizedPadding = cfg.padding / exportScale;
// scale the whole frame by cfg.scale (on top of whatever canvasScale we
// calculated above)
exportScale *= cfg.scale;
width *= cfg.scale;
height *= cfg.scale;
const exportWidth = width + cfg.padding * 2 * cfg.scale;
const exportHeight = height + cfg.padding * 2 * cfg.scale;
return {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
};
};
// ---------------------------------------------------------------------------
// exportToCanvas
// ---------------------------------------------------------------------------
/**
* This API is usually used as a precursor to searializing to Blob or PNG,
* but can also be used to create a canvas for other purposes.
*/
export const exportToCanvas = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
const {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
} = await configExportDimension({ data, config });
const canvas = cfg.createCanvas
? cfg.createCanvas()
: document.createElement("canvas");
canvas.width = exportWidth;
canvas.height = exportHeight;
const { imageCache } = await updateImageCache({ const { imageCache } = await updateImageCache({
imageCache: new Map(), imageCache: new Map(),
fileIds: getInitializedImageElements(elementsForRender).map( fileIds: getInitializedImageElements(elementsForRender).map(
(element) => element.fileId, (element) => element.fileId,
), ),
files, files: data.files || {},
}); });
const theme =
cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT);
// Determine the background color for the canvas
const viewBackgroundColor =
cfg.canvasBackgroundColor === false
? // "transparent" triggers clearRect in bootstrapCanvas
"transparent"
: cfg.canvasBackgroundColor ||
appState.viewBackgroundColor ||
COLOR_WHITE;
renderStaticScene({ renderStaticScene({
canvas, canvas,
rc: rough.canvas(canvas), rc: rough.canvas(canvas),
@@ -245,19 +656,23 @@ export const exportToCanvas = async (
arrayToMap(elementsForRender), arrayToMap(elementsForRender),
), ),
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>( allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(syncInvalidIndices(elements)), arrayToMap(syncInvalidIndices(data.elements)),
), ),
visibleElements: elementsForRender, visibleElements: elementsForRender,
scale, scale: exportScale,
appState: { appState: {
...appState, ...appState,
frameRendering, frameRendering,
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, width,
scrollX: -minX + exportPadding, height,
scrollY: -minY + exportPadding, offsetLeft: 0,
zoom: defaultAppState.zoom, offsetTop: 0,
scrollX: -x + normalizedPadding,
scrollY: -y + normalizedPadding,
zoom: { value: DEFAULT_ZOOM_VALUE },
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
viewBackgroundColor,
}, },
renderConfig: { renderConfig: {
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: viewBackgroundColor,
@@ -268,13 +683,44 @@ export const exportToCanvas = async (
embedsValidationStatus: new Map(), embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(), elementsPendingErasure: new Set(),
pendingFlowchartNodes: null, pendingFlowchartNodes: null,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
}, },
}); });
return canvas; return canvas;
}; };
// ---------------------------------------------------------------------------
// exportToSvg
// ---------------------------------------------------------------------------
type ExportToSvgConfig = Pick<
ExportSceneConfig,
| "canvasBackgroundColor"
| "padding"
| "theme"
| "exportingFrame"
| "scale"
| "width"
| "height"
| "x"
| "y"
| "origin"
| "fit"
| "position"
| "maxWidthOrHeight"
| "widthOrHeight"
| "getDimensions"
| "loadFonts"
> & {
/**
* if true, all embeddables passed in will be rendered when possible.
*/
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
};
const createHTMLComment = (text: string) => { const createHTMLComment = (text: string) => {
// surrounding with spaces to maintain prettified consistency with previous // surrounding with spaces to maintain prettified consistency with previous
// iterations // iterations
@@ -282,61 +728,34 @@ const createHTMLComment = (text: string) => {
return document.createComment(` ${text} `); return document.createComment(` ${text} `);
}; };
export const exportToSvg = async ( export const exportToSvg = async ({
elements: readonly NonDeletedExcalidrawElement[], data,
appState: { config,
exportBackground: boolean; }: {
exportPadding?: number; data: ExportSceneData;
exportScale?: number; config?: ExportToSvgConfig;
viewBackgroundColor: string; }) => {
exportWithDarkMode?: boolean; const {
exportEmbedScene?: boolean; config: cfg,
frameRendering?: AppState["frameRendering"]; normalizedPadding,
}, exportWidth,
files: BinaryFiles | null, exportHeight,
opts?: { exportScale,
/** x,
* if true, all embeddables passed in will be rendered when possible. y,
*/ elementsForRender,
renderEmbeddables?: boolean; appState,
exportingFrame?: ExcalidrawFrameLikeElement | null;
skipInliningFonts?: true;
reuseImages?: boolean;
},
): Promise<SVGSVGElement> => {
const frameRendering = getFrameRenderingConfig(
opts?.exportingFrame ?? null,
appState.frameRendering ?? null,
);
let {
exportPadding = DEFAULT_EXPORT_PADDING,
exportWithDarkMode = false,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = appState;
const { exportingFrame = null } = opts || {};
const elementsForRender = prepareElementsForRender({
elements,
exportingFrame,
exportWithDarkMode,
frameRendering, frameRendering,
}); } = await configExportDimension({ data, config });
if (exportingFrame) { const offsetX = -(x - normalizedPadding);
exportPadding = 0; const offsetY = -(y - normalizedPadding);
}
const [minX, minY, width, height] = getCanvasSize( const { elements } = data;
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
const offsetX = -minX + exportPadding; const theme =
const offsetY = -minY + exportPadding; cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT);
const exportWithDarkMode = theme === THEME.DARK;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// initialize SVG root element // initialize SVG root element
@@ -346,9 +765,12 @@ export const exportToSvg = async (
svgRoot.setAttribute("version", "1.1"); svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS); svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`); svgRoot.setAttribute(
svgRoot.setAttribute("width", `${width * exportScale}`); "viewBox",
svgRoot.setAttribute("height", `${height * exportScale}`); `0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`,
);
svgRoot.setAttribute("width", `${exportWidth}`);
svgRoot.setAttribute("height", `${exportHeight}`);
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs"); const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
@@ -367,7 +789,7 @@ export const exportToSvg = async (
// we need to serialize the "original" elements before we put them through // we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids // the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) { if (appState.exportEmbedScene) {
try { try {
encodeSvgBase64Payload({ encodeSvgBase64Payload({
metadataElement, metadataElement,
@@ -375,7 +797,7 @@ export const exportToSvg = async (
// elements which don't contain the temp frame labels. // elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with // But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra. // only the elements that we're exporting, and no extra.
payload: serializeAsJSON(elements, appState, files || {}, "local"), payload: serializeAsJSON(elements, appState, data.files || {}, "local"),
}); });
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@@ -413,7 +835,7 @@ export const exportToSvg = async (
rect.setAttribute("width", `${frame.width}`); rect.setAttribute("width", `${frame.width}`);
rect.setAttribute("height", `${frame.height}`); rect.setAttribute("height", `${frame.height}`);
if (!exportingFrame) { if (!cfg.exportingFrame) {
rect.setAttribute("rx", `${FRAME_STYLE.radius}`); rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
rect.setAttribute("ry", `${FRAME_STYLE.radius}`); rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
} }
@@ -428,9 +850,10 @@ export const exportToSvg = async (
// inline font faces // inline font faces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const fontFaces = !opts?.skipInliningFonts const fontFaces =
? await Fonts.generateFontFaceDeclarations(elements) config?.skipInliningFonts !== true
: []; ? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces const delimiter = "\n "; // 6 spaces
@@ -447,17 +870,16 @@ export const exportToSvg = async (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// render background rect // render background rect
if (appState.exportBackground && viewBackgroundColor) { if (appState.exportBackground && appState.viewBackgroundColor) {
const bgColor = cfg.canvasBackgroundColor || appState.viewBackgroundColor;
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect"); const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0"); rect.setAttribute("x", "0");
rect.setAttribute("y", "0"); rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`); rect.setAttribute("width", `${exportWidth / exportScale}`);
rect.setAttribute("height", `${height}`); rect.setAttribute("height", `${exportHeight / exportScale}`);
rect.setAttribute( rect.setAttribute(
"fill", "fill",
exportWithDarkMode exportWithDarkMode ? applyDarkModeFilter(bgColor) : bgColor,
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
); );
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
@@ -468,14 +890,14 @@ export const exportToSvg = async (
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
const renderEmbeddables = opts?.renderEmbeddables ?? false; const renderEmbeddables = config?.renderEmbeddables ?? false;
renderSceneToSvg( renderSceneToSvg(
elementsForRender, elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)), toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
rsvg, rsvg,
svgRoot, svgRoot,
files || {}, data.files || {},
{ {
offsetX, offsetX,
offsetY, offsetY,
@@ -483,7 +905,7 @@ export const exportToSvg = async (
exportWithDarkMode, exportWithDarkMode,
renderEmbeddables, renderEmbeddables,
frameRendering, frameRendering,
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: appState.viewBackgroundColor,
embedsValidationStatus: renderEmbeddables embedsValidationStatus: renderEmbeddables
? new Map( ? new Map(
elementsForRender elementsForRender
@@ -491,8 +913,8 @@ export const exportToSvg = async (
.map((element) => [element.id, true]), .map((element) => [element.id, true]),
) )
: new Map(), : new Map(),
reuseImages: opts?.reuseImages ?? true, reuseImages: config?.reuseImages ?? true,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
}, },
); );
@@ -501,6 +923,10 @@ export const exportToSvg = async (
return svgRoot; return svgRoot;
}; };
// ---------------------------------------------------------------------------
// SVG payload encoding/decoding
// ---------------------------------------------------------------------------
export const encodeSvgBase64Payload = ({ export const encodeSvgBase64Payload = ({
payload, payload,
metadataElement, metadataElement,
@@ -556,26 +982,149 @@ export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
throw new Error("INVALID"); throw new Error("INVALID");
}; };
// ---------------------------------------------------------------------------
// getCanvasSize
// ---------------------------------------------------------------------------
// calculate smallest area to fit the contents in // calculate smallest area to fit the contents in
const getCanvasSize = ( export const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): Bounds => { ): Bounds => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = distance(minX, maxX) + exportPadding * 2; const width = distance(minX, maxX);
const height = distance(minY, maxY) + exportPadding * 2; const height = distance(minY, maxY);
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };
/**
* Gets the export dimensions for a set of elements.
*
* @param elements - Elements to calculate size for
* @param exportPadding - Padding to add around the elements
* @param scale - Scale factor
* @returns [width, height] tuple
*/
export const getExportSize = ( export const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
scale: number, scale: number,
): [number, number] => { ): [number, number] => {
const [, , width, height] = getCanvasSize(elements, exportPadding).map( const [, , width, height] = getCanvasSize(elements);
(dimension) => Math.trunc(dimension * scale),
);
return [width, height]; return [
Math.trunc((width + exportPadding * 2) * scale),
Math.trunc((height + exportPadding * 2) * scale),
];
};
// ---------------------------------------------------------------------------
// exportToBlob
// ---------------------------------------------------------------------------
export { MIME_TYPES };
type ExportToBlobConfig = ExportSceneConfig & {
mimeType?: string;
quality?: number;
};
export const exportToBlob = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportToBlobConfig;
}): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = config || {};
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && config?.canvasBackgroundColor !== false) {
if (config?.canvasBackgroundColor === undefined) {
console.warn(
`Defaulting "canvasBackgroundColor" for "${MIME_TYPES.jpg}" mimeType`,
);
config = {
...config,
canvasBackgroundColor:
data.appState?.viewBackgroundColor || COLOR_WHITE,
};
}
}
const canvas = await exportToCanvas({ data, config });
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
data.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
data.elements,
data.appState,
data.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
// ---------------------------------------------------------------------------
// exportToClipboard
// ---------------------------------------------------------------------------
export const exportToClipboard = async ({
type,
data,
config,
}: {
data: ExportSceneData;
} & (
| { type: "png"; config?: ExportToBlobConfig }
| { type: "svg"; config?: ExportToSvgConfig }
| { type: "json"; config?: never }
)) => {
if (type === "svg") {
const svg = await exportToSvg({
data: {
...data,
appState: restoreAppState(data.appState, null),
},
config,
});
await copyTextToSystemClipboard(svg.outerHTML);
} else if (type === "png") {
await copyBlobToClipboardAsPng(exportToBlob({ data, config }));
} else if (type === "json") {
await copyToClipboard(data.elements, data.files);
} else {
throw new Error("Invalid export type");
}
}; };
@@ -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">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 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 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
@@ -0,0 +1,111 @@
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,6 +200,26 @@ 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",
+14 -6
View File
@@ -83,11 +83,13 @@ describe("export", () => {
}); });
it("export svg-embedded scene", async () => { it("export svg-embedded scene", async () => {
const svg = await exportToSvg( const svg = await exportToSvg({
testElements, data: {
{ ...getDefaultAppState(), exportEmbedScene: true }, elements: testElements,
{}, appState: { ...getDefaultAppState(), exportEmbedScene: true },
); files: {},
},
});
const svgText = svg.outerHTML; const svgText = svg.outerHTML;
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`); expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
@@ -205,7 +207,13 @@ describe("export", () => {
}, },
} as const; } as const;
const svg = await exportToSvg(elements, appState, files); const svg = await exportToSvg({
data: {
elements,
appState,
files,
},
});
const svgText = svg.outerHTML; const svgText = svg.outerHTML;
@@ -41,13 +41,15 @@ describe("event callbacks", () => {
await render( await render(
<Excalidraw <Excalidraw
onExcalidrawAPI={(api) => onExcalidrawAPI={(api) => {
lifecyclePromise.resolve({ if (api) {
api: api as ExcalidrawImperativeAPI, lifecyclePromise.resolve({
mount: api.onEvent("editor:mount"), api,
initialize: api.onEvent("editor:initialize"), mount: api.onEvent("editor:mount"),
}) initialize: api.onEvent("editor:initialize"),
} });
}
}}
/>, />,
); );
File diff suppressed because one or more lines are too long
+146 -93
View File
@@ -1,9 +1,10 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils"; import { exportToCanvas, exportToSvg } from "../../scene/export";
import { import {
applyDarkModeFilter, applyDarkModeFilter,
FONT_FAMILY, FONT_FAMILY,
FRAME_STYLE, FRAME_STYLE,
THEME,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -53,39 +54,42 @@ describe("exportToSvg", () => {
}, },
] as NonDeletedExcalidrawElement[]; ] as NonDeletedExcalidrawElement[];
const DEFAULT_OPTIONS = { const DEFAULT_APP_STATE = {
exportBackground: false, exportBackground: false,
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
files: {},
}; };
it("with default arguments", async () => { it("with default arguments", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
DEFAULT_OPTIONS, elements: ELEMENTS,
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement).toMatchSnapshot(); expect(svgElement).toMatchSnapshot();
}); });
it("with a CJK font", async () => { it("with a CJK font", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
[ data: {
...ELEMENTS, elements: [
{ ...ELEMENTS,
...textFixture, {
height: ELEMENT_HEIGHT, ...textFixture,
width: ELEMENT_WIDTH, height: ELEMENT_HEIGHT,
text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.", width: ELEMENT_WIDTH,
originalText: text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
"中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.", originalText:
index: "a4" as FractionalIndex, "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
} as ExcalidrawTextElement, index: "a4" as FractionalIndex,
], } as ExcalidrawTextElement,
DEFAULT_OPTIONS, ],
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement).toMatchSnapshot(); expect(svgElement).toMatchSnapshot();
// extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers) // extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers)
@@ -94,15 +98,17 @@ describe("exportToSvg", () => {
it("with background color", async () => { it("with background color", async () => {
const BACKGROUND_COLOR = "#abcdef"; const BACKGROUND_COLOR = "#abcdef";
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportBackground: true, ...DEFAULT_APP_STATE,
viewBackgroundColor: BACKGROUND_COLOR, exportBackground: true,
viewBackgroundColor: BACKGROUND_COLOR,
},
files: {},
}, },
null, });
);
expect(svgElement.querySelector("rect")).toHaveAttribute( expect(svgElement.querySelector("rect")).toHaveAttribute(
"fill", "fill",
@@ -111,14 +117,18 @@ describe("exportToSvg", () => {
}); });
it("with dark mode", async () => { it("with dark mode", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportWithDarkMode: true, ...DEFAULT_APP_STATE,
},
files: {},
}, },
null, config: {
); theme: THEME.DARK,
},
});
const textElements = svgElement.querySelectorAll("text"); const textElements = svgElement.querySelectorAll("text");
expect(textElements.length).toBeGreaterThan(0); expect(textElements.length).toBeGreaterThan(0);
@@ -132,14 +142,16 @@ describe("exportToSvg", () => {
}); });
it("with exportPadding", async () => { it("with exportPadding", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: DEFAULT_APP_STATE,
exportPadding: 0, files: {},
}, },
null, config: {
); padding: 0,
},
});
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString()); expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString()); expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
@@ -152,15 +164,17 @@ describe("exportToSvg", () => {
it("with scale", async () => { it("with scale", async () => {
const SCALE = 2; const SCALE = 2;
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: DEFAULT_APP_STATE,
exportPadding: 0, files: {},
exportScale: SCALE,
}, },
null, config: {
); padding: 0,
scale: SCALE,
},
});
expect(svgElement).toHaveAttribute( expect(svgElement).toHaveAttribute(
"height", "height",
@@ -173,23 +187,27 @@ describe("exportToSvg", () => {
}); });
it("with exportEmbedScene", async () => { it("with exportEmbedScene", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportEmbedScene: true, ...DEFAULT_APP_STATE,
exportEmbedScene: true,
},
files: {},
}, },
null, });
);
expect(svgElement.innerHTML).toMatchSnapshot(); expect(svgElement.innerHTML).toMatchSnapshot();
}); });
it("with elements that have a link", async () => { it("with elements that have a link", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
[rectangleWithLinkFixture], data: {
DEFAULT_OPTIONS, elements: [rectangleWithLinkFixture],
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement.innerHTML).toMatchSnapshot(); expect(svgElement.innerHTML).toMatchSnapshot();
}); });
}); });
@@ -229,9 +247,14 @@ describe("exporting frames", () => {
]; ];
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements, data: {
files: null, elements,
exportPadding: 0, appState: {},
files: null,
},
config: {
padding: 0,
},
}); });
expect(canvas.width).toEqual(200); expect(canvas.width).toEqual(200);
@@ -258,10 +281,15 @@ describe("exporting frames", () => {
]; ];
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements, data: {
files: null, elements,
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
expect(canvas.width).toEqual(frame.width); expect(canvas.width).toEqual(frame.width);
@@ -297,10 +325,15 @@ describe("exporting frames", () => {
}); });
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [rectOverlapping, frame, frameChild], data: {
files: null, elements: [rectOverlapping, frame, frameChild],
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -341,10 +374,15 @@ describe("exporting frames", () => {
}); });
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [frameChild, frame, elementOutside], data: {
files: null, elements: [frameChild, frame, elementOutside],
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -409,10 +447,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frames themselves should be exported when multiple frames selected // frames themselves should be exported when multiple frames selected
@@ -454,10 +497,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -513,10 +561,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frame shouldn't be exported // frame shouldn't be exported
+8 -1
View File
@@ -573,11 +573,15 @@ 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) => void; onExcalidrawAPI?: (api: ExcalidrawImperativeAPI | null) => 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.
*/ */
@@ -907,9 +911,12 @@ 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"];
-216
View File
@@ -1,216 +0,0 @@
import { MIME_TYPES } from "@excalidraw/common";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "@excalidraw/excalidraw/clipboard";
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import {
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import {
exportToCanvas as _exportToCanvas,
exportToSvg as _exportToSvg,
} from "@excalidraw/excalidraw/scene/export";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
} from "@excalidraw/element/types";
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
export { MIME_TYPES };
type ExportOpts = {
elements: readonly NonDeleted<ExcalidrawElement>[];
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameLikeElement | null;
getDimensions?: (
width: number,
height: number,
) => { width: number; height: number; scale?: number };
};
export const exportToCanvas = ({
elements,
appState,
files,
maxWidthOrHeight,
getDimensions,
exportPadding,
exportingFrame,
}: ExportOpts & {
exportPadding?: number;
}) => {
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas(
restoredElements,
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {},
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
(width: number, height: number) => {
const canvas = document.createElement("canvas");
if (maxWidthOrHeight) {
if (typeof getDimensions === "function") {
console.warn(
"`getDimensions()` is ignored when `maxWidthOrHeight` is supplied.",
);
}
const max = Math.max(width, height);
// if content is less then maxWidthOrHeight, fallback on supplied scale
const scale =
maxWidthOrHeight < max
? maxWidthOrHeight / max
: appState?.exportScale ?? 1;
canvas.width = width * scale;
canvas.height = height * scale;
return {
canvas,
scale,
};
}
const ret = getDimensions?.(width, height) || { width, height };
canvas.width = ret.width;
canvas.height = ret.height;
return {
canvas,
scale: ret.scale ?? 1,
};
},
);
};
export const exportToBlob = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
exportPadding?: number;
},
): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = opts;
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && !opts.appState?.exportBackground) {
console.warn(
`Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`,
);
opts = {
...opts,
appState: { ...opts.appState, exportBackground: true },
};
}
const canvas = await exportToCanvas(opts);
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
opts.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
opts.elements,
opts.appState,
opts.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
export const exportToSvg = async ({
elements,
appState = getDefaultAppState(),
files = {},
exportPadding,
renderEmbeddables,
exportingFrame,
skipInliningFonts,
reuseImages,
}: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number;
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
}): Promise<SVGSVGElement> => {
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const exportAppState = {
...restoredAppState,
exportPadding,
};
return _exportToSvg(restoredElements, exportAppState, files, {
exportingFrame,
renderEmbeddables,
skipInliningFonts,
reuseImages,
});
};
export const exportToClipboard = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
type: "png" | "svg" | "json";
},
) => {
if (opts.type === "svg") {
const svg = await exportToSvg(opts);
await copyTextToSystemClipboard(svg.outerHTML);
} else if (opts.type === "png") {
await copyBlobToClipboardAsPng(exportToBlob(opts));
} else if (opts.type === "json") {
await copyToClipboard(opts.elements, opts.files);
} else {
throw new Error("Invalid export type");
}
};
+14 -1
View File
@@ -1,4 +1,17 @@
export * from "./export"; // Re-export from @excalidraw/excalidraw for backwards compatibility
export {
exportToCanvas,
exportToBlob,
exportToSvg,
exportToClipboard,
MIME_TYPES,
} from "@excalidraw/excalidraw/scene/export";
export type {
ExportSceneData,
ExportSceneConfig,
} from "@excalidraw/excalidraw/scene/export";
export * from "./withinBounds"; export * from "./withinBounds";
export * from "./bbox"; export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element"; export { getCommonBounds } from "@excalidraw/element";
+58 -35
View File
@@ -10,9 +10,22 @@ const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
describe("exportToCanvas", async () => { describe("exportToCanvas", async () => {
const EXPORT_PADDING = 10; const EXPORT_PADDING = 10;
it("with default arguments", async () => { it("with default arguments (no padding)", async () => {
const canvas = await utils.exportToCanvas({ const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
});
// New API has no default padding - call sites must explicitly set it
expect(canvas.width).toBe(100);
expect(canvas.height).toBe(100);
});
it("with padding", async () => {
const canvas = await utils.exportToCanvas({
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
config: {
padding: EXPORT_PADDING,
},
}); });
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING); expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
@@ -21,8 +34,10 @@ describe("exportToCanvas", async () => {
it("when custom width and height", async () => { it("when custom width and height", async () => {
const canvas = await utils.exportToCanvas({ const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
getDimensions: () => ({ width: 200, height: 200, scale: 1 }), config: {
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
},
}); });
expect(canvas.width).toBe(200); expect(canvas.width).toBe(200);
@@ -33,20 +48,27 @@ describe("exportToCanvas", async () => {
describe("exportToBlob", async () => { describe("exportToBlob", async () => {
describe("mime type", () => { describe("mime type", () => {
it("should change image/jpg to image/jpeg", async () => { it("should change image/jpg to image/jpeg", async () => {
const diagramData = diagramFactory();
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
...diagramFactory(), data: {
getDimensions: (width, height) => ({ width, height, scale: 1 }), elements: diagramData.elements,
// testing typo in MIME type (jpg → jpeg) appState: {
mimeType: "image/jpg", ...diagramData.appState,
appState: { exportBackground: true,
exportBackground: true, },
files: diagramData.files,
},
config: {
getDimensions: (width, height) => ({ width, height, scale: 1 }),
// testing typo in MIME type (jpg → jpeg)
mimeType: "image/jpg",
}, },
}); });
expect(blob?.type).toBe(MIME_TYPES.jpg); expect(blob?.type).toBe(MIME_TYPES.jpg);
}); });
it("should default to image/png", async () => { it("should default to image/png", async () => {
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
...diagramFactory(), data: diagramFactory(),
}); });
expect(blob?.type).toBe(MIME_TYPES.png); expect(blob?.type).toBe(MIME_TYPES.png);
}); });
@@ -56,9 +78,11 @@ describe("exportToBlob", async () => {
.spyOn(console, "warn") .spyOn(console, "warn")
.mockImplementationOnce(() => void 0); .mockImplementationOnce(() => void 0);
await utils.exportToBlob({ await utils.exportToBlob({
...diagramFactory(), data: diagramFactory(),
mimeType: MIME_TYPES.png, config: {
quality: 1, mimeType: MIME_TYPES.png,
quality: 1,
},
}); });
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`, `"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
@@ -68,8 +92,9 @@ describe("exportToBlob", async () => {
}); });
describe("exportToSvg", () => { describe("exportToSvg", () => {
const passedElements = () => exportToSvgSpy.mock.calls[0][0]; const getPassedArg = () => exportToSvgSpy.mock.calls[0][0];
const passedOptions = () => exportToSvgSpy.mock.calls[0][1]; const passedData = () => getPassedArg().data;
const passedConfig = () => getPassedArg().config;
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -77,18 +102,14 @@ describe("exportToSvg", () => {
it("with default arguments", async () => { it("with default arguments", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },
}), }),
}); });
const passedOptionsWhenDefault = { const data = passedData();
...passedOptions(), expect(data.elements.length).toBe(3);
// To avoid varying snapshots expect(passedConfig()).toMatchSnapshot();
name: "name",
};
expect(passedElements().length).toBe(3);
expect(passedOptionsWhenDefault).toMatchSnapshot();
}); });
// FIXME the utils.exportToSvg no longer filters out deleted elements. // FIXME the utils.exportToSvg no longer filters out deleted elements.
@@ -96,37 +117,39 @@ describe("exportToSvg", () => {
// type-checking for it correctly. // type-checking for it correctly.
it.skip("with deleted elements", async () => { it.skip("with deleted elements", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },
elementOverrides: { isDeleted: true }, elementOverrides: { isDeleted: true },
}), }),
}); });
expect(passedElements().length).toBe(0); expect(passedData().elements.length).toBe(0);
}); });
it("with exportPadding", async () => { it("with padding", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ overrides: { appState: { name: "diagram name" } } }), data: diagramFactory({ overrides: { appState: { name: "diagram name" } } }),
exportPadding: 0, config: {
padding: 0,
},
}); });
expect(passedElements().length).toBe(3); expect(passedData().elements.length).toBe(3);
expect(passedOptions()).toEqual( expect(passedConfig()).toEqual(
expect.objectContaining({ exportPadding: 0 }), expect.objectContaining({ padding: 0 }),
); );
}); });
it("with exportEmbedScene", async () => { it("with exportEmbedScene", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { overrides: {
appState: { name: "diagram name", exportEmbedScene: true }, appState: { name: "diagram name", exportEmbedScene: true },
}, },
}), }),
}); });
expect(passedElements().length).toBe(3); expect(passedData().elements.length).toBe(3);
expect(passedOptions().exportEmbedScene).toBe(true); expect(passedData().appState?.exportEmbedScene).toBe(true);
}); });
}); });
+19 -13
View File
@@ -19,13 +19,15 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse]; const sourceElements = [rectangle, ellipse];
const svgNode = await utils.exportToSvg({ const svgNode = await utils.exportToSvg({
elements: sourceElements, data: {
appState: { elements: sourceElements,
viewBackgroundColor: "#ffffff", appState: {
gridModeEnabled: false, viewBackgroundColor: "#ffffff",
exportEmbedScene: true, gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
}, },
files: null,
}); });
const svg = svgNode.outerHTML; const svg = svgNode.outerHTML;
@@ -49,14 +51,18 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse]; const sourceElements = [rectangle, ellipse];
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
mimeType: "image/png", data: {
elements: sourceElements, elements: sourceElements,
appState: { appState: {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
gridModeEnabled: false, gridModeEnabled: false,
exportEmbedScene: true, exportEmbedScene: true,
},
files: null,
},
config: {
mimeType: "image/png",
}, },
files: null,
}); });
const parsedString = await decodePngMetadata(blob); const parsedString = await decodePngMetadata(blob);
+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.0.0-rc4": "@excalidraw/mermaid-to-excalidraw@2.1.1":
version "2.0.0-rc4" version "2.1.1"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.1.tgz#659c934a607dd2cf57f2a69282588ee2b0722959"
integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw== integrity sha512-jU+frqcxazsY+t5yOBf2mgrQy+WUrbrzA36if3SQB/Vwaf2qOJjnWxucNafgZZk/3+9xGmRotUeOviSOJG+wYA==
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"