|
@@ -1,24 +1,144 @@
|
|
|
import rough from "roughjs/bin/rough";
|
|
|
-import { NonDeletedExcalidrawElement } from "../element/types";
|
|
|
+import {
|
|
|
+ ExcalidrawElement,
|
|
|
+ ExcalidrawFrameElement,
|
|
|
+ ExcalidrawTextElement,
|
|
|
+ NonDeletedExcalidrawElement,
|
|
|
+} from "../element/types";
|
|
|
import {
|
|
|
Bounds,
|
|
|
getCommonBounds,
|
|
|
getElementAbsoluteCoords,
|
|
|
} from "../element/bounds";
|
|
|
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
|
|
|
-import { distance, isOnlyExportingSingleFrame } from "../utils";
|
|
|
+import { distance, getFontString } from "../utils";
|
|
|
import { AppState, BinaryFiles } from "../types";
|
|
|
-import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
|
|
|
+import {
|
|
|
+ DEFAULT_EXPORT_PADDING,
|
|
|
+ FRAME_STYLE,
|
|
|
+ SVG_NS,
|
|
|
+ THEME_FILTER,
|
|
|
+} from "../constants";
|
|
|
import { getDefaultAppState } from "../appState";
|
|
|
import { serializeAsJSON } from "../data/json";
|
|
|
import {
|
|
|
getInitializedImageElements,
|
|
|
updateImageCache,
|
|
|
} from "../element/image";
|
|
|
+import { elementsOverlappingBBox } from "../packages/withinBounds";
|
|
|
+import { getFrameElements, getRootElements } from "../frame";
|
|
|
+import { isFrameElement, newTextElement } from "../element";
|
|
|
+import { Mutable } from "../utility-types";
|
|
|
+import { newElementWith } from "../element/mutateElement";
|
|
|
import Scene from "./Scene";
|
|
|
|
|
|
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
|
|
|
|
|
+// getContainerElement and getBoundTextElement and potentially other helpers
|
|
|
+// depend on `Scene` which will not be available when these pure utils are
|
|
|
+// called outside initialized Excalidraw editor instance or even if called
|
|
|
+// from inside Excalidraw if the elements were never cached by Scene (e.g.
|
|
|
+// for library elements).
|
|
|
+//
|
|
|
+// As such, before passing the elements down, we need to initialize a custom
|
|
|
+// Scene instance and assign them to it.
|
|
|
+//
|
|
|
+// FIXME This is a super hacky workaround and we'll need to rewrite this soon.
|
|
|
+const __createSceneForElementsHack__ = (
|
|
|
+ elements: readonly ExcalidrawElement[],
|
|
|
+) => {
|
|
|
+ const scene = new Scene();
|
|
|
+ // we can't duplicate elements to regenerate ids because we need the
|
|
|
+ // orig ids when embedding. So we do another hack of not mapping element
|
|
|
+ // ids to Scene instances so that we don't override the editor elements
|
|
|
+ // mapping
|
|
|
+ scene.replaceAllElements(elements, false);
|
|
|
+ return scene;
|
|
|
+};
|
|
|
+
|
|
|
+const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
|
|
+ if (element.width <= maxWidth) {
|
|
|
+ return element;
|
|
|
+ }
|
|
|
+ const canvas = document.createElement("canvas");
|
|
|
+ const ctx = canvas.getContext("2d")!;
|
|
|
+ ctx.font = getFontString({
|
|
|
+ fontFamily: element.fontFamily,
|
|
|
+ fontSize: element.fontSize,
|
|
|
+ });
|
|
|
+
|
|
|
+ let text = element.text;
|
|
|
+
|
|
|
+ const metrics = ctx.measureText(text);
|
|
|
+
|
|
|
+ if (metrics.width > maxWidth) {
|
|
|
+ // we iterate from the right, removing characters one by one instead
|
|
|
+ // of bulding the string up. This assumes that it's more likely
|
|
|
+ // your frame names will overflow by not that many characters
|
|
|
+ // (if ever), so it sohuld be faster this way.
|
|
|
+ for (let i = text.length; i > 0; i--) {
|
|
|
+ const newText = `${text.slice(0, i)}...`;
|
|
|
+ if (ctx.measureText(newText).width <= maxWidth) {
|
|
|
+ text = newText;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return newElementWith(element, { text, width: maxWidth });
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * When exporting frames, we need to render frame labels which are currently
|
|
|
+ * being rendered in DOM when editing. Adding the labels as regular text
|
|
|
+ * elements seems like a simple hack. In the future we'll want to move to
|
|
|
+ * proper canvas rendering, even within editor (instead of DOM).
|
|
|
+ */
|
|
|
+const addFrameLabelsAsTextElements = (
|
|
|
+ elements: readonly NonDeletedExcalidrawElement[],
|
|
|
+ opts: Pick<AppState, "exportWithDarkMode">,
|
|
|
+) => {
|
|
|
+ const nextElements: NonDeletedExcalidrawElement[] = [];
|
|
|
+ let frameIdx = 0;
|
|
|
+ for (const element of elements) {
|
|
|
+ if (isFrameElement(element)) {
|
|
|
+ frameIdx++;
|
|
|
+ let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
|
|
|
+ x: element.x,
|
|
|
+ y: element.y - FRAME_STYLE.nameOffsetY,
|
|
|
+ fontFamily: 4,
|
|
|
+ fontSize: FRAME_STYLE.nameFontSize,
|
|
|
+ lineHeight:
|
|
|
+ FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"],
|
|
|
+ strokeColor: opts.exportWithDarkMode
|
|
|
+ ? FRAME_STYLE.nameColorDarkTheme
|
|
|
+ : FRAME_STYLE.nameColorLightTheme,
|
|
|
+ text: element.name || `Frame ${frameIdx}`,
|
|
|
+ });
|
|
|
+ textElement.y -= textElement.height;
|
|
|
+
|
|
|
+ textElement = truncateText(textElement, element.width);
|
|
|
+
|
|
|
+ nextElements.push(textElement);
|
|
|
+ }
|
|
|
+ nextElements.push(element);
|
|
|
+ }
|
|
|
+
|
|
|
+ return nextElements;
|
|
|
+};
|
|
|
+
|
|
|
+const getFrameRenderingConfig = (
|
|
|
+ exportingFrame: ExcalidrawFrameElement | null,
|
|
|
+ frameRendering: AppState["frameRendering"] | null,
|
|
|
+): AppState["frameRendering"] => {
|
|
|
+ frameRendering = frameRendering || getDefaultAppState().frameRendering;
|
|
|
+ return {
|
|
|
+ enabled: exportingFrame ? true : frameRendering.enabled,
|
|
|
+ outline: exportingFrame ? false : frameRendering.outline,
|
|
|
+ name: exportingFrame ? false : frameRendering.name,
|
|
|
+ clip: exportingFrame ? true : frameRendering.clip,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
export const exportToCanvas = async (
|
|
|
elements: readonly NonDeletedExcalidrawElement[],
|
|
|
appState: AppState,
|
|
@@ -27,10 +147,12 @@ export const exportToCanvas = async (
|
|
|
exportBackground,
|
|
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
|
|
viewBackgroundColor,
|
|
|
+ exportingFrame,
|
|
|
}: {
|
|
|
exportBackground: boolean;
|
|
|
exportPadding?: number;
|
|
|
viewBackgroundColor: string;
|
|
|
+ exportingFrame?: ExcalidrawFrameElement | null;
|
|
|
},
|
|
|
createCanvas: (
|
|
|
width: number,
|
|
@@ -42,7 +164,26 @@ export const exportToCanvas = async (
|
|
|
return { canvas, scale: appState.exportScale };
|
|
|
},
|
|
|
) => {
|
|
|
- const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
|
|
|
+ const tempScene = __createSceneForElementsHack__(elements);
|
|
|
+ elements = tempScene.getNonDeletedElements();
|
|
|
+
|
|
|
+ let nextElements: ExcalidrawElement[];
|
|
|
+
|
|
|
+ if (exportingFrame) {
|
|
|
+ exportPadding = 0;
|
|
|
+ nextElements = elementsOverlappingBBox({
|
|
|
+ elements,
|
|
|
+ bounds: exportingFrame,
|
|
|
+ type: "overlap",
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ nextElements = addFrameLabelsAsTextElements(elements, appState);
|
|
|
+ }
|
|
|
+
|
|
|
+ const [minX, minY, width, height] = getCanvasSize(
|
|
|
+ exportingFrame ? [exportingFrame] : getRootElements(nextElements),
|
|
|
+ exportPadding,
|
|
|
+ );
|
|
|
|
|
|
const { canvas, scale = 1 } = createCanvas(width, height);
|
|
|
|
|
@@ -50,25 +191,27 @@ export const exportToCanvas = async (
|
|
|
|
|
|
const { imageCache } = await updateImageCache({
|
|
|
imageCache: new Map(),
|
|
|
- fileIds: getInitializedImageElements(elements).map(
|
|
|
+ fileIds: getInitializedImageElements(nextElements).map(
|
|
|
(element) => element.fileId,
|
|
|
),
|
|
|
files,
|
|
|
});
|
|
|
|
|
|
- const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
|
|
-
|
|
|
renderStaticScene({
|
|
|
canvas,
|
|
|
rc: rough.canvas(canvas),
|
|
|
- elements,
|
|
|
- visibleElements: elements,
|
|
|
+ elements: nextElements,
|
|
|
+ visibleElements: nextElements,
|
|
|
scale,
|
|
|
appState: {
|
|
|
...appState,
|
|
|
+ frameRendering: getFrameRenderingConfig(
|
|
|
+ exportingFrame ?? null,
|
|
|
+ appState.frameRendering ?? null,
|
|
|
+ ),
|
|
|
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
|
|
- scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
|
|
|
- scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
|
|
|
+ scrollX: -minX + exportPadding,
|
|
|
+ scrollY: -minY + exportPadding,
|
|
|
zoom: defaultAppState.zoom,
|
|
|
shouldCacheIgnoreZoom: false,
|
|
|
theme: appState.exportWithDarkMode ? "dark" : "light",
|
|
@@ -80,6 +223,8 @@ export const exportToCanvas = async (
|
|
|
},
|
|
|
});
|
|
|
|
|
|
+ tempScene.destroy();
|
|
|
+
|
|
|
return canvas;
|
|
|
};
|
|
|
|
|
@@ -92,35 +237,65 @@ export const exportToSvg = async (
|
|
|
viewBackgroundColor: string;
|
|
|
exportWithDarkMode?: boolean;
|
|
|
exportEmbedScene?: boolean;
|
|
|
- renderFrame?: boolean;
|
|
|
+ frameRendering?: AppState["frameRendering"];
|
|
|
},
|
|
|
files: BinaryFiles | null,
|
|
|
opts?: {
|
|
|
- serializeAsJSON?: () => string;
|
|
|
renderEmbeddables?: boolean;
|
|
|
+ exportingFrame?: ExcalidrawFrameElement | null;
|
|
|
},
|
|
|
): Promise<SVGSVGElement> => {
|
|
|
- const {
|
|
|
+ const tempScene = __createSceneForElementsHack__(elements);
|
|
|
+ elements = tempScene.getNonDeletedElements();
|
|
|
+
|
|
|
+ let {
|
|
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
|
|
viewBackgroundColor,
|
|
|
exportScale = 1,
|
|
|
exportEmbedScene,
|
|
|
} = appState;
|
|
|
+
|
|
|
+ const { exportingFrame = null } = opts || {};
|
|
|
+
|
|
|
+ let nextElements: ExcalidrawElement[] = [];
|
|
|
+
|
|
|
+ if (exportingFrame) {
|
|
|
+ exportPadding = 0;
|
|
|
+ nextElements = elementsOverlappingBBox({
|
|
|
+ elements,
|
|
|
+ bounds: exportingFrame,
|
|
|
+ type: "overlap",
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ nextElements = addFrameLabelsAsTextElements(elements, {
|
|
|
+ exportWithDarkMode: appState.exportWithDarkMode ?? false,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
let metadata = "";
|
|
|
+
|
|
|
+ // we need to serialize the "original" elements before we put them through
|
|
|
+ // the tempScene hack which duplicates and regenerates ids
|
|
|
if (exportEmbedScene) {
|
|
|
try {
|
|
|
metadata = await (
|
|
|
await import(/* webpackChunkName: "image" */ "../../src/data/image")
|
|
|
).encodeSvgMetadata({
|
|
|
- text: opts?.serializeAsJSON
|
|
|
- ? opts?.serializeAsJSON?.()
|
|
|
- : serializeAsJSON(elements, appState, files || {}, "local"),
|
|
|
+ // 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, appState, files || {}, "local"),
|
|
|
});
|
|
|
} catch (error: any) {
|
|
|
console.error(error);
|
|
|
}
|
|
|
}
|
|
|
- const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
|
|
|
+
|
|
|
+ const [minX, minY, width, height] = getCanvasSize(
|
|
|
+ exportingFrame ? [exportingFrame] : getRootElements(nextElements),
|
|
|
+ exportPadding,
|
|
|
+ );
|
|
|
|
|
|
// initialize SVG root
|
|
|
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
|
@@ -148,33 +323,23 @@ export const exportToSvg = async (
|
|
|
assetPath = `${assetPath}/dist/excalidraw-assets/`;
|
|
|
}
|
|
|
|
|
|
- // do not apply clipping when we're exporting the whole scene
|
|
|
- const isExportingWholeCanvas =
|
|
|
- Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
|
|
|
- elements.length;
|
|
|
+ const offsetX = -minX + exportPadding;
|
|
|
+ const offsetY = -minY + exportPadding;
|
|
|
|
|
|
- const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
|
|
-
|
|
|
- const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
|
|
|
- const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
|
|
|
-
|
|
|
- const exportingFrame =
|
|
|
- isExportingWholeCanvas || !onlyExportingSingleFrame
|
|
|
- ? undefined
|
|
|
- : elements.find((element) => element.type === "frame");
|
|
|
+ const frameElements = getFrameElements(elements);
|
|
|
|
|
|
let exportingFrameClipPath = "";
|
|
|
- if (exportingFrame) {
|
|
|
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame);
|
|
|
- const cx = (x2 - x1) / 2 - (exportingFrame.x - x1);
|
|
|
- const cy = (y2 - y1) / 2 - (exportingFrame.y - y1);
|
|
|
-
|
|
|
- exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
|
|
|
- <rect transform="translate(${exportingFrame.x + offsetX} ${
|
|
|
- exportingFrame.y + offsetY
|
|
|
- }) rotate(${exportingFrame.angle} ${cx} ${cy})"
|
|
|
- width="${exportingFrame.width}"
|
|
|
- height="${exportingFrame.height}"
|
|
|
+ for (const frame of frameElements) {
|
|
|
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
|
|
|
+ const cx = (x2 - x1) / 2 - (frame.x - x1);
|
|
|
+ const cy = (y2 - y1) / 2 - (frame.y - y1);
|
|
|
+
|
|
|
+ exportingFrameClipPath += `<clipPath id=${frame.id}>
|
|
|
+ <rect transform="translate(${frame.x + offsetX} ${
|
|
|
+ frame.y + offsetY
|
|
|
+ }) rotate(${frame.angle} ${cx} ${cy})"
|
|
|
+ width="${frame.width}"
|
|
|
+ height="${frame.height}"
|
|
|
>
|
|
|
</rect>
|
|
|
</clipPath>`;
|
|
@@ -193,6 +358,10 @@ export const exportToSvg = async (
|
|
|
font-family: "Cascadia";
|
|
|
src: url("${assetPath}Cascadia.woff2");
|
|
|
}
|
|
|
+ @font-face {
|
|
|
+ font-family: "Assistant";
|
|
|
+ src: url("${assetPath}Assistant-Regular.woff2");
|
|
|
+ }
|
|
|
</style>
|
|
|
${exportingFrameClipPath}
|
|
|
</defs>
|
|
@@ -210,14 +379,19 @@ export const exportToSvg = async (
|
|
|
}
|
|
|
|
|
|
const rsvg = rough.svg(svgRoot);
|
|
|
- renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
|
|
+ renderSceneToSvg(nextElements, rsvg, svgRoot, files || {}, {
|
|
|
offsetX,
|
|
|
offsetY,
|
|
|
- exportWithDarkMode: appState.exportWithDarkMode,
|
|
|
- exportingFrameId: exportingFrame?.id || null,
|
|
|
- renderEmbeddables: opts?.renderEmbeddables,
|
|
|
+ exportWithDarkMode: appState.exportWithDarkMode ?? false,
|
|
|
+ renderEmbeddables: opts?.renderEmbeddables ?? false,
|
|
|
+ frameRendering: getFrameRenderingConfig(
|
|
|
+ exportingFrame ?? null,
|
|
|
+ appState.frameRendering ?? null,
|
|
|
+ ),
|
|
|
});
|
|
|
|
|
|
+ tempScene.destroy();
|
|
|
+
|
|
|
return svgRoot;
|
|
|
};
|
|
|
|
|
@@ -226,36 +400,9 @@ const getCanvasSize = (
|
|
|
elements: readonly NonDeletedExcalidrawElement[],
|
|
|
exportPadding: number,
|
|
|
): Bounds => {
|
|
|
- // we should decide if we are exporting the whole canvas
|
|
|
- // if so, we are not clipping elements in the frame
|
|
|
- // and therefore, we should not do anything special
|
|
|
-
|
|
|
- const isExportingWholeCanvas =
|
|
|
- Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
|
|
|
- elements.length;
|
|
|
-
|
|
|
- const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
|
|
-
|
|
|
- if (!isExportingWholeCanvas || onlyExportingSingleFrame) {
|
|
|
- const frames = elements.filter((element) => element.type === "frame");
|
|
|
-
|
|
|
- const exportedFrameIds = frames.reduce((acc, frame) => {
|
|
|
- acc[frame.id] = true;
|
|
|
- return acc;
|
|
|
- }, {} as Record<string, true>);
|
|
|
-
|
|
|
- // elements in a frame do not affect the canvas size if we're not exporting
|
|
|
- // the whole canvas
|
|
|
- elements = elements.filter(
|
|
|
- (element) => !exportedFrameIds[element.frameId ?? ""],
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
|
|
- const width =
|
|
|
- distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
|
|
- const height =
|
|
|
- distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
|
|
+ const width = distance(minX, maxX) + exportPadding * 2;
|
|
|
+ const height = distance(minY, maxY) + exportPadding * 2;
|
|
|
|
|
|
return [minX, minY, width, height];
|
|
|
};
|