fix: split renderScene so that locales aren't imported unnecessarily (#7718)

* fix: split renderScene so that locales aren't imported unnecessarily

* lint

* split export code

* rename renderScene to helpers.ts

* add helpers

* fix typo

* fixes

* move renderElementToSvg to export

* lint

* rename export to staticSvgScene

* fix
This commit is contained in:
Aakansha Doshi 2024-02-27 10:37:44 +05:30 committed by GitHub
parent dd8529743a
commit b09b5cb5f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1528 additions and 1480 deletions

View File

@ -1,5 +1,4 @@
import React, { useEffect, useRef } from "react";
import { renderInteractiveScene } from "../../renderer/renderScene";
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
import { CURSOR_TYPE } from "../../constants";
import { t } from "../../i18n";
@ -12,6 +11,7 @@ import type {
} from "../../scene/types";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import { isRenderThrottlingEnabled } from "../../reactUtils";
import { renderInteractiveScene } from "../../renderer/interactiveScene";
type InteractiveCanvasProps = {
containerRef: React.RefObject<HTMLDivElement>;

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef } from "react";
import { RoughCanvas } from "roughjs/bin/canvas";
import { renderStaticScene } from "../../renderer/renderScene";
import { renderStaticScene } from "../../renderer/staticScene";
import { isShallowEqual } from "../../utils";
import type { AppState, StaticCanvasAppState } from "../../types";
import type {

View File

@ -0,0 +1,75 @@
import { StaticCanvasAppState, AppState } from "../types";
import { StaticCanvasRenderConfig } from "../scene/types";
import { THEME_FILTER } from "../constants";
export const fillCircle = (
context: CanvasRenderingContext2D,
cx: number,
cy: number,
radius: number,
stroke = true,
) => {
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill();
if (stroke) {
context.stroke();
}
};
export const getNormalizedCanvasDimensions = (
canvas: HTMLCanvasElement,
scale: number,
): [number, number] => {
// When doing calculations based on canvas width we should used normalized one
return [canvas.width / scale, canvas.height / scale];
};
export const bootstrapCanvas = ({
canvas,
scale,
normalizedWidth,
normalizedHeight,
theme,
isExporting,
viewBackgroundColor,
}: {
canvas: HTMLCanvasElement;
scale: number;
normalizedWidth: number;
normalizedHeight: number;
theme?: AppState["theme"];
isExporting?: StaticCanvasRenderConfig["isExporting"];
viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"];
}): CanvasRenderingContext2D => {
const context = canvas.getContext("2d")!;
context.setTransform(1, 0, 0, 1, 0, 0);
context.scale(scale, scale);
if (isExporting && theme === "dark") {
context.filter = THEME_FILTER;
}
// Paint background
if (typeof viewBackgroundColor === "string") {
const hasTransparence =
viewBackgroundColor === "transparent" ||
viewBackgroundColor.length === 5 || // #RGBA
viewBackgroundColor.length === 9 || // #RRGGBBA
/(hsla|rgba)\(/.test(viewBackgroundColor);
if (hasTransparence) {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = viewBackgroundColor;
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
return context;
};

View File

@ -20,27 +20,17 @@ import {
} from "../element/typeChecks";
import { getElementAbsoluteCoords } from "../element/bounds";
import type { RoughCanvas } from "roughjs/bin/canvas";
import type { Drawable } from "roughjs/bin/core";
import type { RoughSVG } from "roughjs/bin/svg";
import {
SVGRenderConfig,
StaticCanvasRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import {
distance,
getFontString,
getFontFamilyString,
isRTL,
isTestEnv,
} from "../utils";
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
import { distance, getFontString, isRTL } from "../utils";
import { getCornerRadius, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import {
AppState,
StaticCanvasAppState,
BinaryFiles,
Zoom,
InteractiveCanvasAppState,
ElementsPendingErasure,
@ -50,9 +40,7 @@ import {
BOUND_TEXT_PADDING,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES,
SVG_NS,
} from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand";
import {
@ -64,19 +52,16 @@ import {
getBoundTextMaxWidth,
} from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
createPlaceholderEmbeddableLabel,
getEmbedLink,
} from "../element/embeddable";
import { getContainingFrame } from "../frame";
import { normalizeLink, toValidURL } from "../data/url";
import { ShapeCache } from "../scene/ShapeCache";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
// color scheme (it's still not quite there and the colors look slightly
// desatured, alas...)
const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)";
export const IMAGE_INVERT_FILTER =
"invert(100%) hue-rotate(180deg) saturate(1.25)";
const defaultAppState = getDefaultAppState();
@ -905,564 +890,6 @@ export const renderElement = (
context.globalAlpha = 1;
};
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,
drawable: Drawable,
precision?: number,
) => {
if (typeof precision === "undefined") {
return rsvg.draw(drawable);
}
const pshape: Drawable = {
sets: drawable.sets,
shape: drawable.shape,
options: { ...drawable.options, fixedDecimalPlaceDigits: precision },
};
return rsvg.draw(pshape);
};
const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement,
root: SVGElement,
nodes: SVGElement[],
frameRendering: AppState["frameRendering"],
elementsMap: RenderableElementsMap,
) => {
if (!frameRendering.enabled || !frameRendering.clip) {
return null;
}
const frame = getContainingFrame(element, elementsMap);
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));
return g;
}
return null;
};
export const renderElementToSvg = (
element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
offsetX: number,
offsetY: number,
renderConfig: SVGRenderConfig,
) => {
const offset = { x: offsetX, y: offsetY };
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
let cx = (x2 - x1) / 2 - (element.x - x1);
let cy = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (isArrowElement(container)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap);
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
elementsMap,
);
cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
offsetX = offsetX + boundTextCoords.x - element.x;
offsetY = offsetY + boundTextCoords.y - element.y;
}
}
const degree = (180 * element.angle) / Math.PI;
// element to append node to, most of the time svgRoot
let root = svgRoot;
// if the element has a link, create an anchor tag and make that the new root
if (element.link) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link));
root.appendChild(anchorTag);
root = anchorTag;
}
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
if (isTestEnv()) {
node.setAttribute("data-id", element.id);
}
root.appendChild(node);
};
const opacity =
((getContainingFrame(element, elementsMap)?.opacity ?? 100) *
element.opacity) /
10000;
switch (element.type) {
case "selection": {
// Since this is used only during editing experience, which is canvas based,
// this should not happen
throw new Error("Selection rendering is not supported for SVG");
}
case "rectangle":
case "diamond":
case "ellipse": {
const shape = ShapeCache.generateElementShape(element, null);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
break;
}
case "iframe":
case "embeddable": {
// render placeholder rectangle
const shape = ShapeCache.generateElementShape(element, renderConfig);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
const opacity = element.opacity / 100;
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
addToRoot(node, element);
const label: ExcalidrawElement =
createPlaceholderEmbeddableLabel(element);
renderElementToSvg(
label,
elementsMap,
rsvg,
root,
files,
label.x + offset.x - element.x,
label.y + offset.y - element.y,
renderConfig,
);
// render embeddable element + iframe
const embeddableNode = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
embeddableNode.setAttribute("stroke-linecap", "round");
embeddableNode.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
while (embeddableNode.firstChild) {
embeddableNode.removeChild(embeddableNode.firstChild);
}
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const embedLink = getEmbedLink(toValidURL(element.link || ""));
// if rendering embeddables explicitly disabled or
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
// replace with a link instead
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");
anchorTag.setAttribute("rel", "noopener noreferrer");
anchorTag.style.borderRadius = `${radius}px`;
embeddableNode.appendChild(anchorTag);
} else {
const foreignObject = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"foreignObject",
);
foreignObject.style.width = `${element.width}px`;
foreignObject.style.height = `${element.height}px`;
foreignObject.style.border = "none";
const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
div.style.width = "100%";
div.style.height = "100%";
const iframe = div.ownerDocument!.createElement("iframe");
iframe.src = embedLink?.link ?? "";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.borderRadius = `${radius}px`;
iframe.style.top = "0";
iframe.style.left = "0";
iframe.allowFullscreen = true;
div.appendChild(iframe);
foreignObject.appendChild(div);
embeddableNode.appendChild(foreignObject);
}
addToRoot(embeddableNode, element);
break;
}
case "line":
case "arrow": {
const boundText = getBoundTextElement(element, elementsMap);
const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
if (boundText) {
maskPath.setAttribute("id", `mask-${element.id}`);
const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
offsetX = offsetX || 0;
offsetY = offsetY || 0;
maskRectVisible.setAttribute("x", "0");
maskRectVisible.setAttribute("y", "0");
maskRectVisible.setAttribute("fill", "#fff");
maskRectVisible.setAttribute(
"width",
`${element.width + 100 + offsetX}`,
);
maskRectVisible.setAttribute(
"height",
`${element.height + 100 + offsetY}`,
);
maskPath.appendChild(maskRectVisible);
const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
element,
boundText,
elementsMap,
);
const maskX = offsetX + boundTextCoords.x - element.x;
const maskY = offsetY + boundTextCoords.y - element.y;
maskRectInvisible.setAttribute("x", maskX.toString());
maskRectInvisible.setAttribute("y", maskY.toString());
maskRectInvisible.setAttribute("fill", "#000");
maskRectInvisible.setAttribute("width", `${boundText.width}`);
maskRectInvisible.setAttribute("height", `${boundText.height}`);
maskRectInvisible.setAttribute("opacity", "1");
maskPath.appendChild(maskRectInvisible);
}
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (boundText) {
group.setAttribute("mask", `url(#mask-${element.id})`);
}
group.setAttribute("stroke-linecap", "round");
const shapes = ShapeCache.generateElementShape(element, renderConfig);
shapes.forEach((shape) => {
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (
element.type === "line" &&
isPathALoop(element.points) &&
element.backgroundColor !== "transparent"
) {
node.setAttribute("fill-rule", "evenodd");
}
group.appendChild(node);
});
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[group, maskPath],
renderConfig.frameRendering,
elementsMap,
);
if (g) {
addToRoot(g, element);
root.appendChild(g);
} else {
addToRoot(group, element);
root.append(maskPath);
}
break;
}
case "freedraw": {
const backgroundFillShape = ShapeCache.generateElementShape(
element,
renderConfig,
);
const node = backgroundFillShape
? roughSVGDrawWithPrecision(
rsvg,
backgroundFillShape,
MAX_DECIMALS_FOR_SVG_EXPORT,
)
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
node.setAttribute("stroke", "none");
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
path.setAttribute("fill", element.strokeColor);
path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
break;
}
case "image": {
const width = Math.round(element.width);
const height = Math.round(element.height);
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (fileData) {
const symbolId = `image-${fileData.id}`;
let symbol = svgRoot.querySelector(`#${symbolId}`);
if (!symbol) {
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
symbol.id = symbolId;
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL);
symbol.appendChild(image);
root.prepend(symbol);
}
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
use.setAttribute("href", `#${symbolId}`);
// in dark theme, revert the image color filter
if (
renderConfig.exportWithDarkMode &&
fileData.mimeType !== MIME_TYPES.svg
) {
use.setAttribute("filter", IMAGE_INVERT_FILTER);
}
use.setAttribute("width", `${width}`);
use.setAttribute("height", `${height}`);
use.setAttribute("opacity", `${opacity}`);
// We first apply `scale` transforms (horizontal/vertical mirroring)
// on the <use> element, then apply translation and rotation
// on the <g> element which wraps the <use>.
// Doing this separately is a quick hack to to work around compositing
// the transformations correctly (the transform-origin was not being
// applied correctly).
if (element.scale[0] !== 1 || element.scale[1] !== 1) {
const translateX = element.scale[0] !== 1 ? -width : 0;
const translateY = element.scale[1] !== 1 ? -height : 0;
use.setAttribute(
"transform",
`scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`,
);
}
const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
g.appendChild(use);
g.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (element.roundness) {
const clipPath = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"clipPath",
);
clipPath.id = `image-clipPath-${element.id}`;
const clipRect = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
clipRect.setAttribute("width", `${element.width}`);
clipRect.setAttribute("height", `${element.height}`);
clipRect.setAttribute("rx", `${radius}`);
clipRect.setAttribute("ry", `${radius}`);
clipPath.appendChild(clipRect);
addToRoot(clipPath, element);
g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`);
}
const clipG = maybeWrapNodesInFrameClipPath(
element,
root,
[g],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(clipG || g, element);
}
break;
}
// frames are not rendered and only acts as a container
case "frame":
case "magicframe": {
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: {
if (isTextElement(element)) {
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const horizontalOffset =
element.textAlign === "center"
? element.width / 2
: element.textAlign === "right"
? element.width
: 0;
const direction = isRTL(element.text) ? "rtl" : "ltr";
const textAnchor =
element.textAlign === "center"
? "middle"
: element.textAlign === "right" || direction === "rtl"
? "end"
: "start";
for (let i = 0; i < lines.length; i++) {
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`);
text.setAttribute("y", `${i * lineHeightPx}`);
text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor);
text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;");
text.setAttribute("direction", direction);
text.setAttribute("dominant-baseline", "text-before-edge");
node.appendChild(text);
}
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
} else {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`);
}
}
}
};
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {

View File

@ -0,0 +1,370 @@
import { FRAME_STYLE } from "../constants";
import { getElementAbsoluteCoords } from "../element";
import {
elementOverlapsWithFrame,
getTargetFrame,
isElementInFrame,
} from "../frame";
import {
isEmbeddableElement,
isIframeLikeElement,
} from "../element/typeChecks";
import { renderElement } from "../renderer/renderElement";
import { createPlaceholderEmbeddableLabel } from "../element/embeddable";
import { StaticCanvasAppState, Zoom } from "../types";
import {
ElementsMap,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
StaticCanvasRenderConfig,
StaticSceneRenderConfig,
} from "../scene/types";
import {
EXTERNAL_LINK_IMG,
getLinkHandleFromCoords,
} from "../components/hyperlink/helpers";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { throttleRAF } from "../utils";
const strokeGrid = (
context: CanvasRenderingContext2D,
gridSize: number,
scrollX: number,
scrollY: number,
zoom: Zoom,
width: number,
height: number,
) => {
const BOLD_LINE_FREQUENCY = 5;
enum GridLineColor {
Bold = "#cccccc",
Regular = "#e5e5e5",
}
const offsetX =
-Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize);
const offsetY =
-Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize);
const lineWidth = Math.min(1 / zoom.value, 1);
const spaceWidth = 1 / zoom.value;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.save();
context.lineWidth = lineWidth;
for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
const isBold =
Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0;
context.beginPath();
context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(x, offsetY - gridSize);
context.lineTo(x, offsetY + height + gridSize * 2);
context.stroke();
}
for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
const isBold =
Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0;
context.beginPath();
context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(offsetX - gridSize, y);
context.lineTo(offsetX + width + gridSize * 2, y);
context.stroke();
}
context.restore();
};
const frameClip = (
frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.beginPath();
if (context.roundRect) {
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / appState.zoom.value,
);
} else {
context.rect(0, 0, frame.width, frame.height);
}
context.clip();
context.translate(
-(frame.x + appState.scrollX),
-(frame.y + appState.scrollY),
);
};
let linkCanvasCache: any;
const renderLinkIcon = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState,
elementsMap: ElementsMap,
) => {
if (element.link && !appState.selectedElementIds[element.id]) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y, width, height] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
element.angle,
appState,
);
const centerX = x + width / 2;
const centerY = y + height / 2;
context.save();
context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
context.rotate(element.angle);
if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) {
linkCanvasCache = document.createElement("canvas");
linkCanvasCache.zoom = appState.zoom.value;
linkCanvasCache.width =
width * window.devicePixelRatio * appState.zoom.value;
linkCanvasCache.height =
height * window.devicePixelRatio * appState.zoom.value;
const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
linkCanvasCacheContext.scale(
window.devicePixelRatio * appState.zoom.value,
window.devicePixelRatio * appState.zoom.value,
);
linkCanvasCacheContext.fillStyle = "#fff";
linkCanvasCacheContext.fillRect(0, 0, width, height);
linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height);
linkCanvasCacheContext.restore();
context.drawImage(
linkCanvasCache,
x - centerX,
y - centerY,
width,
height,
);
} else {
context.drawImage(
linkCanvasCache,
x - centerX,
y - centerY,
width,
height,
);
}
context.restore();
}
};
const _renderStaticScene = ({
canvas,
rc,
elementsMap,
allElementsMap,
visibleElements,
scale,
appState,
renderConfig,
}: StaticSceneRenderConfig) => {
if (canvas === null) {
return;
}
const { renderGrid = true, isExporting } = renderConfig;
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
theme: appState.theme,
isExporting,
viewBackgroundColor: appState.viewBackgroundColor,
});
// Apply zoom
context.scale(appState.zoom.value, appState.zoom.value);
// Grid
if (renderGrid && appState.gridSize) {
strokeGrid(
context,
appState.gridSize,
appState.scrollX,
appState.scrollY,
appState.zoom,
normalizedWidth / appState.zoom.value,
normalizedHeight / appState.zoom.value,
);
}
const groupsToBeAddedToFrame = new Set<string>();
visibleElements.forEach((element) => {
if (
element.groupIds.length > 0 &&
appState.frameToHighlight &&
appState.selectedElementIds[element.id] &&
(elementOverlapsWithFrame(
element,
appState.frameToHighlight,
elementsMap,
) ||
element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId)))
) {
element.groupIds.forEach((groupId) =>
groupsToBeAddedToFrame.add(groupId),
);
}
});
// Paint visible elements
visibleElements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
try {
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
context.save();
const frame = getTargetFrame(element, elementsMap, appState);
// TODO do we need to check isElementInFrame here?
if (frame && isElementInFrame(element, elementsMap, appState)) {
frameClip(frame, context, renderConfig, appState);
}
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
context.restore();
} else {
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
if (!isExporting) {
renderLinkIcon(element, context, appState, elementsMap);
}
} catch (error: any) {
console.error(error);
}
});
// render embeddables on top
visibleElements
.filter((el) => isIframeLikeElement(el))
.forEach((element) => {
try {
const render = () => {
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
if (
isIframeLikeElement(element) &&
(isExporting ||
(isEmbeddableElement(element) &&
renderConfig.embedsValidationStatus.get(element.id) !==
true)) &&
element.width &&
element.height
) {
const label = createPlaceholderEmbeddableLabel(element);
renderElement(
label,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
if (!isExporting) {
renderLinkIcon(element, context, appState, elementsMap);
}
};
// - 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 &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
context.save();
const frame = getTargetFrame(element, elementsMap, appState);
if (frame && isElementInFrame(element, elementsMap, appState)) {
frameClip(frame, context, renderConfig, appState);
}
render();
context.restore();
} else {
render();
}
} catch (error: any) {
console.error(error);
}
});
};
/** throttled to animation framerate */
export const renderStaticSceneThrottled = throttleRAF(
(config: StaticSceneRenderConfig) => {
_renderStaticScene(config);
},
{ trailing: true },
);
/**
* Static scene is the non-ui canvas where we render elements.
*/
export const renderStaticScene = (
renderConfig: StaticSceneRenderConfig,
throttle?: boolean,
) => {
if (throttle) {
renderStaticSceneThrottled(renderConfig);
return;
}
_renderStaticScene(renderConfig);
};

View File

@ -0,0 +1,653 @@
import { Drawable } from "roughjs/bin/core";
import { RoughSVG } from "roughjs/bin/svg";
import {
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES,
SVG_NS,
} from "../constants";
import { normalizeLink, toValidURL } from "../data/url";
import { getElementAbsoluteCoords } from "../element";
import {
createPlaceholderEmbeddableLabel,
getEmbedLink,
} from "../element/embeddable";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
getBoundTextElement,
getContainerElement,
getLineHeightInPx,
} from "../element/textElement";
import {
isArrowElement,
isIframeLikeElement,
isInitializedImageElement,
isTextElement,
} from "../element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawTextElementWithContainer,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getContainingFrame } from "../frame";
import { getCornerRadius, isPathALoop } from "../math";
import { ShapeCache } from "../scene/ShapeCache";
import { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
import { AppState, BinaryFiles } from "../types";
import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,
drawable: Drawable,
precision?: number,
) => {
if (typeof precision === "undefined") {
return rsvg.draw(drawable);
}
const pshape: Drawable = {
sets: drawable.sets,
shape: drawable.shape,
options: { ...drawable.options, fixedDecimalPlaceDigits: precision },
};
return rsvg.draw(pshape);
};
const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement,
root: SVGElement,
nodes: SVGElement[],
frameRendering: AppState["frameRendering"],
elementsMap: RenderableElementsMap,
) => {
if (!frameRendering.enabled || !frameRendering.clip) {
return null;
}
const frame = getContainingFrame(element, elementsMap);
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));
return g;
}
return null;
};
const renderElementToSvg = (
element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
offsetX: number,
offsetY: number,
renderConfig: SVGRenderConfig,
) => {
const offset = { x: offsetX, y: offsetY };
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
let cx = (x2 - x1) / 2 - (element.x - x1);
let cy = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (isArrowElement(container)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap);
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
elementsMap,
);
cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
offsetX = offsetX + boundTextCoords.x - element.x;
offsetY = offsetY + boundTextCoords.y - element.y;
}
}
const degree = (180 * element.angle) / Math.PI;
// element to append node to, most of the time svgRoot
let root = svgRoot;
// if the element has a link, create an anchor tag and make that the new root
if (element.link) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link));
root.appendChild(anchorTag);
root = anchorTag;
}
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
if (isTestEnv()) {
node.setAttribute("data-id", element.id);
}
root.appendChild(node);
};
const opacity =
((getContainingFrame(element, elementsMap)?.opacity ?? 100) *
element.opacity) /
10000;
switch (element.type) {
case "selection": {
// Since this is used only during editing experience, which is canvas based,
// this should not happen
throw new Error("Selection rendering is not supported for SVG");
}
case "rectangle":
case "diamond":
case "ellipse": {
const shape = ShapeCache.generateElementShape(element, null);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
break;
}
case "iframe":
case "embeddable": {
// render placeholder rectangle
const shape = ShapeCache.generateElementShape(element, renderConfig);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
const opacity = element.opacity / 100;
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
addToRoot(node, element);
const label: ExcalidrawElement =
createPlaceholderEmbeddableLabel(element);
renderElementToSvg(
label,
elementsMap,
rsvg,
root,
files,
label.x + offset.x - element.x,
label.y + offset.y - element.y,
renderConfig,
);
// render embeddable element + iframe
const embeddableNode = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
embeddableNode.setAttribute("stroke-linecap", "round");
embeddableNode.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
while (embeddableNode.firstChild) {
embeddableNode.removeChild(embeddableNode.firstChild);
}
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const embedLink = getEmbedLink(toValidURL(element.link || ""));
// if rendering embeddables explicitly disabled or
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
// replace with a link instead
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");
anchorTag.setAttribute("rel", "noopener noreferrer");
anchorTag.style.borderRadius = `${radius}px`;
embeddableNode.appendChild(anchorTag);
} else {
const foreignObject = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"foreignObject",
);
foreignObject.style.width = `${element.width}px`;
foreignObject.style.height = `${element.height}px`;
foreignObject.style.border = "none";
const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
div.style.width = "100%";
div.style.height = "100%";
const iframe = div.ownerDocument!.createElement("iframe");
iframe.src = embedLink?.link ?? "";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.borderRadius = `${radius}px`;
iframe.style.top = "0";
iframe.style.left = "0";
iframe.allowFullscreen = true;
div.appendChild(iframe);
foreignObject.appendChild(div);
embeddableNode.appendChild(foreignObject);
}
addToRoot(embeddableNode, element);
break;
}
case "line":
case "arrow": {
const boundText = getBoundTextElement(element, elementsMap);
const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
if (boundText) {
maskPath.setAttribute("id", `mask-${element.id}`);
const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
offsetX = offsetX || 0;
offsetY = offsetY || 0;
maskRectVisible.setAttribute("x", "0");
maskRectVisible.setAttribute("y", "0");
maskRectVisible.setAttribute("fill", "#fff");
maskRectVisible.setAttribute(
"width",
`${element.width + 100 + offsetX}`,
);
maskRectVisible.setAttribute(
"height",
`${element.height + 100 + offsetY}`,
);
maskPath.appendChild(maskRectVisible);
const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
element,
boundText,
elementsMap,
);
const maskX = offsetX + boundTextCoords.x - element.x;
const maskY = offsetY + boundTextCoords.y - element.y;
maskRectInvisible.setAttribute("x", maskX.toString());
maskRectInvisible.setAttribute("y", maskY.toString());
maskRectInvisible.setAttribute("fill", "#000");
maskRectInvisible.setAttribute("width", `${boundText.width}`);
maskRectInvisible.setAttribute("height", `${boundText.height}`);
maskRectInvisible.setAttribute("opacity", "1");
maskPath.appendChild(maskRectInvisible);
}
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (boundText) {
group.setAttribute("mask", `url(#mask-${element.id})`);
}
group.setAttribute("stroke-linecap", "round");
const shapes = ShapeCache.generateElementShape(element, renderConfig);
shapes.forEach((shape) => {
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (
element.type === "line" &&
isPathALoop(element.points) &&
element.backgroundColor !== "transparent"
) {
node.setAttribute("fill-rule", "evenodd");
}
group.appendChild(node);
});
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[group, maskPath],
renderConfig.frameRendering,
elementsMap,
);
if (g) {
addToRoot(g, element);
root.appendChild(g);
} else {
addToRoot(group, element);
root.append(maskPath);
}
break;
}
case "freedraw": {
const backgroundFillShape = ShapeCache.generateElementShape(
element,
renderConfig,
);
const node = backgroundFillShape
? roughSVGDrawWithPrecision(
rsvg,
backgroundFillShape,
MAX_DECIMALS_FOR_SVG_EXPORT,
)
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
node.setAttribute("stroke", "none");
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
path.setAttribute("fill", element.strokeColor);
path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
break;
}
case "image": {
const width = Math.round(element.width);
const height = Math.round(element.height);
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (fileData) {
const symbolId = `image-${fileData.id}`;
let symbol = svgRoot.querySelector(`#${symbolId}`);
if (!symbol) {
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
symbol.id = symbolId;
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL);
symbol.appendChild(image);
root.prepend(symbol);
}
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
use.setAttribute("href", `#${symbolId}`);
// in dark theme, revert the image color filter
if (
renderConfig.exportWithDarkMode &&
fileData.mimeType !== MIME_TYPES.svg
) {
use.setAttribute("filter", IMAGE_INVERT_FILTER);
}
use.setAttribute("width", `${width}`);
use.setAttribute("height", `${height}`);
use.setAttribute("opacity", `${opacity}`);
// We first apply `scale` transforms (horizontal/vertical mirroring)
// on the <use> element, then apply translation and rotation
// on the <g> element which wraps the <use>.
// Doing this separately is a quick hack to to work around compositing
// the transformations correctly (the transform-origin was not being
// applied correctly).
if (element.scale[0] !== 1 || element.scale[1] !== 1) {
const translateX = element.scale[0] !== 1 ? -width : 0;
const translateY = element.scale[1] !== 1 ? -height : 0;
use.setAttribute(
"transform",
`scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`,
);
}
const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
g.appendChild(use);
g.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (element.roundness) {
const clipPath = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"clipPath",
);
clipPath.id = `image-clipPath-${element.id}`;
const clipRect = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
clipRect.setAttribute("width", `${element.width}`);
clipRect.setAttribute("height", `${element.height}`);
clipRect.setAttribute("rx", `${radius}`);
clipRect.setAttribute("ry", `${radius}`);
clipPath.appendChild(clipRect);
addToRoot(clipPath, element);
g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`);
}
const clipG = maybeWrapNodesInFrameClipPath(
element,
root,
[g],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(clipG || g, element);
}
break;
}
// frames are not rendered and only acts as a container
case "frame":
case "magicframe": {
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: {
if (isTextElement(element)) {
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const horizontalOffset =
element.textAlign === "center"
? element.width / 2
: element.textAlign === "right"
? element.width
: 0;
const direction = isRTL(element.text) ? "rtl" : "ltr";
const textAnchor =
element.textAlign === "center"
? "middle"
: element.textAlign === "right" || direction === "rtl"
? "end"
: "start";
for (let i = 0; i < lines.length; i++) {
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`);
text.setAttribute("y", `${i * lineHeightPx}`);
text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor);
text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;");
text.setAttribute("direction", direction);
text.setAttribute("dominant-baseline", "text-before-edge");
node.appendChild(text);
}
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
} else {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`);
}
}
}
};
export const renderSceneToSvg = (
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: RenderableElementsMap,
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
renderConfig: SVGRenderConfig,
) => {
if (!svgRoot) {
return;
}
// render elements
elements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
renderElementToSvg(
element,
elementsMap,
rsvg,
svgRoot,
files,
element.x + renderConfig.offsetX,
element.y + renderConfig.offsetY,
renderConfig,
);
} catch (error: any) {
console.error(error);
}
}
});
// render embeddables on top
elements
.filter((el) => isIframeLikeElement(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
renderElementToSvg(
element,
elementsMap,
rsvg,
svgRoot,
files,
element.x + renderConfig.offsetX,
element.y + renderConfig.offsetY,
renderConfig,
);
} catch (error: any) {
console.error(error);
}
}
});
};

View File

@ -4,7 +4,9 @@ import {
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
} from "../element/types";
import { cancelRender } from "../renderer/renderScene";
import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene";
import { renderStaticSceneThrottled } from "../renderer/staticScene";
import { AppState } from "../types";
import { memoize, toBrandedType } from "../utils";
import Scene from "./Scene";
@ -147,7 +149,8 @@ export class Renderer {
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
// safe to break TS contract here (for upstream cases)
public destroy() {
cancelRender();
renderInteractiveSceneThrottled.cancel();
renderStaticSceneThrottled.cancel();
this.getRenderableElements.clear();
}
}

View File

@ -11,7 +11,7 @@ import {
getCommonBounds,
getElementAbsoluteCoords,
} from "../element/bounds";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { renderSceneToSvg } from "../renderer/staticSvgScene";
import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
import { AppState, BinaryFiles } from "../types";
import {
@ -38,6 +38,7 @@ import { Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement";
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
import { RenderableElementsMap } from "./types";
import { renderStaticScene } from "../renderer/staticScene";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;

View File

@ -1,12 +1,12 @@
import ReactDOM from "react-dom";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import { reseed } from "../random";
import { render, queryByTestId } from "../tests/test-utils";
import { Excalidraw } from "../index";
import { vi } from "vitest";
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
describe("Test <App/>", () => {
beforeEach(async () => {

View File

@ -12,7 +12,7 @@ import {
togglePopover,
} from "./test-utils";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { KEYS } from "../keys";
@ -39,7 +39,7 @@ const mouse = new Pointer("mouse");
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderStaticScene.mockClear();

View File

@ -1,6 +1,7 @@
import ReactDOM from "react-dom";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveScene from "../renderer/interactiveScene";
import { KEYS } from "../keys";
import {
render,
@ -15,8 +16,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveScene,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();

View File

@ -8,7 +8,9 @@ import {
import { Excalidraw } from "../index";
import { centerPoint } from "../math";
import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
import { API } from "../tests/helpers/api";
@ -26,8 +28,11 @@ import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
import { vi } from "vitest";
import { arrayToMap } from "../utils";
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
const { h } = window;
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;

View File

@ -1,7 +1,8 @@
import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import { reseed } from "../random";
import { bindOrUnbindLinearElement } from "../element/binding";
import {
@ -16,8 +17,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();

View File

@ -6,7 +6,8 @@ import {
restoreOriginalGetBoundingClientRect,
} from "./test-utils";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import { KEYS } from "../keys";
import { ExcalidrawLinearElement } from "../element/types";
import { reseed } from "../random";
@ -15,8 +16,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();

View File

@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types";
import { CODES, KEYS } from "../keys";
import { Excalidraw } from "../index";
import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import { setDateTimeForTests } from "../utils";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
@ -19,7 +19,7 @@ import { vi } from "vitest";
const { h } = window;
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
const mouse = new Pointer("mouse");
const finger1 = new Pointer("touch", 1);

View File

@ -7,7 +7,8 @@ import {
assertSelectedElements,
} from "./test-utils";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import { KEYS } from "../keys";
import { reseed } from "../random";
import { API } from "./helpers/api";
@ -18,8 +19,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();