Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle c9f62f20d5 debuggs 2026-03-09 22:04:43 +01:00
45 changed files with 1310 additions and 2412 deletions
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow",
x: 450,
y: 20,
startArrowhead: "circle",
startArrowhead: "dot",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [
+8 -12
View File
@@ -25,19 +25,15 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({
data: {
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
});
const dataURL = await getDataURL(blob);
-32
View File
@@ -1,32 +0,0 @@
import type { Arrowhead, AnyArrowhead } from "./types";
export const normalizeArrowhead = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
switch (arrowhead) {
case undefined:
case null:
return null;
case "dot":
return "circle";
case "crowfoot_one":
return "cardinality_one";
case "crowfoot_many":
return "cardinality_many";
case "crowfoot_one_or_many":
return "cardinality_one_or_many";
default:
return arrowhead;
}
};
export const getArrowheadForPicker = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
const normalizedArrowhead = normalizeArrowhead(arrowhead);
if (normalizedArrowhead === null) {
return null;
}
return normalizedArrowhead;
};
+26 -39
View File
@@ -709,9 +709,6 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
};
const CARDINALITY_MARKER_SIZE = 20;
const CROWFOOT_ARROWHEAD_SIZE = 15;
/** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) {
@@ -720,14 +717,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond":
case "diamond_outline":
return 12;
case "cardinality_many":
case "cardinality_one_or_many":
case "cardinality_zero_or_many":
return CROWFOOT_ARROWHEAD_SIZE;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
default:
return 15;
}
@@ -750,12 +743,7 @@ export const getArrowheadPoints = (
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
offsetMultiplier = 0,
) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) {
return null;
}
@@ -836,30 +824,29 @@ export const getArrowheadPoints = (
const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier);
const tx = x2 - nx * minSize * offsetMultiplier;
const ty = y2 - ny * minSize * offsetMultiplier;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
const xs = x2 - nx * minSize;
const ys = y2 - ny * minSize;
if (arrowhead === "circle" || arrowhead === "circle_outline") {
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
return [tx, ty, diameter];
if (
arrowhead === "dot" ||
arrowhead === "circle" ||
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
}
const angle = getArrowheadAngle(arrowhead);
if (
arrowhead === "cardinality_many" ||
arrowhead === "cardinality_one_or_many"
) {
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
pointFrom(tx, ty),
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
pointFrom(tx, ty),
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(angle),
);
@@ -869,12 +856,12 @@ export const getArrowheadPoints = (
// Return points
const [x3, y3] = pointRotateRads(
pointFrom(xs, ys),
pointFrom(tx, ty),
pointFrom(x2, y2),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
pointFrom(xs, ys),
pointFrom(tx, ty),
pointFrom(x2, y2),
degreesToRadians(angle),
);
@@ -887,9 +874,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(tx + minSize * 2, ty),
pointFrom(tx, ty),
Math.atan2(py - ty, px - tx) as Radians,
pointFrom(x2 + minSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(py - y2, px - x2) as Radians,
);
} else {
const [px, py] =
@@ -898,16 +885,16 @@ export const getArrowheadPoints = (
: [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(tx - minSize * 2, ty),
pointFrom(tx, ty),
Math.atan2(ty - py, tx - px) as Radians,
pointFrom(x2 - minSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians,
);
}
return [tx, ty, x3, y3, ox, oy, x4, y4];
return [x2, y2, x3, y3, ox, oy, x4, y4];
}
return [tx, ty, x3, y3, x4, y4];
return [x2, y2, x3, y3, x4, y4];
};
// TODO reuse shape.ts
-1
View File
@@ -99,4 +99,3 @@ export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
export * from "./arrowheads";
+86 -223
View File
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
ExcalidrawLineElement,
Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
@@ -296,82 +296,6 @@ const modifyIframeLikeForRoughOptions = (
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 = (
element: ExcalidrawLinearElement,
shape: Drawable[],
@@ -382,54 +306,63 @@ const getArrowheadShapes = (
canvasBackgroundColor: string,
isDarkMode: boolean,
) => {
if (arrowhead === null) {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
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
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
const backgroundFillColor = isDarkMode
? applyDarkModeFilter(canvasBackgroundColor)
: canvasBackgroundColor;
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
switch (arrowhead) {
case "dot":
case "circle":
case "circle_outline": {
return generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, arrowhead),
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
);
const [x, y, diameter] = arrowheadPoints;
// always use solid stroke for arrowhead
delete options.strokeLineDash;
return [
generator.circle(x, y, diameter, {
...options,
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
}
case "triangle":
case "triangle_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
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
delete triangleOptions.strokeLineDash;
delete options.strokeLineDash;
return [
generator.polygon(
@@ -439,34 +372,24 @@ const getArrowheadShapes = (
[x3, y3],
[x, y],
],
triangleOptions,
{
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
),
];
}
case "diamond":
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 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
delete diamondOptions.strokeLineDash;
delete options.strokeLineDash;
return [
generator.polygon(
@@ -477,106 +400,46 @@ const getArrowheadShapes = (
[x4, y4],
[x, y],
],
diamondOptions,
),
];
}
case "cardinality_one":
return generateArrowheadCardinalityOne(
generator,
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,
{
...options,
fill:
arrowhead === "diamond_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
),
];
}
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar":
case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: {
return generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
options.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash;
}
options.roughness = Math.min(1, options.roughness || 0);
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
];
}
}
};
+4 -17
View File
@@ -303,32 +303,19 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean }
>;
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 =
| "arrow"
| "bar"
| "dot" // legacy. Do not use for new elements.
| "circle"
| "circle_outline"
| "triangle"
| "triangle_outline"
| "diamond"
| "diamond_outline"
| CardinalityArrowhead;
export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
+10 -18
View File
@@ -330,30 +330,22 @@ describe("Cropping and other features", async () => {
const widthToHeightRatio = image.width / image.height;
const canvas = await exportToCanvas({
data: {
elements: [image],
// @ts-ignore
appState: h.state,
files: h.app.files,
},
config: {
padding: 0,
},
elements: [image],
// @ts-ignore
appState: h.state,
files: h.app.files,
exportPadding: 0,
});
const exportedCanvasRatio = canvas.width / canvas.height;
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
const svg = await exportToSvg({
data: {
elements: [image],
// @ts-ignore
appState: h.state,
files: h.app.files,
},
config: {
padding: 0,
},
elements: [image],
// @ts-ignore
appState: h.state,
files: h.app.files,
exportPadding: 0,
});
const svgWidth = svg.getAttribute("width");
const svgHeight = svg.getAttribute("height");
+14 -9
View File
@@ -18,13 +18,22 @@ Please add the latest change on the top under the correct section.
### Breaking changes
- 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
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
- Added `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded.
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
```tsx
<Excalidraw
onMount={({ excalidrawAPI, container }) => {
console.log(container);
excalidrawAPI.scrollToContent();
}}
onInitialize={(api) => {
api.refresh();
}}
/>
```
- Same events are also accessible imperatively through `api.onEvent(...)`.
@@ -32,6 +41,7 @@ Please add the latest change on the top under the correct section.
<Excalidraw
onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
excalidrawAPI.scrollToContent();
console.log(container);
});
@@ -44,9 +54,7 @@ Please add the latest change on the top under the correct section.
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
- 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)`.
- Exported `ExcalidrawAPIProvider`, `useExcalidrawAPI`, and `useAppStateValue` from the package entrypoint. The imperative API also now exposes `onStateChange`.
```tsx
<ExcalidrawAPIProvider>
@@ -55,9 +63,6 @@ Please add the latest change on the top under the correct section.
</ExcalidrawAPIProvider>;
function Logger() {
// initially null before the ExcalidrawAPIProvider initializes ater
// <Excalidraw/> renders
// When <Excalidraw/> unmounts, is reset back to null
const api = useExcalidrawAPI();
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
+14 -111
View File
@@ -1,10 +1,10 @@
# Excalidraw
**Excalidraw** is exported as a React component that you can embed directly in your app.
**Excalidraw** is exported as a component to be directly embedded in your project.
## Installation
Install the package together with its React peer dependencies.
Use `npm` or `yarn` to install the package.
```bash
npm install react react-dom @excalidraw/excalidraw
@@ -12,131 +12,34 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw
```
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
## Quick start
#### Self-hosting fonts
The minimum working setup has two easy-to-miss requirements:
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
1. Import the package CSS:
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
```ts
import "@excalidraw/excalidraw/index.css";
```js
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
```
2. Render Excalidraw inside a container with a non-zero height.
### Dimensions of Excalidraw
```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>
```
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.
## Demo
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
## Integration
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
+1 -1
View File
@@ -399,7 +399,7 @@ export const actionSaveFileToDisk = register({
appState: {
openDialog: null,
fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved"), duration: 3000 },
toast: { message: t("toast.fileSaved") },
},
};
} catch (error: any) {
+84 -132
View File
@@ -36,7 +36,6 @@ import {
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import {
getBoundTextElement,
@@ -125,12 +124,9 @@ import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
ArrowheadCardinalityExactlyOneIcon,
ArrowheadCardinalityManyIcon,
ArrowheadCardinalityOneIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -1554,117 +1550,80 @@ export const actionChangeRoundness = register<"sharp" | "round">({
});
const getArrowheadOptions = (flip: boolean) => {
return {
visibleSections: [
{
name: "default",
options: [
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon flip={flip} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: "r",
},
],
},
],
hiddenSections: [
{
name: "default",
options: [
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
],
},
{
name: t("labels.cardinality"),
options: [
{
value: "cardinality_one",
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;
return [
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon flip={flip} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: "r",
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "c",
},
{
value: "crowfoot_one_or_many",
text: t("labels.arrowhead_crowfoot_one_or_many"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
] as const;
};
export const actionChangeArrowhead = register<{
@@ -1708,52 +1667,45 @@ export const actionChangeArrowhead = register<{
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return (
<fieldset>
<legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList">
<IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>(
elements,
app,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? getArrowheadForPicker(element.startArrowhead)
? element.startArrowhead
: appState.currentItemStartArrowhead,
true,
(hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
<IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>(
elements,
app,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? getArrowheadForPicker(element.endArrowhead)
? element.endArrowhead
: appState.currentItemEndArrowhead,
true,
(hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
</div>
</fieldset>
+210 -79
View File
@@ -517,7 +517,6 @@ EditorInterfaceContext.displayName = "EditorInterfaceContext";
const editorLifecycleEventBehavior = {
"editor:mount": { cardinality: "once", replay: "last" },
"editor:initialize": { cardinality: "once", replay: "last" },
"editor:unmount": { cardinality: "once", replay: "last" },
} as const;
export const ExcalidrawContainerContext = React.createContext<{
@@ -557,7 +556,7 @@ export const ExcalidrawAPIContext =
ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext";
export const ExcalidrawAPISetContext = React.createContext<
((api: ExcalidrawImperativeAPI | null) => void) | null
((api: ExcalidrawImperativeAPI) => void) | null
>(null);
ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext";
@@ -685,6 +684,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null;
lastPointerMoveEvent: PointerEvent | null = null;
_diagPointerMoveLogged = false;
/** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null;
/** previous frame pointer coords */
@@ -732,50 +732,6 @@ class App extends React.Component<AppProps, AppState> {
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) {
super(props);
const defaultAppState = getDefaultAppState();
@@ -836,11 +792,47 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
// in case internal editor APIs call this early, otherwise we need
// to construct this in componentDidMount because componentWillUnmount
// will invalidate it (so in StrictMode, doing this in constructor alone
// would be a problem)
this.api = this.createExcalidrawAPI();
this.api = {
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,
} as const;
props.onExcalidrawAPI?.(this.api);
}
updateEditorAtom = <Value, Args extends unknown[], Result>(
@@ -3012,11 +3004,80 @@ class App extends React.Component<AppProps, AppState> {
public async componentDidMount() {
this.unmounted = false;
this.api = this.createExcalidrawAPI();
this.excalidrawContainerValue.container =
this.excalidrawContainerRef.current;
// [DIAG] Capture-phase listener to detect if pointerdown is intercepted
// before reaching our React handler. Patches stopPropagation/
// stopImmediatePropagation so we can detect if another listener kills
// the event before it reaches the canvas.
document.addEventListener(
"pointerdown",
(e) => {
const target = e.target as HTMLElement | null;
if (
target?.closest(".excalidraw") &&
(target.tagName === "CANVAS" || target.closest("canvas"))
) {
console.info(
"[DIAG] document capture pointerdown on canvas, tool:",
this.state.activeTool?.type,
{
defaultPrevented: e.defaultPrevented,
cancelable: e.cancelable,
isTrusted: e.isTrusted,
target: target.tagName,
pointerType: e.pointerType,
button: e.button,
},
);
// Patch to detect stopPropagation/stopImmediatePropagation
const origStop = e.stopPropagation.bind(e);
const origStopImm = e.stopImmediatePropagation.bind(e);
const origPD = e.preventDefault.bind(e);
e.stopPropagation = () => {
console.warn(
"[DIAG] capture: pointerdown stopPropagation called!",
new Error().stack,
);
origStop();
};
e.stopImmediatePropagation = () => {
console.warn(
"[DIAG] capture: pointerdown stopImmediatePropagation called!",
new Error().stack,
);
origStopImm();
};
e.preventDefault = () => {
console.warn(
"[DIAG] capture: pointerdown preventDefault called!",
new Error().stack,
);
origPD();
};
}
},
true, // capture phase
);
// [DIAG] Bubble-phase listener to confirm event wasn't stopped
document.addEventListener(
"pointerdown",
(e) => {
const target = e.target as HTMLElement | null;
if (
target?.closest(".excalidraw") &&
(target.tagName === "CANVAS" || target.closest("canvas"))
) {
console.info(
"[DIAG] document bubble pointerdown reached (not stopped)",
);
}
},
false, // bubble phase
);
if (isTestEnv() || isDevEnv()) {
const setState = this.setState.bind(this);
Object.defineProperties(window.h, {
@@ -3100,35 +3161,10 @@ class App extends React.Component<AppProps, AppState> {
this.editorLifecycleEvents.emit("editor:mount", mountPayload);
this.props.onMount?.(mountPayload);
this.props.onExcalidrawAPI?.(this.api);
}
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(() => {});
this.renderer.destroy();
this.scene.destroy();
this.scene = new Scene();
@@ -3536,8 +3572,14 @@ class App extends React.Component<AppProps, AppState> {
}
private onTouchStart = (event: TouchEvent) => {
console.info("[DIAG] touchStart", {
touches: event.touches.length,
defaultPrevented: event.defaultPrevented,
isIOS,
});
// fix for Apple Pencil Scribble (do not prevent for other devices)
if (isIOS) {
console.info("[DIAG] touchStart: calling preventDefault (isIOS)");
event.preventDefault();
}
@@ -3703,7 +3745,7 @@ class App extends React.Component<AppProps, AppState> {
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw");
try {
const { elements: skeletonElements, files = {} } =
const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, {
@@ -6452,6 +6494,20 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerMove = (
event: React.PointerEvent<HTMLCanvasElement>,
) => {
if (!this._diagPointerMoveLogged) {
console.info("[DIAG] pointerMove (first)", {
pointerType: event.pointerType,
isPanning,
isHoldingSpace,
viewModeEnabled: this.state.viewModeEnabled,
activeTool: this.state.activeTool.type,
cursorButton: this.state.cursorButton,
});
this._diagPointerMoveLogged = true;
setTimeout(() => {
this._diagPointerMoveLogged = false;
}, 2000);
}
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
this.lastPointerMoveEvent = event.nativeEvent;
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
@@ -7232,6 +7288,56 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>,
) => {
console.info("[DIAG] pointerDown", {
pointerType: event.pointerType,
button: event.button,
isPanning,
isHoldingSpace,
viewModeEnabled: this.state.viewModeEnabled,
penMode: this.state.penMode,
activeTool: this.state.activeTool.type,
editingTextElement: !!this.state.editingTextElement,
openDialog: this.state.openDialog?.name ?? null,
gesturePointers: gesture.pointers.size,
newElement: this.state.newElement?.type ?? null,
target: (event.target as HTMLElement)?.tagName,
defaultPrevented: event.nativeEvent.defaultPrevented,
cancelable: event.nativeEvent.cancelable,
isTrusted: event.nativeEvent.isTrusted,
hasSetPointerCapture: "setPointerCapture" in HTMLElement.prototype,
composedPath: event.nativeEvent
.composedPath()
.slice(0, 3)
.map((el) => (el as HTMLElement).tagName || el.constructor?.name),
});
// Patch nativeEvent to detect if something calls preventDefault/stopPropagation
const nativeEvt = event.nativeEvent;
const origPreventDefault = nativeEvt.preventDefault.bind(nativeEvt);
const origStopProp = nativeEvt.stopPropagation.bind(nativeEvt);
const origStopImmediate =
nativeEvt.stopImmediatePropagation.bind(nativeEvt);
nativeEvt.preventDefault = () => {
console.warn(
"[DIAG] pointerDown preventDefault called!",
new Error().stack,
);
origPreventDefault();
};
nativeEvt.stopPropagation = () => {
console.warn(
"[DIAG] pointerDown stopPropagation called!",
new Error().stack,
);
origStopProp();
};
nativeEvt.stopImmediatePropagation = () => {
console.warn(
"[DIAG] pointerDown stopImmediatePropagation called!",
new Error().stack,
);
origStopImmediate();
};
// If Ctrl is not held, ensure isBindingEnabled reflects the user preference.
if (!event.ctrlKey) {
const preferenceEnabled = this.state.bindingPreference === "enabled";
@@ -7366,6 +7472,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (isPanning) {
console.info("[DIAG] pointerDown EXIT: isPanning");
return;
}
@@ -7375,6 +7482,7 @@ class App extends React.Component<AppProps, AppState> {
// else it will send pointer state & laser pointer events in collab when
// panning
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
console.info("[DIAG] pointerDown EXIT: panUsingWheelOrSpaceDrag");
return;
}
@@ -7439,11 +7547,16 @@ class App extends React.Component<AppProps, AppState> {
event.button !== POINTER_BUTTON.TOUCH &&
event.button !== POINTER_BUTTON.ERASER
) {
console.info("[DIAG] pointerDown EXIT: unhandled button", event.button);
return;
}
// don't select while panning
if (gesture.pointers.size > 1) {
console.info(
"[DIAG] pointerDown EXIT: multi-pointer gesture",
gesture.pointers.size,
);
return;
}
@@ -7456,12 +7569,16 @@ class App extends React.Component<AppProps, AppState> {
});
if (this.handleDraggingScrollBar(event, pointerDownState)) {
console.info("[DIAG] pointerDown EXIT: dragging scrollbar");
return;
}
this.clearSelectionIfNotUsingSelection();
if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
console.info(
"[DIAG] pointerDown EXIT: handled by selectionOnPointerDown",
);
return;
}
@@ -7474,6 +7591,11 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type === "image";
if (!allowOnPointerDown) {
console.info("[DIAG] pointerDown EXIT: penMode blocking touch", {
penMode: this.state.penMode,
pointerType: event.pointerType,
activeTool: this.state.activeTool.type,
});
return;
}
@@ -7661,6 +7783,7 @@ class App extends React.Component<AppProps, AppState> {
);
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
console.info("[DIAG] pointerDown: registering move/up listeners");
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
@@ -7669,12 +7792,20 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.eventListeners.onUp = onPointerUp;
pointerDownState.eventListeners.onKeyUp = onKeyUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown;
} else {
console.info(
"[DIAG] pointerDown: NOT registering listeners (viewMode + non-laser)",
);
}
};
private handleCanvasPointerUp = (
event: React.PointerEvent<HTMLCanvasElement>,
) => {
console.info("[DIAG] pointerUp", {
pointerType: event.pointerType,
button: event.button,
});
if (getFeatureFlag("COMPLEX_BINDINGS")) {
this.resetDelayedBindMode();
}
+3 -15
View File
@@ -6,20 +6,14 @@
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island-stronger);
box-shadow: var(--shadow-island);
border-radius: 4px;
position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-sections,
.picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-container button,
.picker button {
position: relative;
@@ -68,13 +62,7 @@
.picker-collapsible {
font-size: 0.75rem;
padding: 0;
color: var(--text-primary-color);
}
.picker-section-label {
font-size: 0.75rem;
color: var(--text-primary-color);
padding: 0.5rem 0;
}
.picker-keybinding {
+67 -157
View File
@@ -1,6 +1,6 @@
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useEffect, useMemo } from "react";
import React, { useEffect } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -8,15 +8,13 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible";
import { useExcalidrawContainer } from "./App";
import { useEditorInterface, useExcalidrawContainer } from "./App";
import "./IconPicker.scss";
import type { JSX } from "react";
const moreOptionsAtom = atom(false);
const PICKER_COLUMNS = 4;
const DEFAULT_SECTION_NAME = "default";
type Option<T> = {
value: T;
@@ -25,73 +23,28 @@ type Option<T> = {
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>({
visibleSections,
hiddenSections = [],
options,
value,
label,
onChange,
onClose,
numberOfOptionsToAlwaysShow = options.length,
}: {
label: string;
value: T;
visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
options: readonly Option<T>[];
onChange: (value: T) => void;
onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) {
const editorInterface = useEditorInterface();
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 pressedOption = allOptions.find(
const pressedOption = options.find(
(option) => option.keyBinding === event.key.toLowerCase(),
);
)!;
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
@@ -99,17 +52,17 @@ function Picker<T>({
event.preventDefault();
} else if (event.key === KEYS.TAB) {
const index = allOptions.findIndex((option) => option.value === value);
const index = options.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey
? (allOptions.length + index - 1) % allOptions.length
: (index + 1) % allOptions.length;
onChange(allOptions[nextIndex].value);
? (options.length + index - 1) % options.length
: (index + 1) % options.length;
onChange(options[nextIndex].value);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const isRTL = getLanguage().rtl;
const index = allOptions.findIndex((option) => option.value === value);
const index = options.findIndex((option) => option.value === value);
if (index !== -1) {
const length = allOptions.length;
const length = options.length;
let nextIndex = index;
switch (event.key) {
@@ -123,60 +76,18 @@ function Picker<T>({
break;
// Go the next row
case KEYS.ARROW_DOWN: {
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;
}
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
// Go the previous row
case KEYS.ARROW_UP: {
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 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;
}
nextIndex =
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
}
onChange(allOptions[nextIndex].value);
onChange(options[nextIndex].value);
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -188,29 +99,38 @@ function Picker<T>({
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(() => {
if (hasOption(hiddenSections, (option) => option.value === value)) {
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
setShowMoreOptions(true);
}
}, [value, hiddenSections, setShowMoreOptions]);
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
const renderOptions = (options: readonly Option<T>[]) => {
const renderOptions = (options: Option<T>[]) => {
return (
<div className="picker-content">
{options.map((option) => (
{options.map((option, i) => (
<button
type="button"
className={clsx("picker-option", {
active: value === option.value,
})}
onClick={() => {
onClick={(event) => {
onChange(option.value);
}}
title={
option.keyBinding
? `${option.text}${option.keyBinding.toUpperCase()}`
: option.text
}
title={`${option.text} ${
option.keyBinding && `${option.keyBinding.toUpperCase()}`
}`}
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined}
key={option.text}
@@ -233,38 +153,26 @@ function Picker<T>({
);
};
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>
),
);
const isMobile = editorInterface.formFactor === "phone";
return (
<Popover.Content
className="picker"
role="dialog"
aria-modal="true"
aria-label={label}
side={"bottom"}
side={isMobile ? "right" : "bottom"}
align="start"
sideOffset={12}
alignOffset={12}
sideOffset={isMobile ? 8 : 12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
>
<div className="picker-sections">
{renderSections(visibleSections)}
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{hiddenSections.length > 0 && (
{moreOptions.length > 0 && (
<Collapsible
label={t("labels.more_options")}
open={showMoreOptions}
@@ -273,9 +181,7 @@ function Picker<T>({
}}
className="picker-collapsible"
>
<div className="picker-sections">
{renderSections(hiddenSections)}
</div>
{renderOptions(moreOptions)}
</Collapsible>
)}
</div>
@@ -286,45 +192,49 @@ function Picker<T>({
export function IconPicker<T>({
value,
label,
visibleSections,
hiddenSections,
options,
onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: {
label: string;
value: T;
visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
options: readonly {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) {
const [isActive, setActive] = React.useState(false);
const selectedOption = useMemo(
() =>
findOption(visibleSections, (option) => option.value === value) ??
findOption(hiddenSections ?? [], (option) => option.value === value),
[visibleSections, hiddenSections, value],
);
const rPickerButton = React.useRef<any>(null);
return (
<div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger
name={group}
type="button"
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""}
>
{selectedOption?.icon}
{options.find((option) => option.value === value)?.icon}
</Popover.Trigger>
{isActive && (
<Picker
visibleSections={visibleSections}
hiddenSections={hiddenSections}
options={options}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
}}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/>
)}
</Popover.Root>
@@ -1,3 +1,4 @@
import { exportToCanvas } from "@excalidraw/utils/export";
import React, { useEffect, useRef, useState } from "react";
import {
@@ -5,7 +6,6 @@ import {
EXPORT_IMAGE_TYPES,
isFirefox,
EXPORT_SCALES,
THEME,
cloneJSON,
} from "@excalidraw/common";
@@ -26,7 +26,6 @@ import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
@@ -129,26 +128,19 @@ const ImageExportModal = ({
};
exportToCanvas({
data: {
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportScale,
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,
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then(async (canvas) => {
if (isStaleRequest()) {
@@ -72,20 +72,18 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg({
data: {
elements,
appState: {
exportBackground: false,
viewBackgroundColor: "#fff",
},
files: null,
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: "#fff",
exportWithDarkMode: theme === "dark",
},
config: {
null, // files
{
skipInliningFonts: true,
theme,
},
});
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
@@ -136,20 +134,18 @@ const PlainTextPreviewBtn = (props: {
const previewNode = previewRef.current!;
(async () => {
const svg = await exportToSvg({
data: {
elements: [textElement],
appState: {
exportBackground: false,
viewBackgroundColor: "#fff",
},
files: null,
const svg = await exportToSvg(
[textElement],
{
exportBackground: false,
viewBackgroundColor: "#fff",
exportWithDarkMode: theme === "dark",
},
config: {
null,
{
skipInliningFonts: true,
theme,
},
});
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
@@ -1,3 +1,4 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export";
import { useCallback, useEffect, useRef, useState } from "react";
import {
@@ -12,7 +13,6 @@ import {
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import { canvasToBlob, resizeImageFile } from "../data/blob";
import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { Dialog } from "./Dialog";
import DialogActionButton from "./DialogActionButton";
@@ -63,14 +63,9 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
// ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({
data: {
elements: item.elements,
files: null,
appState: {},
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
elements: item.elements,
files: null,
maxWidthOrHeight: BOX_SIZE,
});
const { width, height } = itemCanvas;
@@ -132,18 +127,14 @@ const SingleLibraryItem = ({
}
(async () => {
const svg = await exportToSvg({
data: {
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: "#fff",
exportBackground: true,
},
files: null,
},
config: {
skipInliningFonts: true,
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: "#fff",
exportBackground: true,
},
files: null,
skipInliningFonts: true,
});
node.innerHTML = svg.outerHTML;
})();
@@ -236,40 +236,15 @@ const MermaidToExcalidraw = ({
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
classLink={(el) => (
<a
href="https://mermaid.js.org/syntax/classDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
erdLink={(el) => (
<a
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a>
)}
/>
</div>
@@ -1,4 +1,8 @@
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
import {
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element";
@@ -90,7 +94,7 @@ export const convertMermaidToExcalidraw = async ({
}
}
const { elements, files = {} } = ret;
const { elements, files } = ret;
setError(null);
data.current = {
@@ -101,16 +105,14 @@ export const convertMermaidToExcalidraw = async ({
};
const canvas = await exportToCanvas({
data: {
elements: data.current.elements,
files: data.current.files,
},
config: {
padding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
theme,
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
appState: {
exportWithDarkMode: theme === THEME.DARK,
},
});
+78 -137
View File
@@ -69,11 +69,6 @@ const modifiedTablerIconProps: Opts = {
strokeLinejoin: "round",
} as const;
const arrowheadPreviewIconProps: Opts = {
width: 40,
height: 20,
} as const;
// -----------------------------------------------------------------------------
// tabler-icons: present
@@ -1296,17 +1291,16 @@ export const ArrowheadNoneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
transform={flip ? "translate(24, 0) scale(-1, 1)" : ""}
stroke="currentColor"
opacity={0.3}
strokeWidth={2}
fill="none"
strokeLinecap="round"
>
<path d="M7,11 H19" />
<path d="M25,6 L33,16 M33,6 L25,16" />
<path d="M12 12l-9 0" />
<path d="M21 9l-6 6" />
<path d="M21 15l-6 -6" />
</g>,
arrowheadPreviewIconProps,
tablerIconProps,
),
);
@@ -1318,46 +1312,11 @@ export const ArrowheadArrowIcon = React.memo(
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7,11 H33 M23,5 L33,11 L23,17" />
<path d="M34 10H6M34 10L27 5M34 10L27 15" />
<path d="M27.5 5L34.5 10L27.5 15" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadTriangleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
>
<path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
<path d="M23,5 L35,11 L23,17 Z" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadTriangleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
>
<path d="M7,11 H23" />
<path d="M23,5 L35,11 L23,17 Z" />
</g>,
arrowheadPreviewIconProps,
{ width: 40, height: 20 },
),
);
@@ -1369,10 +1328,10 @@ export const ArrowheadCircleIcon = React.memo(
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" />
<path d="M32 10L6 10" strokeWidth={2} />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
arrowheadPreviewIconProps,
{ width: 40, height: 20 },
),
);
@@ -1385,10 +1344,58 @@ export const ArrowheadCircleOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M7,11 H25" strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
<path d="M26 10L6 10" />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
arrowheadPreviewIconProps,
{ width: 40, height: 20 },
),
);
export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
stroke="currentColor"
strokeWidth={2}
fill="none"
/>
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadTriangleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadTriangleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
strokeLinejoin="round"
>
<path d="M6,9.5H27" />
<path d="M27,5L34,10L27,14Z" fill="none" />
</g>,
{ width: 40, height: 20 },
),
);
@@ -1400,11 +1407,12 @@ export const ArrowheadDiamondIcon = React.memo(
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" />
<path d="M21,11 L28,5 L35,11 L28,17 Z" />
<path d="M6,9.5H20" />
<path d="M27,5L34,10L27,14L20,9.5Z" />
</g>,
arrowheadPreviewIconProps,
{ width: 40, height: 20 },
),
);
@@ -1417,32 +1425,15 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
strokeWidth={2}
strokeLinecap="round"
>
<path d="M7,11 H21" />
<path d="M21,11 L28,5 L35,11 L28,17 Z" />
<path d="M6,9.5H20" />
<path d="M27,5L34,10L27,14L20,9.5Z" />
</g>,
arrowheadPreviewIconProps,
{ width: 40, height: 20 },
),
);
export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
>
<path d="M11,11 H31 M31,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityOneIcon = React.memo(
export const ArrowheadCrowfootIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
@@ -1452,13 +1443,13 @@ export const ArrowheadCardinalityOneIcon = React.memo(
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H7 M15,5 V17" />
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
</g>,
arrowheadPreviewIconProps,
{ width: 40, height: 20 },
),
);
export const ArrowheadCardinalityManyIcon = React.memo(
export const ArrowheadCrowfootOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
@@ -1468,13 +1459,13 @@ export const ArrowheadCardinalityManyIcon = React.memo(
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" />
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
</g>,
arrowheadPreviewIconProps,
{ width: 40, height: 20 },
),
);
export const ArrowheadCardinalityOneOrManyIcon = React.memo(
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
@@ -1484,59 +1475,9 @@ export const ArrowheadCardinalityOneOrManyIcon = React.memo(
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" />
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
</g>,
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,
{ width: 40, height: 20 },
),
);
+6 -4
View File
@@ -34,10 +34,9 @@
--popup-text-color: #000;
--popup-text-inverted-color: #fff;
--select-highlight-color: #{$color-blue-5};
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--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%);
--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);
--button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high);
@@ -211,6 +210,9 @@
--popup-text-color: #{$color-gray-4};
--popup-text-inverted-color: #2c2c2c;
--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),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
+17 -35
View File
@@ -4,7 +4,6 @@ import {
IMAGE_MIME_TYPES,
isFirefox,
MIME_TYPES,
THEME,
cloneJSON,
SVG_DOCUMENT_PREAMBLE,
} from "@excalidraw/common";
@@ -116,29 +115,20 @@ export const exportCanvas = async (
if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg({
data: {
elements,
appState: {
...appState,
exportBackground,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
const svgPromise = exportToSvg(
elements,
{
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
config: {
padding: exportPadding,
exportingFrame,
theme,
canvasBackgroundColor: exportBackground
? viewBackgroundColor
: "transparent",
},
});
files,
{ exportingFrame },
);
if (type === "svg") {
return fileSave(
@@ -168,19 +158,11 @@ export const exportCanvas = async (
}
}
const tempCanvas = exportToCanvas({
data: {
elements,
appState,
files,
},
config: {
canvasBackgroundColor: exportBackground ? viewBackgroundColor : false,
padding: exportPadding,
theme,
scale: appState.exportScale,
exportingFrame,
},
const tempCanvas = exportToCanvas(elements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
if (type === "png") {
+2 -8
View File
@@ -22,7 +22,6 @@ import {
import {
calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements,
normalizeArrowhead,
isPointInElement,
isValidPolygon,
projectFixedPointOntoDiagonal,
@@ -427,8 +426,7 @@ export const restoreElement = (
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
case "draw":
const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead = normalizeArrowhead(element.endArrowhead);
const { startArrowhead = null, endArrowhead = null } = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
@@ -460,11 +458,7 @@ export const restoreElement = (
...getSizeFromPoints(points),
});
case "arrow": {
const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead =
element.endArrowhead === undefined
? "arrow"
: normalizeArrowhead(element.endArrowhead);
const { startArrowhead = null, endArrowhead = "arrow" } = element;
const x: number | undefined = element.x;
const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
+47 -109
View File
@@ -6,51 +6,6 @@ import { getDefaultAppState } from "../appState";
import type { AppState } from "../types";
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
* only when the specified prop(s) change not on every appState update.
@@ -64,109 +19,92 @@ const getLatestValue = (
* - `(keyof AppState)[]` whole `AppState`
* - 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
* value once available.
*/
export function useAppStateValue<K extends keyof AppState>(
prop: K,
callback?: (value: AppState[K], appState: AppState) => void,
_internal?: boolean,
): AppState[K];
export function useAppStateValue(
props: (keyof AppState)[],
callback?: (props: AppState, appState: AppState) => void,
_internal?: boolean,
): AppState;
export function useAppStateValue<T>(
selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
_internal?: boolean,
): T;
export function useAppStateValue(
selector: AppStateSelector,
selector:
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown),
callback?: (value: any, appState: AppState) => void,
_internal: boolean = true,
): unknown {
const api = useExcalidrawAPI();
const [, rerender] = useState(0);
const stateRef = useRef<{
selector: AppStateSelector;
isInitialized: boolean;
latestValue: unknown;
} | null>(null);
if (!stateRef.current) {
stateRef.current = {
selector,
isInitialized: !!api,
latestValue: getLatestValue(api, selector, _internal),
};
}
stateRef.current.selector = selector;
if (!stateRef.current.isInitialized && api && !api.isDestroyed) {
stateRef.current.isInitialized = true;
stateRef.current.latestValue = getLatestValue(api, selector, _internal);
}
useEffect(() => {
const currentStateRef = stateRef.current;
if (!api || api.isDestroyed || !currentStateRef) {
return;
const getLatestValue = () => {
let appState = api?.getAppState();
if (!appState) {
if (!_internal) {
return undefined;
}
console.warn(
"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?",
);
// 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(),
);
}
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
return api.onStateChange(currentStateRef.selector, (newValue: any) => {
currentStateRef.latestValue = newValue;
rerender((value) => value + 1);
});
}, [api]);
return stateRef.current.latestValue;
}
/**
* Subscribes to specific appState changes without causing component rerenders.
*
* The callback is called on every matching change, but also on initial render
* so you can initialize your state.
*/
export function useOnAppStateChange<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
props: (keyof AppState)[],
callback: (props: AppState, appState: AppState) => void,
): undefined;
export function useOnAppStateChange<T>(
selector: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
selector: AppStateSelector,
callback: (value: any, appState: AppState) => void,
): undefined {
const api = useExcalidrawAPI();
const [value, setValue] = useState<unknown>(getLatestValue);
const stateRef = useRef({
selector,
callback,
isInitialized: !!api,
latestValue: value,
});
stateRef.current.selector = selector;
stateRef.current.callback = callback;
if (!stateRef.current.isInitialized && api) {
stateRef.current.isInitialized = true;
stateRef.current.latestValue = getLatestValue();
}
useEffect(() => {
if (!api || api.isDestroyed) {
if (!api) {
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);
stateRef.current.latestValue = newValue;
stateRef.current.callback?.(newValue, state);
setValue(newValue);
},
);
}, [api]);
return undefined;
return stateRef.current.latestValue;
}
+10 -12
View File
@@ -1,9 +1,8 @@
import { exportToSvg } from "@excalidraw/utils/export";
import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "@excalidraw/common";
import { exportToSvg } from "../scene/export";
import { atom, useAtom } from "../editor-jotai";
import type { LibraryItem } from "../types";
@@ -13,18 +12,17 @@ export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
export const libraryItemSvgsCache = atom<SvgCache>(new Map());
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
// TODO should pass theme (appState.exportWithDark) - we're still using
// CSS filter here
return await exportToSvg({
data: {
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
},
config: {
skipInliningFonts: true,
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
});
};
+23 -19
View File
@@ -1,3 +1,4 @@
import { getDefaultAppState } from "./appState";
import { exportToCanvas } from "./scene/export";
const fs = require("fs");
@@ -58,23 +59,26 @@ const elements = [
registerFont("./public/Virgil.woff2", { family: "Virgil" });
registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
(async () => {
const canvas = await exportToCanvas({
data: {
elements: elements as any,
appState: {},
files: {},
},
config: {
canvasBackgroundColor: "#ffffff",
createCanvas,
},
});
const canvas = exportToCanvas(
elements as any,
{
...getDefaultAppState(),
offsetTop: 0,
offsetLeft: 0,
width: 0,
height: 0,
},
{}, // files
{
exportBackground: true,
viewBackgroundColor: "#ffffff",
},
createCanvas,
);
const out = fs.createWriteStream("test.png");
const stream = (canvas as any).createPNGStream();
stream.pipe(out);
out.on("finish", () => {
console.info("test.png was created.");
});
})();
const out = fs.createWriteStream("test.png");
const stream = (canvas as any).createPNGStream();
stream.pipe(out);
out.on("finish", () => {
console.info("test.png was created.");
});
+18 -33
View File
@@ -1,10 +1,4 @@
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
@@ -18,10 +12,7 @@ import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollab
import MainMenu from "./components/main-menu/MainMenu";
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
import { defaultLang } from "./i18n";
import {
useAppStateValue as _useAppStateValue,
useOnAppStateChange as _useOnAppStateChange,
} from "./hooks/useAppStateValue";
import { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue";
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
import polyfill from "./polyfill";
@@ -40,8 +31,8 @@ polyfill();
/**
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built
* on it, such as `useAppStateValue()` and `useOnAppStateChange()`) to work
* outside the <Excalidraw> component tree.
* on it, such as `useAppStateValue()`) to work outside the <Excalidraw>
* component tree.
*/
export const ExcalidrawAPIProvider = ({
children,
@@ -66,7 +57,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
initialData,
onExcalidrawAPI,
onMount,
onUnmount,
onInitialize,
isCollaborating = false,
onPointerUpdate,
@@ -129,16 +119,12 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
}
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
const onExcalidrawAPIRef = useRef(onExcalidrawAPI);
onExcalidrawAPIRef.current = onExcalidrawAPI;
const handleExcalidrawAPI = useCallback(
(api: ExcalidrawImperativeAPI | null) => {
(api: ExcalidrawImperativeAPI) => {
setExcalidrawAPI?.(api);
onExcalidrawAPIRef.current?.(api);
onExcalidrawAPI?.(api);
},
[setExcalidrawAPI],
[onExcalidrawAPI, setExcalidrawAPI],
);
useEffect(() => {
@@ -176,7 +162,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
initialData={initialData}
onExcalidrawAPI={handleExcalidrawAPI}
onMount={onMount}
onUnmount={onUnmount}
onInitialize={onInitialize}
isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate}
@@ -304,9 +289,7 @@ export {
exportToBlob,
exportToSvg,
exportToClipboard,
} from "./scene/export";
export type { ExportSceneData, ExportSceneConfig } from "./scene/export";
} from "@excalidraw/utils/export";
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
export {
@@ -395,7 +378,7 @@ export {
} from "./charts";
// -----------------------------------------------------------------------------
// useExcalidrawStateValue() wrapper for host apps for the return type to reflect the
// useAppStateValue() wrapper for host apps for the return type to reflect the
// the potentially `undefined` value for initial render before the excalidrawAPI
// is ready.
//
@@ -405,23 +388,25 @@ export {
* @param prop - appState prop(s) to subscribe to, or a selector function.
* NOTE `prop/selector` is memoized and will not change after initial render
*/
export function useExcalidrawStateValue<K extends keyof AppState>(
export function useAppStateValue<K extends keyof AppState>(
prop: K,
callback?: (value: AppState[K], appState: AppState) => void,
): AppState[K] | undefined;
export function useExcalidrawStateValue<T extends keyof AppState>(
export function useAppStateValue<T extends keyof AppState>(
props: T[],
callback?: (values: AppState, appState: AppState) => void,
): AppState | undefined;
export function useExcalidrawStateValue<T>(
export function useAppStateValue<T>(
selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
): T | undefined;
export function useExcalidrawStateValue(
export function useAppStateValue(
selector:
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown),
callback?: (value: any, appState: AppState) => void,
) {
return _useAppStateValue(selector as any, false);
return _useAppStateValue(selector as any, callback, false);
}
// -----------------------------------------------------------------------------
export { _useOnAppStateChange as useOnExcalidrawStateChange };
+2 -9
View File
@@ -53,14 +53,7 @@
"arrowhead_crowfoot_many": "Crow's foot (many)",
"arrowhead_crowfoot_one": "Crow's foot (one)",
"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",
"cardinality": "Cardinality",
"arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow",
@@ -631,7 +624,7 @@
"mermaid": {
"title": "Mermaid to Excalidraw",
"button": "Insert",
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>, <sequenceLink>Sequence</sequenceLink>, <classLink>Class</classLink>, and <erdLink>Entity Relationship</erdLink> Diagrams are supported. The other types will be rendered as image in Excalidraw.",
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax",
"preview": "Preview",
"label": "Mermaid",
@@ -661,7 +654,7 @@
"placeholder": {
"title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.",
"hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams."
"hint": "At the moment we know Flowchart, Sequence, and Class diagrams."
},
"preview": "Preview",
"insert": "Insert",
+1 -1
View File
@@ -88,7 +88,7 @@
"@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.1.1",
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
"@excalidraw/random-username": "1.1.0",
"browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1",
+123 -672
View File
@@ -1,13 +1,13 @@
import rough from "roughjs/bin/rough";
import {
DEFAULT_EXPORT_PADDING,
FRAME_STYLE,
FONT_FAMILY,
SVG_NS,
THEME,
MIME_TYPES,
EXPORT_DATA_TYPES,
COLOR_WHITE,
arrayToMap,
distance,
getFontString,
@@ -47,33 +47,20 @@ import type {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Theme,
} from "@excalidraw/element/types";
import { getDefaultAppState } from "../appState";
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
import { serializeAsJSON } from "../data/json";
import { restoreAppState } from "../data/restore";
import { encodePngMetadata } from "../data/image";
import { Fonts } from "../fonts";
import { renderStaticScene } from "../renderer/staticScene";
import { renderSceneToSvg } from "../renderer/staticSvgScene";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "../clipboard";
import type { RenderableElementsMap } 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;
import type { AppState, BinaryFiles } from "../types";
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) {
@@ -182,205 +169,36 @@ const prepareElementsForRender = ({
return nextElements;
};
// ---------------------------------------------------------------------------
// Types for the new API
// ---------------------------------------------------------------------------
export type ExportSceneData = {
elements: readonly NonDeletedExcalidrawElement[];
appState?: Partial<
Omit<AppState, "offsetTop" | "offsetLeft" | "exportWithDarkMode">
>;
files: BinaryFiles | null;
};
export type ExportSceneConfig = {
theme?: Theme;
/**
* 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?: (
export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
exportingFrame,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
exportingFrame?: ExcalidrawFrameLikeElement | null;
},
createCanvas: (
width: number,
height: number,
) => { width: number; height: number; scale?: number };
exportingFrame?: ExcalidrawFrameLikeElement | null;
loadFonts?: () => Promise<void>;
};
// ---------------------------------------------------------------------------
// Internal helper to configure export dimensions
// ---------------------------------------------------------------------------
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);
) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => {
const canvas = document.createElement("canvas");
canvas.width = width * appState.exportScale;
canvas.height = height * appState.exportScale;
return { canvas, scale: appState.exportScale };
},
loadFonts: () => Promise<void> = async () => {
await Fonts.loadElementsFonts(elements);
},
) => {
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
await loadFonts();
const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null,
@@ -400,255 +218,26 @@ const configExportDimension = async ({
});
if (exportingFrame) {
cfg.padding = 0;
exportPadding = 0;
}
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(
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
// variables for original content bounding box
const [origX, origY, origWidth, origHeight] = origCanvasSize;
// variables for target bounding box
let [x, y, width, height] = origCanvasSize;
const { canvas, scale = 1 } = createCanvas(width, height);
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 defaultAppState = getDefaultAppState();
const { imageCache } = await updateImageCache({
imageCache: new Map(),
fileIds: getInitializedImageElements(elementsForRender).map(
(element) => element.fileId,
),
files: data.files || {},
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({
canvas,
rc: rough.canvas(canvas),
@@ -656,23 +245,19 @@ export const exportToCanvas = async ({
arrayToMap(elementsForRender),
),
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(syncInvalidIndices(data.elements)),
arrayToMap(syncInvalidIndices(elements)),
),
visibleElements: elementsForRender,
scale: exportScale,
scale,
appState: {
...appState,
frameRendering,
width,
height,
offsetLeft: 0,
offsetTop: 0,
scrollX: -x + normalizedPadding,
scrollY: -y + normalizedPadding,
zoom: { value: DEFAULT_ZOOM_VALUE },
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + exportPadding,
scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom,
shouldCacheIgnoreZoom: false,
theme,
viewBackgroundColor,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
},
renderConfig: {
canvasBackgroundColor: viewBackgroundColor,
@@ -683,44 +268,13 @@ export const exportToCanvas = async ({
embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(),
pendingFlowchartNodes: null,
theme,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
},
});
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) => {
// surrounding with spaces to maintain prettified consistency with previous
// iterations
@@ -728,34 +282,61 @@ const createHTMLComment = (text: string) => {
return document.createComment(` ${text} `);
};
export const exportToSvg = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportToSvgConfig;
}) => {
const {
config: cfg,
normalizedPadding,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
export const exportToSvg = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: {
exportBackground: boolean;
exportPadding?: number;
exportScale?: number;
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
frameRendering?: AppState["frameRendering"];
},
files: BinaryFiles | null,
opts?: {
/**
* if true, all embeddables passed in will be rendered when possible.
*/
renderEmbeddables?: boolean;
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,
} = await configExportDimension({ data, config });
});
const offsetX = -(x - normalizedPadding);
const offsetY = -(y - normalizedPadding);
if (exportingFrame) {
exportPadding = 0;
}
const { elements } = data;
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
const theme =
cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT);
const exportWithDarkMode = theme === THEME.DARK;
const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
// ---------------------------------------------------------------------------
// initialize SVG root element
@@ -765,12 +346,9 @@ export const exportToSvg = async ({
svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute(
"viewBox",
`0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`,
);
svgRoot.setAttribute("width", `${exportWidth}`);
svgRoot.setAttribute("height", `${exportHeight}`);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
svgRoot.setAttribute("width", `${width * exportScale}`);
svgRoot.setAttribute("height", `${height * exportScale}`);
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
@@ -789,7 +367,7 @@ export const exportToSvg = async ({
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (appState.exportEmbedScene) {
if (exportEmbedScene) {
try {
encodeSvgBase64Payload({
metadataElement,
@@ -797,7 +375,7 @@ export const exportToSvg = async ({
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
payload: serializeAsJSON(elements, appState, data.files || {}, "local"),
payload: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (error: any) {
console.error(error);
@@ -835,7 +413,7 @@ export const exportToSvg = async ({
rect.setAttribute("width", `${frame.width}`);
rect.setAttribute("height", `${frame.height}`);
if (!cfg.exportingFrame) {
if (!exportingFrame) {
rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
}
@@ -850,10 +428,9 @@ export const exportToSvg = async ({
// inline font faces
// ---------------------------------------------------------------------------
const fontFaces =
config?.skipInliningFonts !== true
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const fontFaces = !opts?.skipInliningFonts
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces
@@ -870,16 +447,17 @@ export const exportToSvg = async ({
// ---------------------------------------------------------------------------
// render background rect
if (appState.exportBackground && appState.viewBackgroundColor) {
const bgColor = cfg.canvasBackgroundColor || appState.viewBackgroundColor;
if (appState.exportBackground && viewBackgroundColor) {
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0");
rect.setAttribute("y", "0");
rect.setAttribute("width", `${exportWidth / exportScale}`);
rect.setAttribute("height", `${exportHeight / exportScale}`);
rect.setAttribute("width", `${width}`);
rect.setAttribute("height", `${height}`);
rect.setAttribute(
"fill",
exportWithDarkMode ? applyDarkModeFilter(bgColor) : bgColor,
exportWithDarkMode
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
);
svgRoot.appendChild(rect);
}
@@ -890,14 +468,14 @@ export const exportToSvg = async ({
const rsvg = rough.svg(svgRoot);
const renderEmbeddables = config?.renderEmbeddables ?? false;
const renderEmbeddables = opts?.renderEmbeddables ?? false;
renderSceneToSvg(
elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
rsvg,
svgRoot,
data.files || {},
files || {},
{
offsetX,
offsetY,
@@ -905,7 +483,7 @@ export const exportToSvg = async ({
exportWithDarkMode,
renderEmbeddables,
frameRendering,
canvasBackgroundColor: appState.viewBackgroundColor,
canvasBackgroundColor: viewBackgroundColor,
embedsValidationStatus: renderEmbeddables
? new Map(
elementsForRender
@@ -913,8 +491,8 @@ export const exportToSvg = async ({
.map((element) => [element.id, true]),
)
: new Map(),
reuseImages: config?.reuseImages ?? true,
theme,
reuseImages: opts?.reuseImages ?? true,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
},
);
@@ -923,10 +501,6 @@ export const exportToSvg = async ({
return svgRoot;
};
// ---------------------------------------------------------------------------
// SVG payload encoding/decoding
// ---------------------------------------------------------------------------
export const encodeSvgBase64Payload = ({
payload,
metadataElement,
@@ -982,149 +556,26 @@ export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
throw new Error("INVALID");
};
// ---------------------------------------------------------------------------
// getCanvasSize
// ---------------------------------------------------------------------------
// calculate smallest area to fit the contents in
export const getCanvasSize = (
const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): Bounds => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = distance(minX, maxX);
const height = distance(minY, maxY);
const width = distance(minX, maxX) + exportPadding * 2;
const height = distance(minY, maxY) + exportPadding * 2;
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 = (
elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
scale: number,
): [number, number] => {
const [, , width, height] = getCanvasSize(elements);
const [, , width, height] = getCanvasSize(elements, exportPadding).map(
(dimension) => Math.trunc(dimension * scale),
);
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");
}
return [width, height];
};
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD
@@ -1,111 +0,0 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { getDefaultAppState } from "../appState";
import { ExcalidrawAPIContext } from "../components/App";
import { AppStateObserver } from "../components/AppStateObserver";
import {
useAppStateValue,
useOnAppStateChange,
} from "../hooks/useAppStateValue";
import type { AppState, ExcalidrawImperativeAPI } from "../types";
const createAppState = (): AppState => ({
...getDefaultAppState(),
width: 0,
height: 0,
offsetLeft: 0,
offsetTop: 0,
});
const createMockAPI = (initialState: AppState) => {
let state = initialState;
const observer = new AppStateObserver(() => state);
return {
api: {
isDestroyed: false,
getAppState: () => state,
onStateChange: observer.onStateChange,
} as Pick<
ExcalidrawImperativeAPI,
"isDestroyed" | "getAppState" | "onStateChange"
> as ExcalidrawImperativeAPI,
updateAppState: (partial: Partial<AppState>) => {
const prevState = state;
state = { ...state, ...partial };
observer.flush(prevState);
},
};
};
describe("app state hooks", () => {
afterEach(() => {
cleanup();
});
it("useAppStateValue rerenders when the selected value changes", () => {
const renderSpy = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ValueConsumer = () => {
const value = useAppStateValue("viewModeEnabled");
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ValueConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("false");
expect(renderSpy).toHaveBeenCalledTimes(1);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(screen.getByTestId("value").textContent).toBe("true");
expect(renderSpy).toHaveBeenCalledTimes(2);
});
it("useOnAppStateChange notifies without rerendering", () => {
const renderSpy = vi.fn();
const callback = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ChangeConsumer = () => {
const value = useOnAppStateChange("viewModeEnabled", callback);
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ChangeConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("undefined");
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
false,
expect.objectContaining({ viewModeEnabled: false }),
);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ viewModeEnabled: true }),
);
});
});
@@ -200,26 +200,6 @@ describe("restoreElements", () => {
});
});
it("should normalize legacy crowfoot arrowheads on restore", () => {
const arrowElement = API.createElement({
type: "arrow",
});
const restoredArrow = restore.restoreElements(
[
{
...arrowElement,
startArrowhead: "crowfoot_one",
endArrowhead: "crowfoot_one_or_many",
} as any,
],
null,
)[0] as ExcalidrawLinearElement;
expect(restoredArrow.startArrowhead).toBe("cardinality_one");
expect(restoredArrow.endArrowhead).toBe("cardinality_one_or_many");
});
it("should strip element if restore fails", () => {
const rect1 = API.createElement({
type: "rectangle",
+6 -14
View File
@@ -83,13 +83,11 @@ describe("export", () => {
});
it("export svg-embedded scene", async () => {
const svg = await exportToSvg({
data: {
elements: testElements,
appState: { ...getDefaultAppState(), exportEmbedScene: true },
files: {},
},
});
const svg = await exportToSvg(
testElements,
{ ...getDefaultAppState(), exportEmbedScene: true },
{},
);
const svgText = svg.outerHTML;
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
@@ -207,13 +205,7 @@ describe("export", () => {
},
} as const;
const svg = await exportToSvg({
data: {
elements,
appState,
files,
},
});
const svg = await exportToSvg(elements, appState, files);
const svgText = svg.outerHTML;
@@ -41,15 +41,13 @@ describe("event callbacks", () => {
await render(
<Excalidraw
onExcalidrawAPI={(api) => {
if (api) {
lifecyclePromise.resolve({
api,
mount: api.onEvent("editor:mount"),
initialize: api.onEvent("editor:initialize"),
});
}
}}
onExcalidrawAPI={(api) =>
lifecyclePromise.resolve({
api: api as ExcalidrawImperativeAPI,
mount: api.onEvent("editor:mount"),
initialize: api.onEvent("editor:initialize"),
})
}
/>,
);
File diff suppressed because one or more lines are too long
+93 -146
View File
@@ -1,10 +1,9 @@
import { exportToCanvas, exportToSvg } from "../../scene/export";
import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
import {
applyDarkModeFilter,
FONT_FAMILY,
FRAME_STYLE,
THEME,
} from "@excalidraw/common";
import type {
@@ -54,42 +53,39 @@ describe("exportToSvg", () => {
},
] as NonDeletedExcalidrawElement[];
const DEFAULT_APP_STATE = {
const DEFAULT_OPTIONS = {
exportBackground: false,
viewBackgroundColor: "#ffffff",
files: {},
};
it("with default arguments", async () => {
const svgElement = await exportUtils.exportToSvg({
data: {
elements: ELEMENTS,
appState: DEFAULT_APP_STATE,
files: {},
},
});
const svgElement = await exportUtils.exportToSvg(
ELEMENTS,
DEFAULT_OPTIONS,
null,
);
expect(svgElement).toMatchSnapshot();
});
it("with a CJK font", async () => {
const svgElement = await exportUtils.exportToSvg({
data: {
elements: [
...ELEMENTS,
{
...textFixture,
height: ELEMENT_HEIGHT,
width: ELEMENT_WIDTH,
text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
originalText:
"中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
index: "a4" as FractionalIndex,
} as ExcalidrawTextElement,
],
appState: DEFAULT_APP_STATE,
files: {},
},
});
const svgElement = await exportUtils.exportToSvg(
[
...ELEMENTS,
{
...textFixture,
height: ELEMENT_HEIGHT,
width: ELEMENT_WIDTH,
text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
originalText:
"中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
index: "a4" as FractionalIndex,
} as ExcalidrawTextElement,
],
DEFAULT_OPTIONS,
null,
);
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)
@@ -98,17 +94,15 @@ describe("exportToSvg", () => {
it("with background color", async () => {
const BACKGROUND_COLOR = "#abcdef";
const svgElement = await exportUtils.exportToSvg({
data: {
elements: ELEMENTS,
appState: {
...DEFAULT_APP_STATE,
exportBackground: true,
viewBackgroundColor: BACKGROUND_COLOR,
},
files: {},
const svgElement = await exportUtils.exportToSvg(
ELEMENTS,
{
...DEFAULT_OPTIONS,
exportBackground: true,
viewBackgroundColor: BACKGROUND_COLOR,
},
});
null,
);
expect(svgElement.querySelector("rect")).toHaveAttribute(
"fill",
@@ -117,18 +111,14 @@ describe("exportToSvg", () => {
});
it("with dark mode", async () => {
const svgElement = await exportUtils.exportToSvg({
data: {
elements: ELEMENTS,
appState: {
...DEFAULT_APP_STATE,
},
files: {},
const svgElement = await exportUtils.exportToSvg(
ELEMENTS,
{
...DEFAULT_OPTIONS,
exportWithDarkMode: true,
},
config: {
theme: THEME.DARK,
},
});
null,
);
const textElements = svgElement.querySelectorAll("text");
expect(textElements.length).toBeGreaterThan(0);
@@ -142,16 +132,14 @@ describe("exportToSvg", () => {
});
it("with exportPadding", async () => {
const svgElement = await exportUtils.exportToSvg({
data: {
elements: ELEMENTS,
appState: DEFAULT_APP_STATE,
files: {},
const svgElement = await exportUtils.exportToSvg(
ELEMENTS,
{
...DEFAULT_OPTIONS,
exportPadding: 0,
},
config: {
padding: 0,
},
});
null,
);
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
@@ -164,17 +152,15 @@ describe("exportToSvg", () => {
it("with scale", async () => {
const SCALE = 2;
const svgElement = await exportUtils.exportToSvg({
data: {
elements: ELEMENTS,
appState: DEFAULT_APP_STATE,
files: {},
const svgElement = await exportUtils.exportToSvg(
ELEMENTS,
{
...DEFAULT_OPTIONS,
exportPadding: 0,
exportScale: SCALE,
},
config: {
padding: 0,
scale: SCALE,
},
});
null,
);
expect(svgElement).toHaveAttribute(
"height",
@@ -187,27 +173,23 @@ describe("exportToSvg", () => {
});
it("with exportEmbedScene", async () => {
const svgElement = await exportUtils.exportToSvg({
data: {
elements: ELEMENTS,
appState: {
...DEFAULT_APP_STATE,
exportEmbedScene: true,
},
files: {},
const svgElement = await exportUtils.exportToSvg(
ELEMENTS,
{
...DEFAULT_OPTIONS,
exportEmbedScene: true,
},
});
null,
);
expect(svgElement.innerHTML).toMatchSnapshot();
});
it("with elements that have a link", async () => {
const svgElement = await exportUtils.exportToSvg({
data: {
elements: [rectangleWithLinkFixture],
appState: DEFAULT_APP_STATE,
files: {},
},
});
const svgElement = await exportUtils.exportToSvg(
[rectangleWithLinkFixture],
DEFAULT_OPTIONS,
null,
);
expect(svgElement.innerHTML).toMatchSnapshot();
});
});
@@ -247,14 +229,9 @@ describe("exporting frames", () => {
];
const canvas = await exportToCanvas({
data: {
elements,
appState: {},
files: null,
},
config: {
padding: 0,
},
elements,
files: null,
exportPadding: 0,
});
expect(canvas.width).toEqual(200);
@@ -281,15 +258,10 @@ describe("exporting frames", () => {
];
const canvas = await exportToCanvas({
data: {
elements,
appState: {},
files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
elements,
files: null,
exportPadding: 0,
exportingFrame: frame,
});
expect(canvas.width).toEqual(frame.width);
@@ -325,15 +297,10 @@ describe("exporting frames", () => {
});
const svg = await exportToSvg({
data: {
elements: [rectOverlapping, frame, frameChild],
appState: {},
files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
elements: [rectOverlapping, frame, frameChild],
files: null,
exportPadding: 0,
exportingFrame: frame,
});
// frame itself isn't exported
@@ -374,15 +341,10 @@ describe("exporting frames", () => {
});
const svg = await exportToSvg({
data: {
elements: [frameChild, frame, elementOutside],
appState: {},
files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
elements: [frameChild, frame, elementOutside],
files: null,
exportPadding: 0,
exportingFrame: frame,
});
// frame itself isn't exported
@@ -447,15 +409,10 @@ describe("exporting frames", () => {
);
const svg = await exportToSvg({
data: {
elements: exportedElements,
appState: {},
files: null,
},
config: {
padding: 0,
exportingFrame,
},
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
// frames themselves should be exported when multiple frames selected
@@ -497,15 +454,10 @@ describe("exporting frames", () => {
);
const svg = await exportToSvg({
data: {
elements: exportedElements,
appState: {},
files: null,
},
config: {
padding: 0,
exportingFrame,
},
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
// frame itself isn't exported
@@ -561,15 +513,10 @@ describe("exporting frames", () => {
);
const svg = await exportToSvg({
data: {
elements: exportedElements,
appState: {},
files: null,
},
config: {
padding: 0,
exportingFrame,
},
elements: exportedElements,
files: null,
exportPadding: 0,
exportingFrame,
});
// frame shouldn't be exported
+1 -8
View File
@@ -573,15 +573,11 @@ export interface ExcalidrawProps {
* Invoked as soon as the Excalidraw API is available
* NOTE editor is not yet mounted, and state is not yet initialized
*/
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI | null) => void;
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
/**
* Invoked once the editor root is mounted.
*/
onMount?: (payload: ExcalidrawMountPayload) => void;
/**
* Invoked when the editor root is unmounted.
*/
onUnmount?: () => void;
/**
* Invoked once the initial scene is loaded.
*/
@@ -911,12 +907,9 @@ export type ExcalidrawMountPayload = {
export type ExcalidrawImperativeAPIEventMap = {
"editor:mount": [payload: ExcalidrawMountPayload];
"editor:initialize": [api: ExcalidrawImperativeAPI];
"editor:unmount": [];
};
export interface ExcalidrawImperativeAPI {
/** Whether the editor has been unmounted and the API is no longer usable. */
isDestroyed: boolean;
updateScene: InstanceType<typeof App>["updateScene"];
applyDeltas: InstanceType<typeof App>["applyDeltas"];
mutateElement: InstanceType<typeof App>["mutateElement"];
+216
View File
@@ -0,0 +1,216 @@
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");
}
};
+1 -14
View File
@@ -1,17 +1,4 @@
// 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 "./export";
export * from "./withinBounds";
export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element";
+35 -58
View File
@@ -10,22 +10,9 @@ const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
describe("exportToCanvas", async () => {
const EXPORT_PADDING = 10;
it("with default arguments (no padding)", async () => {
it("with default arguments", async () => {
const canvas = await utils.exportToCanvas({
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,
},
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
});
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
@@ -34,10 +21,8 @@ describe("exportToCanvas", async () => {
it("when custom width and height", async () => {
const canvas = await utils.exportToCanvas({
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
config: {
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
},
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
});
expect(canvas.width).toBe(200);
@@ -48,27 +33,20 @@ describe("exportToCanvas", async () => {
describe("exportToBlob", async () => {
describe("mime type", () => {
it("should change image/jpg to image/jpeg", async () => {
const diagramData = diagramFactory();
const blob = await utils.exportToBlob({
data: {
elements: diagramData.elements,
appState: {
...diagramData.appState,
exportBackground: true,
},
files: diagramData.files,
},
config: {
getDimensions: (width, height) => ({ width, height, scale: 1 }),
// testing typo in MIME type (jpg → jpeg)
mimeType: "image/jpg",
...diagramFactory(),
getDimensions: (width, height) => ({ width, height, scale: 1 }),
// testing typo in MIME type (jpg → jpeg)
mimeType: "image/jpg",
appState: {
exportBackground: true,
},
});
expect(blob?.type).toBe(MIME_TYPES.jpg);
});
it("should default to image/png", async () => {
const blob = await utils.exportToBlob({
data: diagramFactory(),
...diagramFactory(),
});
expect(blob?.type).toBe(MIME_TYPES.png);
});
@@ -78,11 +56,9 @@ describe("exportToBlob", async () => {
.spyOn(console, "warn")
.mockImplementationOnce(() => void 0);
await utils.exportToBlob({
data: diagramFactory(),
config: {
mimeType: MIME_TYPES.png,
quality: 1,
},
...diagramFactory(),
mimeType: MIME_TYPES.png,
quality: 1,
});
expect(consoleSpy).toHaveBeenCalledWith(
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
@@ -92,9 +68,8 @@ describe("exportToBlob", async () => {
});
describe("exportToSvg", () => {
const getPassedArg = () => exportToSvgSpy.mock.calls[0][0];
const passedData = () => getPassedArg().data;
const passedConfig = () => getPassedArg().config;
const passedElements = () => exportToSvgSpy.mock.calls[0][0];
const passedOptions = () => exportToSvgSpy.mock.calls[0][1];
afterEach(() => {
vi.clearAllMocks();
@@ -102,14 +77,18 @@ describe("exportToSvg", () => {
it("with default arguments", async () => {
await utils.exportToSvg({
data: diagramFactory({
...diagramFactory({
overrides: { appState: void 0 },
}),
});
const data = passedData();
expect(data.elements.length).toBe(3);
expect(passedConfig()).toMatchSnapshot();
const passedOptionsWhenDefault = {
...passedOptions(),
// To avoid varying snapshots
name: "name",
};
expect(passedElements().length).toBe(3);
expect(passedOptionsWhenDefault).toMatchSnapshot();
});
// FIXME the utils.exportToSvg no longer filters out deleted elements.
@@ -117,39 +96,37 @@ describe("exportToSvg", () => {
// type-checking for it correctly.
it.skip("with deleted elements", async () => {
await utils.exportToSvg({
data: diagramFactory({
...diagramFactory({
overrides: { appState: void 0 },
elementOverrides: { isDeleted: true },
}),
});
expect(passedData().elements.length).toBe(0);
expect(passedElements().length).toBe(0);
});
it("with padding", async () => {
it("with exportPadding", async () => {
await utils.exportToSvg({
data: diagramFactory({ overrides: { appState: { name: "diagram name" } } }),
config: {
padding: 0,
},
...diagramFactory({ overrides: { appState: { name: "diagram name" } } }),
exportPadding: 0,
});
expect(passedData().elements.length).toBe(3);
expect(passedConfig()).toEqual(
expect.objectContaining({ padding: 0 }),
expect(passedElements().length).toBe(3);
expect(passedOptions()).toEqual(
expect.objectContaining({ exportPadding: 0 }),
);
});
it("with exportEmbedScene", async () => {
await utils.exportToSvg({
data: diagramFactory({
...diagramFactory({
overrides: {
appState: { name: "diagram name", exportEmbedScene: true },
},
}),
});
expect(passedData().elements.length).toBe(3);
expect(passedData().appState?.exportEmbedScene).toBe(true);
expect(passedElements().length).toBe(3);
expect(passedOptions().exportEmbedScene).toBe(true);
});
});
+13 -19
View File
@@ -19,15 +19,13 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse];
const svgNode = await utils.exportToSvg({
data: {
elements: sourceElements,
appState: {
viewBackgroundColor: "#ffffff",
gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
elements: sourceElements,
appState: {
viewBackgroundColor: "#ffffff",
gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
});
const svg = svgNode.outerHTML;
@@ -51,18 +49,14 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse];
const blob = await utils.exportToBlob({
data: {
elements: sourceElements,
appState: {
viewBackgroundColor: "#ffffff",
gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
},
config: {
mimeType: "image/png",
mimeType: "image/png",
elements: sourceElements,
appState: {
viewBackgroundColor: "#ffffff",
gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
});
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"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.1.tgz#659c934a607dd2cf57f2a69282588ee2b0722959"
integrity sha512-jU+frqcxazsY+t5yOBf2mgrQy+WUrbrzA36if3SQB/Vwaf2qOJjnWxucNafgZZk/3+9xGmRotUeOviSOJG+wYA==
"@excalidraw/mermaid-to-excalidraw@2.0.0-rc4":
version "2.0.0-rc4"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03"
integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw==
dependencies:
"@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3"