dwelle 1 gadu atpakaļ
vecāks
revīzija
0e02366dee
37 mainītis faili ar 1096 papildinājumiem un 597 dzēšanām
  1. 41 29
      examples/excalidraw/components/ExampleApp.tsx
  2. 12 8
      excalidraw-app/components/AI.tsx
  3. 1 1
      excalidraw-app/components/DebugCanvas.tsx
  4. 16 12
      packages/excalidraw/actions/actionClipboard.tsx
  5. 14 2
      packages/excalidraw/actions/actionExport.tsx
  6. 5 4
      packages/excalidraw/appState.ts
  7. 4 7
      packages/excalidraw/charts.ts
  8. 21 23
      packages/excalidraw/colors.ts
  9. 9 7
      packages/excalidraw/components/App.tsx
  10. 1 1
      packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts
  11. 18 12
      packages/excalidraw/components/ImageExportDialog.tsx
  12. 11 9
      packages/excalidraw/components/PasteChartDialog.tsx
  13. 20 11
      packages/excalidraw/components/PublishLibrary.tsx
  14. 10 6
      packages/excalidraw/components/TTDDialog/common.ts
  15. 0 1
      packages/excalidraw/components/canvases/StaticCanvas.tsx
  16. 11 6
      packages/excalidraw/constants.ts
  17. 64 45
      packages/excalidraw/data/index.ts
  18. 12 7
      packages/excalidraw/data/resave.ts
  19. 11 7
      packages/excalidraw/hooks/useLibraryItemSvg.ts
  20. 14 14
      packages/excalidraw/index-node.ts
  21. 2 1
      packages/excalidraw/index.tsx
  22. 9 8
      packages/excalidraw/renderer/helpers.ts
  23. 1 1
      packages/excalidraw/renderer/staticScene.ts
  24. 4 2
      packages/excalidraw/scene/Shape.ts
  25. 3 2
      packages/excalidraw/scene/ShapeCache.ts
  26. 473 89
      packages/excalidraw/scene/export.ts
  27. 9 1
      packages/excalidraw/scene/types.ts
  28. 1 1
      packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
  29. 16 8
      packages/excalidraw/tests/cropElement.test.tsx
  30. 1 1
      packages/excalidraw/tests/export.test.tsx
  31. 6 2
      packages/excalidraw/tests/history.test.tsx
  32. 121 91
      packages/excalidraw/tests/scene/export.test.ts
  33. 0 2
      packages/excalidraw/types.ts
  34. 17 6
      packages/excalidraw/utils.ts
  35. 38 26
      packages/utils/export.test.ts
  36. 81 131
      packages/utils/export.ts
  37. 19 13
      packages/utils/utils.unmocked.test.ts

+ 41 - 29
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";

+ 12 - 8
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);

+ 1 - 1
excalidraw-app/components/DebugCanvas.tsx

@@ -84,7 +84,7 @@ const _debugRenderer = (
     scale,
     normalizedWidth,
     normalizedHeight,
-    viewBackgroundColor: "transparent",
+    canvasBackgroundColor: "transparent",
   });
 
   // Apply zoom

+ 16 - 12
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,

+ 14 - 2
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) => {

+ 5 - 4
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,

+ 4 - 7
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,
       })

+ 21 - 23
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 = <R extends Record<string, any>, 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<R, K[number]>) as Pick<R, K[number]>;
-};
+import {
+  COLOR_WHITE,
+  COLOR_CHARCOAL_BLACK,
+  COLOR_TRANSPARENT,
+} from "./constants";
+import { type Merge } from "./utility-types";
+import { pick } from "./utils";
 
 export type ColorPickerColor =
-  | Exclude<keyof oc, "indigo" | "lime">
+  | Exclude<keyof oc, "indigo" | "lime" | "black">
   | "transparent"
+  | "charcoal"
   | "bronze";
 export type ColorTuple = readonly [string, string, string, string, string];
 export type ColorPalette = Merge<
   Record<ColorPickerColor, ColorTuple>,
