mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: render frames on export (#7210)
This commit is contained in:
parent
a9a6f8eafb
commit
864c0b3ea8
30 changed files with 989 additions and 332 deletions
|
@ -20,7 +20,13 @@ import type { Drawable } from "roughjs/bin/core";
|
|||
import type { RoughSVG } from "roughjs/bin/svg";
|
||||
|
||||
import { StaticCanvasRenderConfig } from "../scene/types";
|
||||
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
||||
import {
|
||||
distance,
|
||||
getFontString,
|
||||
getFontFamilyString,
|
||||
isRTL,
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import {
|
||||
|
@ -589,11 +595,7 @@ export const renderElement = (
|
|||
) => {
|
||||
switch (element.type) {
|
||||
case "frame": {
|
||||
if (
|
||||
!renderConfig.isExporting &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.outline
|
||||
) {
|
||||
if (appState.frameRendering.enabled && appState.frameRendering.outline) {
|
||||
context.save();
|
||||
context.translate(
|
||||
element.x + appState.scrollX,
|
||||
|
@ -601,7 +603,7 @@ export const renderElement = (
|
|||
);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
context.lineWidth = 2 / appState.zoom.value;
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||
|
||||
if (FRAME_STYLE.radius && context.roundRect) {
|
||||
|
@ -841,10 +843,13 @@ const maybeWrapNodesInFrameClipPath = (
|
|||
element: NonDeletedExcalidrawElement,
|
||||
root: SVGElement,
|
||||
nodes: SVGElement[],
|
||||
exportedFrameId?: string | null,
|
||||
frameRendering: AppState["frameRendering"],
|
||||
) => {
|
||||
if (!frameRendering.enabled || !frameRendering.clip) {
|
||||
return null;
|
||||
}
|
||||
const frame = getContainingFrame(element);
|
||||
if (frame && frame.id === exportedFrameId) {
|
||||
if (frame) {
|
||||
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
|
||||
nodes.forEach((node) => g.appendChild(node));
|
||||
|
@ -861,9 +866,11 @@ export const renderElementToSvg = (
|
|||
files: BinaryFiles,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
exportWithDarkMode?: boolean,
|
||||
exportingFrameId?: string | null,
|
||||
renderEmbeddables?: boolean,
|
||||
renderConfig: {
|
||||
exportWithDarkMode: boolean;
|
||||
renderEmbeddables: boolean;
|
||||
frameRendering: AppState["frameRendering"];
|
||||
},
|
||||
) => {
|
||||
const offset = { x: offsetX, y: offsetY };
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
|
@ -897,6 +904,13 @@ export const renderElementToSvg = (
|
|||
root = anchorTag;
|
||||
}
|
||||
|
||||
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
|
||||
if (isTestEnv()) {
|
||||
node.setAttribute("data-id", element.id);
|
||||
}
|
||||
root.appendChild(node);
|
||||
};
|
||||
|
||||
const opacity =
|
||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||
|
||||
|
@ -931,10 +945,10 @@ export const renderElementToSvg = (
|
|||
element,
|
||||
root,
|
||||
[node],
|
||||
exportingFrameId,
|
||||
renderConfig.frameRendering,
|
||||
);
|
||||
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
addToRoot(g || node, element);
|
||||
break;
|
||||
}
|
||||
case "embeddable": {
|
||||
|
@ -957,7 +971,7 @@ export const renderElementToSvg = (
|
|||
offsetY || 0
|
||||
}) rotate(${degree} ${cx} ${cy})`,
|
||||
);
|
||||
root.appendChild(node);
|
||||
addToRoot(node, element);
|
||||
|
||||
const label: ExcalidrawElement =
|
||||
createPlaceholderEmbeddableLabel(element);
|
||||
|
@ -968,9 +982,7 @@ export const renderElementToSvg = (
|
|||
files,
|
||||
label.x + offset.x - element.x,
|
||||
label.y + offset.y - element.y,
|
||||
exportWithDarkMode,
|
||||
exportingFrameId,
|
||||
renderEmbeddables,
|
||||
renderConfig,
|
||||
);
|
||||
|
||||
// render embeddable element + iframe
|
||||
|
@ -999,7 +1011,10 @@ export const renderElementToSvg = (
|
|||
// if rendering embeddables explicitly disabled or
|
||||
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
|
||||
// replace with a link instead
|
||||
if (renderEmbeddables === false || embedLink?.type === "document") {
|
||||
if (
|
||||
renderConfig.renderEmbeddables === false ||
|
||||
embedLink?.type === "document"
|
||||
) {
|
||||
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
|
||||
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
|
||||
anchorTag.setAttribute("target", "_blank");
|
||||
|
@ -1033,8 +1048,7 @@ export const renderElementToSvg = (
|
|||
|
||||
embeddableNode.appendChild(foreignObject);
|
||||
}
|
||||
|
||||
root.appendChild(embeddableNode);
|
||||
addToRoot(embeddableNode, element);
|
||||
break;
|
||||
}
|
||||
case "line":
|
||||
|
@ -1119,12 +1133,13 @@ export const renderElementToSvg = (
|
|||
element,
|
||||
root,
|
||||
[group, maskPath],
|
||||
exportingFrameId,
|
||||
renderConfig.frameRendering,
|
||||
);
|
||||
if (g) {
|
||||
addToRoot(g, element);
|
||||
root.appendChild(g);
|
||||
} else {
|
||||
root.appendChild(group);
|
||||
addToRoot(group, element);
|
||||
root.append(maskPath);
|
||||
}
|
||||
break;
|
||||
|
@ -1158,10 +1173,10 @@ export const renderElementToSvg = (
|
|||
element,
|
||||
root,
|
||||
[node],
|
||||
exportingFrameId,
|
||||
renderConfig.frameRendering,
|
||||
);
|
||||
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
addToRoot(g || node, element);
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
|
@ -1191,7 +1206,10 @@ export const renderElementToSvg = (
|
|||
use.setAttribute("href", `#${symbolId}`);
|
||||
|
||||
// in dark theme, revert the image color filter
|
||||
if (exportWithDarkMode && fileData.mimeType !== MIME_TYPES.svg) {
|
||||
if (
|
||||
renderConfig.exportWithDarkMode &&
|
||||
fileData.mimeType !== MIME_TYPES.svg
|
||||
) {
|
||||
use.setAttribute("filter", IMAGE_INVERT_FILTER);
|
||||
}
|
||||
|
||||
|
@ -1227,14 +1245,39 @@ export const renderElementToSvg = (
|
|||
element,
|
||||
root,
|
||||
[g],
|
||||
exportingFrameId,
|
||||
renderConfig.frameRendering,
|
||||
);
|
||||
clipG ? root.appendChild(clipG) : root.appendChild(g);
|
||||
addToRoot(clipG || g, element);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// frames are not rendered and only acts as a container
|
||||
case "frame": {
|
||||
if (
|
||||
renderConfig.frameRendering.enabled &&
|
||||
renderConfig.frameRendering.outline
|
||||
) {
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
|
||||
rect.setAttribute(
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${
|
||||
offsetY || 0
|
||||
}) rotate(${degree} ${cx} ${cy})`,
|
||||
);
|
||||
|
||||
rect.setAttribute("width", `${element.width}px`);
|
||||
rect.setAttribute("height", `${element.height}px`);
|
||||
// Rounded corners
|
||||
rect.setAttribute("rx", FRAME_STYLE.radius.toString());
|
||||
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
|
||||
|
||||
rect.setAttribute("fill", "none");
|
||||
rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
|
||||
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
|
||||
|
||||
addToRoot(rect, element);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
@ -1288,10 +1331,10 @@ export const renderElementToSvg = (
|
|||
element,
|
||||
root,
|
||||
[node],
|
||||
exportingFrameId,
|
||||
renderConfig.frameRendering,
|
||||
);
|
||||
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
addToRoot(g || node, element);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
|
|
|
@ -60,7 +60,7 @@ import {
|
|||
TransformHandles,
|
||||
TransformHandleType,
|
||||
} from "../element/transformHandles";
|
||||
import { throttleRAF, isOnlyExportingSingleFrame } from "../utils";
|
||||
import { throttleRAF } from "../utils";
|
||||
import { UserIdleState } from "../types";
|
||||
import { FRAME_STYLE, THEME_FILTER } from "../constants";
|
||||
import {
|
||||
|
@ -74,7 +74,7 @@ import {
|
|||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
isEmbeddableOrFrameLabel,
|
||||
isEmbeddableOrLabel,
|
||||
createPlaceholderEmbeddableLabel,
|
||||
} from "../element/embeddable";
|
||||
import {
|
||||
|
@ -369,7 +369,7 @@ const frameClip = (
|
|||
) => {
|
||||
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
|
||||
context.beginPath();
|
||||
if (context.roundRect && !renderConfig.isExporting) {
|
||||
if (context.roundRect) {
|
||||
context.roundRect(
|
||||
0,
|
||||
0,
|
||||
|
@ -963,20 +963,15 @@ const _renderStaticScene = ({
|
|||
|
||||
// Paint visible elements
|
||||
visibleElements
|
||||
.filter((el) => !isEmbeddableOrFrameLabel(el))
|
||||
.filter((el) => !isEmbeddableOrLabel(el))
|
||||
.forEach((element) => {
|
||||
try {
|
||||
// - when exporting the whole canvas, we DO NOT apply clipping
|
||||
// - when we are exporting a particular frame, apply clipping
|
||||
// if the containing frame is not selected, apply clipping
|
||||
const frameId = element.frameId || appState.frameToHighlight?.id;
|
||||
|
||||
if (
|
||||
frameId &&
|
||||
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
|
||||
(!renderConfig.isExporting &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip))
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
context.save();
|
||||
|
||||
|
@ -1001,7 +996,7 @@ const _renderStaticScene = ({
|
|||
|
||||
// render embeddables on top
|
||||
visibleElements
|
||||
.filter((el) => isEmbeddableOrFrameLabel(el))
|
||||
.filter((el) => isEmbeddableOrLabel(el))
|
||||
.forEach((element) => {
|
||||
try {
|
||||
const render = () => {
|
||||
|
@ -1027,10 +1022,8 @@ const _renderStaticScene = ({
|
|||
|
||||
if (
|
||||
frameId &&
|
||||
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
|
||||
(!renderConfig.isExporting &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip))
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
context.save();
|
||||
|
||||
|
@ -1298,7 +1291,7 @@ const renderFrameHighlight = (
|
|||
const height = y2 - y1;
|
||||
|
||||
context.strokeStyle = "rgb(0,118,255)";
|
||||
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / appState.zoom.value;
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
|
@ -1454,24 +1447,29 @@ export const renderSceneToSvg = (
|
|||
{
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
exportWithDarkMode = false,
|
||||
exportingFrameId = null,
|
||||
exportWithDarkMode,
|
||||
renderEmbeddables,
|
||||
frameRendering,
|
||||
}: {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
exportWithDarkMode?: boolean;
|
||||
exportingFrameId?: string | null;
|
||||
renderEmbeddables?: boolean;
|
||||
} = {},
|
||||
exportWithDarkMode: boolean;
|
||||
renderEmbeddables: boolean;
|
||||
frameRendering: AppState["frameRendering"];
|
||||
},
|
||||
) => {
|
||||
if (!svgRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderConfig = {
|
||||
exportWithDarkMode,
|
||||
renderEmbeddables,
|
||||
frameRendering,
|
||||
};
|
||||
// render elements
|
||||
elements
|
||||
.filter((el) => !isEmbeddableOrFrameLabel(el))
|
||||
.filter((el) => !isEmbeddableOrLabel(el))
|
||||
.forEach((element) => {
|
||||
if (!element.isDeleted) {
|
||||
try {
|
||||
|
@ -1482,9 +1480,7 @@ export const renderSceneToSvg = (
|
|||
files,
|
||||
element.x + offsetX,
|
||||
element.y + offsetY,
|
||||
exportWithDarkMode,
|
||||
exportingFrameId,
|
||||
renderEmbeddables,
|
||||
renderConfig,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -1505,9 +1501,7 @@ export const renderSceneToSvg = (
|
|||
files,
|
||||
element.x + offsetX,
|
||||
element.y + offsetY,
|
||||
exportWithDarkMode,
|
||||
exportingFrameId,
|
||||
renderEmbeddables,
|
||||
renderConfig,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue