align svg exports with canvas exports

This commit is contained in:
Ryan Di 2024-12-06 19:00:30 +08:00
parent cd8bfb06b5
commit e49db1dd3c
8 changed files with 96 additions and 178 deletions

View File

@ -50,9 +50,6 @@ const ChartPreviewBtn = (props: {
},
files: null,
},
config: {
skipInliningFonts: true,
},
});
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();

View File

@ -140,9 +140,6 @@ const SingleLibraryItem = ({
},
files: null,
},
config: {
skipInliningFonts: true,
},
});
node.innerHTML = svg.outerHTML;
})();

View File

@ -19,10 +19,6 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
},
files: null,
},
config: {
renderEmbeddables: false,
skipInliningFonts: true,
},
});
};

View File

@ -29,14 +29,14 @@ import {
getInitializedImageElements,
updateImageCache,
} from "../element/image";
import { restore, restoreAppState } from "../data/restore";
import { restoreAppState } from "../data/restore";
import {
getElementsOverlappingFrame,
getFrameLikeElements,
getFrameLikeTitle,
getRootElements,
} from "../frame";
import { getNonDeletedElements, newTextElement } from "../element";
import { newTextElement } from "../element";
import { type Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks";
@ -164,13 +164,13 @@ type ExportToCanvasAppState = Partial<
Omit<AppState, "offsetTop" | "offsetLeft">
>;
export type ExportToCanvasData = {
export type ExportSceneData = {
elements: readonly NonDeletedExcalidrawElement[];
appState?: ExportToCanvasAppState;
files: BinaryFiles | null;
};
export type ExportToCanvasConfig = {
export type ExportSceneConfig = {
theme?: Theme;
/**
* Canvas background. Valid values are:
@ -339,8 +339,8 @@ const configExportDimension = async ({
data,
config,
}: {
data: ExportToCanvasData;
config?: ExportToCanvasConfig;
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
// clone
const cfg = Object.assign({}, config);
@ -626,8 +626,8 @@ export const exportToCanvas = async ({
data,
config,
}: {
data: ExportToCanvasData;
config?: ExportToCanvasConfig;
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
const {
config: cfg,
@ -706,7 +706,7 @@ export const exportToCanvas = async ({
};
type ExportToSvgConfig = Pick<
ExportToCanvasConfig,
ExportSceneConfig,
"canvasBackgroundColor" | "padding" | "theme" | "exportingFrame"
> & {
/**
@ -721,108 +721,75 @@ export const exportToSvg = async ({
data,
config,
}: {
data: {
elements: readonly NonDeletedExcalidrawElement[];
appState: {
exportBackground: boolean;
exportScale?: number;
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
frameRendering?: AppState["frameRendering"];
gridModeEnabled?: boolean;
};
files: BinaryFiles | null;
};
config?: ExportToSvgConfig;
}): Promise<SVGSVGElement> => {
// clone
const cfg = Object.assign({}, config);
cfg.exportingFrame = cfg.exportingFrame ?? null;
const { elements: restoredElements } = restore(
{ ...data, files: data.files || {} },
null,
null,
);
const elements = getNonDeletedElements(restoredElements);
const frameRendering = getFrameRenderingConfig(
cfg?.exportingFrame ?? null,
data.appState.frameRendering ?? null,
);
let {
exportWithDarkMode = false,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = data.appState;
let padding = cfg.padding ?? 0;
const elementsForRender = prepareElementsForRender({
elements,
exportingFrame: cfg.exportingFrame,
exportWithDarkMode,
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
const {
config: cfg,
normalizedPadding,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
});
} = await configExportDimension({ data, config });
if (cfg.exportingFrame) {
padding = 0;
const offsetX = -(x - normalizedPadding);
const offsetY = -(y - normalizedPadding);
const { elements } = data;
// initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg");
svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute(
"viewBox",
`0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`,
);
svgRoot.setAttribute("width", `${exportWidth}`);
svgRoot.setAttribute("height", `${exportHeight}`);
if (cfg.theme === THEME.DARK) {
svgRoot.setAttribute("filter", THEME_FILTER);
}
const fontFaces = cfg.loadFonts
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces
let metadata = "";
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) {
if (appState.exportEmbedScene) {
try {
metadata = (await import("../data/image")).encodeSvgMetadata({
// when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
text: serializeAsJSON(
elements,
data.appState,
data.files || {},
"local",
),
text: serializeAsJSON(elements, appState, data.files || {}, "local"),
});
} catch (error: any) {
console.error(error);
}
}
let [minX, minY, width, height] = getCanvasSize(
cfg.exportingFrame
? [cfg.exportingFrame]
: getRootElements(elementsForRender),
);
width += padding * 2;
height += padding * 2;
// initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg");
svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
svgRoot.setAttribute("width", `${width * exportScale}`);
svgRoot.setAttribute("height", `${height * exportScale}`);
if (exportWithDarkMode) {
svgRoot.setAttribute("filter", THEME_FILTER);
let exportContentClipPath = "";
if (cfg.width != null && cfg.height != null) {
exportContentClipPath = `<clipPath id="content">
<rect x="${offsetX}" y="${offsetY}" width="${exportWidth}" height="${exportWidth}"></rect>
</clipPath>`;
}
const offsetX = -minX + padding;
const offsetY = -minY + padding;
const frameElements = getFrameLikeElements(elements);
let exportingFrameClipPath = "";
const elementsMap = arrayToMap(elements);
const frameElements = getFrameLikeElements(elements);
for (const frame of frameElements) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
const cx = (x2 - x1) / 2 - (frame.x - x1);
@ -844,36 +811,33 @@ export const exportToSvg = async ({
</clipPath>`;
}
const fontFaces = !cfg?.skipInliningFonts
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
<defs>
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
</style>
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}</style>
${exportContentClipPath}
${exportingFrameClipPath}
</defs>
`;
// render background rect
if (data.appState.exportBackground && viewBackgroundColor) {
if (appState.exportBackground && appState.viewBackgroundColor) {
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0");
rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`);
rect.setAttribute("height", `${height}`);
rect.setAttribute("fill", viewBackgroundColor);
rect.setAttribute("width", `${exportWidth / exportScale}`);
rect.setAttribute("height", `${exportHeight / exportScale}`);
rect.setAttribute(
"fill",
cfg.canvasBackgroundColor || appState.viewBackgroundColor,
);
svgRoot.appendChild(rect);
}
const rsvg = rough.svg(svgRoot);
const renderEmbeddables = cfg.renderEmbeddables ?? false;
// const renderEmbeddables = appState.embe ?? false;
renderSceneToSvg(
elementsForRender,
@ -885,18 +849,18 @@ export const exportToSvg = async ({
offsetX,
offsetY,
isExporting: true,
exportWithDarkMode,
renderEmbeddables,
exportWithDarkMode: cfg.theme === THEME.DARK,
renderEmbeddables: false,
frameRendering,
canvasBackgroundColor: viewBackgroundColor,
embedsValidationStatus: renderEmbeddables
canvasBackgroundColor: appState.viewBackgroundColor,
embedsValidationStatus: false
? new Map(
elementsForRender
.filter((element) => isFrameLikeElement(element))
.map((element) => [element.id, true]),
)
: new Map(),
reuseImages: cfg?.reuseImages ?? true,
reuseImages: true,
},
);
@ -916,7 +880,7 @@ export const getCanvasSize = (
export { MIME_TYPES };
type ExportToBlobConfig = ExportToCanvasConfig & {
type ExportToBlobConfig = ExportSceneConfig & {
mimeType?: string;
quality?: number;
};
@ -925,7 +889,7 @@ export const exportToBlob = async ({
data,
config,
}: {
data: ExportToCanvasData;
data: ExportSceneData;
config?: ExportToBlobConfig;
}): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = config || {};
@ -990,7 +954,7 @@ export const exportToClipboard = async ({
data,
config,
}: {
data: ExportToCanvasData;
data: ExportSceneData;
} & (
| { type: "png"; config?: ExportToBlobConfig }
| { type: "svg"; config?: ExportToSvgConfig }

View File

@ -6,7 +6,7 @@ exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active too
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="9" height="0" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="300" height="0" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `

View File

@ -6,8 +6,8 @@ exports[`export > exporting svg containing transformed images > svg export outpu
<defs>
<style class="style-fonts">
</style>
</style>
</defs>
<clipPath id="image-clipPath-id1" data-id="id1"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(20.710678118654755 20.710678118654755) rotate(315 50 50)" clip-path="url(#image-clipPath-id1)" data-id="id1"><use href="#image-file_A" width="100" height="100" opacity="1"></use></g><clipPath id="image-clipPath-id2" data-id="id2"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(120.71067811865476 20.710678118654755) rotate(45 25 25)" clip-path="url(#image-clipPath-id2)" data-id="id2"><use href="#image-file_A" width="50" height="50" opacity="1" transform="translate(25 25) scale(-1 1) translate(-25 -25)"></use></g><clipPath id="image-clipPath-id3" data-id="id3"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(20.710678118654755 120.71067811865476) rotate(45 50 50)" clip-path="url(#image-clipPath-id3)" data-id="id3"><use href="#image-file_A" width="100" height="100" opacity="1" transform="translate(50 50) scale(1 -1) translate(-50 -50)"></use></g><clipPath id="image-clipPath-id4" data-id="id4"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(120.71067811865476 120.71067811865476) rotate(315 25 25)" clip-path="url(#image-clipPath-id4)" data-id="id4"><use href="#image-file_A" width="50" height="50" opacity="1" transform="translate(25 25) scale(-1 -1) translate(-25 -25)"></use></g></svg>"

File diff suppressed because one or more lines are too long

View File

@ -117,9 +117,7 @@ describe("exportToSvg", () => {
},
});
expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
`"_themeFilter_1883f3"`,
);
expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(`null`);
});
it("with exportPadding", async () => {
@ -149,10 +147,12 @@ describe("exportToSvg", () => {
elements: ELEMENTS,
appState: {
...DEFAULT_OPTIONS,
exportScale: SCALE,
},
files: null,
},
config: {
scale: SCALE,
},
});
expect(svgElement).toHaveAttribute(