mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
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:
parent
dd8529743a
commit
b09b5cb5f4
17 changed files with 1528 additions and 1480 deletions
|
@ -1,5 +1,4 @@
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { renderInteractiveScene } from "../../renderer/renderScene";
|
|
||||||
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
|
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
|
||||||
import { CURSOR_TYPE } from "../../constants";
|
import { CURSOR_TYPE } from "../../constants";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
|
@ -12,6 +11,7 @@ import type {
|
||||||
} from "../../scene/types";
|
} from "../../scene/types";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||||
|
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||||
|
|
||||||
type InteractiveCanvasProps = {
|
type InteractiveCanvasProps = {
|
||||||
containerRef: React.RefObject<HTMLDivElement>;
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import { renderStaticScene } from "../../renderer/renderScene";
|
import { renderStaticScene } from "../../renderer/staticScene";
|
||||||
import { isShallowEqual } from "../../utils";
|
import { isShallowEqual } from "../../utils";
|
||||||
import type { AppState, StaticCanvasAppState } from "../../types";
|
import type { AppState, StaticCanvasAppState } from "../../types";
|
||||||
import type {
|
import type {
|
||||||
|
|
75
packages/excalidraw/renderer/helpers.ts
Normal file
75
packages/excalidraw/renderer/helpers.ts
Normal 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;
|
||||||
|
};
|
File diff suppressed because it is too large
Load diff
|
@ -20,27 +20,17 @@ import {
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { getElementAbsoluteCoords } from "../element/bounds";
|
import { getElementAbsoluteCoords } from "../element/bounds";
|
||||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import type { Drawable } from "roughjs/bin/core";
|
|
||||||
import type { RoughSVG } from "roughjs/bin/svg";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SVGRenderConfig,
|
|
||||||
StaticCanvasRenderConfig,
|
StaticCanvasRenderConfig,
|
||||||
RenderableElementsMap,
|
RenderableElementsMap,
|
||||||
} from "../scene/types";
|
} from "../scene/types";
|
||||||
import {
|
import { distance, getFontString, isRTL } from "../utils";
|
||||||
distance,
|
import { getCornerRadius, isRightAngle } from "../math";
|
||||||
getFontString,
|
|
||||||
getFontFamilyString,
|
|
||||||
isRTL,
|
|
||||||
isTestEnv,
|
|
||||||
} from "../utils";
|
|
||||||
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import {
|
import {
|
||||||
AppState,
|
AppState,
|
||||||
StaticCanvasAppState,
|
StaticCanvasAppState,
|
||||||
BinaryFiles,
|
|
||||||
Zoom,
|
Zoom,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
ElementsPendingErasure,
|
ElementsPendingErasure,
|
||||||
|
@ -50,9 +40,7 @@ import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
SVG_NS,
|
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||||
import {
|
import {
|
||||||
|
@ -64,19 +52,16 @@ import {
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
|
||||||
createPlaceholderEmbeddableLabel,
|
|
||||||
getEmbedLink,
|
|
||||||
} from "../element/embeddable";
|
|
||||||
import { getContainingFrame } from "../frame";
|
import { getContainingFrame } from "../frame";
|
||||||
import { normalizeLink, toValidURL } from "../data/url";
|
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
|
|
||||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
// 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
|
// 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
|
// color scheme (it's still not quite there and the colors look slightly
|
||||||
// desatured, alas...)
|
// 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();
|
const defaultAppState = getDefaultAppState();
|
||||||
|
|
||||||
|
@ -905,564 +890,6 @@ export const renderElement = (
|
||||||
context.globalAlpha = 1;
|
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 const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
|
||||||
|
|
||||||
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
||||||
|
|
370
packages/excalidraw/renderer/staticScene.ts
Normal file
370
packages/excalidraw/renderer/staticScene.ts
Normal 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);
|
||||||
|
};
|
653
packages/excalidraw/renderer/staticSvgScene.ts
Normal file
653
packages/excalidraw/renderer/staticSvgScene.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -4,7 +4,9 @@ import {
|
||||||
NonDeletedElementsMap,
|
NonDeletedElementsMap,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { cancelRender } from "../renderer/renderScene";
|
import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene";
|
||||||
|
import { renderStaticSceneThrottled } from "../renderer/staticScene";
|
||||||
|
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { memoize, toBrandedType } from "../utils";
|
import { memoize, toBrandedType } from "../utils";
|
||||||
import Scene from "./Scene";
|
import Scene from "./Scene";
|
||||||
|
@ -147,7 +149,8 @@ export class Renderer {
|
||||||
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
|
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
|
||||||
// safe to break TS contract here (for upstream cases)
|
// safe to break TS contract here (for upstream cases)
|
||||||
public destroy() {
|
public destroy() {
|
||||||
cancelRender();
|
renderInteractiveSceneThrottled.cancel();
|
||||||
|
renderStaticSceneThrottled.cancel();
|
||||||
this.getRenderableElements.clear();
|
this.getRenderableElements.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
} from "../element/bounds";
|
} from "../element/bounds";
|
||||||
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
|
import { renderSceneToSvg } from "../renderer/staticSvgScene";
|
||||||
import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
|
import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import {
|
import {
|
||||||
|
@ -38,6 +38,7 @@ import { Mutable } from "../utility-types";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { RenderableElementsMap } from "./types";
|
import { RenderableElementsMap } from "./types";
|
||||||
|
import { renderStaticScene } from "../renderer/staticScene";
|
||||||
|
|
||||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as StaticScene from "../renderer/staticScene";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import { render, queryByTestId } from "../tests/test-utils";
|
import { render, queryByTestId } from "../tests/test-utils";
|
||||||
|
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
describe("Test <App/>", () => {
|
describe("Test <App/>", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
togglePopover,
|
togglePopover,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as StaticScene from "../renderer/staticScene";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
@ -39,7 +39,7 @@ const mouse = new Pointer("mouse");
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
renderStaticScene.mockClear();
|
renderStaticScene.mockClear();
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { Excalidraw } from "../index";
|
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 { KEYS } from "../keys";
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
|
@ -15,8 +16,11 @@ import { vi } from "vitest";
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
const renderInteractiveScene = vi.spyOn(
|
||||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
InteractiveScene,
|
||||||
|
"renderInteractiveScene",
|
||||||
|
);
|
||||||
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|
|
@ -8,7 +8,9 @@ import {
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { centerPoint } from "../math";
|
import { centerPoint } from "../math";
|
||||||
import { reseed } from "../random";
|
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 { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
|
import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
|
||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
|
@ -26,8 +28,11 @@ import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
const renderInteractiveScene = vi.spyOn(
|
||||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
InteractiveCanvas,
|
||||||
|
"renderInteractiveScene",
|
||||||
|
);
|
||||||
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { render, fireEvent } from "./test-utils";
|
import { render, fireEvent } from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
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 { reseed } from "../random";
|
||||||
import { bindOrUnbindLinearElement } from "../element/binding";
|
import { bindOrUnbindLinearElement } from "../element/binding";
|
||||||
import {
|
import {
|
||||||
|
@ -16,8 +17,11 @@ import { vi } from "vitest";
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
const renderInteractiveScene = vi.spyOn(
|
||||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
InteractiveCanvas,
|
||||||
|
"renderInteractiveScene",
|
||||||
|
);
|
||||||
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|
|
@ -6,7 +6,8 @@ import {
|
||||||
restoreOriginalGetBoundingClientRect,
|
restoreOriginalGetBoundingClientRect,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
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 { KEYS } from "../keys";
|
||||||
import { ExcalidrawLinearElement } from "../element/types";
|
import { ExcalidrawLinearElement } from "../element/types";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
|
@ -15,8 +16,11 @@ import { vi } from "vitest";
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
const renderInteractiveScene = vi.spyOn(
|
||||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
InteractiveCanvas,
|
||||||
|
"renderInteractiveScene",
|
||||||
|
);
|
||||||
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as StaticScene from "../renderer/staticScene";
|
||||||
import { setDateTimeForTests } from "../utils";
|
import { setDateTimeForTests } from "../utils";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
|
@ -19,7 +19,7 @@ import { vi } from "vitest";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
const finger1 = new Pointer("touch", 1);
|
const finger1 = new Pointer("touch", 1);
|
||||||
|
|
|
@ -7,7 +7,8 @@ import {
|
||||||
assertSelectedElements,
|
assertSelectedElements,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
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 { KEYS } from "../keys";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
|
@ -18,8 +19,11 @@ import { vi } from "vitest";
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
const renderInteractiveScene = vi.spyOn(
|
||||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
InteractiveCanvas,
|
||||||
|
"renderInteractiveScene",
|
||||||
|
);
|
||||||
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|
Loading…
Add table
Reference in a new issue