diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index 661f65f38..0b0f3c05a 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"; @@ -78,7 +78,7 @@ export const actionCopyAsSvg = register({ true, ); try { - await exportCanvas( + await exportAsImage( "clipboard-svg", selectedElements.length ? selectedElements @@ -122,7 +122,7 @@ export const actionCopyAsPng = register({ true, ); try { - await exportCanvas( + await exportAsImage( "clipboard", selectedElements.length ? selectedElements diff --git a/src/components/App.tsx b/src/components/App.tsx index a48510bf9..dd2ffcdf5 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1347,14 +1347,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 fb2c1ec81..1fea1a24a 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -99,10 +99,18 @@ const ImageExportModal = ({ if (!previewNode) { return; } - exportToCanvas(exportedElements, appState, files, { - exportBackground, - viewBackgroundColor, - exportPadding, + exportToCanvas({ + data: { + elements: exportedElements, + appState, + files, + }, + config: { + canvasBackgroundColor: !exportBackground ? false : viewBackgroundColor, + padding: exportPadding, + theme: appState.exportWithDarkMode ? "dark" : "light", + scale: appState.exportScale, + }, }) .then((canvas) => { setRenderError(null); diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index bea074d62..eed24f842 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import React from "react"; import { ActionManager } from "../actions/manager"; import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; -import { exportCanvas } from "../data"; +import { exportAsImage } from "../data"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; @@ -153,7 +153,7 @@ const LayerUI = ({ (type: ExportType): ExportCB => async (exportedElements) => { trackEvent("export", type, "ui"); - const fileHandle = await exportCanvas( + const fileHandle = await exportAsImage( type, exportedElements, appState, diff --git a/src/components/PublishLibrary.tsx b/src/components/PublishLibrary.tsx index 0d060dfaa..6852d4f40 100644 --- a/src/components/PublishLibrary.tsx +++ b/src/components/PublishLibrary.tsx @@ -5,7 +5,6 @@ import { Dialog } from "./Dialog"; import { t } from "../i18n"; import { AppState, LibraryItems, LibraryItem } from "../types"; -import { exportToCanvas } from "../packages/utils"; import { EXPORT_DATA_TYPES, EXPORT_SOURCE, @@ -19,6 +18,7 @@ import SingleLibraryItem from "./SingleLibraryItem"; import { canvasToBlob, resizeImageFile } from "../data/blob"; import { chunk } from "../utils"; import DialogActionButton from "./DialogActionButton"; +import { exportToCanvas } from "../scene/export"; interface PublishLibraryDataParams { authorName: string; @@ -85,9 +85,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; diff --git a/src/components/SingleLibraryItem.tsx b/src/components/SingleLibraryItem.tsx index 45959199c..3affe1465 100644 --- a/src/components/SingleLibraryItem.tsx +++ b/src/components/SingleLibraryItem.tsx @@ -31,13 +31,15 @@ const SingleLibraryItem = ({ } (async () => { const svg = await exportToSvg({ - elements: libItem.elements, - appState: { - ...appState, - viewBackgroundColor: oc.white, - exportBackground: true, + data: { + elements: libItem.elements, + appState: { + ...appState, + viewBackgroundColor: oc.white, + exportBackground: true, + }, + files: null, }, - files: null, }); node.innerHTML = svg.outerHTML; })(); diff --git a/src/data/index.ts b/src/data/index.ts index 89877aab9..e5a782ec3 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 ede6f424a..649835efa 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/index-node.ts b/src/index-node.ts index e966b1d52..b6e901e7a 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 1f4a6c7fd..bdcee0ab2 100644 --- a/src/packages/excalidraw/example/App.tsx +++ b/src/packages/excalidraw/example/App.tsx @@ -252,10 +252,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)); }} @@ -791,12 +799,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/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 1305f91f3..444940a03 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -206,7 +206,6 @@ export { restoreLibraryItems, } from "../../data/restore"; export { - exportToCanvas, exportToBlob, exportToSvg, serializeAsJSON, @@ -245,3 +244,5 @@ export { MainMenu }; export { useDevice } from "../../components/App"; export { WelcomeScreen }; export { LiveCollaborationTrigger }; + +export { exportToCanvas } from "../../scene/export"; diff --git a/src/packages/utils.ts b/src/packages/utils.ts index d81995080..8f3cd2f6f 100644 --- a/src/packages/utils.ts +++ b/src/packages/utils.ts @@ -1,13 +1,13 @@ import { - exportToCanvas as _exportToCanvas, + 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 { 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 { @@ -18,82 +18,24 @@ import { 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( - getNonDeletedElements(restoredElements), - { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 }, - files || {}, - { exportBackground, exportPadding, viewBackgroundColor }, - (width: number, height: number) => { - const canvas = document.createElement("canvas"); +type ExportToSvgConfig = Pick< + ExportToCanvasConfig, + "canvasBackgroundColor" | "padding" | "theme" +>; - if (maxWidthOrHeight) { - if (typeof getDimensions === "function") { - console.warn( - "`getDimensions()` is ignored when `maxWidthOrHeight` is supplied.", - ); - } - - const max = Math.max(width, height); - - const scale = maxWidthOrHeight / max; - - 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`); @@ -104,17 +46,21 @@ 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); + const canvas = await exportToCanvas({ + data, + config, + }); quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8; @@ -127,14 +73,14 @@ export const exportToBlob = async ( if ( blob && mimeType === MIME_TYPES.png && - opts.appState?.exportEmbedScene + data.appState?.exportEmbedScene ) { blob = await encodePngMetadata({ blob, metadata: serializeAsJSON( - opts.elements, - opts.appState, - opts.files || {}, + data.elements, + data.appState, + data.files || {}, "local", ), }); @@ -148,50 +94,50 @@ 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, ); return _exportToSvg( getNonDeletedElements(restoredElements), - { - ...restoredAppState, - exportPadding, - }, - files, + { ...restoredAppState, exportPadding: config?.padding }, + data.files || {}, ); }; -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") { + } else if (type === "png") { + await copyBlobToClipboardAsPng(exportToBlob({ data, config })); + } else if (type === "json") { const appState = { offsetTop: 0, offsetLeft: 0, width: 0, height: 0, ...getDefaultAppState(), - ...opts.appState, + ...data.appState, }; - await copyToClipboard(opts.elements, appState, opts.files); + await copyToClipboard(data.elements, appState, data.files); } else { throw new Error("Invalid export type"); } diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index c8b64b47b..ccacd07d2 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -317,14 +317,12 @@ const renderLinearElementPointHighlight = ( export const _renderScene = ({ elements, appState, - scale, rc, canvas, renderConfig, }: { elements: readonly NonDeletedExcalidrawElement[]; appState: AppState; - scale: number; rc: RoughCanvas; canvas: HTMLCanvasElement; renderConfig: RenderConfig; @@ -347,27 +345,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 { @@ -796,7 +794,6 @@ const renderSceneThrottled = throttleRAF( (config: { elements: readonly NonDeletedExcalidrawElement[]; appState: AppState; - scale: number; rc: RoughCanvas; canvas: HTMLCanvasElement; renderConfig: RenderConfig; @@ -813,7 +810,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 a6e4297ad..3e2d3d702 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -1,73 +1,309 @@ import rough from "roughjs/bin/rough"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import { NonDeletedExcalidrawElement, Theme } from "../element/types"; import { getCommonBounds } from "../element/bounds"; import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; import { distance } 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 { 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 is no larger than this value, while keeping the + * aspect ratio. + * + * Technically can get smaller/larger if used in conjunction with + * `scale`. + */ + maxWidthOrHeight?: number; + // ------------------------------------------------------------------------- + /** + * Width of the frame. Supply `x` or `y` if you want to ofsset the canvas. + * + * Defaults to the content bounding box width. + */ + width?: number; + /** + * Height of the frame. + * + * If height omitted, the height is calculated from the the content's + * bounding box to preserve the aspect ratio. + * + * Defaults to the content bounding box height. + */ + height?: number; + /** + * Left canvas position. Defaults to the `x` postion of the content bounding + * box. + * + */ + x?: number; + /** + * Top canvas position. + * + * 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, 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"; + /** + * If `fit` is set to `none` or `cover`, and neither `x` or `y` are + * specified, indicates how the canvas should be aligned. + * + * - `none` - canvas aligned to top left. + * - `center` - canvas is centered. Aligned to either axis (or both) that's + * not specified. + * + * @default "center" + */ + position?: "center" | "none"; + // ------------------------------------------------------------------------- + /** + * A multiplier to increase/decrease the canvas resolution. + * + * For example, if your canvas is 300x150 and you set scale to 2, the + * resoluting size will be 600x300. + * + * @default 1 + */ + scale?: number; + /** + * If you need to suply your own canvas, e.g. in test environments or on + * Node.js. + * + * Do not set canvas.width/height or modify the 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` or `width` 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 }; +}; - const { canvas, scale = 1 } = createCanvas(width, height); +/** + * 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 defaultAppState = getDefaultAppState(); + 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.DEVELOPMENT) { + 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.DEVELOPMENT) { + 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; + // --------------------------------------------------------------------------- + + let canvasScale = 1; + + const canvasSize = getCanvasSize(elements, cfg.padding); + const [contentX, contentY, contentWidth, contentHeight] = canvasSize; + let [x, y, width, height] = canvasSize; + + if (cfg.maxWidthOrHeight != null) { + canvasScale = cfg.maxWidthOrHeight / Math.max(contentWidth, contentHeight); + + width *= canvasScale; + height *= canvasScale; + } else if (cfg.width != null) { + width = cfg.width; + + if (cfg.height) { + height = cfg.height; + } else { + height *= width / contentWidth; + } + } else if (cfg.height != null) { + height = cfg.height; + width *= height / contentHeight; + } 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 oRatio = contentWidth / contentHeight; + const cRatio = width / height; + + if (oRatio > cRatio) { + canvasScale = width / contentWidth; + } else { + canvasScale = height / contentHeight; + } + } else if (cfg.fit === "cover") { + const wRatio = width / contentWidth; + const hRatio = height / contentHeight; + canvasScale = wRatio > hRatio ? wRatio : hRatio; + } + + if (cfg.origin === "content") { + if (cfg.x != null) { + cfg.x = cfg.x + contentX; + } + if (cfg.y != null) { + cfg.y = cfg.y + contentY; + } + } + + x = cfg.x ?? contentX; + y = cfg.y ?? contentY; + + if (cfg.position === "center") { + if (cfg.x == null) { + x -= width / canvasScale / 2 - contentWidth / 2; + } + if (cfg.y == null) { + y -= height / canvasScale / 2 - contentHeight / 2; + } + } + + const canvas = cfg.createCanvas + ? cfg.createCanvas() + : document.createElement("canvas"); + + 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 || {}, }); renderScene({ elements, - appState, - scale, + appState: { ...appState, width, height, offsetLeft: 0, offsetTop: 0 }, rc: rough.canvas(canvas), canvas, renderConfig: { - viewBackgroundColor: exportBackground ? viewBackgroundColor : null, - scrollX: -minX + exportPadding, - scrollY: -minY + exportPadding, - zoom: defaultAppState.zoom, + canvasBackgroundColor: + cfg.canvasBackgroundColor === false + ? // null indicates transparent background + null + : cfg.canvasBackgroundColor || + appState.viewBackgroundColor || + DEFAULT_BACKGROUND_COLOR, + scrollX: -x + cfg.padding, + scrollY: -y + 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, @@ -176,7 +412,7 @@ export const exportToSvg = async ( const getCanvasSize = ( elements: readonly NonDeletedExcalidrawElement[], exportPadding: number, -): [number, number, number, number] => { +): [minX: number, minY: number, width: number, height: number] => { const [minX, minY, maxX, maxY] = getCommonBounds(elements); const width = distance(minX, maxX) + exportPadding * 2; const height = distance(minY, maxY) + exportPadding + exportPadding; @@ -186,10 +422,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 a54b02b26..66669d8e9 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 824b5d15a..468cef8c1 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 }, },