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 * fixpull/7729/head
parent
dd8529743a
commit
b09b5cb5f4
@ -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
@ -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);
|
||||
};
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
Loading…
Reference in New Issue