summaryrefslogtreecommitdiffstats
path: root/packages/utils/export.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/utils/export.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/utils/export.ts')
-rw-r--r--packages/utils/export.ts213
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");
+ }
+};