-  { 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<ColorShadesIndexes>,
 ) => {
@@ -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,

+ 9 - 7
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<AppProps, AppState> {
     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);

+ 1 - 1
packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts

@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
       });
 
       if (!baseColorName) {
-        onChange(COLOR_PALETTE.black);
+        onChange(COLOR_PALETTE.charcoal);
       }
     }
 

+ 18 - 12
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);

+ 11 - 9
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);

+ 20 - 11
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;
     })();

+ 10 - 6
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)

+ 0 - 1
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,

+ 11 - 6
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",

+ 64 - 45
packages/excalidraw/data/index.ts

@@ -81,46 +81,54 @@ export const prepareElementsForExport = (
   };
 };
 
-export const exportCanvas = async (
-  type: Omit<ExportType, "backend">,
-  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<ExportType, "backend">;
+  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 {

+ 12 - 7
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 };

+ 11 - 7
packages/excalidraw/hooks/useLibraryItemSvg.ts

@@ -11,14 +11,18 @@ export const libraryItemSvgsCache = atom<SvgCache>(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,
   });
 };
 

+ 14 - 14
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");

+ 2 - 1
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";

+ 9 - 8
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 {

+ 1 - 1
packages/excalidraw/renderer/staticScene.ts

@@ -216,7 +216,7 @@ const _renderStaticScene = ({
     normalizedHeight,
     theme: appState.theme,
     isExporting,
-    viewBackgroundColor: appState.viewBackgroundColor,
+    canvasBackgroundColor: renderConfig.canvasBackgroundColor,
   });
 
   // Apply zoom

+ 4 - 2
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 => {

+ 3 - 2
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,
   ) => {

+ 473 - 89
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<Omit<AppState, "offsetTop" | "offsetLeft">>;
+  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<void> = 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<void>;
+};
+
+/**
+ * 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<SVGSVGElement> => {
+  };
+}): Promise<SVGSVGElement> => {
+  // 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 (
         </clipPath>`;
   }
 
-  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<RenderableElementsMap>(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];
-};

+ 9 - 1
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;

+ 1 - 1
packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap

@@ -6,7 +6,7 @@ exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active too
  B --&gt; C{Let me think}
  C --&gt;|One| D[Laptop]
  C --&gt;|Two| E[iPhone]
- C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
+ C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="9" height="0" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
 `;
 
 exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `

+ 16 - 8
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");

+ 1 - 1
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;
 

+ 6 - 2
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];

+ 121 - 91
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

+ 0 - 2
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"];

+ 17 - 6
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 = <T>(
 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<T> = Promise<T> & {
@@ -1225,3 +1221,18 @@ export class PromisePool<T> {
     });
   }
 }
+
+export const pick = <
+  R extends Record<string, any>,
+  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<R, K[number]>) as Pick<R, K[number]>;
+};

+ 38 - 26
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 },
         },

+ 81 - 131
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<ExcalidrawElement>[];
-  appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
-  files: BinaryFiles | null;
-  maxWidthOrHeight?: number;
-  exportingFrame?: ExcalidrawFrameLikeElement | null;
-  getDimensions?: (
-    width: number,
-    height: number,
-  ) => { width: number; height: number; scale?: number };
+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<Blob> => {
-  let { mimeType = MIME_TYPES.png, quality } = opts;
+export const exportToBlob = async ({
+  data,
+  config,
+}: {
+  data: ExportToCanvasData;
+  config?: ExportToBlobConfig;
+}): Promise<Blob> => {
+  let { mimeType = MIME_TYPES.png, quality } = config || {};
 
   if (mimeType === MIME_TYPES.png && typeof quality === "number") {
     console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
@@ -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<ExportOpts, "getDimensions"> & {
-  exportPadding?: number;
-  renderEmbeddables?: boolean;
-  skipInliningFonts?: true;
-  reuseImages?: boolean;
+  data,
+  config,
+}: {
+  data: ExportToCanvasData;
+  config?: ExportToSvgConfig;
 }): Promise<SVGSVGElement> => {
   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");
   }

+ 19 - 13
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);