From a218bec3438494ee2f792a7b3a5bc1a8786513d1 Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Thu, 7 Mar 2024 20:00:03 +0100 Subject: [PATCH] wip --- examples/excalidraw/components/ExampleApp.tsx | 70 ++- excalidraw-app/components/AI.tsx | 20 +- excalidraw-app/components/DebugCanvas.tsx | 2 +- .../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 | 44 +- packages/excalidraw/components/App.tsx | 16 +- .../ColorPicker/keyboardNavHandlers.ts | 2 +- .../components/ImageExportDialog.tsx | 30 +- .../components/PasteChartDialog.tsx | 20 +- .../excalidraw/components/PublishLibrary.tsx | 31 +- .../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 | 18 +- packages/excalidraw/index-node.ts | 28 +- packages/excalidraw/index.tsx | 3 +- packages/excalidraw/renderer/helpers.ts | 17 +- packages/excalidraw/renderer/staticScene.ts | 2 +- packages/excalidraw/scene/Shape.ts | 6 +- packages/excalidraw/scene/ShapeCache.ts | 5 +- packages/excalidraw/scene/export.ts | 562 +++++++++++++++--- packages/excalidraw/scene/types.ts | 10 +- .../MermaidToExcalidraw.test.tsx.snap | 2 +- .../__snapshots__/excalidraw.test.tsx.snap | 2 +- .../excalidraw/tests/cropElement.test.tsx | 24 +- packages/excalidraw/tests/export.test.tsx | 2 +- packages/excalidraw/tests/history.test.tsx | 8 +- .../excalidraw/tests/scene/export.test.ts | 212 ++++--- packages/excalidraw/types.ts | 2 - packages/excalidraw/utils.ts | 23 +- packages/utils/export.test.ts | 64 +- packages/utils/export.ts | 212 +++---- packages/utils/utils.unmocked.test.ts | 32 +- 38 files changed, 1097 insertions(+), 598 deletions(-) diff --git a/examples/excalidraw/components/ExampleApp.tsx b/examples/excalidraw/components/ExampleApp.tsx index 1e296786ef..453d91bd8c 100644 --- a/examples/excalidraw/components/ExampleApp.tsx +++ b/examples/excalidraw/components/ExampleApp.tsx @@ -369,10 +369,12 @@ export default function ExampleApp({ 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 ExampleApp({ 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 ExampleApp({ 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 ExampleApp({ 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 Excalifont"; @@ -885,12 +895,14 @@ export default function ExampleApp({ 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 Excalifont"; diff --git a/excalidraw-app/components/AI.tsx b/excalidraw-app/components/AI.tsx index 621d6befa7..3fb8610c53 100644 --- a/excalidraw-app/components/AI.tsx +++ b/excalidraw-app/components/AI.tsx @@ -21,15 +21,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/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 471167989c..49e072d0cb 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -84,7 +84,7 @@ const _debugRenderer = ( scale, normalizedWidth, normalizedHeight, - viewBackgroundColor: "transparent", + canvasBackgroundColor: "transparent", }); // Apply zoom diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index c030b8150e..c18ab45e27 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 { getTextFromElements, isTextElement } from "../element"; +import { prepareElementsForExport } from "../data/index"; import { t } from "../i18n"; import { isFirefox } from "../constants"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; @@ -136,17 +137,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(), }, - ); + }); const selectedElements = app.scene.getSelectedElements({ selectedElementIds: appState.selectedElementIds, @@ -208,11 +207,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 224edf4739..5b20bb0335 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 type { Theme } from "../element/types"; +import type { NonDeletedExcalidrawElement, Theme } from "../element/types"; import "../components/ToolIcon.scss"; import { StoreAction } from "../store"; @@ -58,6 +58,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 355bfe5063..11b7e057cd 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -1,17 +1,18 @@ -import { COLOR_PALETTE } from "./colors"; import { ARROW_TYPE, + COLOR_WHITE, DEFAULT_ELEMENT_PROPS, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, DEFAULT_TEXT_ALIGN, DEFAULT_GRID_SIZE, + DEFAULT_ZOOM_VALUE, EXPORT_SCALES, STATS_PANELS, THEME, DEFAULT_GRID_STEP, } from "./constants"; -import type { AppState, NormalizedZoomValue } from "./types"; +import type { AppState } from "./types"; const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) ? devicePixelRatio @@ -99,10 +100,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 6e379c30a5..ea935c8cd1 100644 --- a/packages/excalidraw/charts.ts +++ b/packages/excalidraw/charts.ts @@ -1,11 +1,8 @@ import type { Radians } from "../math"; import { pointFrom } from "../math"; +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, @@ -173,7 +170,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, @@ -324,7 +321,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 e4cd67a94a..a1e20552fa 100644 --- a/packages/excalidraw/colors.ts +++ b/packages/excalidraw/colors.ts @@ -1,27 +1,25 @@ import oc from "open-color"; -import type { 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 { + COLOR_WHITE, + COLOR_CHARCOAL_BLACK, + COLOR_TRANSPARENT, +} from "./constants"; +import { type Merge } from "./utility-types"; +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 c4b4f71e20..502b383134 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -90,7 +90,7 @@ import { DEFAULT_TEXT_ALIGN, } from "../constants"; import type { ExportedElements } from "../data"; -import { exportCanvas, loadFromBlob } from "../data"; +import { exportAsImage, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restore, restoreElements } from "../data/restore"; import { @@ -1815,18 +1815,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); diff --git a/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts index 7767692edf..46732cabf0 100644 --- a/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts +++ b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts @@ -204,7 +204,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 8c225ab542..a1be9044cf 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -123,19 +123,25 @@ const ImageExportModal = ({ } 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 08a5634a9d..1071f58867 100644 --- a/packages/excalidraw/components/PasteChartDialog.tsx +++ b/packages/excalidraw/components/PasteChartDialog.tsx @@ -1,9 +1,9 @@ -import oc from "open-color"; import React, { useLayoutEffect, useRef, useState } from "react"; import { trackEvent } from "../analytics"; import type { ChartElements, Spreadsheet } from "../charts"; import { renderSpreadsheet } from "../charts"; import type { ChartType } from "../element/types"; +import { COLOR_WHITE } from "../constants"; import { t } from "../i18n"; import { exportToSvg } from "../scene/export"; import type { UIAppState } from "../types"; @@ -41,17 +41,19 @@ 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 - { + config: { skipInliningFonts: true, }, - ); + }); 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 f3d0fe1b46..4ea6188dd4 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -9,6 +9,7 @@ import Trans from "./Trans"; import type { LibraryItems, LibraryItem, UIAppState } from "../types"; import { exportToCanvas, exportToSvg } from "../../utils/export"; import { + COLOR_WHITE, EDITOR_LS_KEYS, EXPORT_DATA_TYPES, EXPORT_SOURCE, @@ -55,16 +56,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; @@ -126,14 +131,18 @@ 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, + }, + 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 ddaa930457..a626144346 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -91,12 +91,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 8ec38ac6bc..5a19a66a67 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 6bd8f1e99f..6b29b14bd5 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -1,7 +1,7 @@ import cssVariables from "./css/variables.module.scss"; import type { AppProps, AppState } from "./types"; import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; -import { COLOR_PALETTE } from "./colors"; +import type { NormalizedZoomValue } from "./types"; export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform); @@ -108,7 +108,6 @@ export const YOUTUBE_STATES = { export const ENV = { TEST: "test", - DEVELOPMENT: "development", }; export const CLASSES = { @@ -184,6 +183,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 SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING; // a small epsilon to make side resizing always take precedence @@ -192,8 +199,6 @@ const EPSILON = 0.00001; export const DEFAULT_COLLISION_THRESHOLD = 2 * SIDE_RESIZING_THRESHOLD - EPSILON; -export const COLOR_WHITE = "#ffffff"; -export const COLOR_CHARCOAL_BLACK = "#1e1e1e"; // keep this in sync with CSS export const COLOR_VOICE_CALL = "#a2f1a6"; @@ -384,8 +389,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 9aae145358..32bf784f1c 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -81,46 +81,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) => { @@ -128,9 +136,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") { @@ -144,22 +152,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", + ), }), ), ); @@ -167,11 +186,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 624918425e..c86b0ff7ca 100644 --- a/packages/excalidraw/data/resave.ts +++ b/packages/excalidraw/data/resave.ts @@ -1,6 +1,7 @@ import type { ExcalidrawElement } from "../element/types"; import type { AppState, BinaryFiles } from "../types"; -import { exportCanvas, prepareElementsForExport } from "."; +import { prepareElementsForExport } from "."; +import { exportAsImage } 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 b1332cb05a..dc8cac3c99 100644 --- a/packages/excalidraw/hooks/useLibraryItemSvg.ts +++ b/packages/excalidraw/hooks/useLibraryItemSvg.ts @@ -11,14 +11,18 @@ 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, + skipInliningFonts: true, }, - files: null, - renderEmbeddables: false, - skipInliningFonts: true, }); }; diff --git a/packages/excalidraw/index-node.ts b/packages/excalidraw/index-node.ts index e966b1d528..fea7eeb3ca 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 0335a9f388..5c55b759fd 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -226,7 +226,6 @@ export { export { reconcileElements } from "./data/reconcile"; export { - exportToCanvas, exportToBlob, exportToSvg, exportToClipboard, @@ -274,6 +273,8 @@ export { WelcomeScreen }; export { LiveCollaborationTrigger }; export { Stats } from "./components/Stats"; +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/helpers.ts b/packages/excalidraw/renderer/helpers.ts index 90f40099f0..cae562bf42 100644 --- a/packages/excalidraw/renderer/helpers.ts +++ b/packages/excalidraw/renderer/helpers.ts @@ -34,15 +34,16 @@ export 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")!; @@ -54,17 +55,17 @@ export 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 { diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index 90c07e8cbe..b85e69d0db 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -216,7 +216,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 0426b3f70f..867cf96b6b 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -164,8 +164,10 @@ const getArrowheadShapes = ( arrowhead: Arrowhead, generator: RoughGenerator, options: Options, - canvasBackgroundColor: string, + canvasBackgroundColor: string | null, ) => { + canvasBackgroundColor = canvasBackgroundColor || "transparent"; + const arrowheadPoints = getArrowheadPoints( element, shape, @@ -293,7 +295,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 39d388a7bf..98e5fea909 100644 --- a/packages/excalidraw/scene/ShapeCache.ts +++ b/packages/excalidraw/scene/ShapeCache.ts @@ -8,7 +8,8 @@ import { elementWithCanvasCache } from "../renderer/renderElement"; import { _generateElementShape } from "./Shape"; import type { ElementShape, ElementShapes } from "./types"; import { COLOR_PALETTE } from "../colors"; -import type { AppState, EmbedsValidationStatus } from "../types"; +import type { EmbedsValidationStatus } from "../types"; +import type { StaticCanvasRenderConfig } from "./types"; export class ShapeCache { private static rg = new RoughGenerator(); @@ -50,7 +51,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 c4ab1b8657..3f53d3e3c1 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -5,6 +5,7 @@ import type { ExcalidrawTextElement, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, + Theme, } from "../element/types"; import type { Bounds } from "../element/bounds"; import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; @@ -12,7 +13,9 @@ import { renderSceneToSvg } from "../renderer/staticSvgScene"; import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import type { AppState, BinaryFiles } from "../types"; import { + COLOR_WHITE, DEFAULT_EXPORT_PADDING, + DEFAULT_ZOOM_VALUE, FRAME_STYLE, FONT_FAMILY, SVG_NS, @@ -25,6 +28,7 @@ import { getInitializedImageElements, updateImageCache, } from "../element/image"; +import { restoreAppState } from "../data/restore"; import { getElementsOverlappingFrame, getFrameLikeElements, @@ -149,36 +153,204 @@ 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 }; - }, - 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; +}; + +/** + * 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, @@ -198,24 +370,220 @@ export const exportToCanvas = async ( }); if (exportingFrame) { - exportPadding = 0; + cfg.padding = 0; + } + + 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; + } + // --------------------------------------------------------------------------- + + // 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); } - const [minX, minY, width, height] = getCanvasSize( + // 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; + + // 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"); - const defaultAppState = getDefaultAppState(); + // 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({ @@ -228,19 +596,29 @@ export const exportToCanvas = async ( arrayToMap(syncInvalidIndices(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 ? THEME.DARK : THEME.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, @@ -254,19 +632,24 @@ 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. */ @@ -274,11 +657,18 @@ export const exportToSvg = async ( exportingFrame?: ExcalidrawFrameLikeElement | null; skipInliningFonts?: true; reuseImages?: boolean; - }, -): 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 { @@ -287,18 +677,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; } @@ -313,18 +701,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"); @@ -355,7 +752,7 @@ export const exportToSvg = async ( width="${frame.width}" height="${frame.height}" ${ - exportingFrame + cfg.exportingFrame ? "" : `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}` } @@ -364,7 +761,7 @@ export const exportToSvg = async ( `; } - const fontFaces = !opts?.skipInliningFonts + const fontFaces = !cfg?.skipInliningFonts ? await Fonts.generateFontFaceDeclarations(elements) : []; @@ -381,7 +778,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"); @@ -393,14 +790,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, @@ -416,7 +813,7 @@ export const exportToSvg = async ( .map((element) => [element.id, true]), ) : new Map(), - reuseImages: opts?.reuseImages ?? true, + reuseImages: cfg?.reuseImages ?? true, }, ); @@ -424,25 +821,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 46ee26b742..9c3752d396 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -24,7 +24,6 @@ export type RenderableElementsMap = NonDeletedElementsMap & MakeBrand<"RenderableElementsMap">; export type StaticCanvasRenderConfig = { - canvasBackgroundColor: AppState["viewBackgroundColor"]; // extra options passed to the renderer // --------------------------------------------------------------------------- imageCache: AppClassProperties["imageCache"]; @@ -32,6 +31,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; pendingFlowchartNodes: PendingExcalidrawElements | null; @@ -81,6 +82,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 2943aeee71..75ca8cb7b1 100644 --- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -6,7 +6,7 @@ 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
" `; exports[`Test > should show error in preview when mermaid library throws error 1`] = ` diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index dcbfcb1fe3..e5e431dfc6 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -20,7 +20,7 @@ exports[` > > should render main menu with host menu it diff --git a/packages/excalidraw/tests/cropElement.test.tsx b/packages/excalidraw/tests/cropElement.test.tsx index 9b03c5261c..74ae3dfaec 100644 --- a/packages/excalidraw/tests/cropElement.test.tsx +++ b/packages/excalidraw/tests/cropElement.test.tsx @@ -315,20 +315,28 @@ describe("Cropping and other features", async () => { const widthToHeightRatio = image.width / image.height; const canvas = await exportToCanvas({ - elements: [image], - appState: h.state, - files: h.app.files, - exportPadding: 0, + data: { + elements: [image], + 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], - appState: h.state, - files: h.app.files, - exportPadding: 0, + data: { + elements: [image], + 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/tests/export.test.tsx b/packages/excalidraw/tests/export.test.tsx index 65b399dbb8..4b9014a29e 100644 --- a/packages/excalidraw/tests/export.test.tsx +++ b/packages/excalidraw/tests/export.test.tsx @@ -163,7 +163,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/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 6a5db9753f..5fd48e0afb 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -15,7 +15,11 @@ import { getDefaultAppState } from "../appState"; import { fireEvent, queryByTestId, waitFor } from "@testing-library/react"; import { createUndoAction, createRedoAction } from "../actions/actionHistory"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; -import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; +import { + COLOR_CHARCOAL_BLACK, + EXPORT_DATA_TYPES, + MIME_TYPES, +} from "../constants"; import type { AppState } from "../types"; import { arrayToMap } from "../utils"; import { @@ -77,7 +81,7 @@ const checkpoint = (name: string) => { const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); const transparent = COLOR_PALETTE.transparent; -const black = COLOR_PALETTE.black; +const black = COLOR_CHARCOAL_BLACK; const red = COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]; const blue = COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]; const yellow = COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]; diff --git a/packages/excalidraw/tests/scene/export.test.ts b/packages/excalidraw/tests/scene/export.test.ts index 33a554f1ed..f7d4467fe5 100644 --- a/packages/excalidraw/tests/scene/export.test.ts +++ b/packages/excalidraw/tests/scene/export.test.ts @@ -49,36 +49,35 @@ describe("exportToSvg", () => { const DEFAULT_OPTIONS = { 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_OPTIONS, files: null }, + }); 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, + ], + files: null, + appState: DEFAULT_OPTIONS, + }, + }); 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) @@ -87,15 +86,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", @@ -104,14 +105,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"`, @@ -119,14 +122,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()); @@ -139,15 +144,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", @@ -160,23 +167,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], + files: null, + appState: DEFAULT_OPTIONS, + }, + }); expect(svgElement.innerHTML).toMatchSnapshot(); }); }); @@ -216,9 +227,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); @@ -245,10 +260,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); @@ -284,10 +303,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 @@ -328,10 +348,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 @@ -396,10 +417,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 @@ -441,10 +463,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 @@ -500,10 +526,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 453851a0e8..e01597cf87 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -173,8 +173,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 8176b1a747..bf261df07c 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1,8 +1,8 @@ import Pool from "es6-promise-pool"; import { average } from "../math"; -import { COLOR_PALETTE } from "./colors"; import type { EVENT } from "./constants"; import { + COLOR_TRANSPARENT, DEFAULT_VERSION, FONT_FAMILY, getFontFamilyFallbacks, @@ -536,11 +536,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 & { @@ -1225,3 +1221,18 @@ export class PromisePool { }); } } + +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 b04ec44e24..be0dca2981 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); @@ -34,19 +37,24 @@ describe("exportToBlob", async () => { describe("mime type", () => { it("should change image/jpg to image/jpeg", async () => { const blob = await utils.exportToBlob({ - ...diagramFactory(), - getDimensions: (width, height) => ({ width, height, scale: 1 }), - // testing typo in MIME type (jpg → jpeg) - mimeType: "image/jpg", - appState: { - exportBackground: true, + 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); }); @@ -56,9 +64,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 +78,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(); @@ -77,7 +87,7 @@ describe("exportToSvg", () => { it("with default arguments", async () => { await utils.exportToSvg({ - ...diagramFactory({ + data: diagramFactory({ overrides: { appState: void 0 }, }), }); @@ -96,7 +106,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 }, }), @@ -107,8 +117,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); @@ -119,7 +131,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 a82ef66e62..18c53af134 100644 --- a/packages/utils/export.ts +++ b/packages/utils/export.ts @@ -1,16 +1,11 @@ import { exportToCanvas as _exportToCanvas, + type ExportToCanvasConfig, + type ExportToCanvasData, exportToSvg as _exportToSvg, } from "../excalidraw/scene/export"; -import { getDefaultAppState } from "../excalidraw/appState"; -import type { AppState, BinaryFiles } from "../excalidraw/types"; -import type { - 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,48 @@ 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; -}) => { - 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; +type ExportToSvgConfig = Pick< + ExportToCanvasConfig, + "canvasBackgroundColor" | "padding" | "theme" | "exportingFrame" +> & { + /** + * if true, all embeddables passed in will be rendered when possible. + */ + renderEmbeddables?: boolean; + skipInliningFonts?: true; + reuseImages?: boolean; +}; - return { - canvas, - scale: ret.scale ?? 1, - }; - }, - ); +export const exportToCanvas = async ({ + data, + config, +}: { + data: ExportToCanvasData; + config?: ExportToCanvasConfig; +}) => { + 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 +65,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 +88,7 @@ export const exportToBlob = async ( if ( blob && mimeType === MIME_TYPES.png && - opts.appState?.exportEmbedScene + data.appState?.exportEmbedScene ) { blob = await encodePngMetadata({ blob, @@ -144,9 +96,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,53 +112,51 @@ export const exportToBlob = async ( }; export const exportToSvg = async ({ - elements, - appState = getDefaultAppState(), - files = {}, - exportPadding, - renderEmbeddables, - exportingFrame, - skipInliningFonts, - reuseImages, -}: Omit & { - exportPadding?: number; - renderEmbeddables?: boolean; - skipInliningFonts?: true; - reuseImages?: 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, - }; - - return _exportToSvg(restoredElements, exportAppState, files, { - exportingFrame, - renderEmbeddables, - skipInliningFonts, - reuseImages, + const appState = { ...restoredAppState, exportPadding: config?.padding }; + const elements = getNonDeletedElements(restoredElements); + const files = data.files || {}; + + return _exportToSvg({ + data: { elements, appState, files }, + config: { + exportingFrame: config?.exportingFrame, + renderEmbeddables: config?.renderEmbeddables, + skipInliningFonts: config?.skipInliningFonts, + reuseImages: config?.reuseImages, + }, }); }; -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 7c892cb593..cdf6b6bb5b 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", - gridModeEnabled: false, - exportEmbedScene: true, + data: { + elements: sourceElements, + appState: { + viewBackgroundColor: "#ffffff", + gridModeEnabled: false, + 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", - 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);