Compare commits

..

3 Commits

Author SHA1 Message Date
Ryan Di 7e84a931e1 remove agent.md 2026-03-18 21:29:14 +11:00
Ryan Di 3b6de119b3 fix: clip frame children completely out of bounds 2026-03-18 21:16:40 +11:00
David Luzar 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
22 changed files with 724 additions and 1122 deletions
+8 -12
View File
@@ -25,19 +25,15 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState(); const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({ const blob = await exportToBlob({
data: { elements: children,
elements: children, appState: {
appState: { ...appState,
...appState, exportBackground: true,
exportBackground: true, viewBackgroundColor: appState.viewBackgroundColor,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
}, },
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
}); });
const dataURL = await getDataURL(blob); const dataURL = await getDataURL(blob);
+13
View File
@@ -872,6 +872,19 @@ export const shouldApplyFrameClip = (
return true; return true;
} }
// Elements that belong to a frame should still render through that frame's
// clip, even when fully outside the frame bounds (e.g. generated content).
if (
!appState.selectedElementsAreBeingDragged &&
element.frameId === frame.id
) {
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, true);
}
return true;
}
// if an element is outside the frame, but is part of a group that has some elements // if an element is outside the frame, but is part of a group that has some elements
// "in" the frame, we should clip the element // "in" the frame, we should clip the element
if ( if (
+10 -18
View File
@@ -330,30 +330,22 @@ describe("Cropping and other features", async () => {
const widthToHeightRatio = image.width / image.height; const widthToHeightRatio = image.width / image.height;
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
data: { elements: [image],
elements: [image], // @ts-ignore
// @ts-ignore appState: h.state,
appState: h.state, files: h.app.files,
files: h.app.files, exportPadding: 0,
},
config: {
padding: 0,
},
}); });
const exportedCanvasRatio = canvas.width / canvas.height; const exportedCanvasRatio = canvas.width / canvas.height;
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio); expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: [image],
elements: [image], // @ts-ignore
// @ts-ignore appState: h.state,
appState: h.state, files: h.app.files,
files: h.app.files, exportPadding: 0,
},
config: {
padding: 0,
},
}); });
const svgWidth = svg.getAttribute("width"); const svgWidth = svg.getAttribute("width");
const svgHeight = svg.getAttribute("height"); const svgHeight = svg.getAttribute("height");
+78
View File
@@ -2,6 +2,7 @@ import {
convertToExcalidrawElements, convertToExcalidrawElements,
Excalidraw, Excalidraw,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
@@ -10,6 +11,8 @@ import {
render, render,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import { shouldApplyFrameClip } from "../src/frame";
import type { ExcalidrawElement } from "../src/types"; import type { ExcalidrawElement } from "../src/types";
const { h } = window; const { h } = window;
@@ -561,3 +564,78 @@ describe("adding elements to frames", () => {
}); });
}); });
}); });
describe("frame clipping", () => {
const getAppStateForFrameClip = () =>
({
frameRendering: {
enabled: true,
clip: true,
},
selectedElementsAreBeingDragged: false,
selectedElementIds: {},
frameToHighlight: null,
editingGroupId: null,
} as any);
it("clips a frame child even when fully outside the frame bounds", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const outsideChild = API.createElement({
type: "rectangle",
id: "outside-child",
x: 250,
y: 250,
width: 50,
height: 50,
frameId: frame.id,
});
const elementsMap = arrayToMap([outsideChild, frame]);
expect(
shouldApplyFrameClip(
outsideChild,
frame,
getAppStateForFrameClip(),
elementsMap,
),
).toBe(true);
});
it("does not clip an outside element that does not belong to the frame", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const outsideElement = API.createElement({
type: "rectangle",
id: "outside",
x: 250,
y: 250,
width: 50,
height: 50,
});
const elementsMap = arrayToMap([outsideElement, frame]);
expect(
shouldApplyFrameClip(
outsideElement,
frame,
getAppStateForFrameClip(),
elementsMap,
),
).toBe(false);
});
});
@@ -1,3 +1,4 @@
import { exportToCanvas } from "@excalidraw/utils/export";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { import {
@@ -5,7 +6,6 @@ import {
EXPORT_IMAGE_TYPES, EXPORT_IMAGE_TYPES,
isFirefox, isFirefox,
EXPORT_SCALES, EXPORT_SCALES,
THEME,
cloneJSON, cloneJSON,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -26,7 +26,6 @@ import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -129,26 +128,19 @@ const ImageExportModal = ({
}; };
exportToCanvas({ exportToCanvas({
data: { elements: exportedElements,
elements: exportedElements, appState: {
appState: { ...appStateSnapshot,
...appStateSnapshot, name: projectName,
name: projectName, exportBackground: exportWithBackground,
exportBackground: exportWithBackground, exportWithDarkMode,
exportScale, exportScale,
exportEmbedScene: embedScene, exportEmbedScene: embedScene,
},
files,
},
config: {
padding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
canvasBackgroundColor: exportWithBackground
? appStateSnapshot.viewBackgroundColor
: false,
}, },
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
}) })
.then(async (canvas) => { .then(async (canvas) => {
if (isStaleRequest()) { if (isStaleRequest()) {
@@ -72,20 +72,18 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
svg = await exportToSvg({ svg = await exportToSvg(
data: { elements,
elements, {
appState: { exportBackground: false,
exportBackground: false, viewBackgroundColor: "#fff",
viewBackgroundColor: "#fff", exportWithDarkMode: theme === "dark",
},
files: null,
}, },
config: { null, // files
{
skipInliningFonts: true, skipInliningFonts: true,
theme,
}, },
}); );
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -136,20 +134,18 @@ const PlainTextPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
const svg = await exportToSvg({ const svg = await exportToSvg(
data: { [textElement],
elements: [textElement], {
appState: { exportBackground: false,
exportBackground: false, viewBackgroundColor: "#fff",
viewBackgroundColor: "#fff", exportWithDarkMode: theme === "dark",
},
files: null,
}, },
config: { null,
{
skipInliningFonts: true, skipInliningFonts: true,
theme,
}, },
}); );
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -1,3 +1,4 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
@@ -12,7 +13,6 @@ import {
import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { EditorLocalStorage } from "../data/EditorLocalStorage";
import { canvasToBlob, resizeImageFile } from "../data/blob"; import { canvasToBlob, resizeImageFile } from "../data/blob";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
@@ -63,14 +63,9 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) { for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({ const itemCanvas = await exportToCanvas({
data: { elements: item.elements,
elements: item.elements, files: null,
files: null, maxWidthOrHeight: BOX_SIZE,
appState: {},
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
}); });
const { width, height } = itemCanvas; const { width, height } = itemCanvas;
@@ -132,18 +127,14 @@ const SingleLibraryItem = ({
} }
(async () => { (async () => {
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: libItem.elements,
elements: libItem.elements, appState: {
appState: { ...appState,
...appState, viewBackgroundColor: "#fff",
viewBackgroundColor: "#fff", exportBackground: true,
exportBackground: true,
},
files: null,
},
config: {
skipInliningFonts: true,
}, },
files: null,
skipInliningFonts: true,
}); });
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
@@ -1,4 +1,8 @@
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common"; import {
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element"; import { convertToExcalidrawElements } from "@excalidraw/element";
@@ -101,16 +105,14 @@ export const convertMermaidToExcalidraw = async ({
}; };
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
data: { elements: data.current.elements,
elements: data.current.elements, files: data.current.files,
files: data.current.files, exportPadding: DEFAULT_EXPORT_PADDING,
}, maxWidthOrHeight:
config: { Math.max(parent.offsetWidth, parent.offsetHeight) *
padding: DEFAULT_EXPORT_PADDING, window.devicePixelRatio,
maxWidthOrHeight: appState: {
Math.max(parent.offsetWidth, parent.offsetHeight) * exportWithDarkMode: theme === THEME.DARK,
window.devicePixelRatio,
theme,
}, },
}); });
+17 -35
View File
@@ -4,7 +4,6 @@ import {
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
isFirefox, isFirefox,
MIME_TYPES, MIME_TYPES,
THEME,
cloneJSON, cloneJSON,
SVG_DOCUMENT_PREAMBLE, SVG_DOCUMENT_PREAMBLE,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -116,29 +115,20 @@ export const exportCanvas = async (
if (elements.length === 0) { if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas")); throw new Error(t("alerts.cannotExportEmptyCanvas"));
} }
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
if (type === "svg" || type === "clipboard-svg") { if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg({ const svgPromise = exportToSvg(
data: { elements,
elements, {
appState: { exportBackground,
...appState, exportWithDarkMode: appState.exportWithDarkMode,
exportBackground, viewBackgroundColor,
exportEmbedScene: appState.exportEmbedScene && type === "svg", exportPadding,
}, exportScale: appState.exportScale,
files, exportEmbedScene: appState.exportEmbedScene && type === "svg",
}, },
config: { files,
padding: exportPadding, { exportingFrame },
exportingFrame, );
theme,
canvasBackgroundColor: exportBackground
? viewBackgroundColor
: "transparent",
},
});
if (type === "svg") { if (type === "svg") {
return fileSave( return fileSave(
@@ -168,19 +158,11 @@ export const exportCanvas = async (
} }
} }
const tempCanvas = exportToCanvas({ const tempCanvas = exportToCanvas(elements, appState, files, {
data: { exportBackground,
elements, viewBackgroundColor,
appState, exportPadding,
files, exportingFrame,
},
config: {
canvasBackgroundColor: exportBackground ? viewBackgroundColor : false,
padding: exportPadding,
theme,
scale: appState.exportScale,
exportingFrame,
},
}); });
if (type === "png") { if (type === "png") {
-4
View File
@@ -20,10 +20,6 @@ export const resaveAsImageWithScene = async (
) => { ) => {
const fileHandleType = getFileHandleType(fileHandle); const fileHandleType = getFileHandleType(fileHandle);
if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) { if (!isImageFileHandleType(fileHandleType)) {
throw new Error( throw new Error(
"fileHandle should exist and should be of type svg or png when resaving", "fileHandle should exist and should be of type svg or png when resaving",
+10 -12
View File
@@ -1,9 +1,8 @@
import { exportToSvg } from "@excalidraw/utils/export";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "@excalidraw/common"; import { COLOR_PALETTE } from "@excalidraw/common";
import { exportToSvg } from "../scene/export";
import { atom, useAtom } from "../editor-jotai"; import { atom, useAtom } from "../editor-jotai";
import type { LibraryItem } from "../types"; import type { LibraryItem } from "../types";
@@ -13,18 +12,17 @@ export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
export const libraryItemSvgsCache = atom<SvgCache>(new Map()); export const libraryItemSvgsCache = atom<SvgCache>(new Map());
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
// TODO should pass theme (appState.exportWithDark) - we're still using
// CSS filter here
return await exportToSvg({ return await exportToSvg({
data: { elements,
elements, appState: {
appState: { exportBackground: false,
exportBackground: false, viewBackgroundColor: COLOR_PALETTE.white,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
},
config: {
skipInliningFonts: true,
}, },
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
}); });
}; };
+23 -19
View File
@@ -1,3 +1,4 @@
import { getDefaultAppState } from "./appState";
import { exportToCanvas } from "./scene/export"; import { exportToCanvas } from "./scene/export";
const fs = require("fs"); const fs = require("fs");
@@ -58,23 +59,26 @@ const elements = [
registerFont("./public/Virgil.woff2", { family: "Virgil" }); registerFont("./public/Virgil.woff2", { family: "Virgil" });
registerFont("./public/Cascadia.woff2", { family: "Cascadia" }); registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
(async () => { const canvas = exportToCanvas(
const canvas = await exportToCanvas({ elements as any,
data: { {
elements: elements as any, ...getDefaultAppState(),
appState: {}, offsetTop: 0,
files: {}, offsetLeft: 0,
}, width: 0,
config: { height: 0,
canvasBackgroundColor: "#ffffff", },
createCanvas, {}, // files
}, {
}); exportBackground: true,
viewBackgroundColor: "#ffffff",
},
createCanvas,
);
const out = fs.createWriteStream("test.png"); const out = fs.createWriteStream("test.png");
const stream = (canvas as any).createPNGStream(); const stream = (canvas as any).createPNGStream();
stream.pipe(out); stream.pipe(out);
out.on("finish", () => { out.on("finish", () => {
console.info("test.png was created."); console.info("test.png was created.");
}); });
})();
+1 -3
View File
@@ -304,9 +304,7 @@ export {
exportToBlob, exportToBlob,
exportToSvg, exportToSvg,
exportToClipboard, exportToClipboard,
} from "./scene/export"; } from "@excalidraw/utils/export";
export type { ExportSceneData, ExportSceneConfig } from "./scene/export";
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
export { export {
+123 -672
View File
@@ -1,13 +1,13 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
DEFAULT_EXPORT_PADDING,
FRAME_STYLE, FRAME_STYLE,
FONT_FAMILY, FONT_FAMILY,
SVG_NS, SVG_NS,
THEME, THEME,
MIME_TYPES, MIME_TYPES,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
COLOR_WHITE,
arrayToMap, arrayToMap,
distance, distance,
getFontString, getFontString,
@@ -47,33 +47,20 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
Theme,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode"; import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
import { restoreAppState } from "../data/restore";
import { encodePngMetadata } from "../data/image";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { renderStaticScene } from "../renderer/staticScene"; import { renderStaticScene } from "../renderer/staticScene";
import { renderSceneToSvg } from "../renderer/staticSvgScene"; import { renderSceneToSvg } from "../renderer/staticSvgScene";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "../clipboard";
import type { RenderableElementsMap } from "./types"; import type { RenderableElementsMap } from "./types";
import type { AppState, BinaryFiles, NormalizedZoomValue } from "../types"; import type { AppState, BinaryFiles } from "../types";
// Default minimum export size in pixels
const DEFAULT_SMALLEST_EXPORT_SIZE = 20;
const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue;
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) { if (element.width <= maxWidth) {
@@ -182,205 +169,36 @@ const prepareElementsForRender = ({
return nextElements; return nextElements;
}; };
// --------------------------------------------------------------------------- export const exportToCanvas = async (
// Types for the new API elements: readonly NonDeletedExcalidrawElement[],
// --------------------------------------------------------------------------- appState: AppState,
files: BinaryFiles,
export type ExportSceneData = { {
elements: readonly NonDeletedExcalidrawElement[]; exportBackground,
appState?: Partial< exportPadding = DEFAULT_EXPORT_PADDING,
Omit<AppState, "offsetTop" | "offsetLeft" | "exportWithDarkMode"> viewBackgroundColor,
>; exportingFrame,
files: BinaryFiles | null; }: {
}; exportBackground: boolean;
exportPadding?: number;
export type ExportSceneConfig = { viewBackgroundColor: string;
theme?: Theme; exportingFrame?: ExcalidrawFrameLikeElement | null;
/** },
* Canvas background. Valid values are: createCanvas: (
*
* - `undefined` - the background of "appState.viewBackgroundColor" is used.
* - `false` - no background is used (set to "transparent").
* - `string` - should be a valid CSS color.
*
* @default undefined
*/
canvasBackgroundColor?: string | false;
/**
* Canvas padding in pixels. Affected by `scale`.
*
* When `fit` is set to `none`, padding is added to the content bounding box
* (including if you set `width` or `height` or `maxWidthOrHeight` or
* `widthOrHeight`).
*
* When `fit` set to `contain`, padding is subtracted from the content
* bounding box (ensuring the size doesn't exceed the supplied values, with
* the exeception of using alongside `scale` as noted above), and the padding
* serves as a minimum distance between the content and the canvas edges, as
* it may exceed the supplied padding value from one side or the other in
* order to maintain the aspect ratio. It is recommended to set `position`
* to `center` when using `fit=contain`.
*
* When `fit` is set to `none` and either `width` or `height` or
* `maxWidthOrHeight` is set, padding is simply adding to the bounding box
* and the content may overflow the canvas, thus right or bottom padding
* may be ignored.
*
* @default 0
*/
padding?: number;
// -------------------------------------------------------------------------
/**
* Makes sure the canvas content fits into a frame of width/height no larger
* than this value, while maintaining the aspect ratio.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
maxWidthOrHeight?: number;
/**
* Scale the canvas content to be excatly this many pixels wide/tall,
* maintaining the aspect ratio.
*
* Cannot be used in conjunction with `maxWidthOrHeight`.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
widthOrHeight?: number;
// -------------------------------------------------------------------------
/**
* Width of the frame. Supply `x` or `y` if you want to ofsset the canvas
* content.
*
* If `width` omitted but `height` supplied, `width` is calculated from the
* the content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box width when both `width` and `height`
* are omitted.
*/
width?: number;
/**
* Height of the frame.
*
* If `height` omitted but `width` supplied, `height` is calculated from the
* content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box height when both `width` and `height`
* are omitted.
*/
height?: number;
/**
* Left canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `x` postion of the content bounding box.
*/
x?: number;
/**
* Top canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `y` postion of the content bounding box.
*/
y?: number;
/**
* Indicates the coordinate system of the `x` and `y` values.
*
* - `canvas` - `x` and `y` are relative to the canvas [0, 0] position.
* - `content` - `x` and `y` are relative to the content bounding box.
*
* @default "canvas"
*/
origin?: "canvas" | "content";
/**
* If dimensions specified and `x` and `y` are not specified, this indicates
* how the canvas should be scaled.
*
* Behavior aligns with the `object-fit` CSS property.
*
* - `none` - no scaling.
* - `contain` - scale to fit the frame. Includes `padding`.
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `fit` is ignored.
*
* @default "contain" unless `width`, `height`, `maxWidthOrHeight`, or
* `widthOrHeight` is specified in which case `none` is the default (can be
* changed). If `x` or `y` are specified, `none` is forced.
*/
fit?: "none" | "contain";
/**
* When either `x` or `y` are not specified, indicates how the canvas should
* be aligned on the respective axis.
*
* - `none` - canvas aligned to top left.
* - `center` - canvas is centered on the axis which is not specified
* (or both).
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `position` is ignored.
*
* @default "center"
*/
position?: "center" | "topLeft";
// -------------------------------------------------------------------------
/**
* A multiplier to increase/decrease the frame dimensions
* (content resolution).
*
* For example, if your canvas is 300x150 and you set scale to 2, the
* resulting size will be 600x300.
*
* @default 1
*/
scale?: number;
/**
* If you need to suply your own canvas, e.g. in test environments or in
* Node.js.
*
* Do not set `canvas.width/height` or modify the canvas context as that's
* handled by Excalidraw.
*
* Defaults to `document.createElement("canvas")`.
*/
createCanvas?: () => HTMLCanvasElement;
/**
* If you want to supply `width`/`height` dynamically (or derive from the
* content bounding box), you can use this function.
*
* Ignored if `maxWidthOrHeight`, `width`, or `height` is set.
*/
getDimensions?: (
width: number, width: number,
height: number, height: number,
) => { width: number; height: number; scale?: number }; ) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => {
const canvas = document.createElement("canvas");
exportingFrame?: ExcalidrawFrameLikeElement | null; canvas.width = width * appState.exportScale;
canvas.height = height * appState.exportScale;
loadFonts?: () => Promise<void>; return { canvas, scale: appState.exportScale };
}; },
loadFonts: () => Promise<void> = async () => {
// --------------------------------------------------------------------------- await Fonts.loadElementsFonts(elements);
// Internal helper to configure export dimensions },
// --------------------------------------------------------------------------- ) => {
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
const configExportDimension = async ({ await loadFonts();
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
// clone
const cfg = Object.assign({}, config);
const { exportingFrame } = cfg;
const elements = data.elements;
// initialize defaults
// ---------------------------------------------------------------------------
const appState = restoreAppState(data.appState, null);
const frameRendering = getFrameRenderingConfig( const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null, exportingFrame ?? null,
@@ -400,255 +218,26 @@ const configExportDimension = async ({
}); });
if (exportingFrame) { if (exportingFrame) {
cfg.padding = 0; exportPadding = 0;
} }
cfg.fit = const [minX, minY, width, height] = getCanvasSize(
cfg.fit ??
(cfg.width != null ||
cfg.height != null ||
cfg.maxWidthOrHeight != null ||
cfg.widthOrHeight != null
? "contain"
: "none");
cfg.padding = cfg.padding ?? 0;
cfg.scale = cfg.scale ?? 1;
cfg.origin = cfg.origin ?? "canvas";
cfg.position = cfg.position ?? "center";
if (cfg.maxWidthOrHeight != null && cfg.widthOrHeight != null) {
if (!import.meta.env.PROD) {
console.warn("`maxWidthOrHeight` is ignored when `widthOrHeight` is set");
}
cfg.maxWidthOrHeight = undefined;
}
if (
(cfg.maxWidthOrHeight != null || cfg.width != null || cfg.height != null) &&
cfg.getDimensions
) {
if (!import.meta.env.PROD) {
console.warn(
"`getDimensions` is ignored when `width`, `height`, or `maxWidthOrHeight` is set",
);
}
cfg.getDimensions = undefined;
}
// ---------------------------------------------------------------------------
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
if (cfg.loadFonts) {
await cfg.loadFonts();
} else {
await Fonts.loadElementsFonts(elements);
}
// value used to scale the canvas context. By default, we use this to
// make the canvas fit into the frame (e.g. for `cfg.fit` set to `contain`).
// If `cfg.scale` is set, we multiply the resulting canvasScale by it to
// scale the output further.
let exportScale = 1;
const origCanvasSize = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
); );
// variables for original content bounding box const { canvas, scale = 1 } = createCanvas(width, height);
const [origX, origY, origWidth, origHeight] = origCanvasSize;
// variables for target bounding box
let [x, y, width, height] = origCanvasSize;
x = cfg.x ?? x; const defaultAppState = getDefaultAppState();
y = cfg.y ?? y;
width = cfg.width ?? width;
height = cfg.height ?? height;
if (cfg.fit === "contain" || cfg.widthOrHeight || cfg.maxWidthOrHeight) {
cfg.padding =
cfg.padding && cfg.padding > 0
? Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
)
: 0;
if (cfg.getDimensions != null) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.padding = Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.widthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.widthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.maxWidthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.maxWidthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
}
}
if (cfg.maxWidthOrHeight != null || cfg.widthOrHeight != null) {
if (cfg.padding) {
if (cfg.maxWidthOrHeight != null) {
cfg.maxWidthOrHeight -= cfg.padding * 2;
} else if (cfg.widthOrHeight != null) {
cfg.widthOrHeight -= cfg.padding * 2;
}
}
const max = Math.max(width, height);
if (cfg.widthOrHeight != null) {
// calculate by how much do we need to scale the canvas to fit into the
// target dimension (e.g. target: max 50px, actual: 70x100px => scale: 0.5)
exportScale = cfg.widthOrHeight / max;
} else if (cfg.maxWidthOrHeight != null) {
exportScale = cfg.maxWidthOrHeight < max ? cfg.maxWidthOrHeight / max : 1;
}
width *= exportScale;
height *= exportScale;
} else if (cfg.getDimensions) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.scale = ret.scale ?? cfg.scale;
} else if (cfg.fit === "contain") {
width -= cfg.padding * 2;
height -= cfg.padding * 2;
const wRatio = width / origWidth;
const hRatio = height / origHeight;
// scale the orig canvas to fit in the target region
exportScale = Math.min(wRatio, hRatio);
}
x = cfg.x ?? origX;
y = cfg.y ?? origY;
// if we switch to "content" coords, we need to offset cfg-supplied
// coords by the x/y of content bounding box
if (cfg.origin === "content") {
if (cfg.x != null) {
x += origX;
}
if (cfg.y != null) {
y += origY;
}
}
// Centering the content to the frame.
// We divide width/height by canvasScale so that we calculate in the original
// aspect ratio dimensions.
if (cfg.position === "center") {
x -=
width / exportScale / 2 -
(cfg.x == null ? origWidth : width + cfg.padding * 2) / 2;
y -=
height / exportScale / 2 -
(cfg.y == null ? origHeight : height + cfg.padding * 2) / 2;
}
// rescale padding based on current canvasScale factor so that the resulting
// padding is kept the same as supplied by user (with the exception of
// `cfg.scale` being set, which also scales the padding)
const normalizedPadding = cfg.padding / exportScale;
// scale the whole frame by cfg.scale (on top of whatever canvasScale we
// calculated above)
exportScale *= cfg.scale;
width *= cfg.scale;
height *= cfg.scale;
const exportWidth = width + cfg.padding * 2 * cfg.scale;
const exportHeight = height + cfg.padding * 2 * cfg.scale;
return {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
};
};
// ---------------------------------------------------------------------------
// exportToCanvas
// ---------------------------------------------------------------------------
/**
* This API is usually used as a precursor to searializing to Blob or PNG,
* but can also be used to create a canvas for other purposes.
*/
export const exportToCanvas = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
const {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
} = await configExportDimension({ data, config });
const canvas = cfg.createCanvas
? cfg.createCanvas()
: document.createElement("canvas");
canvas.width = exportWidth;
canvas.height = exportHeight;
const { imageCache } = await updateImageCache({ const { imageCache } = await updateImageCache({
imageCache: new Map(), imageCache: new Map(),
fileIds: getInitializedImageElements(elementsForRender).map( fileIds: getInitializedImageElements(elementsForRender).map(
(element) => element.fileId, (element) => element.fileId,
), ),
files: data.files || {}, files,
}); });
const theme =
cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT);
// Determine the background color for the canvas
const viewBackgroundColor =
cfg.canvasBackgroundColor === false
? // "transparent" triggers clearRect in bootstrapCanvas
"transparent"
: cfg.canvasBackgroundColor ||
appState.viewBackgroundColor ||
COLOR_WHITE;
renderStaticScene({ renderStaticScene({
canvas, canvas,
rc: rough.canvas(canvas), rc: rough.canvas(canvas),
@@ -656,23 +245,19 @@ export const exportToCanvas = async ({
arrayToMap(elementsForRender), arrayToMap(elementsForRender),
), ),
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>( allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(syncInvalidIndices(data.elements)), arrayToMap(syncInvalidIndices(elements)),
), ),
visibleElements: elementsForRender, visibleElements: elementsForRender,
scale: exportScale, scale,
appState: { appState: {
...appState, ...appState,
frameRendering, frameRendering,
width, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
height, scrollX: -minX + exportPadding,
offsetLeft: 0, scrollY: -minY + exportPadding,
offsetTop: 0, zoom: defaultAppState.zoom,
scrollX: -x + normalizedPadding,
scrollY: -y + normalizedPadding,
zoom: { value: DEFAULT_ZOOM_VALUE },
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
theme, theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
viewBackgroundColor,
}, },
renderConfig: { renderConfig: {
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: viewBackgroundColor,
@@ -683,44 +268,13 @@ export const exportToCanvas = async ({
embedsValidationStatus: new Map(), embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(), elementsPendingErasure: new Set(),
pendingFlowchartNodes: null, pendingFlowchartNodes: null,
theme, theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
}, },
}); });
return canvas; return canvas;
}; };
// ---------------------------------------------------------------------------
// exportToSvg
// ---------------------------------------------------------------------------
type ExportToSvgConfig = Pick<
ExportSceneConfig,
| "canvasBackgroundColor"
| "padding"
| "theme"
| "exportingFrame"
| "scale"
| "width"
| "height"
| "x"
| "y"
| "origin"
| "fit"
| "position"
| "maxWidthOrHeight"
| "widthOrHeight"
| "getDimensions"
| "loadFonts"
> & {
/**
* if true, all embeddables passed in will be rendered when possible.
*/
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
};
const createHTMLComment = (text: string) => { const createHTMLComment = (text: string) => {
// surrounding with spaces to maintain prettified consistency with previous // surrounding with spaces to maintain prettified consistency with previous
// iterations // iterations
@@ -728,34 +282,61 @@ const createHTMLComment = (text: string) => {
return document.createComment(` ${text} `); return document.createComment(` ${text} `);
}; };
export const exportToSvg = async ({ export const exportToSvg = async (
data, elements: readonly NonDeletedExcalidrawElement[],
config, appState: {
}: { exportBackground: boolean;
data: ExportSceneData; exportPadding?: number;
config?: ExportToSvgConfig; exportScale?: number;
}) => { viewBackgroundColor: string;
const { exportWithDarkMode?: boolean;
config: cfg, exportEmbedScene?: boolean;
normalizedPadding, frameRendering?: AppState["frameRendering"];
exportWidth, },
exportHeight, files: BinaryFiles | null,
exportScale, opts?: {
x, /**
y, * if true, all embeddables passed in will be rendered when possible.
elementsForRender, */
appState, renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameLikeElement | null;
skipInliningFonts?: true;
reuseImages?: boolean;
},
): Promise<SVGSVGElement> => {
const frameRendering = getFrameRenderingConfig(
opts?.exportingFrame ?? null,
appState.frameRendering ?? null,
);
let {
exportPadding = DEFAULT_EXPORT_PADDING,
exportWithDarkMode = false,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = appState;
const { exportingFrame = null } = opts || {};
const elementsForRender = prepareElementsForRender({
elements,
exportingFrame,
exportWithDarkMode,
frameRendering, frameRendering,
} = await configExportDimension({ data, config }); });
const offsetX = -(x - normalizedPadding); if (exportingFrame) {
const offsetY = -(y - normalizedPadding); exportPadding = 0;
}
const { elements } = data; const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
const theme = const offsetX = -minX + exportPadding;
cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT); const offsetY = -minY + exportPadding;
const exportWithDarkMode = theme === THEME.DARK;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// initialize SVG root element // initialize SVG root element
@@ -765,12 +346,9 @@ export const exportToSvg = async ({
svgRoot.setAttribute("version", "1.1"); svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS); svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute( svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
"viewBox", svgRoot.setAttribute("width", `${width * exportScale}`);
`0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`, svgRoot.setAttribute("height", `${height * exportScale}`);
);
svgRoot.setAttribute("width", `${exportWidth}`);
svgRoot.setAttribute("height", `${exportHeight}`);
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs"); const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
@@ -789,7 +367,7 @@ export const exportToSvg = async ({
// we need to serialize the "original" elements before we put them through // we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids // the tempScene hack which duplicates and regenerates ids
if (appState.exportEmbedScene) { if (exportEmbedScene) {
try { try {
encodeSvgBase64Payload({ encodeSvgBase64Payload({
metadataElement, metadataElement,
@@ -797,7 +375,7 @@ export const exportToSvg = async ({
// elements which don't contain the temp frame labels. // elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with // But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra. // only the elements that we're exporting, and no extra.
payload: serializeAsJSON(elements, appState, data.files || {}, "local"), payload: serializeAsJSON(elements, appState, files || {}, "local"),
}); });
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@@ -835,7 +413,7 @@ export const exportToSvg = async ({
rect.setAttribute("width", `${frame.width}`); rect.setAttribute("width", `${frame.width}`);
rect.setAttribute("height", `${frame.height}`); rect.setAttribute("height", `${frame.height}`);
if (!cfg.exportingFrame) { if (!exportingFrame) {
rect.setAttribute("rx", `${FRAME_STYLE.radius}`); rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
rect.setAttribute("ry", `${FRAME_STYLE.radius}`); rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
} }
@@ -850,10 +428,9 @@ export const exportToSvg = async ({
// inline font faces // inline font faces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const fontFaces = const fontFaces = !opts?.skipInliningFonts
config?.skipInliningFonts !== true ? await Fonts.generateFontFaceDeclarations(elements)
? await Fonts.generateFontFaceDeclarations(elements) : [];
: [];
const delimiter = "\n "; // 6 spaces const delimiter = "\n "; // 6 spaces
@@ -870,16 +447,17 @@ export const exportToSvg = async ({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// render background rect // render background rect
if (appState.exportBackground && appState.viewBackgroundColor) { if (appState.exportBackground && viewBackgroundColor) {
const bgColor = cfg.canvasBackgroundColor || appState.viewBackgroundColor;
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect"); const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0"); rect.setAttribute("x", "0");
rect.setAttribute("y", "0"); rect.setAttribute("y", "0");
rect.setAttribute("width", `${exportWidth / exportScale}`); rect.setAttribute("width", `${width}`);
rect.setAttribute("height", `${exportHeight / exportScale}`); rect.setAttribute("height", `${height}`);
rect.setAttribute( rect.setAttribute(
"fill", "fill",
exportWithDarkMode ? applyDarkModeFilter(bgColor) : bgColor, exportWithDarkMode
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
); );
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
@@ -890,14 +468,14 @@ export const exportToSvg = async ({
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
const renderEmbeddables = config?.renderEmbeddables ?? false; const renderEmbeddables = opts?.renderEmbeddables ?? false;
renderSceneToSvg( renderSceneToSvg(
elementsForRender, elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)), toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
rsvg, rsvg,
svgRoot, svgRoot,
data.files || {}, files || {},
{ {
offsetX, offsetX,
offsetY, offsetY,
@@ -905,7 +483,7 @@ export const exportToSvg = async ({
exportWithDarkMode, exportWithDarkMode,
renderEmbeddables, renderEmbeddables,
frameRendering, frameRendering,
canvasBackgroundColor: appState.viewBackgroundColor, canvasBackgroundColor: viewBackgroundColor,
embedsValidationStatus: renderEmbeddables embedsValidationStatus: renderEmbeddables
? new Map( ? new Map(
elementsForRender elementsForRender
@@ -913,8 +491,8 @@ export const exportToSvg = async ({
.map((element) => [element.id, true]), .map((element) => [element.id, true]),
) )
: new Map(), : new Map(),
reuseImages: config?.reuseImages ?? true, reuseImages: opts?.reuseImages ?? true,
theme, theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
}, },
); );
@@ -923,10 +501,6 @@ export const exportToSvg = async ({
return svgRoot; return svgRoot;
}; };
// ---------------------------------------------------------------------------
// SVG payload encoding/decoding
// ---------------------------------------------------------------------------
export const encodeSvgBase64Payload = ({ export const encodeSvgBase64Payload = ({
payload, payload,
metadataElement, metadataElement,
@@ -982,149 +556,26 @@ export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
throw new Error("INVALID"); throw new Error("INVALID");
}; };
// ---------------------------------------------------------------------------
// getCanvasSize
// ---------------------------------------------------------------------------
// calculate smallest area to fit the contents in // calculate smallest area to fit the contents in
export const getCanvasSize = ( const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): Bounds => { ): Bounds => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = distance(minX, maxX); const width = distance(minX, maxX) + exportPadding * 2;
const height = distance(minY, maxY); const height = distance(minY, maxY) + exportPadding * 2;
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };
/**
* Gets the export dimensions for a set of elements.
*
* @param elements - Elements to calculate size for
* @param exportPadding - Padding to add around the elements
* @param scale - Scale factor
* @returns [width, height] tuple
*/
export const getExportSize = ( export const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
scale: number, scale: number,
): [number, number] => { ): [number, number] => {
const [, , width, height] = getCanvasSize(elements); const [, , width, height] = getCanvasSize(elements, exportPadding).map(
(dimension) => Math.trunc(dimension * scale),
);
return [ return [width, height];
Math.trunc((width + exportPadding * 2) * scale),
Math.trunc((height + exportPadding * 2) * scale),
];
};
// ---------------------------------------------------------------------------
// exportToBlob
// ---------------------------------------------------------------------------
export { MIME_TYPES };
type ExportToBlobConfig = ExportSceneConfig & {
mimeType?: string;
quality?: number;
};
export const exportToBlob = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportToBlobConfig;
}): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = config || {};
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && config?.canvasBackgroundColor !== false) {
if (config?.canvasBackgroundColor === undefined) {
console.warn(
`Defaulting "canvasBackgroundColor" for "${MIME_TYPES.jpg}" mimeType`,
);
config = {
...config,
canvasBackgroundColor:
data.appState?.viewBackgroundColor || COLOR_WHITE,
};
}
}
const canvas = await exportToCanvas({ data, config });
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
data.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
data.elements,
data.appState,
data.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
// ---------------------------------------------------------------------------
// exportToClipboard
// ---------------------------------------------------------------------------
export const exportToClipboard = async ({
type,
data,
config,
}: {
data: ExportSceneData;
} & (
| { type: "png"; config?: ExportToBlobConfig }
| { type: "svg"; config?: ExportToSvgConfig }
| { type: "json"; config?: never }
)) => {
if (type === "svg") {
const svg = await exportToSvg({
data: {
...data,
appState: restoreAppState(data.appState, null),
},
config,
});
await copyTextToSystemClipboard(svg.outerHTML);
} else if (type === "png") {
await copyBlobToClipboardAsPng(exportToBlob({ data, config }));
} else if (type === "json") {
await copyToClipboard(data.elements, data.files);
} else {
throw new Error("Invalid export type");
}
}; };
+6 -14
View File
@@ -83,13 +83,11 @@ describe("export", () => {
}); });
it("export svg-embedded scene", async () => { it("export svg-embedded scene", async () => {
const svg = await exportToSvg({ const svg = await exportToSvg(
data: { testElements,
elements: testElements, { ...getDefaultAppState(), exportEmbedScene: true },
appState: { ...getDefaultAppState(), exportEmbedScene: true }, {},
files: {}, );
},
});
const svgText = svg.outerHTML; const svgText = svg.outerHTML;
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`); expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
@@ -207,13 +205,7 @@ describe("export", () => {
}, },
} as const; } as const;
const svg = await exportToSvg({ const svg = await exportToSvg(elements, appState, files);
data: {
elements,
appState,
files,
},
});
const svgText = svg.outerHTML; const svgText = svg.outerHTML;
File diff suppressed because one or more lines are too long
+93 -146
View File
@@ -1,10 +1,9 @@
import { exportToCanvas, exportToSvg } from "../../scene/export"; import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
import { import {
applyDarkModeFilter, applyDarkModeFilter,
FONT_FAMILY, FONT_FAMILY,
FRAME_STYLE, FRAME_STYLE,
THEME,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -54,42 +53,39 @@ describe("exportToSvg", () => {
}, },
] as NonDeletedExcalidrawElement[]; ] as NonDeletedExcalidrawElement[];
const DEFAULT_APP_STATE = { const DEFAULT_OPTIONS = {
exportBackground: false, exportBackground: false,
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
files: {},
}; };
it("with default arguments", async () => { it("with default arguments", async () => {
const svgElement = await exportUtils.exportToSvg({ const svgElement = await exportUtils.exportToSvg(
data: { ELEMENTS,
elements: ELEMENTS, DEFAULT_OPTIONS,
appState: DEFAULT_APP_STATE, null,
files: {}, );
},
});
expect(svgElement).toMatchSnapshot(); expect(svgElement).toMatchSnapshot();
}); });
it("with a CJK font", async () => { it("with a CJK font", async () => {
const svgElement = await exportUtils.exportToSvg({ const svgElement = await exportUtils.exportToSvg(
data: { [
elements: [ ...ELEMENTS,
...ELEMENTS, {
{ ...textFixture,
...textFixture, height: ELEMENT_HEIGHT,
height: ELEMENT_HEIGHT, width: ELEMENT_WIDTH,
width: ELEMENT_WIDTH, text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.", originalText:
originalText: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
"中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.", index: "a4" as FractionalIndex,
index: "a4" as FractionalIndex, } as ExcalidrawTextElement,
} as ExcalidrawTextElement, ],
], DEFAULT_OPTIONS,
appState: DEFAULT_APP_STATE, null,
files: {}, );
},
});
expect(svgElement).toMatchSnapshot(); expect(svgElement).toMatchSnapshot();
// extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers) // extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers)
@@ -98,17 +94,15 @@ describe("exportToSvg", () => {
it("with background color", async () => { it("with background color", async () => {
const BACKGROUND_COLOR = "#abcdef"; const BACKGROUND_COLOR = "#abcdef";
const svgElement = await exportUtils.exportToSvg({ const svgElement = await exportUtils.exportToSvg(
data: { ELEMENTS,
elements: ELEMENTS, {
appState: { ...DEFAULT_OPTIONS,
...DEFAULT_APP_STATE, exportBackground: true,
exportBackground: true, viewBackgroundColor: BACKGROUND_COLOR,
viewBackgroundColor: BACKGROUND_COLOR,
},
files: {},
}, },
}); null,
);
expect(svgElement.querySelector("rect")).toHaveAttribute( expect(svgElement.querySelector("rect")).toHaveAttribute(
"fill", "fill",
@@ -117,18 +111,14 @@ describe("exportToSvg", () => {
}); });
it("with dark mode", async () => { it("with dark mode", async () => {
const svgElement = await exportUtils.exportToSvg({ const svgElement = await exportUtils.exportToSvg(
data: { ELEMENTS,
elements: ELEMENTS, {
appState: { ...DEFAULT_OPTIONS,
...DEFAULT_APP_STATE, exportWithDarkMode: true,
},
files: {},
}, },
config: { null,
theme: THEME.DARK, );
},
});
const textElements = svgElement.querySelectorAll("text"); const textElements = svgElement.querySelectorAll("text");
expect(textElements.length).toBeGreaterThan(0); expect(textElements.length).toBeGreaterThan(0);
@@ -142,16 +132,14 @@ describe("exportToSvg", () => {
}); });
it("with exportPadding", async () => { it("with exportPadding", async () => {
const svgElement = await exportUtils.exportToSvg({ const svgElement = await exportUtils.exportToSvg(
data: { ELEMENTS,
elements: ELEMENTS, {
appState: DEFAULT_APP_STATE, ...DEFAULT_OPTIONS,
files: {}, exportPadding: 0,
}, },
config: { null,
padding: 0, );
},
});
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString()); expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString()); expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
@@ -164,17 +152,15 @@ describe("exportToSvg", () => {
it("with scale", async () => { it("with scale", async () => {
const SCALE = 2; const SCALE = 2;
const svgElement = await exportUtils.exportToSvg({ const svgElement = await exportUtils.exportToSvg(
data: { ELEMENTS,
elements: ELEMENTS, {
appState: DEFAULT_APP_STATE, ...DEFAULT_OPTIONS,
files: {}, exportPadding: 0,
exportScale: SCALE,
}, },
config: { null,
padding: 0, );
scale: SCALE,
},
});
expect(svgElement).toHaveAttribute( expect(svgElement).toHaveAttribute(
"height", "height",
@@ -187,27 +173,23 @@ describe("exportToSvg", () => {
}); });
it("with exportEmbedScene", async () => { it("with exportEmbedScene", async () => {
const svgElement = await exportUtils.exportToSvg({ const svgElement = await exportUtils.exportToSvg(
data: { ELEMENTS,
elements: ELEMENTS, {
appState: { ...DEFAULT_OPTIONS,
...DEFAULT_APP_STATE, exportEmbedScene: true,
exportEmbedScene: true,
},
files: {},
}, },
}); null,
);
expect(svgElement.innerHTML).toMatchSnapshot(); expect(svgElement.innerHTML).toMatchSnapshot();
}); });
it("with elements that have a link", async () => { it("with elements that have a link", async () => {
const svgElement = await exportUtils.exportToSvg({ const svgElement = await exportUtils.exportToSvg(
data: { [rectangleWithLinkFixture],
elements: [rectangleWithLinkFixture], DEFAULT_OPTIONS,
appState: DEFAULT_APP_STATE, null,
files: {}, );
},
});
expect(svgElement.innerHTML).toMatchSnapshot(); expect(svgElement.innerHTML).toMatchSnapshot();
}); });
}); });
@@ -247,14 +229,9 @@ describe("exporting frames", () => {
]; ];
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
data: { elements,
elements, files: null,
appState: {}, exportPadding: 0,
files: null,
},
config: {
padding: 0,
},
}); });
expect(canvas.width).toEqual(200); expect(canvas.width).toEqual(200);
@@ -281,15 +258,10 @@ describe("exporting frames", () => {
]; ];
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
data: { elements,
elements, files: null,
appState: {}, exportPadding: 0,
files: null, exportingFrame: frame,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
expect(canvas.width).toEqual(frame.width); expect(canvas.width).toEqual(frame.width);
@@ -325,15 +297,10 @@ describe("exporting frames", () => {
}); });
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: [rectOverlapping, frame, frameChild],
elements: [rectOverlapping, frame, frameChild], files: null,
appState: {}, exportPadding: 0,
files: null, exportingFrame: frame,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -374,15 +341,10 @@ describe("exporting frames", () => {
}); });
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: [frameChild, frame, elementOutside],
elements: [frameChild, frame, elementOutside], files: null,
appState: {}, exportPadding: 0,
files: null, exportingFrame: frame,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -447,15 +409,10 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: exportedElements,
elements: exportedElements, files: null,
appState: {}, exportPadding: 0,
files: null, exportingFrame,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frames themselves should be exported when multiple frames selected // frames themselves should be exported when multiple frames selected
@@ -497,15 +454,10 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: exportedElements,
elements: exportedElements, files: null,
appState: {}, exportPadding: 0,
files: null, exportingFrame,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -561,15 +513,10 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: exportedElements,
elements: exportedElements, files: null,
appState: {}, exportPadding: 0,
files: null, exportingFrame,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frame shouldn't be exported // frame shouldn't be exported
+216
View File
@@ -0,0 +1,216 @@
import { MIME_TYPES } from "@excalidraw/common";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "@excalidraw/excalidraw/clipboard";
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import {
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import {
exportToCanvas as _exportToCanvas,
exportToSvg as _exportToSvg,
} from "@excalidraw/excalidraw/scene/export";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
} from "@excalidraw/element/types";
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
export { MIME_TYPES };
type ExportOpts = {
elements: readonly NonDeleted<ExcalidrawElement>[];
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameLikeElement | null;
getDimensions?: (
width: number,
height: number,
) => { width: number; height: number; scale?: number };
};
export const exportToCanvas = ({
elements,
appState,
files,
maxWidthOrHeight,
getDimensions,
exportPadding,
exportingFrame,
}: ExportOpts & {
exportPadding?: number;
}) => {
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas(
restoredElements,
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {},
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
(width: number, height: number) => {
const canvas = document.createElement("canvas");
if (maxWidthOrHeight) {
if (typeof getDimensions === "function") {
console.warn(
"`getDimensions()` is ignored when `maxWidthOrHeight` is supplied.",
);
}
const max = Math.max(width, height);
// if content is less then maxWidthOrHeight, fallback on supplied scale
const scale =
maxWidthOrHeight < max
? maxWidthOrHeight / max
: appState?.exportScale ?? 1;
canvas.width = width * scale;
canvas.height = height * scale;
return {
canvas,
scale,
};
}
const ret = getDimensions?.(width, height) || { width, height };
canvas.width = ret.width;
canvas.height = ret.height;
return {
canvas,
scale: ret.scale ?? 1,
};
},
);
};
export const exportToBlob = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
exportPadding?: number;
},
): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = opts;
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && !opts.appState?.exportBackground) {
console.warn(
`Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`,
);
opts = {
...opts,
appState: { ...opts.appState, exportBackground: true },
};
}
const canvas = await exportToCanvas(opts);
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
opts.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
opts.elements,
opts.appState,
opts.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
export const exportToSvg = async ({
elements,
appState = getDefaultAppState(),
files = {},
exportPadding,
renderEmbeddables,
exportingFrame,
skipInliningFonts,
reuseImages,
}: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number;
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
}): Promise<SVGSVGElement> => {
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const exportAppState = {
...restoredAppState,
exportPadding,
};
return _exportToSvg(restoredElements, exportAppState, files, {
exportingFrame,
renderEmbeddables,
skipInliningFonts,
reuseImages,
});
};
export const exportToClipboard = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
type: "png" | "svg" | "json";
},
) => {
if (opts.type === "svg") {
const svg = await exportToSvg(opts);
await copyTextToSystemClipboard(svg.outerHTML);
} else if (opts.type === "png") {
await copyBlobToClipboardAsPng(exportToBlob(opts));
} else if (opts.type === "json") {
await copyToClipboard(opts.elements, opts.files);
} else {
throw new Error("Invalid export type");
}
};
+1 -14
View File
@@ -1,17 +1,4 @@
// Re-export from @excalidraw/excalidraw for backwards compatibility export * from "./export";
export {
exportToCanvas,
exportToBlob,
exportToSvg,
exportToClipboard,
MIME_TYPES,
} from "@excalidraw/excalidraw/scene/export";
export type {
ExportSceneData,
ExportSceneConfig,
} from "@excalidraw/excalidraw/scene/export";
export * from "./withinBounds"; export * from "./withinBounds";
export * from "./bbox"; export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element"; export { getCommonBounds } from "@excalidraw/element";
+35 -58
View File
@@ -10,22 +10,9 @@ const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
describe("exportToCanvas", async () => { describe("exportToCanvas", async () => {
const EXPORT_PADDING = 10; const EXPORT_PADDING = 10;
it("with default arguments (no padding)", async () => { it("with default arguments", async () => {
const canvas = await utils.exportToCanvas({ const canvas = await utils.exportToCanvas({
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }), ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
});
// New API has no default padding - call sites must explicitly set it
expect(canvas.width).toBe(100);
expect(canvas.height).toBe(100);
});
it("with padding", async () => {
const canvas = await utils.exportToCanvas({
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
config: {
padding: EXPORT_PADDING,
},
}); });
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING); expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
@@ -34,10 +21,8 @@ describe("exportToCanvas", async () => {
it("when custom width and height", async () => { it("when custom width and height", async () => {
const canvas = await utils.exportToCanvas({ const canvas = await utils.exportToCanvas({
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }), ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
config: { getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
},
}); });
expect(canvas.width).toBe(200); expect(canvas.width).toBe(200);
@@ -48,27 +33,20 @@ describe("exportToCanvas", async () => {
describe("exportToBlob", async () => { describe("exportToBlob", async () => {
describe("mime type", () => { describe("mime type", () => {
it("should change image/jpg to image/jpeg", async () => { it("should change image/jpg to image/jpeg", async () => {
const diagramData = diagramFactory();
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
data: { ...diagramFactory(),
elements: diagramData.elements, getDimensions: (width, height) => ({ width, height, scale: 1 }),
appState: { // testing typo in MIME type (jpg → jpeg)
...diagramData.appState, mimeType: "image/jpg",
exportBackground: true, appState: {
}, exportBackground: true,
files: diagramData.files,
},
config: {
getDimensions: (width, height) => ({ width, height, scale: 1 }),
// testing typo in MIME type (jpg → jpeg)
mimeType: "image/jpg",
}, },
}); });
expect(blob?.type).toBe(MIME_TYPES.jpg); expect(blob?.type).toBe(MIME_TYPES.jpg);
}); });
it("should default to image/png", async () => { it("should default to image/png", async () => {
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
data: diagramFactory(), ...diagramFactory(),
}); });
expect(blob?.type).toBe(MIME_TYPES.png); expect(blob?.type).toBe(MIME_TYPES.png);
}); });
@@ -78,11 +56,9 @@ describe("exportToBlob", async () => {
.spyOn(console, "warn") .spyOn(console, "warn")
.mockImplementationOnce(() => void 0); .mockImplementationOnce(() => void 0);
await utils.exportToBlob({ await utils.exportToBlob({
data: diagramFactory(), ...diagramFactory(),
config: { mimeType: MIME_TYPES.png,
mimeType: MIME_TYPES.png, quality: 1,
quality: 1,
},
}); });
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`, `"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
@@ -92,9 +68,8 @@ describe("exportToBlob", async () => {
}); });
describe("exportToSvg", () => { describe("exportToSvg", () => {
const getPassedArg = () => exportToSvgSpy.mock.calls[0][0]; const passedElements = () => exportToSvgSpy.mock.calls[0][0];
const passedData = () => getPassedArg().data; const passedOptions = () => exportToSvgSpy.mock.calls[0][1];
const passedConfig = () => getPassedArg().config;
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -102,14 +77,18 @@ describe("exportToSvg", () => {
it("with default arguments", async () => { it("with default arguments", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
data: diagramFactory({ ...diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },
}), }),
}); });
const data = passedData(); const passedOptionsWhenDefault = {
expect(data.elements.length).toBe(3); ...passedOptions(),
expect(passedConfig()).toMatchSnapshot(); // To avoid varying snapshots
name: "name",
};
expect(passedElements().length).toBe(3);
expect(passedOptionsWhenDefault).toMatchSnapshot();
}); });
// FIXME the utils.exportToSvg no longer filters out deleted elements. // FIXME the utils.exportToSvg no longer filters out deleted elements.
@@ -117,39 +96,37 @@ describe("exportToSvg", () => {
// type-checking for it correctly. // type-checking for it correctly.
it.skip("with deleted elements", async () => { it.skip("with deleted elements", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
data: diagramFactory({ ...diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },
elementOverrides: { isDeleted: true }, elementOverrides: { isDeleted: true },
}), }),
}); });
expect(passedData().elements.length).toBe(0); expect(passedElements().length).toBe(0);
}); });
it("with padding", async () => { it("with exportPadding", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
data: diagramFactory({ overrides: { appState: { name: "diagram name" } } }), ...diagramFactory({ overrides: { appState: { name: "diagram name" } } }),
config: { exportPadding: 0,
padding: 0,
},
}); });
expect(passedData().elements.length).toBe(3); expect(passedElements().length).toBe(3);
expect(passedConfig()).toEqual( expect(passedOptions()).toEqual(
expect.objectContaining({ padding: 0 }), expect.objectContaining({ exportPadding: 0 }),
); );
}); });
it("with exportEmbedScene", async () => { it("with exportEmbedScene", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
data: diagramFactory({ ...diagramFactory({
overrides: { overrides: {
appState: { name: "diagram name", exportEmbedScene: true }, appState: { name: "diagram name", exportEmbedScene: true },
}, },
}), }),
}); });
expect(passedData().elements.length).toBe(3); expect(passedElements().length).toBe(3);
expect(passedData().appState?.exportEmbedScene).toBe(true); expect(passedOptions().exportEmbedScene).toBe(true);
}); });
}); });
+13 -19
View File
@@ -19,15 +19,13 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse]; const sourceElements = [rectangle, ellipse];
const svgNode = await utils.exportToSvg({ const svgNode = await utils.exportToSvg({
data: { elements: sourceElements,
elements: sourceElements, appState: {
appState: { viewBackgroundColor: "#ffffff",
viewBackgroundColor: "#ffffff", gridModeEnabled: false,
gridModeEnabled: false, exportEmbedScene: true,
exportEmbedScene: true,
},
files: null,
}, },
files: null,
}); });
const svg = svgNode.outerHTML; const svg = svgNode.outerHTML;
@@ -51,18 +49,14 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse]; const sourceElements = [rectangle, ellipse];
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
data: { mimeType: "image/png",
elements: sourceElements, elements: sourceElements,
appState: { appState: {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
gridModeEnabled: false, gridModeEnabled: false,
exportEmbedScene: true, exportEmbedScene: true,
},
files: null,
},
config: {
mimeType: "image/png",
}, },
files: null,
}); });
const parsedString = await decodePngMetadata(blob); const parsedString = await decodePngMetadata(blob);
+4 -4
View File
@@ -1531,10 +1531,10 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.1.1": "@excalidraw/mermaid-to-excalidraw@2.1.0":
version "2.1.1" version "2.1.0"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.1.tgz#659c934a607dd2cf57f2a69282588ee2b0722959" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358"
integrity sha512-jU+frqcxazsY+t5yOBf2mgrQy+WUrbrzA36if3SQB/Vwaf2qOJjnWxucNafgZZk/3+9xGmRotUeOviSOJG+wYA== integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw==
dependencies: dependencies:
"@excalidraw/markdown-to-text" "0.1.2" "@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3" "@mermaid-js/parser" "^0.6.3"