This commit is contained in:
dwelle
2026-03-14 12:38:38 +01:00
parent c9ba7f839c
commit 1625edeb07
19 changed files with 1118 additions and 633 deletions
-216
View File
@@ -1,216 +0,0 @@
import { MIME_TYPES } from "@excalidraw/common";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "@excalidraw/excalidraw/clipboard";
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import {
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import {
exportToCanvas as _exportToCanvas,
exportToSvg as _exportToSvg,
} from "@excalidraw/excalidraw/scene/export";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
} from "@excalidraw/element/types";
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
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 restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, 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 restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, 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");
}
};
+14 -1
View File
@@ -1,4 +1,17 @@
export * from "./export";
// Re-export from @excalidraw/excalidraw for backwards compatibility
export {
exportToCanvas,
exportToBlob,
exportToSvg,
exportToClipboard,
MIME_TYPES,
} from "@excalidraw/excalidraw/scene/export";
export type {
ExportSceneData,
ExportSceneConfig,
} from "@excalidraw/excalidraw/scene/export";
export * from "./withinBounds";
export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element";
+58 -35
View File
@@ -10,9 +10,22 @@ const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
describe("exportToCanvas", async () => {
const EXPORT_PADDING = 10;
it("with default arguments", async () => {
it("with default arguments (no padding)", async () => {
const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
});
// New API has no default padding - call sites must explicitly set it
expect(canvas.width).toBe(100);
expect(canvas.height).toBe(100);
});
it("with padding", async () => {
const canvas = await utils.exportToCanvas({
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
config: {
padding: EXPORT_PADDING,
},
});
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
@@ -21,8 +34,10 @@ describe("exportToCanvas", async () => {
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 }),
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
config: {
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
},
});
expect(canvas.width).toBe(200);
@@ -33,20 +48,27 @@ describe("exportToCanvas", async () => {
describe("exportToBlob", async () => {
describe("mime type", () => {
it("should change image/jpg to image/jpeg", async () => {
const diagramData = diagramFactory();
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: {
elements: diagramData.elements,
appState: {
...diagramData.appState,
exportBackground: true,
},
files: diagramData.files,
},
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 +78,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 +92,9 @@ describe("exportToBlob", async () => {
});
describe("exportToSvg", () => {
const passedElements = () => exportToSvgSpy.mock.calls[0][0];
const passedOptions = () => exportToSvgSpy.mock.calls[0][1];
const getPassedArg = () => exportToSvgSpy.mock.calls[0][0];
const passedData = () => getPassedArg().data;
const passedConfig = () => getPassedArg().config;
afterEach(() => {
vi.clearAllMocks();
@@ -77,18 +102,14 @@ describe("exportToSvg", () => {
it("with default arguments", async () => {
await utils.exportToSvg({
...diagramFactory({
data: diagramFactory({
overrides: { appState: void 0 },
}),
});
const passedOptionsWhenDefault = {
...passedOptions(),
// To avoid varying snapshots
name: "name",
};
expect(passedElements().length).toBe(3);
expect(passedOptionsWhenDefault).toMatchSnapshot();
const data = passedData();
expect(data.elements.length).toBe(3);
expect(passedConfig()).toMatchSnapshot();
});
// FIXME the utils.exportToSvg no longer filters out deleted elements.
@@ -96,37 +117,39 @@ 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 },
}),
});
expect(passedElements().length).toBe(0);
expect(passedData().elements.length).toBe(0);
});
it("with exportPadding", async () => {
it("with padding", 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);
expect(passedOptions()).toEqual(
expect.objectContaining({ exportPadding: 0 }),
expect(passedData().elements.length).toBe(3);
expect(passedConfig()).toEqual(
expect.objectContaining({ padding: 0 }),
);
});
it("with exportEmbedScene", async () => {
await utils.exportToSvg({
...diagramFactory({
data: diagramFactory({
overrides: {
appState: { name: "diagram name", exportEmbedScene: true },
},
}),
});
expect(passedElements().length).toBe(3);
expect(passedOptions().exportEmbedScene).toBe(true);
expect(passedData().elements.length).toBe(3);
expect(passedData().appState?.exportEmbedScene).toBe(true);
});
});
+19 -13
View File
@@ -19,13 +19,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;
@@ -49,14 +51,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);