mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
feat(packages/excalidraw): replace the export functions with exportScene (#12347)
* [export] rewrite - init * restore elements + filter deleted in export API (restores #11470 behavior) * fix snaps * port export callers added on master to the new API + snaps - renderOverrides (#12100), sticky notes (#12064) and arrow-label hole (#10947) tests called the old positional exportToSvg/exportToCanvas - embedded-scene snapshots gain master's new element fields (created, baseFontSize, labelPosition); decoded payloads match master's Claude Opus 5.5 (xhigh) * restore elements in embedded scene + json clipboard export (restores #12060 behavior) - master's #12060 made every public export entry point restore its input (legacy elements reach lower-level code with `created: null`), incl. the PNG metadata embed and the json clipboard copy in the removed @excalidraw/utils wrappers - SVG embed now serializes the restored, non-deleted elements (same as master's utils path), PNG embed serializes restoreElements(data.elements) and json clipboard copies the non-deleted restored elements - port #12060's utils tests to the new API, observing renderers/serializer instead of the (now identical) scene export functions Claude Opus 5.5 (xhigh) * make config.canvasBackgroundColor the single export background knob - background resolved once in configExportDimension; exportToSvg no longer reads appState.exportBackground (now omitted from ExportSceneData's appState type), and honors `canvasBackgroundColor: false` - outline arrowheads (circle/triangle/diamond_outline, cardinality_zero_*) are filled with the resolved background color, also when the background isn't painted (as on master) — the canvas path filled them transparent, showing the arrow's shaft through them; SVG ignored the config color - JPEG: `canvasBackgroundColor: false` falls back to the default background (+ warning) instead of rendering black; no more warning for the default - callers (exportCanvas, ImageExportDialog, library/chart/publish previews, AI) pass the background via config only Claude Opus 5.5 (xhigh) * resolve export theme before creating frame labels - frame labels were colored by appState.exportWithDarkMode, ignoring config.theme (export dialog's dark toggle), and in dark exports the dark-theme label color got dark-filtered again by the renderer (#7a7a7a -> #848484) - theme is now resolved once in configExportDimension; dark labels are pre-reverted so they render with the editor's frame name color Claude Opus 5.5 (xhigh) * drop appState.exportWithDarkMode theme fallback in export - `config.theme` is the only theme input (defaults to light); the type already omitted `exportWithDarkMode` from `data.appState` - callers that relied on the fallback (PublishLibrary item preview, AI frame export) pass `theme` explicitly, keeping their behavior Claude Opus 5.5 (xhigh) * exportToClipboard: don't restore appState before exportToSvg (already restored there) Claude Opus 5.5 (xhigh) * remove unused getExportSize, type getCanvasSize's [x, y, width, height] (not Bounds) Claude Opus 5.5 (xhigh) * fix typos in export comments/JSDoc Claude Opus 5.5 (xhigh) * port with-script-in-browser example to the new export API - exportToSvg/Blob/Canvas/Clipboard take `{ data, config }`; theme via `config.theme`, padding 10 kept explicit (new default is 0) - dropped the no-op `width`/`height` appState overrides from the SVG export Claude Opus 5.5 (xhigh) * call getDimensions once, ignore it when widthOrHeight is set - with an explicit `fit: "contain"`, getDimensions ran twice (the second time on its own output); it now supplies width/height (+ scale) once, and `contain` scales the content into that frame - getDimensions + widthOrHeight both applied; widthOrHeight now wins (with a warning), like width/height/maxWidthOrHeight already did - `fit: "none"` (the E+ export server path) renders identically Claude Opus 5.5 (xhigh) * type the export size options as mutually exclusive - `width`/`height`, `maxWidthOrHeight`, `widthOrHeight` and `getDimensions` are now a union where the unused options are `?: never`, so combining them is a type error (runtime still ignores/warns for JS callers) - the `never` keys are remapped over `keyof`, so every option keeps its JSDoc in completions & hovers (verified via the TS language service) Claude Opus 5.5 (xhigh) * replace getDimensions with a `scale` function, round the canvas size - `config.scale` also accepts `(width, height) => number`, called with the export dimensions at scale 1 (padding included) — covers content-dependent scaling (e.g. E+'s pixel budgets) without the getDimensions round trip - remove `getDimensions`: its returned `scale` replaced `config.scale` and multiplied the returned size, so callers thinking in output pixels had to divide it back out - exportToCanvas rounds the canvas size instead of letting the canvas truncate it (float noise like 28.999999999999996 lost a pixel); half pixels now round up (frame label area 20.5px, mermaid preview 89.5px) Claude Opus 5.5 (xhigh) * align fit/padding/position export docs with the code - `fit` defaults to "contain" when `width`/`height` is set, otherwise "none" (the doc said the opposite); dropped the unimplemented "x/y force none" claim - padding is always subtracted for `maxWidthOrHeight`/`widthOrHeight` (they ignore `fit`), not added - `position` values are "center" | "topLeft" (doc listed "none") Claude Opus 5.5 (xhigh) * derive a missing export width/height from the aspect ratio - with `fit: "contain"` (the default when a size is given), passing only `width` or `height` now derives the other from the content's aspect ratio, as documented — it defaulted to the content's unscaled size, e.g. `{ height: 50 }` of 200x100 content gave a 200x50 canvas with 60x30 content in wide empty bands (now 80x50 with padding 10) - with `fit: "none"` the missing side stays the content extent Claude Opus 5.5 (xhigh) * default export `fit` to "none" when x/y is set - `{ x, y, width, height }` was scaled to fit with `x`/`y` silently ignored (`fit` defaulted to "contain" whenever a size was given); an explicit region now crops by default, `fit: "contain"` still opts in (and ignores `x`/`y`, as now documented) Claude Opus 5.5 (xhigh) * ignore export x/y when the content is scaled to fit - with `fit: "contain"`, `maxWidthOrHeight` or `widthOrHeight`, `x`/`y` shifted the fitted content by a mix of scene units and pixels; they're now dropped with a warning, which the previous commit already documented (its "ignored" claim was based on a coincidental probe) Claude Opus 5.5 (xhigh) * pad an export x/y region on all sides - with `position: "center"` (the default), the centering adjustment also ran on axes pinned by `x`/`y`, shifting the region by the padding: the canvas started exactly at `x`/`y` with all padding on the right/bottom (`position: "topLeft"` padded all sides); `position` now only applies to unpinned axes, as documented Claude Opus 5.5 (xhigh) * consolidate exportToCanvas/Blob/Svg into exportScene({ type, data, config }) - `type: "canvas" | "png" | "jpeg" | "svg"`, return type per `type` (overloads, plus a union overload for dynamic callers); per-type options are type-checked: `quality` only for jpeg, `embedScene` for png/svg, `renderEmbeddables`/`skipInliningFonts`/`reuseImages` for svg - the format replaces `mimeType` (and its "image/jpg" typo handling) - `appState.exportEmbedScene` -> `config.embedScene` (no longer read from `data.appState`); `data.files` is optional - `config.createCanvas` -> `config.environment.createCanvas` (where #11997's render environment and richtext's text metrics will go) - exportToCanvas/Blob/Svg are no longer exported (no aliases); exportToClipboard stays, routed through exportScene - callers, the with-script-in-browser example and tests ported Claude Opus 5.5 (xhigh) * export: take only scene settings from data.appState, frameRendering -> config.frames - `data.appState` is typed as (and stripped at runtime to) the scene settings a .excalidraw file stores; the renderer state is built from those + defaults, so editor state passed in (a live AppState: open dialog, cropping, frame highlight…) can't affect the output. It only supplies the default background and what `embedScene` embeds - `appState.frameRendering` -> `config.frames: { name, outline, clip }` - editor callers translate their export settings via one internal `getExportConfigFromAppState`; `exportCanvas` drops its redundant exportBackground/viewBackgroundColor options - fixes the editor's SVG export ignoring `exportScale` (the branch's exportCanvas SVG path had dropped it; master passed it) Claude Opus 5.5 (xhigh) * type export positioning options as width/height-only - `fit`, `position`, `x`, `y`, `origin` move into the size-option union's `width`/`height` member, so combining them with `maxWidthOrHeight` / `widthOrHeight` (which ignore them) is a type error; runtime unchanged (still ignores/warns for JS callers) - docs stay visible in completions (same `keyof` remap as the size options) Claude Opus 5.5 (xhigh) * move export `loadFonts` into `config.environment` - font loading is a host capability, next to `createCanvas` (svg gets `environment: { loadFonts }` only, as it creates no canvas) Claude Opus 5.5 (xhigh) * changelog: export API (exportScene, config, migration table) Claude Opus 5.5 (xhigh) * export: default padding back to 10, canvas at least 1px - export bounds are geometric (no stroke width), so padding 0 cut half of the strokes on the content's edge and made flat content (a line) zero-height — the library/chart/publish previews, which inherited master's default of 10, rendered blank thumbnails for lines - canvas dimensions never go below 1px (a 0-sized canvas can't be encoded); SVG snapshot sizes are back to master's Claude Opus 5.5 (xhigh) * export: clamp padding against the fitted size, before deriving from it - the padding clamp used the content's unpadded size, so flat content (a horizontal line) lost its padding with maxWidthOrHeight/widthOrHeight (`{ padding: 10, maxWidthOrHeight: 400 }` → 200x0); it now only considers the target being fitted into - a missing width/height was derived before clamping (`{ height: 10, padding: 10 }` → 0x10, SVG viewBox NaN/Infinity) - a zero-extent content axis no longer yields a NaN scale under contain Claude Opus 5.5 (xhigh) * export: clip frame exports to the frame - frame clipping was off for exportingFrame (to avoid the rounded per-element clip), relying on the canvas/SVG edge — which only crops when the export is exactly the frame; a larger export (size options) painted children and overlapping elements into the margins, and SVG frame exports had stopped clipping (master clipped them) - everything drawn is now clipped to the frame with square corners: a canvas clip after the background (`renderConfig.clipToFrame`), and an SVG group with the frame's clip path; the background isn't clipped Claude Opus 5.5 (xhigh) * changelog: fill in PR numbers for the placeholder links Claude Opus 5.5 (xhigh)
This commit is contained in:
@@ -80,9 +80,7 @@ export default function ExampleApp({
|
||||
excalidrawLib,
|
||||
}: AppProps) {
|
||||
const {
|
||||
exportToCanvas,
|
||||
exportToSvg,
|
||||
exportToBlob,
|
||||
exportScene,
|
||||
exportToClipboard,
|
||||
useHandleLibrary,
|
||||
MIME_TYPES,
|
||||
@@ -115,6 +113,10 @@ export default function ExampleApp({
|
||||
const [canvasUrl, setCanvasUrl] = useState<string>("");
|
||||
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
|
||||
const [exportEmbedScene, setExportEmbedScene] = useState(false);
|
||||
const exportConfig = {
|
||||
theme: exportWithDarkMode ? "dark" : "light",
|
||||
padding: 10,
|
||||
} as const;
|
||||
const [theme, setTheme] = useState<Theme>("light");
|
||||
const [disableImageTool, setDisableImageTool] = useState(false);
|
||||
const [isCollaborating, setIsCollaborating] = useState(false);
|
||||
@@ -387,10 +389,12 @@ export default function ExampleApp({
|
||||
return false;
|
||||
}
|
||||
await exportToClipboard({
|
||||
type,
|
||||
data: {
|
||||
elements: excalidrawAPI.getSceneElements(),
|
||||
appState: excalidrawAPI.getAppState(),
|
||||
files: excalidrawAPI.getFiles(),
|
||||
type,
|
||||
},
|
||||
});
|
||||
window.alert(`Copied to clipboard as ${type} successfully`);
|
||||
};
|
||||
@@ -888,16 +892,14 @@ export default function ExampleApp({
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
appState: {
|
||||
...initialData.appState,
|
||||
exportWithDarkMode,
|
||||
exportEmbedScene,
|
||||
width: 300,
|
||||
height: 100,
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: excalidrawAPI.getSceneElements(),
|
||||
appState: initialData.appState,
|
||||
files: excalidrawAPI.getFiles(),
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
config: { ...exportConfig, embedScene: exportEmbedScene },
|
||||
});
|
||||
appRef.current.querySelector(".export-svg").innerHTML =
|
||||
svg.outerHTML;
|
||||
@@ -912,15 +914,14 @@ export default function ExampleApp({
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
const blob = await exportToBlob({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
mimeType: "image/png",
|
||||
appState: {
|
||||
...initialData.appState,
|
||||
exportEmbedScene,
|
||||
exportWithDarkMode,
|
||||
const blob = await exportScene({
|
||||
type: "png",
|
||||
data: {
|
||||
elements: excalidrawAPI.getSceneElements(),
|
||||
appState: initialData.appState,
|
||||
files: excalidrawAPI.getFiles(),
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
config: { ...exportConfig, embedScene: exportEmbedScene },
|
||||
});
|
||||
setBlobUrl(window.URL.createObjectURL(blob));
|
||||
}}
|
||||
@@ -935,13 +936,14 @@ export default function ExampleApp({
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
const canvas = await exportToCanvas({
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: excalidrawAPI.getSceneElements(),
|
||||
appState: {
|
||||
...initialData.appState,
|
||||
exportWithDarkMode,
|
||||
},
|
||||
appState: initialData.appState,
|
||||
files: excalidrawAPI.getFiles(),
|
||||
},
|
||||
config: exportConfig,
|
||||
});
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "30px Excalifont";
|
||||
@@ -956,13 +958,14 @@ export default function ExampleApp({
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
const canvas = await exportToCanvas({
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: excalidrawAPI.getSceneElements(),
|
||||
appState: {
|
||||
...initialData.appState,
|
||||
exportWithDarkMode,
|
||||
},
|
||||
appState: initialData.appState,
|
||||
files: excalidrawAPI.getFiles(),
|
||||
},
|
||||
config: exportConfig,
|
||||
});
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "30px Excalifont";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
DiagramToCodePlugin,
|
||||
exportToBlob,
|
||||
exportScene,
|
||||
getNonDeletedElements,
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
parseSSEStream,
|
||||
TTDDialog,
|
||||
TTDStreamFetch,
|
||||
THEME,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
||||
import { safelyParseJSON } from "@excalidraw/common";
|
||||
@@ -34,16 +34,17 @@ export const AIComponents = ({
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await exportToBlob({
|
||||
const blob = await exportScene({
|
||||
type: "jpeg",
|
||||
data: {
|
||||
elements: getNonDeletedElements(children),
|
||||
appState: {
|
||||
...appState,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
},
|
||||
exportingFrame: frame,
|
||||
appState,
|
||||
files: excalidrawAPI.getFiles(),
|
||||
mimeType: MIME_TYPES.jpg,
|
||||
},
|
||||
config: {
|
||||
exportingFrame: frame,
|
||||
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
const dataURL = await getDataURL(blob);
|
||||
|
||||
@@ -3,11 +3,7 @@ import { vi } from "vitest";
|
||||
|
||||
import { KEYS, cloneJSON } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
Excalidraw,
|
||||
exportToCanvas,
|
||||
exportToSvg,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { Excalidraw, exportScene } from "@excalidraw/excalidraw";
|
||||
import {
|
||||
actionFlipHorizontal,
|
||||
actionFlipVertical,
|
||||
@@ -532,23 +528,33 @@ describe("Cropping and other features", async () => {
|
||||
Keyboard.keyDown(KEYS.ESCAPE);
|
||||
const widthToHeightRatio = image.width / image.height;
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: [image],
|
||||
// @ts-ignore
|
||||
appState: h.state,
|
||||
files: h.app.files,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
const exportedCanvasRatio = canvas.width / canvas.height;
|
||||
|
||||
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
|
||||
|
||||
const svg = await exportToSvg({
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: [image],
|
||||
// @ts-ignore
|
||||
appState: h.state,
|
||||
files: h.app.files,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
const svgWidth = svg.getAttribute("width");
|
||||
const svgHeight = svg.getAttribute("height");
|
||||
|
||||
@@ -13,6 +13,33 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Export API: `exportScene` (2026-10-18) [#12347](https://github.com/excalidraw/excalidraw/pull/12347)
|
||||
|
||||
Breaking. `exportToCanvas`, `exportToBlob` and `exportToSvg` (in both `@excalidraw/excalidraw` and `@excalidraw/utils`) are replaced by a single `exportScene`, and every rendering option moved from `appState` into an explicit `config`.
|
||||
|
||||
- `exportScene({ type, data, config })` with `type` `"canvas"`, `"png"`, `"jpeg"` or `"svg"` resolves to an `HTMLCanvasElement`, a `Blob` or an `SVGSVGElement`. `exportToClipboard({ type: "png" | "svg" | "json", data, config })` stays. Types: `ExportSceneData`, `ExportSceneConfig`, `ExportSceneOptions`.
|
||||
- `data` is `{ elements, appState?, files? }`, the shape of a `.excalidraw` file. Only the scene settings a file stores are read from `appState`: `viewBackgroundColor` as the default background, and the grid settings that `embedScene` embeds. Any other key is ignored, so passing a live `appState` can't affect the output.
|
||||
- Migration:
|
||||
|
||||
| Before | Now |
|
||||
| --- | --- |
|
||||
| `exportToCanvas({ elements, appState, files, … })` | `exportScene({ type: "canvas", data: { elements, appState, files }, config })` |
|
||||
| `exportToBlob({ …, mimeType, quality })` | `exportScene({ type: "png" \| "jpeg", …, config })` — `quality` is JPEG-only |
|
||||
| `exportToSvg({ … })` | `exportScene({ type: "svg", … })` |
|
||||
| `appState.exportBackground: false` | `config.canvasBackgroundColor: false` (a color string overrides the background; JPEG always gets one) |
|
||||
| `appState.exportWithDarkMode` | `config.theme: "dark"` |
|
||||
| `appState.exportScale` | `config.scale` (also multiplies a `maxWidthOrHeight` result) |
|
||||
| `appState.exportEmbedScene` | `config.embedScene` (png, svg) |
|
||||
| `appState.frameRendering` | `config.frames: { name?, outline?, clip? }` |
|
||||
| `exportPadding` | `config.padding` (default 10, as before) |
|
||||
| `getDimensions: (w, h) => ({ width, height, scale })` | `config.width`/`height`, `maxWidthOrHeight` or `widthOrHeight`, plus `config.scale` — a number, or `(width, height) => number` receiving the export size at scale 1 (padding included), e.g. to cap the output by a pixel budget |
|
||||
| `maxWidthOrHeight`, `exportingFrame`, `renderEmbeddables`, `skipInliningFonts`, `reuseImages` | same names, in `config` |
|
||||
|
||||
- New sizing options: `width`/`height` (only one given: the other follows the content's aspect ratio), `widthOrHeight` (scales the longest side to exactly that size), `fit: "contain" | "none"` and `position: "center" | "topLeft"` within a `width`/`height` frame, and `x`/`y`/`origin` to export a region (cropped, with padding on all sides). The size options are mutually exclusive in the types, and `fit`, `position` and `x`/`y` only combine with `width`/`height`.
|
||||
- `config.environment` for non-browser hosts: `createCanvas` (e.g. `node-canvas`) and `loadFonts`.
|
||||
- Canvas dimensions are rounded instead of truncated, so float noise no longer costs a pixel (a 20.5px frame-label band now rounds up), and are at least 1px (flat content with no padding used to produce an empty canvas that couldn't be encoded).
|
||||
- In dark exports, frame names now use the editor's dark frame-name color (they were dark-filtered twice and came out lighter).
|
||||
|
||||
### Selection API (2026-10-10) [#12258](https://github.com/excalidraw/excalidraw/pull/12258)
|
||||
|
||||
- New `excalidrawAPI.selection`: `select(elements, opts?)`, `add(elements, opts?)`, `remove(elements, opts?)` and `clear(opts?)`, taking elements or their ids, one or many. Missing and deleted elements are skipped.
|
||||
@@ -20,12 +47,12 @@ Please add the latest change on the top under the correct section.
|
||||
- Options: `deep` selects the elements themselves rather than their groups, editing their group if they share one (as Ctrl/Cmd+click does); `includeLocked` selects locked elements too; `captureUpdate` records the change for undo as for `updateScene()` — by default it's recorded along with the next undoable change.
|
||||
- Like `updateScene()`, the change applies on the next render: read it back through `onChange()` or `getAppState()` after that. It works in view mode too, where the selection shows without the transform handles.
|
||||
|
||||
### Wheel navigation (2026-09-13) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
### Wheel navigation (2026-09-13) [#12099](https://github.com/excalidraw/excalidraw/pull/12099)
|
||||
|
||||
- Scrolling while holding the wheel (middle) mouse button zooms the canvas around the pointer, the same as ctrl/cmd+wheel — a wheel-button pan can be zoomed one-handed, without reaching for a modifier.
|
||||
- New `appState.inputDevice` preference (`"auto" | "mouse" | "trackpad"`, default `"auto"`, persisted in browser storage) selecting the wheel mappings. With `"trackpad"` a plain wheel pans; with `"mouse"` a plain wheel zooms. Ctrl/cmd+wheel (how a pinch is delivered) zooms with either device. Shift+wheel pans horizontally, and ctrl/cmd+shift+wheel pans vertically. The wheel-button zoom above is unaffected. `"auto"` is reserved for detecting the device from the wheel events and resolves to `"trackpad"` until that is implemented (`resolveInputDevice()`). Set it from the new "Input device" radio in the main menu's preferences submenu, also exposed as `MainMenu.DefaultItems.Preferences.InputDevice`.
|
||||
|
||||
### Sticky notes (2026-09-06) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
### Sticky notes (2026-09-06) [#12064](https://github.com/excalidraw/excalidraw/pull/12064)
|
||||
|
||||
- New `stickynote` element type and toolbar tool (`N`): an always-filled, flat-rendered note whose label auto-fits — the font shrinks from the user's size down to a minimum, and only then does the note grow in height (it never shrinks below the height the user gave it). Click to place a 250×250 note, drag to size it (previewed at the dragged size, snapped to a font-aware minimum on release); text editing starts right away.
|
||||
- Element model: the note carries `baseHeight` (the user's height; `height ≥ baseHeight`), its label carries `baseFontSize` (the user's font ceiling, `null`/absent on any other text) while `fontSize` holds the fitted size. Read a text element's user-facing size through `getBaseFontSize(text, elementsMap)`.
|
||||
@@ -38,11 +65,11 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
## Excalidraw API
|
||||
|
||||
### `setViewport` stops following a collaborator (2026-09-26) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
### `setViewport` stops following a collaborator (2026-09-26) [#12184](https://github.com/excalidraw/excalidraw/pull/12184)
|
||||
|
||||
- `setViewport` now asks to stop following a collaborator whenever it navigates (`onUserFollow` with `action: "UNFOLLOW"`, if `userToFollow` is set), as a user's pan or zoom does. `setViewport(null)` (clearing a lock) and a target that doesn't resolve don't navigate, and leave following alone.
|
||||
|
||||
### `onDuplicate` can replace and veto duplicates (2026-09-18) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
### `onDuplicate` can replace and veto duplicates (2026-09-18) [#12124](https://github.com/excalidraw/excalidraw/pull/12124)
|
||||
|
||||
- `props.onDuplicate` may now return new objects for the duplicated elements in every duplication path. Previously the editor kept using the objects it created, so on paste (and library insert) the frame assignment and bound text redraw went to objects that were no longer in the scene, and alt-drag dragged an element that wasn't in the scene. The returned changes are now merged into the editor's duplicates, so they're part of the duplication's own undo entry and durable increment (no follow-up `updateScene` needed).
|
||||
- When pasting or inserting onto a frame, the duplicates passed to `onDuplicate` already have their `frameId` set (and are ordered under the frame).
|
||||
|
||||
@@ -152,7 +152,6 @@ export const actionCopyAsSvg = register({
|
||||
appState,
|
||||
app.files,
|
||||
{
|
||||
...appState,
|
||||
exportingFrame,
|
||||
name: app.getName(),
|
||||
},
|
||||
@@ -219,7 +218,6 @@ export const actionCopyAsPng = register({
|
||||
);
|
||||
try {
|
||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||
...appState,
|
||||
exportingFrame,
|
||||
name: app.getName(),
|
||||
});
|
||||
|
||||
@@ -2771,9 +2771,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
this.files,
|
||||
{
|
||||
exportBackground: this.state.exportBackground,
|
||||
name: this.getName(),
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
exportingFrame: opts.exportingFrame,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { exportToCanvas } from "@excalidraw/utils/export";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -26,6 +25,7 @@ import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { exportScene, getExportConfigFromAppState } from "../scene/export";
|
||||
|
||||
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
||||
import { Dialog } from "./Dialog";
|
||||
@@ -127,20 +127,25 @@ const ImageExportModal = ({
|
||||
return requestId !== previewRenderRequestIdRef.current;
|
||||
};
|
||||
|
||||
exportToCanvas({
|
||||
exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: exportedElements,
|
||||
appState: {
|
||||
appState: appStateSnapshot,
|
||||
files,
|
||||
},
|
||||
config: {
|
||||
...getExportConfigFromAppState({
|
||||
...appStateSnapshot,
|
||||
name: projectName,
|
||||
exportBackground: exportWithBackground,
|
||||
exportWithDarkMode,
|
||||
exportScale,
|
||||
exportEmbedScene: embedScene,
|
||||
},
|
||||
files,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
// the preview fits `maxWidthOrHeight`
|
||||
exportScale: 1,
|
||||
}),
|
||||
padding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
||||
exportingFrame,
|
||||
},
|
||||
})
|
||||
.then(async (canvas) => {
|
||||
if (isStaleRequest()) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ChartType } from "@excalidraw/element/types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { isSpreadsheetValidForChartType, renderSpreadsheet } from "../charts";
|
||||
import { t } from "../i18n";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { exportScene } from "../scene/export";
|
||||
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
|
||||
@@ -72,18 +72,18 @@ const ChartPreviewBtn = (props: {
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
(async () => {
|
||||
svg = await exportToSvg(
|
||||
svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: "#fff",
|
||||
exportWithDarkMode: theme === "dark",
|
||||
files: null,
|
||||
},
|
||||
null, // files
|
||||
{
|
||||
config: {
|
||||
skipInliningFonts: true,
|
||||
theme,
|
||||
canvasBackgroundColor: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
previewNode.replaceChildren();
|
||||
previewNode.appendChild(svg);
|
||||
@@ -134,18 +134,18 @@ const PlainTextPreviewBtn = (props: {
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
(async () => {
|
||||
const svg = await exportToSvg(
|
||||
[textElement],
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: "#fff",
|
||||
exportWithDarkMode: theme === "dark",
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: [textElement],
|
||||
files: null,
|
||||
},
|
||||
null,
|
||||
{
|
||||
config: {
|
||||
skipInliningFonts: true,
|
||||
theme,
|
||||
canvasBackgroundColor: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
previewNode.replaceChildren();
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
EDITOR_LS_KEYS,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
THEME,
|
||||
VERSIONS,
|
||||
chunk,
|
||||
getExportSource,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||
import { canvasToBlob, resizeImageFile } from "../data/blob";
|
||||
import { t } from "../i18n";
|
||||
import { exportScene } from "../scene/export";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
@@ -62,10 +63,15 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
|
||||
// draw items
|
||||
// ---------------------------------------------------------------------------
|
||||
for (const [index, item] of libraryItems.entries()) {
|
||||
const itemCanvas = await exportToCanvas({
|
||||
const itemCanvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: item.elements,
|
||||
files: null,
|
||||
},
|
||||
config: {
|
||||
maxWidthOrHeight: BOX_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
const { width, height } = itemCanvas;
|
||||
@@ -126,15 +132,17 @@ const SingleLibraryItem = ({
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const svg = await exportToSvg({
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: libItem.elements,
|
||||
appState: {
|
||||
...appState,
|
||||
viewBackgroundColor: "#fff",
|
||||
exportBackground: true,
|
||||
},
|
||||
files: null,
|
||||
},
|
||||
config: {
|
||||
skipInliningFonts: true,
|
||||
canvasBackgroundColor: "#fff",
|
||||
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
|
||||
},
|
||||
});
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
EDITOR_LS_KEYS,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
|
||||
|
||||
import { convertToExcalidrawElements } from "@excalidraw/element";
|
||||
|
||||
import { exportToCanvas } from "@excalidraw/utils";
|
||||
import { exportScene } from "@excalidraw/utils";
|
||||
|
||||
import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
@@ -104,15 +100,18 @@ export const convertMermaidToExcalidraw = async ({
|
||||
files,
|
||||
};
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: data.current.elements,
|
||||
files: data.current.files,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
},
|
||||
config: {
|
||||
padding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight:
|
||||
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
||||
window.devicePixelRatio,
|
||||
appState: {
|
||||
exportWithDarkMode: theme === THEME.DARK,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
import { exportScene, getExportConfigFromAppState } from "../scene/export";
|
||||
|
||||
import { canvasToBlob } from "./blob";
|
||||
import { fileSave } from "./filesystem";
|
||||
@@ -101,16 +101,12 @@ export const exportCanvas = async (
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
viewBackgroundColor,
|
||||
name = appState.name || DEFAULT_FILENAME,
|
||||
fileHandle = null,
|
||||
exportingFrame = null,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
/** filename, if applicable */
|
||||
name?: string;
|
||||
fileHandle?: FileSystemFileHandle | null;
|
||||
@@ -120,20 +116,26 @@ export const exportCanvas = async (
|
||||
if (elements.length === 0) {
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
|
||||
const config = {
|
||||
...getExportConfigFromAppState(appState),
|
||||
padding: exportPadding,
|
||||
exportingFrame,
|
||||
};
|
||||
|
||||
if (type === "svg" || type === "clipboard-svg") {
|
||||
const svgPromise = exportToSvg(
|
||||
const svgPromise = exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements,
|
||||
{
|
||||
exportBackground,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
exportScale: appState.exportScale,
|
||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||
},
|
||||
appState,
|
||||
files,
|
||||
{ exportingFrame },
|
||||
);
|
||||
},
|
||||
config: {
|
||||
...config,
|
||||
embedScene: appState.exportEmbedScene && type === "svg",
|
||||
},
|
||||
});
|
||||
|
||||
if (type === "svg") {
|
||||
return fileSave(
|
||||
@@ -163,11 +165,14 @@ export const exportCanvas = async (
|
||||
}
|
||||
}
|
||||
|
||||
const tempCanvas = exportToCanvas(elements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
exportingFrame,
|
||||
const tempCanvas = exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
},
|
||||
config,
|
||||
});
|
||||
|
||||
if (type === "png") {
|
||||
|
||||
@@ -28,8 +28,6 @@ export const resaveAsImageWithScene = async (
|
||||
|
||||
let { elements, appState, files } = await data;
|
||||
|
||||
const { exportBackground, viewBackgroundColor } = appState;
|
||||
|
||||
appState = {
|
||||
...appState,
|
||||
exportEmbedScene: true,
|
||||
@@ -42,8 +40,6 @@ export const resaveAsImageWithScene = async (
|
||||
);
|
||||
|
||||
await exportCanvas(fileHandleType, exportedElements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name: filename,
|
||||
fileHandle,
|
||||
exportingFrame,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { exportToSvg } from "@excalidraw/utils/export";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
||||
import { exportScene } from "../scene/export";
|
||||
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
|
||||
@@ -12,17 +11,16 @@ 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({
|
||||
return await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements,
|
||||
appState: {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
},
|
||||
files: null,
|
||||
renderEmbeddables: false,
|
||||
},
|
||||
config: {
|
||||
skipInliningFonts: true,
|
||||
canvasBackgroundColor: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getDefaultAppState } from "./appState";
|
||||
import { exportToCanvas } from "./scene/export";
|
||||
import { exportScene } from "./scene/export";
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
@@ -59,22 +58,19 @@ const elements = [
|
||||
registerFont("./public/Virgil.woff2", { family: "Virgil" });
|
||||
registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
|
||||
|
||||
const canvas = exportToCanvas(
|
||||
elements as any,
|
||||
{
|
||||
...getDefaultAppState(),
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
(async () => {
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: elements as any,
|
||||
appState: {},
|
||||
files: {},
|
||||
},
|
||||
{}, // files
|
||||
{
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: "#ffffff",
|
||||
config: {
|
||||
canvasBackgroundColor: "#ffffff",
|
||||
environment: { createCanvas },
|
||||
},
|
||||
createCanvas,
|
||||
);
|
||||
});
|
||||
|
||||
const out = fs.createWriteStream("test.png");
|
||||
const stream = (canvas as any).createPNGStream();
|
||||
@@ -82,3 +78,4 @@ stream.pipe(out);
|
||||
out.on("finish", () => {
|
||||
console.info("test.png was created.");
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -421,12 +421,13 @@ export {
|
||||
|
||||
export { reconcileElements } from "./data/reconcile";
|
||||
|
||||
export {
|
||||
exportToCanvas,
|
||||
exportToBlob,
|
||||
exportToSvg,
|
||||
exportToClipboard,
|
||||
} from "@excalidraw/utils/export";
|
||||
export { exportScene, exportToClipboard } from "./scene/export";
|
||||
|
||||
export type {
|
||||
ExportSceneData,
|
||||
ExportSceneConfig,
|
||||
ExportSceneOptions,
|
||||
} from "./scene/export";
|
||||
|
||||
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
|
||||
export {
|
||||
|
||||
@@ -325,6 +325,18 @@ const _renderStaticScene = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (renderConfig.clipToFrame) {
|
||||
const frame = renderConfig.clipToFrame;
|
||||
context.beginPath();
|
||||
context.rect(
|
||||
frame.x + appState.scrollX,
|
||||
frame.y + appState.scrollY,
|
||||
frame.width,
|
||||
frame.height,
|
||||
);
|
||||
context.clip();
|
||||
}
|
||||
|
||||
const groupsToBeAddedToFrame = new Set<string>();
|
||||
|
||||
visibleElements.forEach((element) => {
|
||||
|
||||
+872
-156
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import type { UserIdleState, EditorInterface } from "@excalidraw/common";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
@@ -27,6 +28,8 @@ export type RenderableElementsMap = NonDeletedElementsMap &
|
||||
|
||||
export type StaticCanvasRenderConfig = {
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
/** export only: clips everything drawn to this frame (square corners) */
|
||||
clipToFrame?: ExcalidrawFrameLikeElement | null;
|
||||
// extra options passed to the renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
imageCache: AppClassProperties["imageCache"];
|
||||
|
||||
@@ -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"><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"><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="90" 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,7 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`export > export svg-embedded scene > svg-embdedded scene export output 1`] = `
|
||||
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTwY7TMFx1MDAxML3zXHUwMDE1UfaK2LRcYlx1MDAwZb3twq7Yw1wiRJE4IFx1MDAwZW48TUZ1bcuebFuqSnxcdTAwMDY3fpFPYOyEuE6XVorkN555b96Mjy+KoqSDhXJRlLCvhULpxK58XHUwMDE58CdwXHUwMDFljebQPJ696VxcXHUwMDFkb7ZEdnF9rVxmJ7TG0+J1VVV9XHUwMDEyKNiCJs/XvvG5KI7xy1x1MDAxMZQh9SZei8A/XoI9JXTPUDWeXHUwMDBl2WmHklpGZm9HqFx1MDAwNWxayjGhXHUwMDFiXHUwMDA1WaInZzbwzijjXHUwMDAy49VcZsI/ka5EvWmc6bRcdTAwMWPvkFx1MDAxM9pb4biZdG+NSi3pXHUwMDEwq7NcdTAwMWbsVjnh+DpInE/w/2UxadNq8MGw2YhcdTAwMWEraqTQ/KxKXVx1MDAwNIX2QUZvvydNTmzhIZirO6VGXHUwMDE4tYT9XHUwMDE0jC1cdTAwMGVsWcBcdTAwMDPITEFcdTAwMWH+XHUwMDA19tHoOrdcdTAwMTf9e547xVx1MDAxMmuhPCRjXHUwMDAz4V3aiYy0s1LQhLd2cIEp1JtpLu/e5lx1MDAxOb64S+zyn9+/fp6NzWha4o8gel6dXHLdw32KZOVDxr3YolxuM3iTlb9R2Fx1MDAwNF9KXHUwMDA167PVYHNcYvk9jGEyNkVrridQg7tcdTAwMWOUcdigXHUwMDE26suz0kVH5jP4XlwiuVx1MDAwZc5dgVx1MDAwZuP2v5onlUqsQH0yXHUwMDFlqZ9goIuxXHUwMDEzf+PilMLaJbHRXHUwMDFj7Vx1MDAxZigvXHUwMDE3yolHPUZgk1x1MDAwN1x1MDAxMXo0XHUwMDEy7rRYqelcdTAwMDDKJ4Td7eVbulrH39DVMLvHTlx1MDAxMS55b+ogMyzHMeg7RX380GCATn9cdTAwMDFWrz23In0=<!-- payload-end --></metadata><defs><style class="style-fonts">
|
||||
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTy27bMFx1MDAxMLz3K1x1MDAwNOVcdTAwMWE0sov24FuSJmhcdTAwMGVcdIq6QFx1MDAwZkVcdTAwMGa0uJZcdTAwMTamSYJcXMV2XHJcdTAwMDP9jN76i/2ELilFlOTUXHUwMDA2XHUwMDA0cPYxw9nl8U2W5XSwkC+yXHUwMDFj9qVQKJ3Y5ZdcdTAwMDF/XHUwMDA259FoXHUwMDBlzePZm8aVMbMmsourK2W4oDaeXHUwMDE274qiaItAwVx1MDAxNjR5TvvO5yw7xi9HUIbS65hcdTAwMTaBXHUwMDE3XoI9JXTPUNGfXHUwMDBlo9NcdTAwMGUl1YzMPvRQXHJY1TTGhK5cdTAwMTSMXG49ObOBW6OMXHUwMDBijFx1MDAxNzNcYv9Eulx1MDAxMuWmcqbRss8hJ7S3wvFlUt5cdTAwMWGVWtIhdmc/2K18wvGtkzif4P+rYtKq1uCDYbNcdTAwMWU1VpRI4fKzXCLdXCIotFx1MDAwZjJ6+yNpcmJcdTAwMGJcdTAwMGbBXFzdKNXDqCVcdTAwMDQvc1GM2LTs2EbZXHUwMDFlQI5cdTAwMTSk4Z9hT0aXY3vRf+S5U2yxXHUwMDE2ykMyNlx1MDAxMN5ccnZcIulurFx1MDAxNDRhLVx1MDAxZJxhXG71ZiqXN2/zXG5b3CS+8t8/v39ccoZmNC3xZ5A8L1x1MDAwNiP3cJ9cIqP2oeJebFGFXHS8XHUwMDFmtb9WWFx1MDAwNVdyXHUwMDA168FisDWE/Fx1MDAxYfowXHUwMDE5m6Il91x1MDAxM6jBnY/JOKxQXHUwMDBi9fVV6aIh81x1MDAwNXwrkVxcXHUwMDAzQ1fgU7/7b+dJpVx1MDAxMitQn41HaudcdTAwMTfoYuzE32h/LqxdXHUwMDEyXHUwMDFizdH2efJqoZx41GJcdTAwMDQ2eVx1MDAxMKFHI+FOi5WaXHUwMDBlIH9G2N2cv6SLdfx1t+pm99gowiVvTVx1MDAxOWSG1ThcdTAwMDZ9p6iPn1x1MDAxOXTQ6Vx1MDAxZmLlO84ifQ==<!-- payload-end --></metadata><defs><style class="style-fonts">
|
||||
</style></defs><rect x="0" y="0" width="36" height="36" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 8 8)" data-id="A"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, sans-serif, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">😀</text></g></svg>"
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import React from "react";
|
||||
|
||||
import { SVG_NS } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
import type { FileId, FractionalIndex } from "@excalidraw/element/types";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import * as clipboardModule from "../clipboard";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data";
|
||||
import { getDataURL } from "../data/blob";
|
||||
import { encodePngMetadata } from "../data/image";
|
||||
import { serializeAsJSON } from "../data/json";
|
||||
@@ -12,7 +14,7 @@ import { Excalidraw } from "../index";
|
||||
import {
|
||||
decodeSvgBase64Payload,
|
||||
encodeSvgBase64Payload,
|
||||
exportToSvg,
|
||||
exportScene,
|
||||
} from "../scene/export";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
@@ -26,6 +28,9 @@ const testElements = [
|
||||
type: "text",
|
||||
id: "A",
|
||||
text: "😀",
|
||||
// a valid index, as in a real scene (restoring on export would
|
||||
// otherwise re-index it, bumping its version with a random nonce)
|
||||
index: "a0" as FractionalIndex,
|
||||
}),
|
||||
// can't get jsdom text measurement to work so this is a temp hack
|
||||
// to ensure the element isn't stripped as invisible
|
||||
@@ -82,12 +87,42 @@ describe("export", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("export svg-embedded scene", async () => {
|
||||
const svg = await exportToSvg(
|
||||
it("exports SVG at the export scale", async () => {
|
||||
const copySpy = vi
|
||||
.spyOn(clipboardModule, "copyTextToSystemClipboard")
|
||||
.mockResolvedValue();
|
||||
const exportedWidthAt = async (exportScale: number) => {
|
||||
const appState = { ...h.state, exportScale };
|
||||
const { exportedElements } = prepareElementsForExport(
|
||||
testElements,
|
||||
{ ...getDefaultAppState(), exportEmbedScene: true },
|
||||
{},
|
||||
appState,
|
||||
false,
|
||||
);
|
||||
await exportCanvas(
|
||||
"clipboard-svg",
|
||||
exportedElements,
|
||||
appState,
|
||||
{},
|
||||
{ exportingFrame: null },
|
||||
);
|
||||
const svg = copySpy.mock.lastCall![0] as string;
|
||||
return Number(/<svg[^>]* width="([\d.]+)"/.exec(svg)![1]);
|
||||
};
|
||||
|
||||
expect(await exportedWidthAt(2)).toBe((await exportedWidthAt(1)) * 2);
|
||||
copySpy.mockRestore();
|
||||
});
|
||||
|
||||
it("export svg-embedded scene", async () => {
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: testElements,
|
||||
appState: getDefaultAppState(),
|
||||
files: {},
|
||||
},
|
||||
config: { embedScene: true },
|
||||
});
|
||||
const svgText = svg.outerHTML;
|
||||
|
||||
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
|
||||
@@ -194,7 +229,7 @@ describe("export", () => {
|
||||
angle: normalizeAngle(315),
|
||||
}),
|
||||
];
|
||||
const appState = { ...getDefaultAppState(), exportBackground: false };
|
||||
const appState = getDefaultAppState();
|
||||
const files = {
|
||||
file_A: {
|
||||
id: "file_A" as FileId,
|
||||
@@ -205,7 +240,15 @@ describe("export", () => {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const svg = await exportToSvg(elements, appState, files);
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
},
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
|
||||
const svgText = svg.outerHTML;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Excalidraw } from "../index";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import * as StaticScene from "../renderer/staticScene";
|
||||
import { Renderer } from "../scene/Renderer";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { exportScene } from "../scene/export";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { getElementRenderOffsets } from "../renderOverrides";
|
||||
|
||||
@@ -274,14 +274,22 @@ describe("setElementRenderOverrides", () => {
|
||||
|
||||
it("leaves exported geometry and opacity unchanged", async () => {
|
||||
const { element, submit } = await setup();
|
||||
const exportScene = async () =>
|
||||
(await exportToSvg(h.app.api.getSceneElements(), h.state, null))
|
||||
.outerHTML;
|
||||
const before = await exportScene();
|
||||
const exportSvg = async () =>
|
||||
(
|
||||
await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: h.app.api.getSceneElements(),
|
||||
appState: h.state,
|
||||
files: null,
|
||||
},
|
||||
})
|
||||
).outerHTML;
|
||||
const before = await exportSvg();
|
||||
submit(
|
||||
new Map([[element.id, { opacity: 0, offset: { x: 1000, y: 1000 } }]]),
|
||||
);
|
||||
expect(await exportScene()).toBe(before);
|
||||
expect(await exportSvg()).toBe(before);
|
||||
});
|
||||
|
||||
it("clears on reset and ignores submissions after unmount", async () => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,10 +1,9 @@
|
||||
import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
|
||||
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
BOUND_TEXT_PADDING,
|
||||
FONT_FAMILY,
|
||||
FRAME_STYLE,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
@@ -18,8 +17,11 @@ import type {
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { getDefaultAppState } from "../../appState";
|
||||
import * as staticSceneModule from "../../renderer/staticScene";
|
||||
import { exportScene } from "../../scene/export";
|
||||
|
||||
import { prepareElementsForExport } from "../../data";
|
||||
import * as exportUtils from "../../scene/export";
|
||||
import {
|
||||
diamondFixture,
|
||||
ellipseFixture,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
} from "../fixtures/elementFixture";
|
||||
import { API } from "../helpers/api";
|
||||
|
||||
import type { ExportSceneData } from "../../scene/export";
|
||||
|
||||
describe("exportToSvg", () => {
|
||||
const ELEMENT_HEIGHT = 100;
|
||||
const ELEMENT_WIDTH = 100;
|
||||
@@ -59,25 +63,29 @@ describe("exportToSvg", () => {
|
||||
},
|
||||
] as NonDeletedExcalidrawElement[];
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
exportBackground: false,
|
||||
const DEFAULT_APP_STATE = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
files: {},
|
||||
};
|
||||
|
||||
it("with default arguments", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: ELEMENTS,
|
||||
appState: DEFAULT_APP_STATE,
|
||||
files: {},
|
||||
},
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
|
||||
expect(svgElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("with a CJK font", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
[
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: [
|
||||
...ELEMENTS,
|
||||
{
|
||||
...textFixture,
|
||||
@@ -89,9 +97,11 @@ describe("exportToSvg", () => {
|
||||
index: "a4" as FractionalIndex,
|
||||
} as NonDeleted<ExcalidrawTextElement>,
|
||||
],
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
appState: DEFAULT_APP_STATE,
|
||||
files: {},
|
||||
},
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
|
||||
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)
|
||||
@@ -100,15 +110,17 @@ describe("exportToSvg", () => {
|
||||
it("with background color", async () => {
|
||||
const BACKGROUND_COLOR = "#abcdef";
|
||||
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportBackground: true,
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: ELEMENTS,
|
||||
appState: {
|
||||
...DEFAULT_APP_STATE,
|
||||
viewBackgroundColor: BACKGROUND_COLOR,
|
||||
},
|
||||
null,
|
||||
);
|
||||
files: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(svgElement.querySelector("rect")).toHaveAttribute(
|
||||
"fill",
|
||||
@@ -117,14 +129,20 @@ describe("exportToSvg", () => {
|
||||
});
|
||||
|
||||
it("with dark mode", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportWithDarkMode: true,
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: ELEMENTS,
|
||||
appState: {
|
||||
...DEFAULT_APP_STATE,
|
||||
},
|
||||
null,
|
||||
);
|
||||
files: {},
|
||||
},
|
||||
config: {
|
||||
canvasBackgroundColor: false,
|
||||
theme: THEME.DARK,
|
||||
},
|
||||
});
|
||||
|
||||
const textElements = svgElement.querySelectorAll("text");
|
||||
expect(textElements.length).toBeGreaterThan(0);
|
||||
@@ -138,14 +156,18 @@ describe("exportToSvg", () => {
|
||||
});
|
||||
|
||||
it("with exportPadding", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportPadding: 0,
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: ELEMENTS,
|
||||
appState: DEFAULT_APP_STATE,
|
||||
files: {},
|
||||
},
|
||||
null,
|
||||
);
|
||||
config: {
|
||||
canvasBackgroundColor: false,
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
|
||||
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
|
||||
@@ -158,15 +180,19 @@ describe("exportToSvg", () => {
|
||||
it("with scale", async () => {
|
||||
const SCALE = 2;
|
||||
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportPadding: 0,
|
||||
exportScale: SCALE,
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: ELEMENTS,
|
||||
appState: DEFAULT_APP_STATE,
|
||||
files: {},
|
||||
},
|
||||
null,
|
||||
);
|
||||
config: {
|
||||
canvasBackgroundColor: false,
|
||||
padding: 0,
|
||||
scale: SCALE,
|
||||
},
|
||||
});
|
||||
|
||||
expect(svgElement).toHaveAttribute(
|
||||
"height",
|
||||
@@ -179,23 +205,28 @@ describe("exportToSvg", () => {
|
||||
});
|
||||
|
||||
it("with exportEmbedScene", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportEmbedScene: true,
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: ELEMENTS,
|
||||
appState: DEFAULT_APP_STATE,
|
||||
files: {},
|
||||
},
|
||||
null,
|
||||
);
|
||||
config: { canvasBackgroundColor: false, embedScene: true },
|
||||
});
|
||||
expect(svgElement.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("with elements that have a link", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
[rectangleWithLinkFixture] as NonDeletedExcalidrawElement[],
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: [rectangleWithLinkFixture] as NonDeletedExcalidrawElement[],
|
||||
appState: DEFAULT_APP_STATE,
|
||||
files: {},
|
||||
},
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
expect(svgElement.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -221,11 +252,15 @@ describe("exportToSvg", () => {
|
||||
containerId: "arrow-11439",
|
||||
});
|
||||
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
[arrow, label] as NonDeletedExcalidrawElement[],
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: [arrow, label] as NonDeletedExcalidrawElement[],
|
||||
appState: DEFAULT_APP_STATE,
|
||||
files: null,
|
||||
},
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
|
||||
const mask = svgElement.querySelector("mask");
|
||||
expect(mask).not.toBeNull();
|
||||
@@ -258,11 +293,15 @@ describe("exportToSvg", () => {
|
||||
containerId: "arrow-hole",
|
||||
});
|
||||
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
[arrow, label] as NonDeletedExcalidrawElement[],
|
||||
{ ...DEFAULT_OPTIONS, exportPadding: 0 },
|
||||
null,
|
||||
);
|
||||
const svgElement = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: [arrow, label] as NonDeletedExcalidrawElement[],
|
||||
appState: DEFAULT_APP_STATE,
|
||||
files: null,
|
||||
},
|
||||
config: { padding: 0, canvasBackgroundColor: false },
|
||||
});
|
||||
|
||||
// the mask's first rect is the visible region, sized to the arrow plus
|
||||
// its offset within the export; the second is the hole cut for the label
|
||||
@@ -326,10 +365,13 @@ describe("exportToCanvas", () => {
|
||||
containerId: "arrow-11591",
|
||||
});
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: [rectangle, arrow, label],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: null,
|
||||
appState: { exportBackground: true, viewBackgroundColor: "#ffffff" },
|
||||
},
|
||||
});
|
||||
|
||||
const context = canvas.getContext("2d") as any;
|
||||
@@ -359,14 +401,163 @@ describe("exportToCanvas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("export background", () => {
|
||||
// an outline arrowhead fakes its hollow interior by filling it with the
|
||||
// background color — the arrow's shaft runs into the arrowhead
|
||||
const sceneWithOutlineArrow = (viewBackgroundColor = "#ffffff") => ({
|
||||
elements: [
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 0)],
|
||||
endArrowhead: "triangle_outline",
|
||||
}),
|
||||
],
|
||||
appState: { viewBackgroundColor },
|
||||
files: null,
|
||||
});
|
||||
const getBackgroundRects = (svg: SVGSVGElement) =>
|
||||
Array.from(svg.children).filter((child) => child.tagName === "rect");
|
||||
const getCanvasFillStyles = (canvas: HTMLCanvasElement) =>
|
||||
(canvas.getContext("2d") as any)
|
||||
.__getEvents()
|
||||
.filter((event: any) => event.type === "fillStyle")
|
||||
.map((event: any) => event.props.value);
|
||||
|
||||
it("exportToSvg paints no background for `false` or a transparent scene background", async () => {
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: sceneWithOutlineArrow(),
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
expect(getBackgroundRects(svg)).toEqual([]);
|
||||
|
||||
const svgOfTransparentScene = await exportScene({
|
||||
type: "svg",
|
||||
data: sceneWithOutlineArrow("transparent"),
|
||||
});
|
||||
expect(getBackgroundRects(svgOfTransparentScene)).toEqual([]);
|
||||
});
|
||||
|
||||
it("exportToSvg fills outline arrowheads with the configured background", async () => {
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: sceneWithOutlineArrow(),
|
||||
config: { canvasBackgroundColor: "#000000" },
|
||||
});
|
||||
expect(getBackgroundRects(svg)[0]).toHaveAttribute("fill", "#000000");
|
||||
expect(svg.querySelector('g path[fill="#000000"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("transparent exports keep filling outline arrowheads with the scene background", async () => {
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: sceneWithOutlineArrow(),
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
expect(getCanvasFillStyles(canvas)).toContain("#ffffff");
|
||||
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: sceneWithOutlineArrow(),
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
expect(svg.querySelector('g path[fill="#ffffff"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("JPEG export paints the default background instead of `false`", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
await exportScene({
|
||||
type: "jpeg",
|
||||
data: sceneWithOutlineArrow("#abcdef"),
|
||||
config: { environment: { createCanvas: () => canvas } },
|
||||
});
|
||||
// the default background needs no warning
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
|
||||
const canvasWithoutBackground = document.createElement("canvas");
|
||||
await exportScene({
|
||||
type: "jpeg",
|
||||
data: sceneWithOutlineArrow("#abcdef"),
|
||||
config: {
|
||||
canvasBackgroundColor: false,
|
||||
environment: { createCanvas: () => canvasWithoutBackground },
|
||||
},
|
||||
});
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(getCanvasFillStyles(canvasWithoutBackground)).toContain("#abcdef");
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("export inputs", () => {
|
||||
it("takes only the scene settings from data.appState", async () => {
|
||||
const renderSpy = vi.spyOn(staticSceneModule, "renderStaticScene");
|
||||
|
||||
await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: [API.createElement({ type: "rectangle" })],
|
||||
// a live AppState, as JS callers (and the editor) may pass
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
viewBackgroundColor: "#abcdef",
|
||||
openDialog: { name: "elementLinkSelector", sourceElementId: "x" },
|
||||
croppingElementId: "x",
|
||||
} as ExportSceneData["appState"],
|
||||
},
|
||||
});
|
||||
|
||||
const { appState } = renderSpy.mock.calls[0][0];
|
||||
expect(appState.viewBackgroundColor).toBe("#abcdef");
|
||||
expect(appState.openDialog).toBe(null);
|
||||
expect(appState.croppingElementId).toBe(null);
|
||||
renderSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("loads fonts through environment.loadFonts", async () => {
|
||||
const loadFonts = vi.fn(async () => {});
|
||||
|
||||
await exportScene({
|
||||
type: "svg",
|
||||
data: { elements: [API.createElement({ type: "text", text: "hi" })] },
|
||||
config: { environment: { loadFonts } },
|
||||
});
|
||||
|
||||
expect(loadFonts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders frame decorations per config.frames", async () => {
|
||||
const elements = [API.createElement({ type: "frame" })];
|
||||
const countFrameNames = (svg: SVGSVGElement) =>
|
||||
Array.from(svg.querySelectorAll("text")).filter(
|
||||
(text) => text.textContent === "Frame",
|
||||
).length;
|
||||
|
||||
const svg = await exportScene({ type: "svg", data: { elements } });
|
||||
const svgWithoutNames = await exportScene({
|
||||
type: "svg",
|
||||
data: { elements },
|
||||
config: { frames: { name: false } },
|
||||
});
|
||||
|
||||
expect(countFrameNames(svg)).toBe(1);
|
||||
expect(countFrameNames(svgWithoutNames)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exporting frames", () => {
|
||||
const getFrameNameHeight = (exportType: "canvas" | "svg") => {
|
||||
const height =
|
||||
FRAME_STYLE.nameFontSize * FRAME_STYLE.nameLineHeight +
|
||||
FRAME_STYLE.nameOffsetY;
|
||||
// canvas truncates dimensions to integers
|
||||
// canvas rounds dimensions to integers
|
||||
if (exportType === "canvas") {
|
||||
return Math.trunc(height);
|
||||
return Math.round(height);
|
||||
}
|
||||
return height;
|
||||
};
|
||||
@@ -393,10 +584,16 @@ describe("exporting frames", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements,
|
||||
appState: {},
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(canvas.width).toEqual(200);
|
||||
@@ -422,11 +619,17 @@ describe("exporting frames", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements,
|
||||
appState: {},
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
exportingFrame: frame,
|
||||
},
|
||||
});
|
||||
|
||||
expect(canvas.width).toEqual(frame.width);
|
||||
@@ -437,6 +640,52 @@ describe("exporting frames", () => {
|
||||
// exportToSvg (so we can test for element existence)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("exportToSvg", () => {
|
||||
it("colors frame names like the editor in the configured theme", async () => {
|
||||
const frame = API.createElement({ type: "frame" });
|
||||
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: { elements: [frame], files: null },
|
||||
config: { theme: THEME.DARK },
|
||||
});
|
||||
|
||||
const label = Array.from(svg.querySelectorAll("text")).find(
|
||||
(text) => text.textContent === "Frame",
|
||||
);
|
||||
expect(label).toHaveAttribute("fill", FRAME_STYLE.nameColorDarkTheme);
|
||||
});
|
||||
|
||||
it("clips a frame export larger than the frame to the frame", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const child = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 20,
|
||||
width: 150,
|
||||
height: 40,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const data = { elements: [frame, child] };
|
||||
const config = { exportingFrame: frame, width: 200, height: 100 };
|
||||
|
||||
const svg = await exportScene({ type: "svg", data, config });
|
||||
expect(
|
||||
svg.querySelector(
|
||||
`g[clip-path="url(#${frame.id})"] [data-id="${child.id}"]`,
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
const canvas = await exportScene({ type: "canvas", data, config });
|
||||
const clips = (canvas.getContext("2d") as any)
|
||||
.__getEvents()
|
||||
.filter((event: any) => event.type === "clip");
|
||||
expect(clips).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exporting frame should include overlapping elements, but crop to frame", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
@@ -461,11 +710,17 @@ describe("exporting frames", () => {
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const svg = await exportToSvg({
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: [rectOverlapping, frame, frameChild],
|
||||
appState: {},
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
exportingFrame: frame,
|
||||
},
|
||||
});
|
||||
|
||||
// frame itself isn't exported
|
||||
@@ -505,11 +760,17 @@ describe("exporting frames", () => {
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const svg = await exportToSvg({
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: [frameChild, frame, elementOutside],
|
||||
appState: {},
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
exportingFrame: frame,
|
||||
},
|
||||
});
|
||||
|
||||
// frame itself isn't exported
|
||||
@@ -573,11 +834,17 @@ describe("exporting frames", () => {
|
||||
true,
|
||||
);
|
||||
|
||||
const svg = await exportToSvg({
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: exportedElements,
|
||||
appState: {},
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
exportingFrame,
|
||||
},
|
||||
});
|
||||
|
||||
// frames themselves should be exported when multiple frames selected
|
||||
@@ -618,11 +885,17 @@ describe("exporting frames", () => {
|
||||
false,
|
||||
);
|
||||
|
||||
const svg = await exportToSvg({
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: exportedElements,
|
||||
appState: {},
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
exportingFrame,
|
||||
},
|
||||
});
|
||||
|
||||
// frame itself isn't exported
|
||||
@@ -677,11 +950,17 @@ describe("exporting frames", () => {
|
||||
true,
|
||||
);
|
||||
|
||||
const svg = await exportToSvg({
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: exportedElements,
|
||||
appState: {},
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
},
|
||||
config: {
|
||||
padding: 0,
|
||||
exportingFrame,
|
||||
},
|
||||
});
|
||||
|
||||
// frame shouldn't be exported
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
resizeSingleElement,
|
||||
updateStickyNoteLayout,
|
||||
} from "@excalidraw/element";
|
||||
import { exportToCanvas } from "@excalidraw/utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
@@ -39,7 +38,7 @@ import { activeEyeDropperAtom } from "../components/EyeDropper";
|
||||
import { getShapeActionPredicates } from "../components/shapeActionPredicates";
|
||||
import { editorJotaiStore } from "../editor-jotai";
|
||||
import { Excalidraw } from "../index";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { exportScene } from "../scene/export";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
@@ -1157,16 +1156,19 @@ describe("sticky notes", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const svg = await exportToSvg(
|
||||
elements,
|
||||
{ exportBackground: false, viewBackgroundColor: "#ffffff" },
|
||||
{},
|
||||
);
|
||||
const svg = await exportScene({
|
||||
type: "svg",
|
||||
data: { elements, files: {} },
|
||||
config: { canvasBackgroundColor: false },
|
||||
});
|
||||
expect(
|
||||
[...svg.querySelectorAll("text")].map((text) => text.textContent),
|
||||
).toEqual(["7 Mar 2025"]);
|
||||
|
||||
const canvas = await exportToCanvas({ elements, files: {} });
|
||||
const canvas = await exportScene({
|
||||
type: "canvas",
|
||||
data: { elements, files: {} },
|
||||
});
|
||||
expect(canvas.getContext("2d")?.fillText).toHaveBeenCalledWith(
|
||||
"7 Mar 2025",
|
||||
expect.any(Number),
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking
|
||||
|
||||
- `exportToCanvas`, `exportToBlob` and `exportToSvg` are replaced by `exportScene({ type: "canvas" | "png" | "jpeg" | "svg", data, config })`, with the rendering options moved from `appState` into `config`. See the "Export API: `exportScene`" entry in the [`@excalidraw/excalidraw` changelog](../excalidraw/CHANGELOG.md) for the migration.
|
||||
|
||||
First release of `@excalidraw/utils` to provide utilities functions.
|
||||
|
||||
- Added `exportToBlob` and `exportToSvg` to export an Excalidraw diagram definition, respectively, to a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and to a [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) ([#2246](https://github.com/excalidraw/excalidraw/pull/2246))
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { MIME_TYPES } from "@excalidraw/common";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
import {
|
||||
copyBlobToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
} from "@excalidraw/excalidraw/clipboard";
|
||||
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
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 {
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
|
||||
|
||||
export { MIME_TYPES };
|
||||
|
||||
type ExportOpts = {
|
||||
/** restored before exporting */
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
||||
files: BinaryFiles | null;
|
||||
maxWidthOrHeight?: number;
|
||||
exportingFrame?: NonDeleted<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 = getNonDeletedElements(
|
||||
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 (restoring keeps the ids)
|
||||
restoreElements(opts.elements, null),
|
||||
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 = getNonDeletedElements(
|
||||
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(
|
||||
getNonDeletedElements(restoreElements(opts.elements, null)),
|
||||
opts.files,
|
||||
);
|
||||
} else {
|
||||
throw new Error("Invalid export type");
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,15 @@
|
||||
export * from "./export";
|
||||
// Re-export from @excalidraw/excalidraw for backwards compatibility
|
||||
export {
|
||||
exportScene,
|
||||
exportToClipboard,
|
||||
MIME_TYPES,
|
||||
} from "@excalidraw/excalidraw/scene/export";
|
||||
|
||||
export type {
|
||||
ExportSceneData,
|
||||
ExportSceneConfig,
|
||||
ExportSceneOptions,
|
||||
} from "@excalidraw/excalidraw/scene/export";
|
||||
|
||||
export { elementsOverlappingBBox } from "@excalidraw/element";
|
||||
export { getCommonBounds } from "@excalidraw/element";
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`exportToSvg > with default arguments 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeLockedId": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"fromSelection": false,
|
||||
"lastActiveTool": null,
|
||||
"locked": false,
|
||||
"type": "selection",
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"colorTopPicks": {
|
||||
"bucketFill": null,
|
||||
"elementBackground": null,
|
||||
"elementStroke": null,
|
||||
"stickyNoteBackground": null,
|
||||
"stickyNoteStroke": null,
|
||||
},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
"currentItemFontFamily": 5,
|
||||
"currentItemFontSize": 20,
|
||||
"currentItemOpacity": 100,
|
||||
"currentItemRoughness": 1,
|
||||
"currentItemRoundness": "sharp",
|
||||
"currentItemStartArrowhead": null,
|
||||
"currentItemStickynoteBackgroundColor": "#ffdf6b",
|
||||
"currentItemStickynoteStrokeColor": "#1e1e1e",
|
||||
"currentItemStrokeColor": "#1e1e1e",
|
||||
"currentItemStrokeStyle": "solid",
|
||||
"currentItemStrokeVariability": "constant",
|
||||
"currentItemStrokeWidthKey": "medium",
|
||||
"currentItemTextAlign": "left",
|
||||
"cursorButton": "up",
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"exportEmbedScene": false,
|
||||
"exportPadding": undefined,
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"fontTopPicks": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
"name": true,
|
||||
"outline": true,
|
||||
},
|
||||
"frameToHighlight": null,
|
||||
"gridModeEnabled": false,
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"hoveredElementIds": {},
|
||||
"inputDevice": "auto",
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isMidpointSnappingEnabled": true,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"lockedMultiSelections": {},
|
||||
"multiElement": null,
|
||||
"name": "name",
|
||||
"newElement": null,
|
||||
"objectsSnapModeEnabled": false,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"penDetected": false,
|
||||
"penMode": false,
|
||||
"preferredSelectionTool": {
|
||||
"initialized": false,
|
||||
"type": "selection",
|
||||
},
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": null,
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
"selectedLinearElement": null,
|
||||
"selectionElement": null,
|
||||
"shouldCacheIgnoreZoom": false,
|
||||
"showHints": true,
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"suggestedBinding": null,
|
||||
"textToolHover": null,
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
"viewBackgroundColor": "#ffffff",
|
||||
"viewModeEnabled": false,
|
||||
"zenModeEnabled": false,
|
||||
"zoom": {
|
||||
"value": 1,
|
||||
},
|
||||
}
|
||||
`;
|
||||
+239
-104
@@ -2,20 +2,37 @@ import { MIME_TYPES } from "@excalidraw/common";
|
||||
import * as clipboardModule from "@excalidraw/excalidraw/clipboard";
|
||||
import * as imageModule from "@excalidraw/excalidraw/data/image";
|
||||
import * as jsonModule from "@excalidraw/excalidraw/data/json";
|
||||
import * as mockedSceneExportUtils from "@excalidraw/excalidraw/scene/export";
|
||||
import * as staticSceneModule from "@excalidraw/excalidraw/renderer/staticScene";
|
||||
import * as staticSvgSceneModule from "@excalidraw/excalidraw/renderer/staticSvgScene";
|
||||
import { diagramFactory } from "@excalidraw/excalidraw/tests/fixtures/diagramFixture";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import * as utils from "../src";
|
||||
|
||||
const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
|
||||
import type { ExportSceneConfig } from "../src";
|
||||
|
||||
describe("exportToCanvas", async () => {
|
||||
describe("exportScene: canvas", async () => {
|
||||
const EXPORT_PADDING = 10;
|
||||
|
||||
it("with default arguments", async () => {
|
||||
const canvas = await utils.exportToCanvas({
|
||||
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
||||
it("with default arguments (padding 10)", async () => {
|
||||
const canvas = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
||||
});
|
||||
|
||||
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
|
||||
expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING);
|
||||
});
|
||||
|
||||
it("with padding", async () => {
|
||||
const canvas = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
||||
config: {
|
||||
padding: EXPORT_PADDING,
|
||||
},
|
||||
});
|
||||
|
||||
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
|
||||
@@ -23,116 +40,222 @@ describe("exportToCanvas", async () => {
|
||||
});
|
||||
|
||||
it("when custom width and height", async () => {
|
||||
const canvas = await utils.exportToCanvas({
|
||||
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
||||
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
|
||||
const canvas = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
||||
config: { width: 200, height: 200 },
|
||||
});
|
||||
|
||||
expect(canvas.width).toBe(200);
|
||||
expect(canvas.height).toBe(200);
|
||||
});
|
||||
|
||||
it("derives a missing width/height from the aspect ratio", async () => {
|
||||
const elements = [
|
||||
API.createElement({ type: "rectangle", width: 200, height: 100 }),
|
||||
];
|
||||
|
||||
const fromHeight = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: { elements, files: null },
|
||||
config: { padding: EXPORT_PADDING, height: 50 },
|
||||
});
|
||||
// 30px of content height → 60px of content width, plus padding
|
||||
expect([fromHeight.width, fromHeight.height]).toEqual([80, 50]);
|
||||
|
||||
const fromWidth = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: { elements, files: null },
|
||||
config: { padding: EXPORT_PADDING, width: 100 },
|
||||
});
|
||||
expect([fromWidth.width, fromWidth.height]).toEqual([100, 60]);
|
||||
});
|
||||
|
||||
describe("exportToBlob", async () => {
|
||||
describe("mime type", () => {
|
||||
it("should change image/jpg to image/jpeg", async () => {
|
||||
const blob = await utils.exportToBlob({
|
||||
...diagramFactory(),
|
||||
getDimensions: (width, height) => ({ width, height, scale: 1 }),
|
||||
// testing typo in MIME type (jpg → jpeg)
|
||||
mimeType: "image/jpg",
|
||||
appState: {
|
||||
exportBackground: true,
|
||||
it("crops (doesn't scale) an x/y region by default", async () => {
|
||||
const canvas = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: [
|
||||
API.createElement({ type: "rectangle", width: 200, height: 100 }),
|
||||
],
|
||||
files: null,
|
||||
},
|
||||
});
|
||||
expect(blob?.type).toBe(MIME_TYPES.jpg);
|
||||
});
|
||||
it("should default to image/png", async () => {
|
||||
const blob = await utils.exportToBlob({
|
||||
...diagramFactory(),
|
||||
});
|
||||
expect(blob?.type).toBe(MIME_TYPES.png);
|
||||
config: { padding: EXPORT_PADDING, x: 50, y: 0, width: 100, height: 100 },
|
||||
});
|
||||
|
||||
it("should warn when using quality with image/png", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
await utils.exportToBlob({
|
||||
...diagramFactory(),
|
||||
mimeType: MIME_TYPES.png,
|
||||
quality: 1,
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
|
||||
);
|
||||
});
|
||||
});
|
||||
// `fit: "none"` adds the padding around the region
|
||||
expect([canvas.width, canvas.height]).toEqual([120, 120]);
|
||||
});
|
||||
|
||||
describe("exportToSvg", () => {
|
||||
const passedElements = () => exportToSvgSpy.mock.calls[0][0];
|
||||
const passedOptions = () => exportToSvgSpy.mock.calls[0][1];
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
it("pads an x/y region on all sides", async () => {
|
||||
const renderSpy = vi.spyOn(staticSceneModule, "renderStaticScene");
|
||||
const canvas = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: [
|
||||
API.createElement({ type: "rectangle", width: 200, height: 100 }),
|
||||
],
|
||||
files: null,
|
||||
},
|
||||
config: { padding: EXPORT_PADDING, x: 0, y: 0, width: 100, height: 100 },
|
||||
});
|
||||
|
||||
it("with default arguments", async () => {
|
||||
await utils.exportToSvg({
|
||||
...diagramFactory({
|
||||
overrides: { appState: void 0 },
|
||||
}),
|
||||
// the canvas covers the scene from -10 to 110 on both axes
|
||||
expect([canvas.width, canvas.height]).toEqual([120, 120]);
|
||||
const { scrollX, scrollY } = renderSpy.mock.calls[0][0].appState;
|
||||
expect([scrollX, scrollY]).toEqual([EXPORT_PADDING, EXPORT_PADDING]);
|
||||
renderSpy.mockRestore();
|
||||
});
|
||||
|
||||
const passedOptionsWhenDefault = {
|
||||
...passedOptions(),
|
||||
// To avoid varying snapshots
|
||||
name: "name",
|
||||
it("ignores x/y when the content is scaled to fit", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const renderSpy = vi.spyOn(staticSceneModule, "renderStaticScene");
|
||||
const exportWith = (config: ExportSceneConfig) =>
|
||||
utils.exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: [
|
||||
API.createElement({ type: "rectangle", width: 200, height: 100 }),
|
||||
],
|
||||
files: null,
|
||||
},
|
||||
config: { padding: EXPORT_PADDING, ...config },
|
||||
});
|
||||
const renderedScroll = (call: number) => {
|
||||
const { scrollX, scrollY } = renderSpy.mock.calls[call][0].appState;
|
||||
return [scrollX, scrollY];
|
||||
};
|
||||
expect(passedElements().length).toBe(3);
|
||||
expect(passedOptionsWhenDefault).toMatchSnapshot();
|
||||
|
||||
await exportWith({ width: 100, height: 100, fit: "contain" });
|
||||
await exportWith({ width: 100, height: 100, fit: "contain", x: 30, y: 20 });
|
||||
await exportWith({ maxWidthOrHeight: 100 });
|
||||
// the types forbid x/y with maxWidthOrHeight, JS callers still can
|
||||
// @ts-expect-error
|
||||
await exportWith({ maxWidthOrHeight: 100, x: 30, y: 20 });
|
||||
|
||||
expect(renderedScroll(1)).toEqual(renderedScroll(0));
|
||||
expect(renderedScroll(3)).toEqual(renderedScroll(2));
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
warn.mockRestore();
|
||||
renderSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Regression test: when all elements are deleted, exportToSvg should pass
|
||||
// zero elements to the lower-level export (line 184-188 of export.ts calls
|
||||
// getNonDeletedElements which filters them out).
|
||||
it("keeps the padding of flat content", async () => {
|
||||
const line = API.createElement({
|
||||
type: "line",
|
||||
width: 200,
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(200, 0)],
|
||||
});
|
||||
const exportLine = (config: ExportSceneConfig) =>
|
||||
utils.exportScene({ type: "canvas", data: { elements: [line] }, config });
|
||||
|
||||
// the padding used to be clamped against the content's (zero) height
|
||||
const fitted = await exportLine({ padding: 10, maxWidthOrHeight: 400 });
|
||||
expect([fitted.width, fitted.height]).toEqual([220, 20]);
|
||||
|
||||
// never a zero-sized canvas, which can't be encoded
|
||||
const unpadded = await exportLine({ padding: 0 });
|
||||
expect([unpadded.width, unpadded.height]).toEqual([200, 1]);
|
||||
});
|
||||
|
||||
it("derives a missing width/height after clamping the padding", async () => {
|
||||
const canvas = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: [
|
||||
API.createElement({ type: "rectangle", width: 200, height: 100 }),
|
||||
],
|
||||
},
|
||||
// a 10px height leaves no room for the padding, so it's dropped
|
||||
config: { height: 10, padding: 10 },
|
||||
});
|
||||
|
||||
expect([canvas.width, canvas.height]).toEqual([20, 10]);
|
||||
});
|
||||
|
||||
it("with a scale function", async () => {
|
||||
const scale = vi.fn((width: number, height: number) => 60 / width);
|
||||
const canvas = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
||||
config: { padding: EXPORT_PADDING, scale },
|
||||
});
|
||||
|
||||
// receives the export dimensions at scale 1, padding included
|
||||
expect(scale).toHaveBeenCalledWith(120, 120);
|
||||
expect(canvas.width).toBe(60);
|
||||
expect(canvas.height).toBe(60);
|
||||
});
|
||||
|
||||
it("rounds the canvas dimensions", async () => {
|
||||
const size = 29 / 1.1;
|
||||
const canvas = await utils.exportScene({
|
||||
type: "canvas",
|
||||
data: {
|
||||
elements: [
|
||||
API.createElement({ type: "rectangle", width: size, height: size }),
|
||||
],
|
||||
files: null,
|
||||
},
|
||||
// size * 1.1 === 28.999999999999996
|
||||
config: { scale: 1.1, padding: 0 },
|
||||
});
|
||||
|
||||
expect(canvas.width).toBe(29);
|
||||
expect(canvas.height).toBe(29);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportScene: png & jpeg", () => {
|
||||
it("exports the requested image type", async () => {
|
||||
const png = await utils.exportScene({
|
||||
type: "png",
|
||||
data: diagramFactory(),
|
||||
});
|
||||
expect(png.type).toBe(MIME_TYPES.png);
|
||||
|
||||
const jpeg = await utils.exportScene({
|
||||
type: "jpeg",
|
||||
data: diagramFactory(),
|
||||
});
|
||||
expect(jpeg.type).toBe(MIME_TYPES.jpg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportScene: svg", () => {
|
||||
it("with default arguments", async () => {
|
||||
const svg = await utils.exportScene({
|
||||
type: "svg",
|
||||
data: diagramFactory({ overrides: { appState: void 0 } }),
|
||||
});
|
||||
|
||||
expect(svg.querySelectorAll("[data-id]")).toHaveLength(3);
|
||||
});
|
||||
|
||||
// Regression test (#11470): deleted elements must be filtered out at
|
||||
// runtime (inside `configExportDimension`), since the type contract alone
|
||||
// doesn't protect JS consumers passing raw scene data.
|
||||
it("with deleted elements", async () => {
|
||||
await utils.exportToSvg({
|
||||
...diagramFactory({
|
||||
const svg = await utils.exportScene({
|
||||
type: "svg",
|
||||
data: diagramFactory({
|
||||
overrides: { appState: void 0 },
|
||||
elementOverrides: { isDeleted: true },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(passedElements().length).toBe(0);
|
||||
expect(svg.querySelectorAll("[data-id]")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("with exportPadding", async () => {
|
||||
await utils.exportToSvg({
|
||||
...diagramFactory({
|
||||
overrides: { appState: { name: "diagram name" } },
|
||||
}),
|
||||
exportPadding: 0,
|
||||
it("with embedScene", async () => {
|
||||
const svg = await utils.exportScene({
|
||||
type: "svg",
|
||||
data: diagramFactory({ overrides: { appState: void 0 } }),
|
||||
config: { embedScene: true },
|
||||
});
|
||||
|
||||
expect(passedElements().length).toBe(3);
|
||||
expect(passedOptions()).toEqual(
|
||||
expect.objectContaining({ exportPadding: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("with exportEmbedScene", async () => {
|
||||
await utils.exportToSvg({
|
||||
...diagramFactory({
|
||||
overrides: {
|
||||
appState: { name: "diagram name", exportEmbedScene: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(passedElements().length).toBe(3);
|
||||
expect(passedOptions().exportEmbedScene).toBe(true);
|
||||
expect(svg.querySelector("metadata")?.innerHTML).toContain("payload-start");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,43 +282,55 @@ describe("elements lacking creation metadata", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("exportToSvg and exportToCanvas restore the elements", async () => {
|
||||
const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
|
||||
it("svg and canvas exports render the restored elements", async () => {
|
||||
const renderSvgSpy = vi.spyOn(staticSvgSceneModule, "renderSceneToSvg");
|
||||
const diagram = legacyDiagram();
|
||||
|
||||
await utils.exportToSvg(diagram);
|
||||
await utils.exportScene({ type: "svg", data: diagram });
|
||||
|
||||
expect(createdOf(exportToSvgSpy.mock.calls[0][0])).toEqual([
|
||||
expect(createdOf(renderSvgSpy.mock.calls[0][0])).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(diagram.elements[0]).not.toHaveProperty("created");
|
||||
|
||||
const exportToCanvasSpy = vi.spyOn(
|
||||
mockedSceneExportUtils,
|
||||
"exportToCanvas",
|
||||
);
|
||||
const canvas = await utils.exportToCanvas(diagram);
|
||||
const renderCanvasSpy = vi.spyOn(staticSceneModule, "renderStaticScene");
|
||||
const canvas = await utils.exportScene({ type: "canvas", data: diagram });
|
||||
|
||||
expect(createdOf(exportToCanvasSpy.mock.calls[0][0])).toEqual([
|
||||
expect(createdOf(renderCanvasSpy.mock.calls[0][0].visibleElements)).toEqual(
|
||||
[null, null, null],
|
||||
);
|
||||
expect(canvas.width).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("svg export embeds the restored scene", async () => {
|
||||
const serializeSpy = vi.spyOn(jsonModule, "serializeAsJSON");
|
||||
|
||||
await utils.exportScene({
|
||||
type: "svg",
|
||||
data: legacyDiagram(),
|
||||
config: { embedScene: true },
|
||||
});
|
||||
|
||||
expect(serializeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(createdOf(serializeSpy.mock.calls[0][0])).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(canvas.width).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("exportToBlob embeds the restored scene into png metadata", async () => {
|
||||
it("png export embeds the restored scene into its metadata", async () => {
|
||||
const serializeSpy = vi.spyOn(jsonModule, "serializeAsJSON");
|
||||
vi.spyOn(imageModule, "encodePngMetadata").mockImplementation(
|
||||
async ({ blob }) => blob,
|
||||
);
|
||||
|
||||
await utils.exportToBlob({
|
||||
...legacyDiagram(),
|
||||
mimeType: MIME_TYPES.png,
|
||||
appState: { exportEmbedScene: true },
|
||||
await utils.exportScene({
|
||||
type: "png",
|
||||
data: legacyDiagram(),
|
||||
config: { embedScene: true },
|
||||
});
|
||||
|
||||
expect(serializeSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -211,7 +346,7 @@ describe("elements lacking creation metadata", () => {
|
||||
.spyOn(clipboardModule, "copyToClipboard")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await utils.exportToClipboard({ ...legacyDiagram(), type: "json" });
|
||||
await utils.exportToClipboard({ type: "json", data: legacyDiagram() });
|
||||
|
||||
expect(copySpy).toHaveBeenCalledTimes(1);
|
||||
expect(createdOf(copySpy.mock.calls[0][0])).toEqual([null, null, null]);
|
||||
|
||||
@@ -11,21 +11,24 @@ import * as utils from "../src";
|
||||
// mocked and unmocked API in the same file.
|
||||
|
||||
describe("embedding scene data", () => {
|
||||
describe("exportToSvg", () => {
|
||||
describe("svg", () => {
|
||||
it("embedding scene data shouldn't modify them", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const ellipse = API.createElement({ type: "ellipse" });
|
||||
|
||||
const sourceElements = [rectangle, ellipse];
|
||||
|
||||
const svgNode = await utils.exportToSvg({
|
||||
const svgNode = await utils.exportScene({
|
||||
type: "svg",
|
||||
data: {
|
||||
elements: sourceElements,
|
||||
appState: {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
gridModeEnabled: false,
|
||||
exportEmbedScene: true,
|
||||
},
|
||||
files: null,
|
||||
},
|
||||
config: { embedScene: true },
|
||||
});
|
||||
|
||||
const svg = svgNode.outerHTML;
|
||||
@@ -41,22 +44,24 @@ describe("embedding scene data", () => {
|
||||
|
||||
// skipped because we can't test png encoding right now
|
||||
// (canvas.toBlob not supported in jsdom)
|
||||
describe.skip("exportToBlob", () => {
|
||||
describe.skip("png", () => {
|
||||
it("embedding scene data shouldn't modify them", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const ellipse = API.createElement({ type: "ellipse" });
|
||||
|
||||
const sourceElements = [rectangle, ellipse];
|
||||
|
||||
const blob = await utils.exportToBlob({
|
||||
mimeType: "image/png",
|
||||
const blob = await utils.exportScene({
|
||||
type: "png",
|
||||
data: {
|
||||
elements: sourceElements,
|
||||
appState: {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
gridModeEnabled: false,
|
||||
exportEmbedScene: true,
|
||||
},
|
||||
files: null,
|
||||
},
|
||||
config: { embedScene: true },
|
||||
});
|
||||
|
||||
const parsedString = await decodePngMetadata(blob);
|
||||
|
||||
Reference in New Issue
Block a user