diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index c1834f51dc59..1a4a684a30c5 100644 --- a/src/actions/actionClipboard.tsx +++ b/src/actions/actionClipboard.tsx @@ -8,7 +8,7 @@ import { } from "../clipboard"; import { actionDeleteSelected } from "./actionDeleteSelected"; import { getSelectedElements } from "../scene/selection"; -import { exportCanvas } from "../data/index"; +import { exportAsImage } from "../data/index"; import { getNonDeletedElements, isTextElement } from "../element"; import { t } from "../i18n"; @@ -84,7 +84,7 @@ export const actionCopyAsSvg = register({ }, ); try { - await exportCanvas( + await exportAsImage( "clipboard-svg", selectedElements.length ? selectedElements @@ -131,7 +131,7 @@ export const actionCopyAsPng = register({ }, ); try { - await exportCanvas( + await exportAsImage( "clipboard", selectedElements.length ? selectedElements diff --git a/src/appState.ts b/src/appState.ts index 104fbcbf508f..ca9999ffb5a4 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -4,11 +4,12 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, DEFAULT_TEXT_ALIGN, + DEFAULT_ZOOM_VALUE, EXPORT_SCALES, THEME, } from "./constants"; import { t } from "./i18n"; -import { AppState, NormalizedZoomValue } from "./types"; +import { AppState } from "./types"; import { getDateTime } from "./utils"; const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) @@ -92,7 +93,7 @@ export const getDefaultAppState = (): Omit< viewBackgroundColor: COLOR_PALETTE.white, zenModeEnabled: false, zoom: { - value: 1 as NormalizedZoomValue, + value: DEFAULT_ZOOM_VALUE, }, viewModeEnabled: false, pendingImageElementId: null, diff --git a/src/components/App.tsx b/src/components/App.tsx index afb39d5fa899..b2baeba87abf 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -85,7 +85,7 @@ import { VERTICAL_ALIGN, ZOOM_STEP, } from "../constants"; -import { exportCanvas, loadFromBlob } from "../data"; +import { exportAsImage, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restore, restoreElements } from "../data/restore"; import { @@ -966,7 +966,7 @@ class App extends React.Component { elements: readonly NonDeletedExcalidrawElement[], ) => { trackEvent("export", type, "ui"); - const fileHandle = await exportCanvas( + const fileHandle = await exportAsImage( type, elements, this.state, @@ -1788,14 +1788,14 @@ class App extends React.Component { { elements: renderingElements, appState: this.state, - scale: window.devicePixelRatio, rc: this.rc!, canvas: this.canvas!, renderConfig: { + canvasScale: window.devicePixelRatio, selectionColor, scrollX: this.state.scrollX, scrollY: this.state.scrollY, - viewBackgroundColor: this.state.viewBackgroundColor, + canvasBackgroundColor: this.state.viewBackgroundColor, zoom: this.state.zoom, remotePointerViewportCoords: pointerViewportCoords, remotePointerButton: cursorButton, diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 960c87f2db95..dde4ef65f9a4 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -101,11 +101,20 @@ const ImageExportModal = ({ return; } exportToCanvas({ - elements: exportedElements, - appState, - files, - exportPadding: DEFAULT_EXPORT_PADDING, - maxWidthOrHeight: Math.max(maxWidth, maxHeight), + data: { + elements: exportedElements, + appState, + files, + }, + config: { + canvasBackgroundColor: !appState.exportBackground + ? false + : appState.viewBackgroundColor, + padding: DEFAULT_EXPORT_PADDING, + theme: appState.exportWithDarkMode ? "dark" : "light", + scale: appState.exportScale, + maxWidthOrHeight: Math.max(maxWidth, maxHeight), + }, }) .then((canvas) => { setRenderError(null); diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 0c03038588e8..8ac57ab5ff6a 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -2,6 +2,7 @@ import clsx from "clsx"; import React from "react"; import { ActionManager } from "../actions/manager"; import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants"; +import { exportAsImage } from "../data"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; diff --git a/src/components/PublishLibrary.tsx b/src/components/PublishLibrary.tsx index 761399570ad4..4851346d9673 100644 --- a/src/components/PublishLibrary.tsx +++ b/src/components/PublishLibrary.tsx @@ -87,9 +87,13 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => { // --------------------------------------------------------------------------- for (const [index, item] of libraryItems.entries()) { const itemCanvas = await exportToCanvas({ - elements: item.elements, - files: null, - maxWidthOrHeight: BOX_SIZE, + data: { + elements: item.elements, + files: null, + }, + config: { + maxWidthOrHeight: BOX_SIZE, + }, }); const { width, height } = itemCanvas; @@ -151,13 +155,15 @@ const SingleLibraryItem = ({ } (async () => { const svg = await exportToSvg({ - elements: libItem.elements, - appState: { - ...appState, - viewBackgroundColor: OpenColor.white, - exportBackground: true, + data: { + elements: libItem.elements, + appState: { + ...appState, + viewBackgroundColor: OpenColor.white, + exportBackground: true, + }, + files: null, }, - files: null, }); node.innerHTML = svg.outerHTML; })(); diff --git a/src/constants.ts b/src/constants.ts index 0e7547b1fdaf..b44e484520bb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,5 @@ import cssVariables from "./css/variables.module.scss"; -import { AppProps } from "./types"; +import { AppProps, NormalizedZoomValue } from "./types"; import { ExcalidrawElement, FontFamilyValues } from "./element/types"; import { COLOR_PALETTE } from "./colors"; @@ -76,6 +76,7 @@ export enum EVENT { export const ENV = { TEST: "test", DEVELOPMENT: "development", + PRODUCTION: "production", }; export const CLASSES = { @@ -112,6 +113,9 @@ export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; +export const DEFAULT_BACKGROUND_COLOR = "#ffffff"; +export const DEFAULT_STROKE_COLOR = "#000000"; +export const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue; export const CANVAS_ONLY_ACTIONS = ["selectAll"]; diff --git a/src/data/index.ts b/src/data/index.ts index 20ba75ebe5b9..f10fa23b6c8b 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -15,7 +15,7 @@ import { serializeAsJSON } from "./json"; export { loadFromBlob } from "./blob"; export { loadFromJSON, saveAsJSON } from "./json"; -export const exportCanvas = async ( +export const exportAsImage = async ( type: Omit, elements: readonly NonDeletedExcalidrawElement[], appState: AppState, @@ -66,10 +66,18 @@ export const exportCanvas = async ( } } - const tempCanvas = await exportToCanvas(elements, appState, files, { - exportBackground, - viewBackgroundColor, - exportPadding, + const tempCanvas = await exportToCanvas({ + data: { + elements, + appState, + files, + }, + config: { + canvasBackgroundColor: !exportBackground ? false : viewBackgroundColor, + padding: exportPadding, + theme: appState.exportWithDarkMode ? "dark" : "light", + scale: appState.exportScale, + }, }); tempCanvas.style.display = "none"; document.body.appendChild(tempCanvas); diff --git a/src/data/resave.ts b/src/data/resave.ts index ede6f424ac18..649835efaf3e 100644 --- a/src/data/resave.ts +++ b/src/data/resave.ts @@ -1,6 +1,6 @@ import { ExcalidrawElement } from "../element/types"; import { AppState, BinaryFiles } from "../types"; -import { exportCanvas } from "."; +import { exportAsImage } from "."; import { getNonDeletedElements } from "../element"; import { getFileHandleType, isImageFileHandleType } from "./blob"; @@ -23,7 +23,7 @@ export const resaveAsImageWithScene = async ( exportEmbedScene: true, }; - await exportCanvas( + await exportAsImage( fileHandleType, getNonDeletedElements(elements), appState, diff --git a/src/hooks/useLibraryItemSvg.ts b/src/hooks/useLibraryItemSvg.ts index 1c27f0ce7abf..b754d04f99d2 100644 --- a/src/hooks/useLibraryItemSvg.ts +++ b/src/hooks/useLibraryItemSvg.ts @@ -11,12 +11,14 @@ 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, }, - files: null, }); }; diff --git a/src/index-node.ts b/src/index-node.ts index e966b1d52857..b6e901e7ac43 100644 --- a/src/index-node.ts +++ b/src/index-node.ts @@ -57,22 +57,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: "#ffffff", + createCanvas, }, - createCanvas, -); +}); const fs = require("fs"); const out = fs.createWriteStream("test.png"); diff --git a/src/packages/excalidraw/example/App.tsx b/src/packages/excalidraw/example/App.tsx index cd9d346cce43..3c063fe3da76 100644 --- a/src/packages/excalidraw/example/App.tsx +++ b/src/packages/excalidraw/example/App.tsx @@ -253,10 +253,12 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { 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`); }; @@ -743,15 +745,17 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { 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; @@ -767,14 +771,18 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { 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)); }} @@ -789,6 +797,20 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { if (!excalidrawAPI) { return; } + const canvas = await exportToCanvas({ + data: { + elements: excalidrawAPI.getSceneElements(), + appState: { + ...initialData.appState, + exportWithDarkMode, + }, + files: excalidrawAPI.getFiles(), + }, + }); + const ctx = canvas.getContext("2d")!; + ctx.font = "30px Virgil"; + ctx.strokeText("My custom text", 50, 60); + setCanvasUrl(canvas.toDataURL()); }} > Export to Canvas @@ -799,12 +821,14 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { return; } const canvas = await exportToCanvas({ - elements: excalidrawAPI.getSceneElements(), - appState: { - ...initialData.appState, - exportWithDarkMode, + data: { + elements: excalidrawAPI.getSceneElements(), + appState: { + ...initialData.appState, + exportWithDarkMode, + }, + files: excalidrawAPI.getFiles(), }, - files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; ctx.font = "30px Virgil"; diff --git a/src/packages/utils.ts b/src/packages/utils.ts index d9365895e448..b57f30ffc126 100644 --- a/src/packages/utils.ts +++ b/src/packages/utils.ts @@ -1,12 +1,14 @@ import { exportToCanvas as _exportToCanvas, + ExportToCanvasConfig, + ExportToCanvasData, exportToSvg as _exportToSvg, } from "../scene/export"; import { getDefaultAppState } from "../appState"; -import { AppState, BinaryFiles } from "../types"; -import { ExcalidrawElement, NonDeleted } from "../element/types"; +import { getNonDeletedElements } from "../element"; +import { ExcalidrawElement } from "../element/types"; import { restore } from "../data/restore"; -import { MIME_TYPES } from "../constants"; +import { DEFAULT_BACKGROUND_COLOR, MIME_TYPES } from "../constants"; import { encodePngMetadata } from "../data/image"; import { serializeAsJSON } from "../data/json"; import { @@ -35,86 +37,24 @@ const passElementsSafely = (elements: readonly ExcalidrawElement[]) => { export { MIME_TYPES }; -type ExportOpts = { - elements: readonly NonDeleted[]; - appState?: Partial>; - files: BinaryFiles | null; - maxWidthOrHeight?: number; - 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, -}: ExportOpts & { - exportPadding?: number; -}) => { - const { elements: restoredElements, appState: restoredAppState } = restore( - { elements, appState }, - null, - null, - ); - const { exportBackground, viewBackgroundColor } = restoredAppState; - return _exportToCanvas( - passElementsSafely(restoredElements), - { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 }, - files || {}, - { exportBackground, exportPadding, viewBackgroundColor }, - (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); +type ExportToSvgConfig = Pick< + ExportToCanvasConfig, + "canvasBackgroundColor" | "padding" | "theme" +>; - // if content is less then maxWidthOrHeight, fallback on supplied scale - const scale = - maxWidthOrHeight < max - ? maxWidthOrHeight / max - : appState?.exportScale ?? 1; - - canvas.width = width * scale; - canvas.height = height * scale; - - return { - canvas, - scale, - }; - } - - const ret = getDimensions?.(width, height) || { width, height }; - - canvas.width = ret.width; - canvas.height = ret.height; - - return { - canvas, - scale: ret.scale ?? 1, - }; - }, - ); -}; - -export const exportToBlob = async ( - opts: ExportOpts & { - mimeType?: string; - quality?: number; - exportPadding?: number; - }, -): Promise => { - let { mimeType = MIME_TYPES.png, quality } = opts; +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`); @@ -125,19 +65,23 @@ 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 || DEFAULT_BACKGROUND_COLOR, }; } - const canvas = await exportToCanvas({ - ...opts, - elements: passElementsSafely(opts.elements), + const canvas = await _exportToCanvas({ + data: { + ...data, + elements: passElementsSafely(data.elements), + }, + config, }); quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8; @@ -150,7 +94,7 @@ export const exportToBlob = async ( if ( blob && mimeType === MIME_TYPES.png && - opts.appState?.exportEmbedScene + data.appState?.exportEmbedScene ) { blob = await encodePngMetadata({ blob, @@ -158,9 +102,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", ), }); @@ -174,53 +118,62 @@ export const exportToBlob = async ( }; export const exportToSvg = async ({ - elements, - appState = getDefaultAppState(), - files = {}, - exportPadding, -}: Omit & { - exportPadding?: number; + 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( - passElementsSafely(restoredElements), - exportAppState, - files, - { - // 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. Hence adding the serializeAsJSON helper - // support into the downstream exportToSvg function. - serializeAsJSON: () => - serializeAsJSON(restoredElements, exportAppState, files || {}, "local"), - }, - ); + const appState = { ...restoredAppState, exportPadding: config?.padding }; + const elements = getNonDeletedElements(restoredElements); + const files = data.files || {}; + + return _exportToSvg(passElementsSafely(elements), appState, files, { + // 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. Hence adding the serializeAsJSON helper + // support into the downstream exportToSvg function. + serializeAsJSON: () => serializeAsJSON(elements, appState, files, "local"), + }); +}; + +export const exportToCanvas = async ({ + data, + config, +}: { + data: ExportToCanvasData; + config?: ExportToCanvasConfig; +}) => { + return _exportToCanvas({ + data: { ...data, elements: passElementsSafely(data.elements) }, + config, + }); }; -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/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 1b39a201e35e..976c3b328495 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -351,14 +351,12 @@ const frameClip = ( export const _renderScene = ({ elements, appState, - scale, rc, canvas, renderConfig, }: { elements: readonly NonDeletedExcalidrawElement[]; appState: AppState; - scale: number; rc: RoughCanvas; canvas: HTMLCanvasElement; renderConfig: RenderConfig; @@ -381,27 +379,27 @@ export const _renderScene = ({ context.setTransform(1, 0, 0, 1, 0, 0); context.save(); - context.scale(scale, scale); + context.scale(renderConfig.canvasScale, renderConfig.canvasScale); // When doing calculations based on canvas width we should used normalized one - const normalizedCanvasWidth = canvas.width / scale; - const normalizedCanvasHeight = canvas.height / scale; + const normalizedCanvasWidth = canvas.width / renderConfig.canvasScale; + const normalizedCanvasHeight = canvas.height / renderConfig.canvasScale; if (isExporting && renderConfig.theme === "dark") { context.filter = THEME_FILTER; } // Paint background - if (typeof renderConfig.viewBackgroundColor === "string") { + if (typeof renderConfig.canvasBackgroundColor === "string") { const hasTransparence = - renderConfig.viewBackgroundColor === "transparent" || - renderConfig.viewBackgroundColor.length === 5 || // #RGBA - renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA - /(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor); + renderConfig.canvasBackgroundColor === "transparent" || + renderConfig.canvasBackgroundColor.length === 5 || // #RGBA + renderConfig.canvasBackgroundColor.length === 9 || // #RRGGBBA + /(hsla|rgba)\(/.test(renderConfig.canvasBackgroundColor); if (hasTransparence) { context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); } context.save(); - context.fillStyle = renderConfig.viewBackgroundColor; + context.fillStyle = renderConfig.canvasBackgroundColor; context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); context.restore(); } else { @@ -912,7 +910,6 @@ const renderSceneThrottled = throttleRAF( (config: { elements: readonly NonDeletedExcalidrawElement[]; appState: AppState; - scale: number; rc: RoughCanvas; canvas: HTMLCanvasElement; renderConfig: RenderConfig; @@ -929,7 +926,6 @@ export const renderScene = ( config: { elements: readonly NonDeletedExcalidrawElement[]; appState: AppState; - scale: number; rc: RoughCanvas; canvas: HTMLCanvasElement; renderConfig: RenderConfig; diff --git a/src/scene/export.ts b/src/scene/export.ts index 861e1f489654..baac6fea5b2e 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -1,76 +1,374 @@ import rough from "roughjs/bin/rough"; -import { NonDeletedExcalidrawElement } from "../element/types"; import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; +import { NonDeletedExcalidrawElement, Theme } from "../element/types"; import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; import { distance, isOnlyExportingSingleFrame } from "../utils"; import { AppState, BinaryFiles } from "../types"; -import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants"; -import { getDefaultAppState } from "../appState"; +import { + DEFAULT_BACKGROUND_COLOR, + DEFAULT_EXPORT_PADDING, + DEFAULT_ZOOM_VALUE, + ENV, + SVG_NS, + THEME, + THEME_FILTER, +} from "../constants"; import { serializeAsJSON } from "../data/json"; import { getInitializedImageElements, updateImageCache, } from "../element/image"; import Scene from "./Scene"; +import { restoreAppState } from "../data/restore"; export const SVG_EXPORT_TAG = ``; -export const exportToCanvas = async ( - elements: readonly NonDeletedExcalidrawElement[], - appState: AppState, - files: BinaryFiles, - { - exportBackground, - exportPadding = DEFAULT_EXPORT_PADDING, - viewBackgroundColor, - }: { - exportBackground: boolean; - exportPadding?: number; - viewBackgroundColor: string; - }, - 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. Ignored if `fit` is set to + * `cover`. + * + * @default 10 + */ + 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. + * + * 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. + * - `cover` - scale to fill the frame while maintaining aspect ratio. If + * content overflows, it will be cropped. + * + * @default "contain" unless `x` or `y` are specified, in which case "none" + * is used (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). + * + * @default "center" + */ + position?: "center" | "none"; + // ------------------------------------------------------------------------- + /** + * 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 }; - }, -) => { - const [minX, minY, width, height] = getCanvasSize(elements, exportPadding); + ) => { width: number; height: number; scale?: number }; +}; + +/** + * 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; +}) => { + // initialize defaults + // --------------------------------------------------------------------------- + const { elements, files } = data; + + const appState = restoreAppState(data.appState, null); + + // clone + const cfg = Object.assign({}, config); + + if (cfg.x != null || cfg.x != null) { + if (cfg.fit != null && cfg.fit !== "none") { + if (process.env.NODE_ENV !== ENV.PRODUCTION) { + console.warn( + "`fit` will be ignored (automatically set to `none`) when you specify `x` or `y` offsets", + ); + } + } + cfg.fit = "none"; + } + + cfg.fit = cfg.fit ?? "contain"; + + if (cfg.fit === "cover" && cfg.padding) { + if (process.env.NODE_ENV !== ENV.PRODUCTION) { + console.warn("`padding` is ignored when `fit` is set to `cover`"); + } + cfg.padding = 0; + } + + cfg.scale = cfg.scale ?? 1; + + cfg.origin = cfg.origin ?? "canvas"; + cfg.position = cfg.position ?? "center"; + cfg.padding = cfg.padding ?? DEFAULT_EXPORT_PADDING; - const { canvas, scale = 1 } = createCanvas(width, height); + if (cfg.maxWidthOrHeight != null && cfg.widthOrHeight != null) { + if (process.env.NODE_ENV !== ENV.PRODUCTION) { + 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 (process.env.NODE_ENV !== ENV.PRODUCTION) { + console.warn( + "`getDimensions` is ignored when `width`, `height`, or `maxWidthOrHeight` is set", + ); + } + cfg.getDimensions = undefined; + } + // --------------------------------------------------------------------------- + + // value used to scale the canvas context. By default, we use this to + // make the canvas fit into the frame (e.g. for `cfg.fit` set to `contain`). + // If `cfg.scale` is set, we multiply the resulting canvasScale by it to + // scale the output further. + let canvasScale = 1; + + const origCanvasSize = getCanvasSize(elements, cfg.padding); + + // 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.maxWidthOrHeight != null || cfg.widthOrHeight != null) { + const max = Math.max(origWidth, origHeight); + 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; + } - const defaultAppState = getDefaultAppState(); + width *= canvasScale; + height *= canvasScale; + } else if (cfg.width != null) { + width = cfg.width; + + if (cfg.height) { + height = cfg.height; + } 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; + // width not specified, so scale the original width to match the new + // height while maintaining aspect ratio + width *= height / origHeight; + } else if (cfg.getDimensions) { + const ret = cfg.getDimensions(width, height); + + width = ret.width; + height = ret.height; + cfg.scale = ret.scale ?? cfg.scale; + } + + if (cfg.fit === "contain" && !cfg.maxWidthOrHeight) { + const wRatio = width / origWidth; + const hRatio = height / origHeight; + // scale the orig canvas to fit in the target frame + 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") { + if (cfg.x == null) { + x -= width / canvasScale / 2 - origWidth / 2; + } + if (cfg.y == null) { + y -= height / canvasScale / 2 - origHeight / 2; + } + } + + const canvas = cfg.createCanvas + ? cfg.createCanvas() + : document.createElement("canvas"); + + // 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; + canvas.height = height; const { imageCache } = await updateImageCache({ imageCache: new Map(), fileIds: getInitializedImageElements(elements).map( (element) => element.fileId, ), - files, + files: files || {}, }); const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements); renderScene({ elements, - appState, - scale, + appState: { ...appState, width, height, offsetLeft: 0, offsetTop: 0 }, rc: rough.canvas(canvas), canvas, renderConfig: { - viewBackgroundColor: exportBackground ? viewBackgroundColor : null, - scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding), - scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding), - zoom: defaultAppState.zoom, + canvasBackgroundColor: + cfg.canvasBackgroundColor === false + ? // null indicates transparent background + null + : cfg.canvasBackgroundColor || + appState.viewBackgroundColor || + DEFAULT_BACKGROUND_COLOR, + scrollX: -x + (onlyExportingSingleFrame ? 0 : cfg.padding), + scrollY: -y + (onlyExportingSingleFrame ? 0 : cfg.padding), + canvasScale, + zoom: { value: DEFAULT_ZOOM_VALUE }, remotePointerViewportCoords: {}, remoteSelectedElementIds: {}, shouldCacheIgnoreZoom: false, remotePointerUsernames: {}, remotePointerUserStates: {}, - theme: appState.exportWithDarkMode ? "dark" : "light", + theme: cfg.theme || THEME.LIGHT, imageCache, renderScrollbars: false, renderSelection: false, @@ -221,7 +519,7 @@ export const exportToSvg = async ( const getCanvasSize = ( elements: readonly NonDeletedExcalidrawElement[], exportPadding: number, -): [number, number, number, number] => { +): [minX: number, minY: number, width: number, height: number] => { // we should decide if we are exporting the whole canvas // if so, we are not clipping elements in the frame // and therefore, we should not do anything special @@ -258,10 +556,10 @@ const getCanvasSize = ( export const getExportSize = ( elements: readonly NonDeletedExcalidrawElement[], - exportPadding: number, + padding: number, scale: number, ): [number, number] => { - const [, , width, height] = getCanvasSize(elements, exportPadding).map( + const [, , width, height] = getCanvasSize(elements, padding).map( (dimension) => Math.trunc(dimension * scale), ); diff --git a/src/scene/types.ts b/src/scene/types.ts index a54b02b26572..66669d8e9cb5 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -2,15 +2,23 @@ import { ExcalidrawTextElement } from "../element/types"; import { AppClassProperties, AppState } from "../types"; export type RenderConfig = { - // AppState values + // canvas related (AppState) // --------------------------------------------------------------------------- scrollX: AppState["scrollX"]; scrollY: AppState["scrollY"]; /** null indicates transparent bg */ - viewBackgroundColor: AppState["viewBackgroundColor"] | null; + canvasBackgroundColor: AppState["viewBackgroundColor"] | null; zoom: AppState["zoom"]; shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"]; theme: AppState["theme"]; + /** + * 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). + */ + canvasScale: number; // collab-related state // --------------------------------------------------------------------------- remotePointerViewportCoords: { [id: string]: { x: number; y: number } }; diff --git a/src/tests/packages/utils.test.ts b/src/tests/packages/utils.test.ts index 824b5d15ae6b..468cef8c1783 100644 --- a/src/tests/packages/utils.test.ts +++ b/src/tests/packages/utils.test.ts @@ -3,6 +3,7 @@ import { diagramFactory } from "../fixtures/diagramFixture"; import * as mockedSceneExportUtils from "../../scene/export"; import { MIME_TYPES } from "../../constants"; +import { exportToCanvas } from "../../scene/export"; jest.mock("../../scene/export", () => ({ __esmodule: true, ...jest.requireActual("../../scene/export"), @@ -13,8 +14,8 @@ describe("exportToCanvas", () => { 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); @@ -22,9 +23,13 @@ describe("exportToCanvas", () => { }); 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); @@ -38,12 +43,17 @@ describe("exportToBlob", () => { 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); @@ -51,7 +61,7 @@ describe("exportToBlob", () => { it("should default to image/png", async () => { const blob = await utils.exportToBlob({ - ...diagramFactory(), + data: diagramFactory(), }); expect(blob?.type).toBe(MIME_TYPES.png); }); @@ -62,9 +72,11 @@ describe("exportToBlob", () => { .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( @@ -82,7 +94,7 @@ describe("exportToSvg", () => { it("with default arguments", async () => { await utils.exportToSvg({ - ...diagramFactory({ + data: diagramFactory({ overrides: { appState: void 0 }, }), }); @@ -98,7 +110,7 @@ describe("exportToSvg", () => { it("with deleted elements", async () => { await utils.exportToSvg({ - ...diagramFactory({ + data: diagramFactory({ overrides: { appState: void 0 }, elementOverrides: { isDeleted: true }, }), @@ -109,8 +121,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); @@ -121,7 +135,7 @@ describe("exportToSvg", () => { it("with exportEmbedScene", async () => { await utils.exportToSvg({ - ...diagramFactory({ + data: diagramFactory({ overrides: { appState: { name: "diagram name", exportEmbedScene: true }, }, diff --git a/src/tests/packages/utils.unmocked.test.ts b/src/tests/packages/utils.unmocked.test.ts index 28db08c49f42..0df3ffb20f33 100644 --- a/src/tests/packages/utils.unmocked.test.ts +++ b/src/tests/packages/utils.unmocked.test.ts @@ -16,13 +16,15 @@ describe("embedding scene data", () => { const sourceElements = [rectangle, ellipse]; const svgNode = await utils.exportToSvg({ - elements: sourceElements, - appState: { - viewBackgroundColor: "#ffffff", - gridSize: null, - exportEmbedScene: true, + data: { + elements: sourceElements, + appState: { + viewBackgroundColor: "#ffffff", + gridSize: null, + exportEmbedScene: true, + }, + files: null, }, - files: null, }); const svg = svgNode.outerHTML; @@ -46,14 +48,18 @@ describe("embedding scene data", () => { const sourceElements = [rectangle, ellipse]; const blob = await utils.exportToBlob({ - mimeType: "image/png", - elements: sourceElements, - appState: { - viewBackgroundColor: "#ffffff", - gridSize: null, - exportEmbedScene: true, + data: { + elements: sourceElements, + appState: { + viewBackgroundColor: "#ffffff", + gridSize: null, + exportEmbedScene: true, + }, + files: null, + }, + config: { + mimeType: "image/png", }, - files: null, }); const parsedString = await decodePngMetadata(blob);