From fdaaed5d999739ff8830e6a8294930cbb7fa4707 Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Wed, 24 Jan 2024 14:59:55 +0100 Subject: [PATCH] wip --- examples/excalidraw/components/App.tsx | 70 ++- .../excalidraw/actions/actionClipboard.tsx | 28 +- packages/excalidraw/actions/actionExport.tsx | 16 +- packages/excalidraw/appState.ts | 9 +- packages/excalidraw/charts.ts | 11 +- packages/excalidraw/colors.ts | 42 +- packages/excalidraw/components/App.tsx | 34 +- .../ColorPicker/keyboardNavHandlers.ts | 2 +- .../components/ImageExportDialog.tsx | 30 +- .../components/PasteChartDialog.tsx | 18 +- .../excalidraw/components/PublishLibrary.tsx | 27 +- .../excalidraw/components/TTDDialog/common.ts | 16 +- .../components/canvases/StaticCanvas.tsx | 1 - packages/excalidraw/constants.ts | 17 +- packages/excalidraw/data/index.ts | 109 ++-- packages/excalidraw/data/resave.ts | 19 +- .../excalidraw/hooks/useLibraryItemSvg.ts | 16 +- packages/excalidraw/index-node.ts | 28 +- packages/excalidraw/index.tsx | 3 +- packages/excalidraw/renderer/renderScene.ts | 19 +- packages/excalidraw/scene/Shape.ts | 6 +- packages/excalidraw/scene/ShapeCache.ts | 6 +- packages/excalidraw/scene/export.ts | 544 +++++++++++++++--- packages/excalidraw/scene/types.ts | 10 +- .../MermaidToExcalidraw.test.tsx.snap | 2 +- packages/excalidraw/tests/export.test.tsx | 2 +- .../excalidraw/tests/scene/export.test.ts | 177 +++--- packages/excalidraw/types.ts | 2 - packages/excalidraw/utils.ts | 23 +- packages/utils/export.test.ts | 64 ++- packages/utils/export.ts | 197 +++---- packages/utils/utils.unmocked.test.ts | 32 +- 32 files changed, 1031 insertions(+), 549 deletions(-) diff --git a/examples/excalidraw/components/App.tsx b/examples/excalidraw/components/App.tsx index eea0da6ca..9586ae6e8 100644 --- a/examples/excalidraw/components/App.tsx +++ b/examples/excalidraw/components/App.tsx @@ -369,10 +369,12 @@ export default function App({ return false; } await exportToClipboard({ - elements: excalidrawAPI.getSceneElements(), - appState: excalidrawAPI.getAppState(), - files: excalidrawAPI.getFiles(), - type, + data: { + elements: excalidrawAPI.getSceneElements(), + appState: excalidrawAPI.getAppState(), + files: excalidrawAPI.getFiles(), + }, + type: "json", }); window.alert(`Copied to clipboard as ${type} successfully`); }; @@ -817,15 +819,17 @@ export default function App({ return; } const svg = await exportToSvg({ - elements: excalidrawAPI?.getSceneElements(), - appState: { - ...initialData.appState, - exportWithDarkMode, - exportEmbedScene, - width: 300, - height: 100, + data: { + elements: excalidrawAPI?.getSceneElements(), + appState: { + ...initialData.appState, + exportWithDarkMode, + exportEmbedScene, + width: 300, + height: 100, + }, + files: excalidrawAPI?.getFiles(), }, - files: excalidrawAPI?.getFiles(), }); appRef.current.querySelector(".export-svg").innerHTML = svg.outerHTML; @@ -841,14 +845,18 @@ export default function App({ return; } const blob = await exportToBlob({ - elements: excalidrawAPI?.getSceneElements(), - mimeType: "image/png", - appState: { - ...initialData.appState, - exportEmbedScene, - exportWithDarkMode, + data: { + elements: excalidrawAPI?.getSceneElements(), + appState: { + ...initialData.appState, + exportEmbedScene, + exportWithDarkMode, + }, + files: excalidrawAPI?.getFiles(), + }, + config: { + mimeType: "image/png", }, - files: excalidrawAPI?.getFiles(), }); setBlobUrl(window.URL.createObjectURL(blob)); }} @@ -864,12 +872,14 @@ export default function App({ return; } const canvas = await exportToCanvas({ - elements: excalidrawAPI.getSceneElements(), - appState: { - ...initialData.appState, - exportWithDarkMode, + data: { + elements: excalidrawAPI.getSceneElements(), + appState: { + ...initialData.appState, + exportWithDarkMode, + }, + files: excalidrawAPI.getFiles(), }, - files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; ctx.font = "30px Virgil"; @@ -885,12 +895,14 @@ export default function App({ return; } const canvas = await exportToCanvas({ - elements: excalidrawAPI.getSceneElements(), - appState: { - ...initialData.appState, - exportWithDarkMode, + data: { + elements: excalidrawAPI.getSceneElements(), + appState: { + ...initialData.appState, + exportWithDarkMode, + }, + files: excalidrawAPI.getFiles(), }, - files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; ctx.font = "30px Virgil"; diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index b9634886b..6155f827a 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -9,8 +9,9 @@ import { readSystemClipboard, } from "../clipboard"; import { actionDeleteSelected } from "./actionDeleteSelected"; -import { exportCanvas, prepareElementsForExport } from "../data/index"; +import { exportAsImage } from "../data/index"; import { isTextElement } from "../element"; +import { prepareElementsForExport } from "../data/index"; import { t } from "../i18n"; import { isFirefox } from "../constants"; @@ -130,17 +131,15 @@ export const actionCopyAsSvg = register({ ); try { - await exportCanvas( - "clipboard-svg", - exportedElements, - appState, - app.files, - { + await exportAsImage({ + type: "clipboard-svg", + data: { elements: exportedElements, appState, files: app.files }, + config: { ...appState, exportingFrame, name: app.getName(), }, - ); + }); return { commitToHistory: false, }; @@ -182,11 +181,16 @@ export const actionCopyAsPng = register({ true, ); try { - await exportCanvas("clipboard", exportedElements, appState, app.files, { - ...appState, - exportingFrame, - name: app.getName(), + await exportAsImage({ + type: "clipboard", + data: { elements: exportedElements, appState, files: app.files }, + config: { + ...appState, + exportingFrame, + name: appState.name || app.getName(), + }, }); + return { appState: { ...appState, diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 7cb6b1291..51e2d15b3 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -10,13 +10,13 @@ import { useDevice } from "../components/App"; import { KEYS } from "../keys"; import { register } from "./register"; import { CheckboxItem } from "../components/CheckboxItem"; -import { getExportSize } from "../scene/export"; +import { getCanvasSize } from "../scene/export"; import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getNonDeletedElements } from "../element"; import { isImageFileHandle } from "../data/blob"; import { nativeFileSystemSupported } from "../data/filesystem"; -import { Theme } from "../element/types"; +import { NonDeletedExcalidrawElement, Theme } from "../element/types"; import "../components/ToolIcon.scss"; @@ -52,6 +52,18 @@ export const actionChangeExportScale = register({ ? getSelectedElements(elements, appState) : elements; + const getExportSize = ( + elements: readonly NonDeletedExcalidrawElement[], + padding: number, + scale: number, + ): [number, number] => { + const [, , width, height] = getCanvasSize(elements).map((dimension) => + Math.trunc(dimension * scale), + ); + + return [width + padding * 2, height + padding * 2]; + }; + return ( <> {EXPORT_SCALES.map((s) => { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index a0ab233c9..e999e05cf 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -1,13 +1,14 @@ -import { COLOR_PALETTE } from "./colors"; import { + COLOR_WHITE, DEFAULT_ELEMENT_PROPS, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, DEFAULT_TEXT_ALIGN, + DEFAULT_ZOOM_VALUE, EXPORT_SCALES, THEME, } from "./constants"; -import { AppState, NormalizedZoomValue } from "./types"; +import { AppState } from "./types"; const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) ? devicePixelRatio @@ -88,10 +89,10 @@ export const getDefaultAppState = (): Omit< editingFrame: null, elementsToHighlight: null, toast: null, - viewBackgroundColor: COLOR_PALETTE.white, + viewBackgroundColor: COLOR_WHITE, zenModeEnabled: false, zoom: { - value: 1 as NormalizedZoomValue, + value: DEFAULT_ZOOM_VALUE, }, viewModeEnabled: false, pendingImageElementId: null, diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 32be94620..290173f05 100644 --- a/packages/excalidraw/charts.ts +++ b/packages/excalidraw/charts.ts @@ -1,9 +1,6 @@ +import { DEFAULT_CHART_COLOR_INDEX, getAllColorsSpecificShade } from "./colors"; import { - COLOR_PALETTE, - DEFAULT_CHART_COLOR_INDEX, - getAllColorsSpecificShade, -} from "./colors"; -import { + COLOR_CHARCOAL_BLACK, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, VERTICAL_ALIGN, @@ -171,7 +168,7 @@ const commonProps = { fontSize: DEFAULT_FONT_SIZE, opacity: 100, roughness: 1, - strokeColor: COLOR_PALETTE.black, + strokeColor: COLOR_CHARCOAL_BLACK, roundness: null, strokeStyle: "solid", strokeWidth: 1, @@ -337,7 +334,7 @@ const chartBaseElements = ( y: y - chartHeight, width: chartWidth, height: chartHeight, - strokeColor: COLOR_PALETTE.black, + strokeColor: COLOR_CHARCOAL_BLACK, fillStyle: "solid", opacity: 6, }) diff --git a/packages/excalidraw/colors.ts b/packages/excalidraw/colors.ts index 905e6cd3f..c8ac4260c 100644 --- a/packages/excalidraw/colors.ts +++ b/packages/excalidraw/colors.ts @@ -1,27 +1,25 @@ import oc from "open-color"; +import { + COLOR_WHITE, + COLOR_CHARCOAL_BLACK, + COLOR_TRANSPARENT, +} from "./constants"; import { Merge } from "./utility-types"; - -// FIXME can't put to utils.ts rn because of circular dependency -const pick = , K extends readonly (keyof R)[]>( - source: R, - keys: K, -) => { - return keys.reduce((acc, key: K[number]) => { - if (key in source) { - acc[key] = source[key]; - } - return acc; - }, {} as Pick) as Pick; -}; +import { pick } from "./utils"; export type ColorPickerColor = - | Exclude + | Exclude | "transparent" + | "charcoal" | "bronze"; export type ColorTuple = readonly [string, string, string, string, string]; export type ColorPalette = Merge< Record, - { black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" } + { + charcoal: typeof COLOR_CHARCOAL_BLACK; + white: typeof COLOR_WHITE; + transparent: typeof COLOR_TRANSPARENT; + } >; // used general type instead of specific type (ColorPalette) to support custom colors @@ -41,7 +39,7 @@ export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const; export const getSpecificColorShades = ( color: Exclude< ColorPickerColor, - "transparent" | "white" | "black" | "bronze" + "transparent" | "charcoal" | "black" | "white" | "bronze" >, indexArr: Readonly, ) => { @@ -49,9 +47,9 @@ export const getSpecificColorShades = ( }; export const COLOR_PALETTE = { - transparent: "transparent", - black: "#1e1e1e", - white: "#ffffff", + transparent: COLOR_TRANSPARENT, + charcoal: COLOR_CHARCOAL_BLACK, + white: COLOR_WHITE, // open-colors gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES), red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES), @@ -87,7 +85,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [ // ORDER matters for positioning in quick picker export const DEFAULT_ELEMENT_STROKE_PICKS = [ - COLOR_PALETTE.black, + COLOR_PALETTE.charcoal, COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX], COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX], COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX], @@ -125,7 +123,7 @@ export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = { transparent: COLOR_PALETTE.transparent, white: COLOR_PALETTE.white, gray: COLOR_PALETTE.gray, - black: COLOR_PALETTE.black, + charcoal: COLOR_PALETTE.charcoal, bronze: COLOR_PALETTE.bronze, // rest ...COMMON_ELEMENT_SHADES, @@ -136,7 +134,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = { transparent: COLOR_PALETTE.transparent, white: COLOR_PALETTE.white, gray: COLOR_PALETTE.gray, - black: COLOR_PALETTE.black, + charcoal: COLOR_PALETTE.charcoal, bronze: COLOR_PALETTE.bronze, ...COMMON_ELEMENT_SHADES, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c9985c88d..1cea25f3f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -90,7 +90,7 @@ import { EDITOR_LS_KEYS, isIOS, } from "../constants"; -import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; +import { exportAsImage, ExportedElements, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restore, restoreElements } from "../data/restore"; import { @@ -1736,18 +1736,20 @@ class App extends React.Component { opts: { exportingFrame: ExcalidrawFrameLikeElement | null }, ) => { trackEvent("export", type, "ui"); - const fileHandle = await exportCanvas( + const fileHandle = await exportAsImage({ type, - elements, - this.state, - this.files, - { + data: { + elements, + appState: this.state, + files: this.files, + }, + config: { exportBackground: this.state.exportBackground, name: this.getName(), viewBackgroundColor: this.state.viewBackgroundColor, exportingFrame: opts.exportingFrame, }, - ) + }) .catch(muteFSAbortError) .catch((error) => { console.error(error); @@ -1860,14 +1862,18 @@ class App extends React.Component { }); const blob = await exportToBlob({ - elements: this.scene.getNonDeletedElements(), - appState: { - ...this.state, - exportBackground: true, - viewBackgroundColor: this.state.viewBackgroundColor, + data: { + elements: this.scene.getNonDeletedElements(), + appState: { + ...this.state, + exportBackground: true, + viewBackgroundColor: this.state.viewBackgroundColor, + }, + files: this.files, + }, + config: { + exportingFrame: magicFrame, }, - exportingFrame: magicFrame, - files: this.files, }); const dataURL = await getDataURL(blob); diff --git a/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts index 95ee7beeb..8316f035d 100644 --- a/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts +++ b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts @@ -205,7 +205,7 @@ export const colorPickerKeyNavHandler = ({ }); if (!baseColorName) { - onChange(COLOR_PALETTE.black); + onChange(COLOR_PALETTE.charcoal); } } diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index cecdfa79a..e390e935a 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -106,19 +106,25 @@ const ImageExportModal = ({ return; } exportToCanvas({ - elements: exportedElements, - appState: { - ...appStateSnapshot, - name: projectName, - exportBackground: exportWithBackground, - exportWithDarkMode: exportDarkMode, - exportScale, - exportEmbedScene: embedScene, + data: { + elements: exportedElements, + appState: { + ...appStateSnapshot, + name: projectName, + exportEmbedScene: embedScene, + }, + files, + }, + config: { + canvasBackgroundColor: !exportWithBackground + ? false + : appStateSnapshot.viewBackgroundColor, + padding: DEFAULT_EXPORT_PADDING, + theme: exportDarkMode ? "dark" : "light", + scale: exportScale, + maxWidthOrHeight: Math.max(maxWidth, maxHeight), + exportingFrame, }, - files, - exportPadding: DEFAULT_EXPORT_PADDING, - maxWidthOrHeight: Math.max(maxWidth, maxHeight), - exportingFrame, }) .then((canvas) => { setRenderError(null); diff --git a/packages/excalidraw/components/PasteChartDialog.tsx b/packages/excalidraw/components/PasteChartDialog.tsx index 17cd7121e..7d9d0ab6c 100644 --- a/packages/excalidraw/components/PasteChartDialog.tsx +++ b/packages/excalidraw/components/PasteChartDialog.tsx @@ -1,7 +1,7 @@ -import oc from "open-color"; import React, { useLayoutEffect, useRef, useState } from "react"; import { trackEvent } from "../analytics"; import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts"; +import { COLOR_WHITE } from "../constants"; import { ChartType } from "../element/types"; import { t } from "../i18n"; import { exportToSvg } from "../scene/export"; @@ -40,14 +40,16 @@ const ChartPreviewBtn = (props: { const previewNode = previewRef.current!; (async () => { - svg = await exportToSvg( - elements, - { - exportBackground: false, - viewBackgroundColor: oc.white, + svg = await exportToSvg({ + data: { + elements, + appState: { + exportBackground: false, + viewBackgroundColor: COLOR_WHITE, + }, + files: null, }, - null, // files - ); + }); 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 51e14febc..752ce7dc0 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -8,6 +8,7 @@ import Trans from "./Trans"; import { LibraryItems, LibraryItem, UIAppState } from "../types"; import { exportToCanvas, exportToSvg } from "../../utils/export"; import { + COLOR_WHITE, EDITOR_LS_KEYS, EXPORT_DATA_TYPES, EXPORT_SOURCE, @@ -54,16 +55,20 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => { const ctx = canvas.getContext("2d")!; - ctx.fillStyle = OpenColor.white; + ctx.fillStyle = COLOR_WHITE; ctx.fillRect(0, 0, canvas.width, canvas.height); // draw items // --------------------------------------------------------------------------- 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, + }, + config: { + maxWidthOrHeight: BOX_SIZE, + }, }); const { width, height } = itemCanvas; @@ -125,13 +130,15 @@ const SingleLibraryItem = ({ } (async () => { const svg = await exportToSvg({ - elements: libItem.elements, - appState: { - ...appState, - viewBackgroundColor: OpenColor.white, - exportBackground: true, + data: { + elements: libItem.elements, + appState: { + ...appState, + viewBackgroundColor: COLOR_WHITE, + exportBackground: true, + }, + files: null, }, - files: null, }); node.innerHTML = svg.outerHTML; })(); diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index 636d160a8..969d28fc8 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -99,12 +99,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, + data: { + elements: data.current.elements, + files: data.current.files, + }, + config: { + padding: DEFAULT_EXPORT_PADDING, + maxWidthOrHeight: + Math.max(parent.offsetWidth, parent.offsetHeight) * + window.devicePixelRatio, + }, }); // if converting to blob fails, there's some problem that will // likely prevent preview and export (e.g. canvas too big) diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index bfdb669e6..258b46ace 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -97,7 +97,6 @@ const getRelevantAppStateProps = ( theme: appState.theme, pendingImageElementId: appState.pendingImageElementId, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, - viewBackgroundColor: appState.viewBackgroundColor, exportScale: appState.exportScale, selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, gridSize: appState.gridSize, diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 09e497564..5d1b85401 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -1,7 +1,7 @@ import cssVariables from "./css/variables.module.scss"; -import { AppProps } from "./types"; +import { AppProps, NormalizedZoomValue } from "./types"; import { ExcalidrawElement, FontFamilyValues } from "./element/types"; -import { COLOR_PALETTE } from "./colors"; + export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); @@ -98,7 +98,6 @@ export const YOUTUBE_STATES = { export const ENV = { TEST: "test", - DEVELOPMENT: "development", }; export const CLASSES = { @@ -143,6 +142,14 @@ export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; +export const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue; + +// ----------------------------------------------- +// !!! these colors are tied to color picker !!! +export const COLOR_WHITE = "#ffffff"; +export const COLOR_CHARCOAL_BLACK = "#1e1e1e"; +export const COLOR_TRANSPARENT = "transparent"; +// ----------------------------------------------- export const CANVAS_ONLY_ACTIONS = ["selectAll"]; @@ -333,8 +340,8 @@ export const DEFAULT_ELEMENT_PROPS: { opacity: ExcalidrawElement["opacity"]; locked: ExcalidrawElement["locked"]; } = { - strokeColor: COLOR_PALETTE.black, - backgroundColor: COLOR_PALETTE.transparent, + strokeColor: COLOR_CHARCOAL_BLACK, + backgroundColor: COLOR_TRANSPARENT, fillStyle: "solid", strokeWidth: 2, strokeStyle: "solid", diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index 51446921f..cb6d88361 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -80,46 +80,54 @@ export const prepareElementsForExport = ( }; }; -export const exportCanvas = async ( - type: Omit, - elements: ExportedElements, - appState: AppState, - files: BinaryFiles, - { - exportBackground, - exportPadding = DEFAULT_EXPORT_PADDING, - viewBackgroundColor, - name = appState.name || DEFAULT_FILENAME, - fileHandle = null, - exportingFrame = null, - }: { +export const exportAsImage = async ({ + type, + data, + config, +}: { + type: Omit; + data: { + elements: ExportedElements; + appState: AppState; + files: BinaryFiles; + }; + config: { exportBackground: boolean; - exportPadding?: number; + padding?: number; viewBackgroundColor: string; /** filename, if applicable */ name?: string; fileHandle?: FileSystemHandle | null; exportingFrame: ExcalidrawFrameLikeElement | null; - }, -) => { - if (elements.length === 0) { + }; +}) => { + // clone + const cfg = Object.assign({}, config); + + cfg.padding = cfg.padding ?? DEFAULT_EXPORT_PADDING; + cfg.fileHandle = cfg.fileHandle ?? null; + cfg.exportingFrame = cfg.exportingFrame ?? null; + cfg.name = cfg.name || DEFAULT_FILENAME; + + if (data.elements.length === 0) { throw new Error(t("alerts.cannotExportEmptyCanvas")); } 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: data.elements, + appState: { + exportBackground: cfg.exportBackground, + exportWithDarkMode: data.appState.exportWithDarkMode, + viewBackgroundColor: data.appState.viewBackgroundColor, + exportPadding: cfg.padding, + exportScale: data.appState.exportScale, + exportEmbedScene: data.appState.exportEmbedScene && type === "svg", + }, + files: data.files, }, - files, - { exportingFrame }, - ); - + config: { exportingFrame: cfg.exportingFrame }, + }); if (type === "svg") { return fileSave( svgPromise.then((svg) => { @@ -127,9 +135,9 @@ export const exportCanvas = async ( }), { description: "Export to SVG", - name, - extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg", - fileHandle, + name: cfg.name, + extension: data.appState.exportEmbedScene ? "excalidraw.svg" : "svg", + fileHandle: cfg.fileHandle, }, ); } else if (type === "clipboard-svg") { @@ -140,22 +148,33 @@ export const exportCanvas = async ( } } - const tempCanvas = exportToCanvas(elements, appState, files, { - exportBackground, - viewBackgroundColor, - exportPadding, - exportingFrame, + const tempCanvas = exportToCanvas({ + data, + config: { + canvasBackgroundColor: !cfg.exportBackground + ? false + : cfg.viewBackgroundColor, + padding: cfg.padding, + theme: data.appState.exportWithDarkMode ? "dark" : "light", + scale: data.appState.exportScale, + fit: "none", + exportingFrame: cfg.exportingFrame, + }, }); if (type === "png") { - let blob = canvasToBlob(tempCanvas); - - if (appState.exportEmbedScene) { - blob = blob.then((blob) => + const blob = canvasToBlob(tempCanvas); + if (data.appState.exportEmbedScene) { + blob.then((blob) => import("./image").then(({ encodePngMetadata }) => encodePngMetadata({ blob, - metadata: serializeAsJSON(elements, appState, files, "local"), + metadata: serializeAsJSON( + data.elements, + data.appState, + data.files, + "local", + ), }), ), ); @@ -163,11 +182,11 @@ export const exportCanvas = async ( return fileSave(blob, { description: "Export to PNG", - name, + name: cfg.name, // FIXME reintroduce `excalidraw.png` when most people upgrade away // from 111.0.5563.64 (arm64), see #6349 extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png", - fileHandle, + fileHandle: cfg.fileHandle, }); } else if (type === "clipboard") { try { diff --git a/packages/excalidraw/data/resave.ts b/packages/excalidraw/data/resave.ts index c73890e22..852cacf72 100644 --- a/packages/excalidraw/data/resave.ts +++ b/packages/excalidraw/data/resave.ts @@ -1,6 +1,7 @@ import { ExcalidrawElement } from "../element/types"; import { AppState, BinaryFiles } from "../types"; -import { exportCanvas, prepareElementsForExport } from "."; +import { exportAsImage } from "."; +import { prepareElementsForExport } from "."; import { getFileHandleType, isImageFileHandleType } from "./blob"; export const resaveAsImageWithScene = async ( @@ -29,12 +30,16 @@ export const resaveAsImageWithScene = async ( false, ); - await exportCanvas(fileHandleType, exportedElements, appState, files, { - exportBackground, - viewBackgroundColor, - name, - fileHandle, - exportingFrame, + await exportAsImage({ + type: fileHandleType, + data: { elements: exportedElements, appState, files }, + config: { + exportBackground, + viewBackgroundColor, + name, + fileHandle, + exportingFrame, + }, }); return { fileHandle }; diff --git a/packages/excalidraw/hooks/useLibraryItemSvg.ts b/packages/excalidraw/hooks/useLibraryItemSvg.ts index ac40140b4..bfa9dcdc1 100644 --- a/packages/excalidraw/hooks/useLibraryItemSvg.ts +++ b/packages/excalidraw/hooks/useLibraryItemSvg.ts @@ -11,13 +11,17 @@ export const libraryItemSvgsCache = atom(new Map()); const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { return await exportToSvg({ - elements, - appState: { - exportBackground: false, - viewBackgroundColor: COLOR_PALETTE.white, + data: { + elements, + appState: { + exportBackground: false, + viewBackgroundColor: COLOR_PALETTE.white, + }, + files: null, + }, + config: { + // renderEmbeddables: false, }, - files: null, - renderEmbeddables: false, }); }; diff --git a/packages/excalidraw/index-node.ts b/packages/excalidraw/index-node.ts index e966b1d52..fea7eeb3c 100644 --- a/packages/excalidraw/index-node.ts +++ b/packages/excalidraw/index-node.ts @@ -1,5 +1,6 @@ import { exportToCanvas } from "./scene/export"; import { getDefaultAppState } from "./appState"; +import { COLOR_WHITE } from "./constants"; const { registerFont, createCanvas } = require("canvas"); @@ -57,22 +58,21 @@ 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, +const canvas = exportToCanvas({ + data: { + elements: elements as any, + appState: { + ...getDefaultAppState(), + width: 0, + height: 0, + }, + files: {}, // files }, - {}, // files - { - exportBackground: true, - viewBackgroundColor: "#ffffff", + config: { + canvasBackgroundColor: COLOR_WHITE, + createCanvas, }, - createCanvas, -); +}); const fs = require("fs"); const out = fs.createWriteStream("test.png"); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index f7be8affc..59f4f232c 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -218,7 +218,6 @@ export { restoreLibraryItems, } from "./data/restore"; export { - exportToCanvas, exportToBlob, exportToSvg, serializeAsJSON, @@ -255,6 +254,8 @@ export { useDevice } from "./components/App"; export { WelcomeScreen }; export { LiveCollaborationTrigger }; +export { exportToCanvas } from "./scene/export"; + export { DefaultSidebar } from "./components/DefaultSidebar"; export { TTDDialog } from "./components/TTDDialog/TTDDialog"; export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger"; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 69926b72d..5b0f5e632 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -412,15 +412,16 @@ const bootstrapCanvas = ({ normalizedHeight, theme, isExporting, - viewBackgroundColor, + canvasBackgroundColor, }: { canvas: HTMLCanvasElement; scale: number; normalizedWidth: number; normalizedHeight: number; theme?: AppState["theme"]; + // static canvas only isExporting?: StaticCanvasRenderConfig["isExporting"]; - viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; + canvasBackgroundColor?: string | null; }): CanvasRenderingContext2D => { const context = canvas.getContext("2d")!; @@ -432,17 +433,17 @@ const bootstrapCanvas = ({ } // Paint background - if (typeof viewBackgroundColor === "string") { + if (typeof canvasBackgroundColor === "string") { const hasTransparence = - viewBackgroundColor === "transparent" || - viewBackgroundColor.length === 5 || // #RGBA - viewBackgroundColor.length === 9 || // #RRGGBBA - /(hsla|rgba)\(/.test(viewBackgroundColor); + canvasBackgroundColor === "transparent" || + canvasBackgroundColor.length === 5 || // #RGBA + canvasBackgroundColor.length === 9 || // #RRGGBBA + /(hsla|rgba)\(/.test(canvasBackgroundColor); if (hasTransparence) { context.clearRect(0, 0, normalizedWidth, normalizedHeight); } context.save(); - context.fillStyle = viewBackgroundColor; + context.fillStyle = canvasBackgroundColor; context.fillRect(0, 0, normalizedWidth, normalizedHeight); context.restore(); } else { @@ -945,7 +946,7 @@ const _renderStaticScene = ({ normalizedHeight, theme: appState.theme, isExporting, - viewBackgroundColor: appState.viewBackgroundColor, + canvasBackgroundColor: renderConfig.canvasBackgroundColor, }); // Apply zoom diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 1d43aef71..261ea54d2 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -156,8 +156,10 @@ const getArrowheadShapes = ( arrowhead: Arrowhead, generator: RoughGenerator, options: Options, - canvasBackgroundColor: string, + canvasBackgroundColor: string | null, ) => { + canvasBackgroundColor = canvasBackgroundColor || "transparent"; + const arrowheadPoints = getArrowheadPoints( element, shape, @@ -285,7 +287,7 @@ export const _generateElementShape = ( embedsValidationStatus, }: { isExporting: boolean; - canvasBackgroundColor: string; + canvasBackgroundColor: string | null; embedsValidationStatus: EmbedsValidationStatus | null; }, ): Drawable | Drawable[] | null => { diff --git a/packages/excalidraw/scene/ShapeCache.ts b/packages/excalidraw/scene/ShapeCache.ts index 3bca88e85..571217743 100644 --- a/packages/excalidraw/scene/ShapeCache.ts +++ b/packages/excalidraw/scene/ShapeCache.ts @@ -6,9 +6,9 @@ import { } from "../element/types"; import { elementWithCanvasCache } from "../renderer/renderElement"; import { _generateElementShape } from "./Shape"; -import { ElementShape, ElementShapes } from "./types"; +import { ElementShape, ElementShapes, StaticCanvasRenderConfig } from "./types"; import { COLOR_PALETTE } from "../colors"; -import { AppState, EmbedsValidationStatus } from "../types"; +import { EmbedsValidationStatus } from "../types"; export class ShapeCache { private static rg = new RoughGenerator(); @@ -50,7 +50,7 @@ export class ShapeCache { element: T, renderConfig: { isExporting: boolean; - canvasBackgroundColor: AppState["viewBackgroundColor"]; + canvasBackgroundColor: StaticCanvasRenderConfig["canvasBackgroundColor"]; embedsValidationStatus: EmbedsValidationStatus; } | null, ) => { diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 42a417cc8..d69591fa8 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -5,6 +5,7 @@ import { ExcalidrawTextElement, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, + Theme, } from "../element/types"; import { Bounds, @@ -15,10 +16,13 @@ import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import { AppState, BinaryFiles } from "../types"; import { + COLOR_WHITE, DEFAULT_EXPORT_PADDING, + DEFAULT_ZOOM_VALUE, FONT_FAMILY, FRAME_STYLE, SVG_NS, + THEME, THEME_FILTER, } from "../constants"; import { getDefaultAppState } from "../appState"; @@ -27,6 +31,7 @@ import { getInitializedImageElements, updateImageCache, } from "../element/image"; +import { restoreAppState } from "../data/restore"; import { getElementsOverlappingFrame, getFrameLikeElements, @@ -158,31 +163,203 @@ 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: ( +export type ExportToCanvasData = { + elements: readonly NonDeletedExcalidrawElement[]; + appState?: Partial>; + files: BinaryFiles | null; +}; + +export type ExportToCanvasConfig = { + 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 `cover`, padding is disabled (set to 0). + * + * 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`. + * - `cover` - scale to fill the frame while maintaining aspect ratio. If + * content overflows, it will be cropped. + * + * 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" | "cover"; + /** + * 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 }; - }, -) => { + ) => { width: number; height: number; scale?: number }; + + exportingFrame?: ExcalidrawFrameLikeElement | null; +}; + +/** + * 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: ExportToCanvasData; + config?: ExportToCanvasConfig; +}) => { + // clone + const cfg = Object.assign({}, config); + + const { files } = data; + const { exportingFrame } = cfg; + + const elements = data.elements; + + // initialize defaults + // --------------------------------------------------------------------------- + + const appState = restoreAppState(data.appState, null); + const frameRendering = getFrameRenderingConfig( exportingFrame ?? null, appState.frameRendering ?? null, @@ -196,24 +373,213 @@ 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"); + + const containPadding = cfg.fit === "contain"; + + if (cfg.x != null || cfg.x != null) { + cfg.fit = "none"; + } + + if (cfg.fit === "cover") { + if (cfg.padding && !import.meta.env.PROD) { + console.warn("`padding` is ignored when `fit` is set to `cover`"); + } + cfg.padding = 0; + } + + 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; + } + // --------------------------------------------------------------------------- + + // 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 canvasScale = 1; + + const origCanvasSize = getCanvasSize( exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), - exportPadding, ); - const { canvas, scale = 1 } = createCanvas(width, height); + // cfg.x = undefined; + // cfg.y = undefined; - const defaultAppState = getDefaultAppState(); + // variables for original content bounding box + const [origX, origY, origWidth, origHeight] = origCanvasSize; + // variables for target bounding box + let [x, y, width, height] = origCanvasSize; + + if (cfg.width != null) { + width = cfg.width; + + if (cfg.padding && containPadding) { + width -= cfg.padding * 2; + } + + if (cfg.height) { + height = cfg.height; + if (cfg.padding && containPadding) { + height -= cfg.padding * 2; + } + } else { + // if height not specified, scale the original height to match the new + // width while maintaining aspect ratio + height *= width / origWidth; + } + } else if (cfg.height != null) { + height = cfg.height; + + if (cfg.padding && containPadding) { + height -= cfg.padding * 2; + } + // width not specified, so scale the original width to match the new + // height while maintaining aspect ratio + width *= height / origHeight; + } + + if (cfg.maxWidthOrHeight != null || cfg.widthOrHeight != null) { + if (containPadding && 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) + canvasScale = cfg.widthOrHeight / max; + } else if (cfg.maxWidthOrHeight != null) { + canvasScale = cfg.maxWidthOrHeight < max ? cfg.maxWidthOrHeight / max : 1; + } + + width *= canvasScale; + height *= canvasScale; + } else if (cfg.getDimensions) { + const ret = cfg.getDimensions(width, height); + + width = ret.width; + height = ret.height; + cfg.scale = ret.scale ?? cfg.scale; + } else if ( + containPadding && + cfg.padding && + cfg.width == null && + cfg.height == null + ) { + const whRatio = width / height; + width -= cfg.padding * 2; + height -= (cfg.padding * 2) / whRatio; + } + + if ( + (cfg.fit === "contain" && !cfg.maxWidthOrHeight) || + (containPadding && cfg.padding) + ) { + if (cfg.fit === "contain") { + const wRatio = width / origWidth; + const hRatio = height / origHeight; + // scale the orig canvas to fit in the target frame + canvasScale = Math.min(wRatio, hRatio); + } else { + const wRatio = (width - cfg.padding * 2) / width; + const hRatio = (height - cfg.padding * 2) / height; + canvasScale = Math.min(wRatio, hRatio); + } + } else if (cfg.fit === "cover") { + const wRatio = width / origWidth; + const hRatio = height / origHeight; + // scale the orig canvas to fill the the target frame + // (opposite of "contain") + canvasScale = Math.max(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 / canvasScale / 2 - + (cfg.x == null ? origWidth : width + cfg.padding * 2) / 2; + y -= + height / canvasScale / 2 - + (cfg.y == null ? origHeight : height + cfg.padding * 2) / 2; + } + + const canvas = cfg.createCanvas + ? cfg.createCanvas() + : document.createElement("canvas"); + + // 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 / canvasScale; + + // scale the whole frame by cfg.scale (on top of whatever canvasScale we + // calculated above) + canvasScale *= cfg.scale; + + width *= cfg.scale; + height *= cfg.scale; + + canvas.width = width + cfg.padding * 2 * cfg.scale; + canvas.height = height + cfg.padding * 2 * cfg.scale; const { imageCache } = await updateImageCache({ imageCache: new Map(), fileIds: getInitializedImageElements(elementsForRender).map( (element) => element.fileId, ), - files, + files: files || {}, }); renderStaticScene({ @@ -226,19 +592,29 @@ export const exportToCanvas = async ( arrayToMap(elements), ), visibleElements: elementsForRender, - scale, 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 ? "dark" : "light", + theme: cfg.theme || THEME.LIGHT, }, + scale: canvasScale, renderConfig: { - canvasBackgroundColor: viewBackgroundColor, + canvasBackgroundColor: + cfg.canvasBackgroundColor === false + ? // null indicates transparent background + null + : cfg.canvasBackgroundColor || + appState.viewBackgroundColor || + COLOR_WHITE, imageCache, renderGrid: false, isExporting: true, @@ -251,29 +627,41 @@ export const exportToCanvas = async ( return canvas; }; -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?: { +export const exportToSvg = async ({ + data, + config, +}: { + data: { + elements: readonly NonDeletedExcalidrawElement[]; + appState: { + exportBackground: boolean; + exportPadding?: number; + exportScale?: number; + viewBackgroundColor: string; + exportWithDarkMode?: boolean; + exportEmbedScene?: boolean; + frameRendering?: AppState["frameRendering"]; + }; + files: BinaryFiles | null; + }; + config?: { /** * if true, all embeddables passed in will be rendered when possible. */ renderEmbeddables?: boolean; exportingFrame?: ExcalidrawFrameLikeElement | null; - }, -): Promise => { + }; +}): Promise => { + // clone + const cfg = Object.assign({}, config); + + cfg.exportingFrame = cfg.exportingFrame ?? null; + + const elements = data.elements; + const frameRendering = getFrameRenderingConfig( - opts?.exportingFrame ?? null, - appState.frameRendering ?? null, + cfg?.exportingFrame ?? null, + data.appState.frameRendering ?? null, ); let { @@ -282,18 +670,16 @@ export const exportToSvg = async ( viewBackgroundColor, exportScale = 1, exportEmbedScene, - } = appState; - - const { exportingFrame = null } = opts || {}; + } = data.appState; const elementsForRender = prepareElementsForRender({ elements, - exportingFrame, + exportingFrame: cfg.exportingFrame, exportWithDarkMode, frameRendering, }); - if (exportingFrame) { + if (cfg.exportingFrame) { exportPadding = 0; } @@ -310,18 +696,27 @@ 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. - text: serializeAsJSON(elements, appState, files || {}, "local"), + text: serializeAsJSON( + elements, + data.appState, + data.files || {}, + "local", + ), }); } catch (error: any) { console.error(error); } } - const [minX, minY, width, height] = getCanvasSize( - exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), - exportPadding, + let [minX, minY, width, height] = getCanvasSize( + cfg.exportingFrame + ? [cfg.exportingFrame] + : getRootElements(elementsForRender), ); + width += exportPadding * 2; + height += exportPadding * 2; + // initialize SVG root const svgRoot = document.createElementNS(SVG_NS, "svg"); svgRoot.setAttribute("version", "1.1"); @@ -394,7 +789,7 @@ export const exportToSvg = async ( `; // render background rect - if (appState.exportBackground && viewBackgroundColor) { + if (data.appState.exportBackground && viewBackgroundColor) { const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect"); rect.setAttribute("x", "0"); rect.setAttribute("y", "0"); @@ -406,14 +801,14 @@ export const exportToSvg = async ( const rsvg = rough.svg(svgRoot); - const renderEmbeddables = opts?.renderEmbeddables ?? false; + const renderEmbeddables = cfg.renderEmbeddables ?? false; renderSceneToSvg( elementsForRender, toBrandedType(arrayToMap(elementsForRender)), rsvg, svgRoot, - files || {}, + data.files || {}, { offsetX, offsetY, @@ -436,25 +831,12 @@ export const exportToSvg = async ( }; // 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]; }; - -export const getExportSize = ( - elements: readonly NonDeletedExcalidrawElement[], - exportPadding: number, - scale: number, -): [number, number] => { - const [, , width, height] = getCanvasSize(elements, exportPadding).map( - (dimension) => Math.trunc(dimension * scale), - ); - - return [width, height]; -}; diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 02aa3b7bf..969042dd4 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -20,7 +20,6 @@ export type RenderableElementsMap = NonDeletedElementsMap & MakeBrand<"RenderableElementsMap">; export type StaticCanvasRenderConfig = { - canvasBackgroundColor: AppState["viewBackgroundColor"]; // extra options passed to the renderer // --------------------------------------------------------------------------- imageCache: AppClassProperties["imageCache"]; @@ -28,6 +27,8 @@ export type StaticCanvasRenderConfig = { /** when exporting the behavior is slightly different (e.g. we can't use CSS filters), and we disable render optimizations for best output */ isExporting: boolean; + /** null indicates transparent bg */ + canvasBackgroundColor: string | null; embedsValidationStatus: EmbedsValidationStatus; elementsPendingErasure: ElementsPendingErasure; }; @@ -69,6 +70,13 @@ export type StaticSceneRenderConfig = { elementsMap: RenderableElementsMap; allElementsMap: NonDeletedSceneElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; + /** + * canvas scale factor. Not related to zoom. In browsers, it's the + * devicePixelRatio. For export, it's the `appState.exportScale` + * (user setting) or whatever scale you want to use when exporting elsewhere. + * + * Bigger the scale, the more pixels (=quality). + */ scale: number; appState: StaticCanvasAppState; renderConfig: StaticCanvasRenderConfig; diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap index 0e25dc33d..ca0a342d2 100644 --- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -6,5 +6,5 @@ exports[`Test > should open mermaid popup when active too B --> C{Let me think} C -->|One| D[Laptop] C -->|Two| E[iPhone] - C -->|Three| F[Car]
Ctrl
Enter
" + C -->|Three| F[Car]
Ctrl
Enter
" `; diff --git a/packages/excalidraw/tests/export.test.tsx b/packages/excalidraw/tests/export.test.tsx index 4e4c7b76c..e7eda7987 100644 --- a/packages/excalidraw/tests/export.test.tsx +++ b/packages/excalidraw/tests/export.test.tsx @@ -162,7 +162,7 @@ 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/export.test.ts b/packages/excalidraw/tests/scene/export.test.ts index ec9a0e6bf..a9d048a47 100644 --- a/packages/excalidraw/tests/scene/export.test.ts +++ b/packages/excalidraw/tests/scene/export.test.ts @@ -26,11 +26,9 @@ describe("exportToSvg", () => { }; it("with default arguments", async () => { - const svgElement = await exportUtils.exportToSvg( - ELEMENTS, - DEFAULT_OPTIONS, - null, - ); + const svgElement = await exportUtils.exportToSvg({ + data: { elements: ELEMENTS, appState: DEFAULT_OPTIONS, files: null }, + }); expect(svgElement).toMatchSnapshot(); }); @@ -38,15 +36,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_OPTIONS, + exportBackground: true, + viewBackgroundColor: BACKGROUND_COLOR, + }, + files: null, }, - null, - ); + }); expect(svgElement.querySelector("rect")).toHaveAttribute( "fill", @@ -55,14 +55,16 @@ 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_OPTIONS, + exportWithDarkMode: true, + }, + files: null, }, - null, - ); + }); expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot( '"_themeFilter_1883f3"', @@ -70,14 +72,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_OPTIONS, + exportPadding: 0, + }, + files: null, }, - null, - ); + }); expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString()); expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString()); @@ -90,15 +94,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_OPTIONS, + exportPadding: 0, + exportScale: SCALE, + }, + files: null, }, - null, - ); + }); expect(svgElement).toHaveAttribute( "height", @@ -111,23 +117,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_OPTIONS, + exportEmbedScene: true, + }, + files: null, }, - 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_OPTIONS, + files: null, + }, + }); expect(svgElement.innerHTML).toMatchSnapshot(); }); }); @@ -167,9 +177,13 @@ describe("exporting frames", () => { ]; const canvas = await exportToCanvas({ - elements, - files: null, - exportPadding: 0, + data: { + elements, + files: null, + }, + config: { + padding: 0, + }, }); expect(canvas.width).toEqual(200); @@ -196,10 +210,14 @@ describe("exporting frames", () => { ]; const canvas = await exportToCanvas({ - elements, - files: null, - exportPadding: 0, - exportingFrame: frame, + data: { + elements, + files: null, + }, + config: { + padding: 0, + exportingFrame: frame, + }, }); expect(canvas.width).toEqual(frame.width); @@ -235,10 +253,11 @@ describe("exporting frames", () => { }); const svg = await exportToSvg({ - elements: [rectOverlapping, frame, frameChild], - files: null, - exportPadding: 0, - exportingFrame: frame, + data: { elements: [rectOverlapping, frame, frameChild], files: null }, + config: { + padding: 0, + exportingFrame: frame, + }, }); // frame itself isn't exported @@ -279,10 +298,11 @@ describe("exporting frames", () => { }); const svg = await exportToSvg({ - elements: [frameChild, frame, elementOutside], - files: null, - exportPadding: 0, - exportingFrame: frame, + data: { elements: [frameChild, frame, elementOutside], files: null }, + config: { + padding: 0, + exportingFrame: frame, + }, }); // frame itself isn't exported @@ -347,10 +367,11 @@ describe("exporting frames", () => { ); const svg = await exportToSvg({ - elements: exportedElements, - files: null, - exportPadding: 0, - exportingFrame, + data: { elements: exportedElements, files: null }, + config: { + padding: 0, + exportingFrame, + }, }); // frames themselves should be exported when multiple frames selected @@ -392,10 +413,14 @@ describe("exporting frames", () => { ); const svg = await exportToSvg({ - elements: exportedElements, - files: null, - exportPadding: 0, - exportingFrame, + data: { + elements: exportedElements, + files: null, + }, + config: { + padding: 0, + exportingFrame, + }, }); // frame itself isn't exported @@ -451,10 +476,14 @@ describe("exporting frames", () => { ); const svg = await exportToSvg({ - elements: exportedElements, - files: null, - exportPadding: 0, - exportingFrame, + data: { + elements: exportedElements, + files: null, + }, + config: { + padding: 0, + exportingFrame, + }, }); // frame shouldn't be exported diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 1eaa04449..df2b32572 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -152,8 +152,6 @@ type _CommonCanvasAppState = { export type StaticCanvasAppState = Readonly< _CommonCanvasAppState & { shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"]; - /** null indicates transparent bg */ - viewBackgroundColor: AppState["viewBackgroundColor"] | null; exportScale: AppState["exportScale"]; selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"]; gridSize: AppState["gridSize"]; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 525652e6b..e63355cdd 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1,5 +1,5 @@ -import { COLOR_PALETTE } from "./colors"; import { + COLOR_TRANSPARENT, DEFAULT_VERSION, EVENT, FONT_FAMILY, @@ -530,11 +530,7 @@ export const findLastIndex = ( export const isTransparent = (color: string) => { const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0"; const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00"; - return ( - isRGBTransparent || - isRRGGBBTransparent || - color === COLOR_PALETTE.transparent - ); + return isRGBTransparent || isRRGGBBTransparent || color === COLOR_TRANSPARENT; }; export type ResolvablePromise = Promise & { @@ -1090,3 +1086,18 @@ export const toBrandedType = ( }; // ----------------------------------------------------------------------------- + +export const pick = < + R extends Record, + K extends readonly (keyof R)[], +>( + source: R, + keys: K, +) => { + return keys.reduce((acc, key: K[number]) => { + if (key in source) { + acc[key] = source[key]; + } + return acc; + }, {} as Pick) as Pick; +}; diff --git a/packages/utils/export.test.ts b/packages/utils/export.test.ts index aa1049cc1..cfbb18197 100644 --- a/packages/utils/export.test.ts +++ b/packages/utils/export.test.ts @@ -5,24 +5,27 @@ import * as mockedSceneExportUtils from "../excalidraw/scene/export"; import { MIME_TYPES } from "../excalidraw/constants"; +import { exportToCanvas } from "../excalidraw/scene/export"; const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg"); describe("exportToCanvas", async () => { - const EXPORT_PADDING = 10; - it("with default arguments", async () => { - const canvas = await utils.exportToCanvas({ - ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), + const canvas = await exportToCanvas({ + data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }), }); - expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING); - expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING); + expect(canvas.width).toBe(100); + expect(canvas.height).toBe(100); }); it("when custom width and height", async () => { - const canvas = await utils.exportToCanvas({ - ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), - getDimensions: () => ({ width: 200, height: 200, scale: 1 }), + const canvas = await exportToCanvas({ + data: { + ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), + }, + config: { + getDimensions: () => ({ width: 200, height: 200, scale: 1 }), + }, }); expect(canvas.width).toBe(200); @@ -35,19 +38,24 @@ describe("exportToBlob", async () => { // afterEach(vi.restoreAllMocks); it("should change image/jpg to image/jpeg", async () => { const blob = await utils.exportToBlob({ - ...diagramFactory(), - getDimensions: (width, height) => ({ width, height, scale: 1 }), - // testing typo in MIME type (jpg → jpeg) - mimeType: "image/jpg", - appState: { - exportBackground: true, + data: { + ...diagramFactory(), + + appState: { + exportBackground: true, + }, + }, + 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); }); @@ -57,9 +65,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`, @@ -69,8 +79,8 @@ describe("exportToBlob", async () => { }); describe("exportToSvg", () => { - const passedElements = () => exportToSvgSpy.mock.calls[0][0]; - const passedOptions = () => exportToSvgSpy.mock.calls[0][1]; + const passedElements = () => exportToSvgSpy.mock.calls[0][0].data.elements; + const passedOptions = () => exportToSvgSpy.mock.calls[0][0].data.appState; afterEach(() => { vi.clearAllMocks(); @@ -78,7 +88,7 @@ describe("exportToSvg", () => { it("with default arguments", async () => { await utils.exportToSvg({ - ...diagramFactory({ + data: diagramFactory({ overrides: { appState: void 0 }, }), }); @@ -97,7 +107,7 @@ 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 }, }), @@ -108,8 +118,10 @@ describe("exportToSvg", () => { it("with exportPadding", 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); @@ -120,7 +132,7 @@ describe("exportToSvg", () => { it("with exportEmbedScene", async () => { await utils.exportToSvg({ - ...diagramFactory({ + data: diagramFactory({ overrides: { appState: { name: "diagram name", exportEmbedScene: true }, }, diff --git a/packages/utils/export.ts b/packages/utils/export.ts index ceb733881..f219f9097 100644 --- a/packages/utils/export.ts +++ b/packages/utils/export.ts @@ -1,16 +1,11 @@ import { exportToCanvas as _exportToCanvas, + ExportToCanvasConfig, + ExportToCanvasData, exportToSvg as _exportToSvg, } from "../excalidraw/scene/export"; -import { getDefaultAppState } from "../excalidraw/appState"; -import { AppState, BinaryFiles } from "../excalidraw/types"; -import { - ExcalidrawElement, - ExcalidrawFrameLikeElement, - NonDeleted, -} from "../excalidraw/element/types"; import { restore } from "../excalidraw/data/restore"; -import { MIME_TYPES } from "../excalidraw/constants"; +import { COLOR_WHITE, MIME_TYPES } from "../excalidraw/constants"; import { encodePngMetadata } from "../excalidraw/data/image"; import { serializeAsJSON } from "../excalidraw/data/json"; import { @@ -18,91 +13,43 @@ import { copyTextToSystemClipboard, copyToClipboard, } from "../excalidraw/clipboard"; +import { getNonDeletedElements } from "../excalidraw"; 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 }; +type ExportToBlobConfig = ExportToCanvasConfig & { + mimeType?: string; + quality?: number; }; -export const exportToCanvas = ({ - elements, - appState, - files, - maxWidthOrHeight, - getDimensions, - exportPadding, - exportingFrame, -}: ExportOpts & { - exportPadding?: number; +type ExportToSvgConfig = Pick< + ExportToCanvasConfig, + "canvasBackgroundColor" | "padding" | "theme" | "exportingFrame" +> & { + renderEmbeddables?: boolean; +}; + +export const exportToCanvas = async ({ + data, + config, +}: { + data: ExportToCanvasData; + config?: ExportToCanvasConfig; }) => { - const { elements: restoredElements, appState: restoredAppState } = restore( - { elements, appState }, - null, - 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, - }; - }, - ); + return _exportToCanvas({ + data, + config, + }); }; -export const exportToBlob = async ( - opts: ExportOpts & { - mimeType?: string; - quality?: number; - exportPadding?: number; - }, -): Promise => { - let { mimeType = MIME_TYPES.png, quality } = opts; +export const exportToBlob = async ({ + data, + config, +}: { + data: ExportToCanvasData; + 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`); @@ -113,17 +60,17 @@ export const exportToBlob = async ( mimeType = MIME_TYPES.jpg; } - if (mimeType === MIME_TYPES.jpg && !opts.appState?.exportBackground) { + if (mimeType === MIME_TYPES.jpg && !config?.canvasBackgroundColor === false) { console.warn( `Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`, ); - opts = { - ...opts, - appState: { ...opts.appState, exportBackground: true }, + config = { + ...config, + canvasBackgroundColor: data.appState?.viewBackgroundColor || COLOR_WHITE, }; } - const canvas = await exportToCanvas(opts); + const canvas = await _exportToCanvas({ data, config }); quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8; @@ -136,7 +83,7 @@ export const exportToBlob = async ( if ( blob && mimeType === MIME_TYPES.png && - opts.appState?.exportEmbedScene + data.appState?.exportEmbedScene ) { blob = await encodePngMetadata({ blob, @@ -144,9 +91,9 @@ export const exportToBlob = async ( // 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 || {}, + data.elements, + data.appState, + data.files || {}, "local", ), }); @@ -160,47 +107,49 @@ export const exportToBlob = async ( }; export const exportToSvg = async ({ - elements, - appState = getDefaultAppState(), - files = {}, - exportPadding, - renderEmbeddables, - exportingFrame, -}: Omit & { - exportPadding?: number; - renderEmbeddables?: boolean; + data, + config, +}: { + data: ExportToCanvasData; + config?: ExportToSvgConfig; }): Promise => { const { elements: restoredElements, appState: restoredAppState } = restore( - { elements, appState }, + { ...data, files: data.files || {} }, null, null, ); - const exportAppState = { - ...restoredAppState, - exportPadding, - }; + const appState = { ...restoredAppState, exportPadding: config?.padding }; + const elements = getNonDeletedElements(restoredElements); + const files = data.files || {}; - return _exportToSvg(restoredElements, exportAppState, files, { - exportingFrame, - renderEmbeddables, + return _exportToSvg({ + data: { elements, appState, files }, + config: { + exportingFrame: config?.exportingFrame, + renderEmbeddables: config?.renderEmbeddables, + }, }); }; -export const exportToClipboard = async ( - opts: ExportOpts & { - mimeType?: string; - quality?: number; - type: "png" | "svg" | "json"; - }, -) => { - if (opts.type === "svg") { - const svg = await exportToSvg(opts); +export const exportToClipboard = async ({ + type, + data, + config, +}: { + data: ExportToCanvasData; +} & ( + | { type: "png"; config?: ExportToBlobConfig } + | { type: "svg"; config?: ExportToSvgConfig } + | { type: "json"; config?: never } +)) => { + if (type === "svg") { + const svg = await exportToSvg({ data, config }); 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 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/utils/utils.unmocked.test.ts b/packages/utils/utils.unmocked.test.ts index ae2fce8b7..e40fd7845 100644 --- a/packages/utils/utils.unmocked.test.ts +++ b/packages/utils/utils.unmocked.test.ts @@ -16,13 +16,15 @@ describe("embedding scene data", () => { const sourceElements = [rectangle, ellipse]; const svgNode = await utils.exportToSvg({ - elements: sourceElements, - appState: { - viewBackgroundColor: "#ffffff", - gridSize: null, - exportEmbedScene: true, + data: { + elements: sourceElements, + appState: { + viewBackgroundColor: "#ffffff", + gridSize: null, + exportEmbedScene: true, + }, + files: null, }, - files: null, }); const svg = svgNode.outerHTML; @@ -46,14 +48,18 @@ describe("embedding scene data", () => { const sourceElements = [rectangle, ellipse]; const blob = await utils.exportToBlob({ - mimeType: "image/png", - elements: sourceElements, - appState: { - viewBackgroundColor: "#ffffff", - gridSize: null, - exportEmbedScene: true, + data: { + elements: sourceElements, + appState: { + viewBackgroundColor: "#ffffff", + gridSize: null, + exportEmbedScene: true, + }, + files: null, + }, + config: { + mimeType: "image/png", }, - files: null, }); const parsedString = await decodePngMetadata(blob);