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