diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/utils/export.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/utils/export.ts')
| -rw-r--r-- | packages/utils/export.ts | 213 |
1 files changed, 213 insertions, 0 deletions
diff --git a/packages/utils/export.ts b/packages/utils/export.ts new file mode 100644 index 0000000..22287ce --- /dev/null +++ b/packages/utils/export.ts @@ -0,0 +1,213 @@ +import { + exportToCanvas as _exportToCanvas, + exportToSvg as _exportToSvg, +} from "@excalidraw/excalidraw/scene/export"; +import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; +import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types"; +import type { + ExcalidrawElement, + ExcalidrawFrameLikeElement, + NonDeleted, +} from "@excalidraw/excalidraw/element/types"; +import { restore } from "@excalidraw/excalidraw/data/restore"; +import { MIME_TYPES } from "@excalidraw/excalidraw/constants"; +import { encodePngMetadata } from "@excalidraw/excalidraw/data/image"; +import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; +import { + copyBlobToClipboardAsPng, + copyTextToSystemClipboard, + copyToClipboard, +} from "@excalidraw/excalidraw/clipboard"; + +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 }; +}; + +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; + + return { + canvas, + scale: ret.scale ?? 1, + }; + }, + ); +}; + +export const exportToBlob = async ( + opts: ExportOpts & { + mimeType?: string; + quality?: number; + exportPadding?: number; + }, +): Promise<Blob> => { + let { mimeType = MIME_TYPES.png, quality } = opts; + + if (mimeType === MIME_TYPES.png && typeof quality === "number") { + console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`); + } + + // typo in MIME type (should be "jpeg") + if (mimeType === "image/jpg") { + mimeType = MIME_TYPES.jpg; + } + + if (mimeType === MIME_TYPES.jpg && !opts.appState?.exportBackground) { + console.warn( + `Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`, + ); + opts = { + ...opts, + appState: { ...opts.appState, exportBackground: true }, + }; + } + + const canvas = await exportToCanvas(opts); + + quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8; + + return new Promise((resolve, reject) => { + canvas.toBlob( + async (blob) => { + if (!blob) { + return reject(new Error("couldn't export to blob")); + } + if ( + blob && + mimeType === MIME_TYPES.png && + opts.appState?.exportEmbedScene + ) { + blob = await encodePngMetadata({ + blob, + metadata: serializeAsJSON( + // 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 || {}, + "local", + ), + }); + } + resolve(blob); + }, + mimeType, + quality, + ); + }); +}; + +export const exportToSvg = async ({ + elements, + appState = getDefaultAppState(), + files = {}, + exportPadding, + renderEmbeddables, + exportingFrame, + skipInliningFonts, + reuseImages, +}: Omit<ExportOpts, "getDimensions"> & { + exportPadding?: number; + renderEmbeddables?: boolean; + skipInliningFonts?: true; + reuseImages?: boolean; +}): Promise<SVGSVGElement> => { + const { elements: restoredElements, appState: restoredAppState } = restore( + { elements, appState }, + null, + null, + ); + + const exportAppState = { + ...restoredAppState, + exportPadding, + }; + + return _exportToSvg(restoredElements, exportAppState, files, { + exportingFrame, + renderEmbeddables, + skipInliningFonts, + reuseImages, + }); +}; + +export const exportToClipboard = async ( + opts: ExportOpts & { + mimeType?: string; + quality?: number; + type: "png" | "svg" | "json"; + }, +) => { + if (opts.type === "svg") { + const svg = await exportToSvg(opts); + 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 { + throw new Error("Invalid export type"); + } +}; |
