aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/renderer')
-rw-r--r--packages/excalidraw/renderer/helpers.ts75
-rw-r--r--packages/excalidraw/renderer/interactiveScene.ts1232
-rw-r--r--packages/excalidraw/renderer/renderElement.ts1070
-rw-r--r--packages/excalidraw/renderer/renderNewElementScene.ts66
-rw-r--r--packages/excalidraw/renderer/renderSnaps.ts207
-rw-r--r--packages/excalidraw/renderer/roundRect.ts41
-rw-r--r--packages/excalidraw/renderer/staticScene.ts480
-rw-r--r--packages/excalidraw/renderer/staticSvgScene.ts744
8 files changed, 3915 insertions, 0 deletions
diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts
new file mode 100644
index 0000000..90f4009
--- /dev/null
+++ b/packages/excalidraw/renderer/helpers.ts
@@ -0,0 +1,75 @@
+import type { StaticCanvasAppState, AppState } from "../types";
+
+import type { StaticCanvasRenderConfig } from "../scene/types";
+
+import { THEME, 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 === 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;
+};
diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts
new file mode 100644
index 0000000..9aa8327
--- /dev/null
+++ b/packages/excalidraw/renderer/interactiveScene.ts
@@ -0,0 +1,1232 @@
+import {
+ getElementAbsoluteCoords,
+ getTransformHandlesFromCoords,
+ getTransformHandles,
+ getCommonBounds,
+} from "../element";
+
+import { roundRect } from "../renderer/roundRect";
+
+import {
+ getScrollBars,
+ SCROLLBAR_COLOR,
+ SCROLLBAR_WIDTH,
+} from "../scene/scrollbars";
+
+import { renderSelectionElement } from "../renderer/renderElement";
+import { getClientColor, renderRemoteCursors } from "../clients";
+import {
+ isSelectedViaGroup,
+ getSelectedGroupIds,
+ getElementsInGroup,
+ selectGroupsFromGivenElements,
+} from "../groups";
+import type {
+ TransformHandles,
+ TransformHandleType,
+} from "../element/transformHandles";
+import {
+ getOmitSidesForDevice,
+ shouldShowBoundingBox,
+} from "../element/transformHandles";
+import { arrayToMap, invariant, throttleRAF } from "../utils";
+import {
+ DEFAULT_TRANSFORM_HANDLE_SPACING,
+ FRAME_STYLE,
+ THEME,
+} from "../constants";
+import { type InteractiveCanvasAppState } from "../types";
+
+import { renderSnaps } from "../renderer/renderSnaps";
+
+import type {
+ SuggestedBinding,
+ SuggestedPointBinding,
+} from "../element/binding";
+import {
+ BINDING_HIGHLIGHT_OFFSET,
+ BINDING_HIGHLIGHT_THICKNESS,
+ maxBindingGap,
+} from "../element/binding";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import {
+ bootstrapCanvas,
+ fillCircle,
+ getNormalizedCanvasDimensions,
+} from "./helpers";
+import oc from "open-color";
+import {
+ isElbowArrow,
+ isFrameLikeElement,
+ isImageElement,
+ isLinearElement,
+ isTextElement,
+} from "../element/typeChecks";
+import type {
+ ElementsMap,
+ ExcalidrawBindableElement,
+ ExcalidrawElement,
+ ExcalidrawFrameLikeElement,
+ ExcalidrawImageElement,
+ ExcalidrawLinearElement,
+ ExcalidrawTextElement,
+ GroupId,
+ NonDeleted,
+} from "../element/types";
+import type {
+ InteractiveCanvasRenderConfig,
+ InteractiveSceneRenderConfig,
+ RenderableElementsMap,
+} from "../scene/types";
+import {
+ pointFrom,
+ type GlobalPoint,
+ type LocalPoint,
+ type Radians,
+} from "@excalidraw/math";
+import { getCornerRadius } from "../shapes";
+
+const renderElbowArrowMidPointHighlight = (
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+) => {
+ invariant(appState.selectedLinearElement, "selectedLinearElement is null");
+
+ const { segmentMidPointHoveredCoords } = appState.selectedLinearElement;
+
+ invariant(segmentMidPointHoveredCoords, "midPointCoords is null");
+
+ context.save();
+ context.translate(appState.scrollX, appState.scrollY);
+
+ highlightPoint(segmentMidPointHoveredCoords, context, appState);
+
+ context.restore();
+};
+
+const renderLinearElementPointHighlight = (
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ elementsMap: ElementsMap,
+) => {
+ const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
+ if (
+ appState.editingLinearElement?.selectedPointsIndices?.includes(
+ hoverPointIndex,
+ )
+ ) {
+ return;
+ }
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
+
+ if (!element) {
+ return;
+ }
+ const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ element,
+ hoverPointIndex,
+ elementsMap,
+ );
+ context.save();
+ context.translate(appState.scrollX, appState.scrollY);
+
+ highlightPoint(point, context, appState);
+ context.restore();
+};
+
+const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
+ point: Point,
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+) => {
+ context.fillStyle = "rgba(105, 101, 219, 0.4)";
+
+ fillCircle(
+ context,
+ point[0],
+ point[1],
+ LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value,
+ false,
+ );
+};
+
+const strokeRectWithRotation = (
+ context: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ cx: number,
+ cy: number,
+ angle: number,
+ fill: boolean = false,
+ /** should account for zoom */
+ radius: number = 0,
+) => {
+ context.save();
+ context.translate(cx, cy);
+ context.rotate(angle);
+ if (fill) {
+ context.fillRect(x - cx, y - cy, width, height);
+ }
+ if (radius && context.roundRect) {
+ context.beginPath();
+ context.roundRect(x - cx, y - cy, width, height, radius);
+ context.stroke();
+ context.closePath();
+ } else {
+ context.strokeRect(x - cx, y - cy, width, height);
+ }
+ context.restore();
+};
+
+const strokeDiamondWithRotation = (
+ context: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ cx: number,
+ cy: number,
+ angle: number,
+) => {
+ context.save();
+ context.translate(cx, cy);
+ context.rotate(angle);
+ context.beginPath();
+ context.moveTo(0, height / 2);
+ context.lineTo(width / 2, 0);
+ context.lineTo(0, -height / 2);
+ context.lineTo(-width / 2, 0);
+ context.closePath();
+ context.stroke();
+ context.restore();
+};
+
+const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ point: Point,
+ radius: number,
+ isSelected: boolean,
+ isPhantomPoint = false,
+) => {
+ context.strokeStyle = "#5e5ad8";
+ context.setLineDash([]);
+ context.fillStyle = "rgba(255, 255, 255, 0.9)";
+ if (isSelected) {
+ context.fillStyle = "rgba(134, 131, 226, 0.9)";
+ } else if (isPhantomPoint) {
+ context.fillStyle = "rgba(177, 151, 252, 0.7)";
+ }
+
+ fillCircle(
+ context,
+ point[0],
+ point[1],
+ radius / appState.zoom.value,
+ !isPhantomPoint,
+ );
+};
+
+const strokeEllipseWithRotation = (
+ context: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ cx: number,
+ cy: number,
+ angle: number,
+) => {
+ context.beginPath();
+ context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
+ context.stroke();
+};
+
+const renderBindingHighlightForBindableElement = (
+ context: CanvasRenderingContext2D,
+ element: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+ zoom: InteractiveCanvasAppState["zoom"],
+) => {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const width = x2 - x1;
+ const height = y2 - y1;
+
+ context.strokeStyle = "rgba(0,0,0,.05)";
+ // When zooming out, make line width greater for visibility
+ const zoomValue = zoom.value < 1 ? zoom.value : 1;
+ context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
+ // To ensure the binding highlight doesn't overlap the element itself
+ const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
+
+ const radius = getCornerRadius(
+ Math.min(element.width, element.height),
+ element,
+ );
+
+ switch (element.type) {
+ case "rectangle":
+ case "text":
+ case "image":
+ case "iframe":
+ case "embeddable":
+ case "frame":
+ case "magicframe":
+ strokeRectWithRotation(
+ context,
+ x1 - padding,
+ y1 - padding,
+ width + padding * 2,
+ height + padding * 2,
+ x1 + width / 2,
+ y1 + height / 2,
+ element.angle,
+ undefined,
+ radius,
+ );
+ break;
+ case "diamond":
+ const side = Math.hypot(width, height);
+ const wPadding = (padding * side) / height;
+ const hPadding = (padding * side) / width;
+ strokeDiamondWithRotation(
+ context,
+ width + wPadding * 2,
+ height + hPadding * 2,
+ x1 + width / 2,
+ y1 + height / 2,
+ element.angle,
+ );
+ break;
+ case "ellipse":
+ strokeEllipseWithRotation(
+ context,
+ width + padding * 2,
+ height + padding * 2,
+ x1 + width / 2,
+ y1 + height / 2,
+ element.angle,
+ );
+ break;
+ }
+};
+
+const renderBindingHighlightForSuggestedPointBinding = (
+ context: CanvasRenderingContext2D,
+ suggestedBinding: SuggestedPointBinding,
+ elementsMap: ElementsMap,
+ zoom: InteractiveCanvasAppState["zoom"],
+) => {
+ const [element, startOrEnd, bindableElement] = suggestedBinding;
+
+ const threshold = maxBindingGap(
+ bindableElement,
+ bindableElement.width,
+ bindableElement.height,
+ zoom,
+ );
+
+ context.strokeStyle = "rgba(0,0,0,0)";
+ context.fillStyle = "rgba(0,0,0,.05)";
+
+ const pointIndices =
+ startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
+ pointIndices.forEach((index) => {
+ const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ element,
+ index,
+ elementsMap,
+ );
+ fillCircle(context, x, y, threshold);
+ });
+};
+
+type ElementSelectionBorder = {
+ angle: number;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ selectionColors: string[];
+ dashed?: boolean;
+ cx: number;
+ cy: number;
+ activeEmbeddable: boolean;
+ padding?: number;
+};
+
+const renderSelectionBorder = (
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ elementProperties: ElementSelectionBorder,
+) => {
+ const {
+ angle,
+ x1,
+ y1,
+ x2,
+ y2,
+ selectionColors,
+ cx,
+ cy,
+ dashed,
+ activeEmbeddable,
+ } = elementProperties;
+ const elementWidth = x2 - x1;
+ const elementHeight = y2 - y1;
+
+ const padding =
+ elementProperties.padding ?? DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
+
+ const linePadding = padding / appState.zoom.value;
+ const lineWidth = 8 / appState.zoom.value;
+ const spaceWidth = 4 / appState.zoom.value;
+
+ context.save();
+ context.translate(appState.scrollX, appState.scrollY);
+ context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value;
+
+ const count = selectionColors.length;
+ for (let index = 0; index < count; ++index) {
+ context.strokeStyle = selectionColors[index];
+ if (dashed) {
+ context.setLineDash([
+ lineWidth,
+ spaceWidth + (lineWidth + spaceWidth) * (count - 1),
+ ]);
+ }
+ context.lineDashOffset = (lineWidth + spaceWidth) * index;
+ strokeRectWithRotation(
+ context,
+ x1 - linePadding,
+ y1 - linePadding,
+ elementWidth + linePadding * 2,
+ elementHeight + linePadding * 2,
+ cx,
+ cy,
+ angle,
+ );
+ }
+ context.restore();
+};
+
+const renderBindingHighlight = (
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ suggestedBinding: SuggestedBinding,
+ elementsMap: ElementsMap,
+) => {
+ const renderHighlight = Array.isArray(suggestedBinding)
+ ? renderBindingHighlightForSuggestedPointBinding
+ : renderBindingHighlightForBindableElement;
+
+ context.save();
+ context.translate(appState.scrollX, appState.scrollY);
+ renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom);
+
+ context.restore();
+};
+
+const renderFrameHighlight = (
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ frame: NonDeleted<ExcalidrawFrameLikeElement>,
+ elementsMap: ElementsMap,
+) => {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
+ const width = x2 - x1;
+ const height = y2 - y1;
+
+ context.strokeStyle = "rgb(0,118,255)";
+ context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
+
+ context.save();
+ context.translate(appState.scrollX, appState.scrollY);
+ strokeRectWithRotation(
+ context,
+ x1,
+ y1,
+ width,
+ height,
+ x1 + width / 2,
+ y1 + height / 2,
+ frame.angle,
+ false,
+ FRAME_STYLE.radius / appState.zoom.value,
+ );
+ context.restore();
+};
+
+const renderElementsBoxHighlight = (
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ elements: NonDeleted<ExcalidrawElement>[],
+) => {
+ const individualElements = elements.filter(
+ (element) => element.groupIds.length === 0,
+ );
+
+ const elementsInGroups = elements.filter(
+ (element) => element.groupIds.length > 0,
+ );
+
+ const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
+ const [x1, y1, x2, y2] = getCommonBounds(elements);
+ return {
+ angle: 0,
+ x1,
+ x2,
+ y1,
+ y2,
+ selectionColors: ["rgb(0,118,255)"],
+ dashed: false,
+ cx: x1 + (x2 - x1) / 2,
+ cy: y1 + (y2 - y1) / 2,
+ activeEmbeddable: false,
+ };
+ };
+
+ const getSelectionForGroupId = (groupId: GroupId) => {
+ const groupElements = getElementsInGroup(elements, groupId);
+ return getSelectionFromElements(groupElements);
+ };
+
+ Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
+ .filter(([id, isSelected]) => isSelected)
+ .map(([id, isSelected]) => id)
+ .map((groupId) => getSelectionForGroupId(groupId))
+ .concat(
+ individualElements.map((element) => getSelectionFromElements([element])),
+ )
+ .forEach((selection) =>
+ renderSelectionBorder(context, appState, selection),
+ );
+};
+
+const renderLinearPointHandles = (
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ element: NonDeleted<ExcalidrawLinearElement>,
+ elementsMap: RenderableElementsMap,
+) => {
+ if (!appState.selectedLinearElement) {
+ return;
+ }
+ context.save();
+ context.translate(appState.scrollX, appState.scrollY);
+ context.lineWidth = 1 / appState.zoom.value;
+ const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
+
+ const { POINT_HANDLE_SIZE } = LinearElementEditor;
+ const radius = appState.editingLinearElement
+ ? POINT_HANDLE_SIZE
+ : POINT_HANDLE_SIZE / 2;
+ points.forEach((point, idx) => {
+ if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
+ return;
+ }
+
+ const isSelected =
+ !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
+
+ renderSingleLinearPoint(context, appState, point, radius, isSelected);
+ });
+
+ // Rendering segment mid points
+ if (isElbowArrow(element)) {
+ const fixedSegments =
+ element.fixedSegments?.map((segment) => segment.index) || [];
+ points.slice(0, -1).forEach((p, idx) => {
+ if (
+ !LinearElementEditor.isSegmentTooShort(
+ element,
+ points[idx + 1],
+ points[idx],
+ idx,
+ appState.zoom,
+ )
+ ) {
+ renderSingleLinearPoint(
+ context,
+ appState,
+ pointFrom<GlobalPoint>(
+ (p[0] + points[idx + 1][0]) / 2,
+ (p[1] + points[idx + 1][1]) / 2,
+ ),
+ POINT_HANDLE_SIZE / 2,
+ false,
+ !fixedSegments.includes(idx + 1),
+ );
+ }
+ });
+ } else {
+ const midPoints = LinearElementEditor.getEditorMidPoints(
+ element,
+ elementsMap,
+ appState,
+ ).filter(
+ (midPoint, idx, midPoints): midPoint is GlobalPoint =>
+ midPoint !== null &&
+ !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)),
+ );
+
+ midPoints.forEach((segmentMidPoint) => {
+ if (appState.editingLinearElement || points.length === 2) {
+ renderSingleLinearPoint(
+ context,
+ appState,
+ segmentMidPoint,
+ POINT_HANDLE_SIZE / 2,
+ false,
+ true,
+ );
+ }
+ });
+ }
+
+ context.restore();
+};
+
+const renderTransformHandles = (
+ context: CanvasRenderingContext2D,
+ renderConfig: InteractiveCanvasRenderConfig,
+ appState: InteractiveCanvasAppState,
+ transformHandles: TransformHandles,
+ angle: number,
+): void => {
+ Object.keys(transformHandles).forEach((key) => {
+ const transformHandle = transformHandles[key as TransformHandleType];
+ if (transformHandle !== undefined) {
+ const [x, y, width, height] = transformHandle;
+
+ context.save();
+ context.lineWidth = 1 / appState.zoom.value;
+ if (renderConfig.selectionColor) {
+ context.strokeStyle = renderConfig.selectionColor;
+ }
+ if (key === "rotation") {
+ fillCircle(context, x + width / 2, y + height / 2, width / 2);
+ // prefer round corners if roundRect API is available
+ } else if (context.roundRect) {
+ context.beginPath();
+ context.roundRect(x, y, width, height, 2 / appState.zoom.value);
+ context.fill();
+ context.stroke();
+ } else {
+ strokeRectWithRotation(
+ context,
+ x,
+ y,
+ width,
+ height,
+ x + width / 2,
+ y + height / 2,
+ angle,
+ true, // fill before stroke
+ );
+ }
+ context.restore();
+ }
+ });
+};
+
+const renderCropHandles = (
+ context: CanvasRenderingContext2D,
+ renderConfig: InteractiveCanvasRenderConfig,
+ appState: InteractiveCanvasAppState,
+ croppingElement: ExcalidrawImageElement,
+ elementsMap: ElementsMap,
+): void => {
+ const [x1, y1, , , cx, cy] = getElementAbsoluteCoords(
+ croppingElement,
+ elementsMap,
+ );
+
+ const LINE_WIDTH = 3;
+ const LINE_LENGTH = 20;
+
+ const ZOOMED_LINE_WIDTH = LINE_WIDTH / appState.zoom.value;
+ const ZOOMED_HALF_LINE_WIDTH = ZOOMED_LINE_WIDTH / 2;
+
+ const HALF_WIDTH = cx - x1 + ZOOMED_LINE_WIDTH;
+ const HALF_HEIGHT = cy - y1 + ZOOMED_LINE_WIDTH;
+
+ const HORIZONTAL_LINE_LENGTH = Math.min(
+ LINE_LENGTH / appState.zoom.value,
+ HALF_WIDTH,
+ );
+ const VERTICAL_LINE_LENGTH = Math.min(
+ LINE_LENGTH / appState.zoom.value,
+ HALF_HEIGHT,
+ );
+
+ context.save();
+ context.fillStyle = renderConfig.selectionColor;
+ context.strokeStyle = renderConfig.selectionColor;
+ context.lineWidth = ZOOMED_LINE_WIDTH;
+
+ const handles: Array<
+ [
+ [number, number],
+ [number, number],
+ [number, number],
+ [number, number],
+ [number, number],
+ ]
+ > = [
+ [
+ // x, y
+ [-HALF_WIDTH, -HALF_HEIGHT],
+ // horizontal line: first start and to
+ [0, ZOOMED_HALF_LINE_WIDTH],
+ [HORIZONTAL_LINE_LENGTH, ZOOMED_HALF_LINE_WIDTH],
+ // vertical line: second start and to
+ [ZOOMED_HALF_LINE_WIDTH, 0],
+ [ZOOMED_HALF_LINE_WIDTH, VERTICAL_LINE_LENGTH],
+ ],
+ [
+ [HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, -HALF_HEIGHT],
+ [ZOOMED_HALF_LINE_WIDTH, ZOOMED_HALF_LINE_WIDTH],
+ [
+ -HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH,
+ ZOOMED_HALF_LINE_WIDTH,
+ ],
+ [0, 0],
+ [0, VERTICAL_LINE_LENGTH],
+ ],
+ [
+ [-HALF_WIDTH, HALF_HEIGHT],
+ [0, -ZOOMED_HALF_LINE_WIDTH],
+ [HORIZONTAL_LINE_LENGTH, -ZOOMED_HALF_LINE_WIDTH],
+ [ZOOMED_HALF_LINE_WIDTH, 0],
+ [ZOOMED_HALF_LINE_WIDTH, -VERTICAL_LINE_LENGTH],
+ ],
+ [
+ [HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, HALF_HEIGHT],
+ [ZOOMED_HALF_LINE_WIDTH, -ZOOMED_HALF_LINE_WIDTH],
+ [
+ -HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH,
+ -ZOOMED_HALF_LINE_WIDTH,
+ ],
+ [0, 0],
+ [0, -VERTICAL_LINE_LENGTH],
+ ],
+ ];
+
+ handles.forEach((handle) => {
+ const [[x, y], [x1s, y1s], [x1t, y1t], [x2s, y2s], [x2t, y2t]] = handle;
+
+ context.save();
+ context.translate(cx, cy);
+ context.rotate(croppingElement.angle);
+
+ context.beginPath();
+ context.moveTo(x + x1s, y + y1s);
+ context.lineTo(x + x1t, y + y1t);
+ context.stroke();
+
+ context.beginPath();
+ context.moveTo(x + x2s, y + y2s);
+ context.lineTo(x + x2t, y + y2t);
+ context.stroke();
+ context.restore();
+ });
+
+ context.restore();
+};
+
+const renderTextBox = (
+ text: NonDeleted<ExcalidrawTextElement>,
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
+) => {
+ context.save();
+ const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
+ const width = text.width + padding * 2;
+ const height = text.height + padding * 2;
+ const cx = text.x + width / 2;
+ const cy = text.y + height / 2;
+ const shiftX = -(width / 2 + padding);
+ const shiftY = -(height / 2 + padding);
+ context.translate(cx + appState.scrollX, cy + appState.scrollY);
+ context.rotate(text.angle);
+ context.lineWidth = 1 / appState.zoom.value;
+ context.strokeStyle = selectionColor;
+ context.strokeRect(shiftX, shiftY, width, height);
+ context.restore();
+};
+
+const _renderInteractiveScene = ({
+ canvas,
+ elementsMap,
+ visibleElements,
+ selectedElements,
+ allElementsMap,
+ scale,
+ appState,
+ renderConfig,
+ device,
+}: InteractiveSceneRenderConfig) => {
+ if (canvas === null) {
+ return { atLeastOneVisibleElement: false, elementsMap };
+ }
+
+ const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
+ canvas,
+ scale,
+ );
+
+ const context = bootstrapCanvas({
+ canvas,
+ scale,
+ normalizedWidth,
+ normalizedHeight,
+ });
+
+ // Apply zoom
+ context.save();
+ context.scale(appState.zoom.value, appState.zoom.value);
+
+ let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
+ undefined;
+
+ visibleElements.forEach((element) => {
+ // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
+ // ShapeCache returns empty hence making sure that we get the
+ // correct element from visible elements
+ if (appState.editingLinearElement?.elementId === element.id) {
+ if (element) {
+ editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
+ }
+ }
+ });
+
+ if (editingLinearElement) {
+ renderLinearPointHandles(
+ context,
+ appState,
+ editingLinearElement,
+ elementsMap,
+ );
+ }
+
+ // Paint selection element
+ if (appState.selectionElement && !appState.isCropping) {
+ try {
+ renderSelectionElement(
+ appState.selectionElement,
+ context,
+ appState,
+ renderConfig.selectionColor,
+ );
+ } catch (error: any) {
+ console.error(error);
+ }
+ }
+
+ if (
+ appState.editingTextElement &&
+ isTextElement(appState.editingTextElement)
+ ) {
+ const textElement = allElementsMap.get(appState.editingTextElement.id) as
+ | ExcalidrawTextElement
+ | undefined;
+ if (textElement && !textElement.autoResize) {
+ renderTextBox(
+ textElement,
+ context,
+ appState,
+ renderConfig.selectionColor,
+ );
+ }
+ }
+
+ if (appState.isBindingEnabled) {
+ appState.suggestedBindings
+ .filter((binding) => binding != null)
+ .forEach((suggestedBinding) => {
+ renderBindingHighlight(
+ context,
+ appState,
+ suggestedBinding!,
+ elementsMap,
+ );
+ });
+ }
+
+ if (appState.frameToHighlight) {
+ renderFrameHighlight(
+ context,
+ appState,
+ appState.frameToHighlight,
+ elementsMap,
+ );
+ }
+
+ if (appState.elementsToHighlight) {
+ renderElementsBoxHighlight(context, appState, appState.elementsToHighlight);
+ }
+
+ const isFrameSelected = selectedElements.some((element) =>
+ isFrameLikeElement(element),
+ );
+
+ // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
+ // ShapeCache returns empty hence making sure that we get the
+ // correct element from visible elements
+ if (
+ selectedElements.length === 1 &&
+ appState.editingLinearElement?.elementId === selectedElements[0].id
+ ) {
+ renderLinearPointHandles(
+ context,
+ appState,
+ selectedElements[0] as NonDeleted<ExcalidrawLinearElement>,
+ elementsMap,
+ );
+ }
+
+ if (
+ isElbowArrow(selectedElements[0]) &&
+ appState.selectedLinearElement &&
+ appState.selectedLinearElement.segmentMidPointHoveredCoords
+ ) {
+ renderElbowArrowMidPointHighlight(context, appState);
+ } else if (
+ appState.selectedLinearElement &&
+ appState.selectedLinearElement.hoverPointIndex >= 0 &&
+ !(
+ isElbowArrow(selectedElements[0]) &&
+ appState.selectedLinearElement.hoverPointIndex > 0 &&
+ appState.selectedLinearElement.hoverPointIndex <
+ selectedElements[0].points.length - 1
+ )
+ ) {
+ renderLinearElementPointHighlight(context, appState, elementsMap);
+ }
+
+ // Paint selected elements
+ if (!appState.multiElement && !appState.editingLinearElement) {
+ const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
+
+ const isSingleLinearElementSelected =
+ selectedElements.length === 1 && isLinearElement(selectedElements[0]);
+ // render selected linear element points
+ if (
+ isSingleLinearElementSelected &&
+ appState.selectedLinearElement?.elementId === selectedElements[0].id &&
+ !selectedElements[0].locked
+ ) {
+ renderLinearPointHandles(
+ context,
+ appState,
+ selectedElements[0] as ExcalidrawLinearElement,
+ elementsMap,
+ );
+ }
+ const selectionColor = renderConfig.selectionColor || oc.black;
+
+ if (showBoundingBox) {
+ // Optimisation for finding quickly relevant element ids
+ const locallySelectedIds = arrayToMap(selectedElements);
+
+ const selections: ElementSelectionBorder[] = [];
+
+ for (const element of elementsMap.values()) {
+ const selectionColors = [];
+ const remoteClients = renderConfig.remoteSelectedElementIds.get(
+ element.id,
+ );
+ if (
+ !(
+ // Elbow arrow elements cannot be selected when bound on either end
+ (
+ isSingleLinearElementSelected &&
+ isElbowArrow(element) &&
+ (element.startBinding || element.endBinding)
+ )
+ )
+ ) {
+ // local user
+ if (
+ locallySelectedIds.has(element.id) &&
+ !isSelectedViaGroup(appState, element)
+ ) {
+ selectionColors.push(selectionColor);
+ }
+ // remote users
+ if (remoteClients) {
+ selectionColors.push(
+ ...remoteClients.map((socketId) => {
+ const background = getClientColor(
+ socketId,
+ appState.collaborators.get(socketId),
+ );
+ return background;
+ }),
+ );
+ }
+ }
+
+ if (selectionColors.length) {
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ true,
+ );
+ selections.push({
+ angle: element.angle,
+ x1,
+ y1,
+ x2,
+ y2,
+ selectionColors,
+ dashed: !!remoteClients,
+ cx,
+ cy,
+ activeEmbeddable:
+ appState.activeEmbeddable?.element === element &&
+ appState.activeEmbeddable.state === "active",
+ padding:
+ element.id === appState.croppingElementId ||
+ isImageElement(element)
+ ? 0
+ : undefined,
+ });
+ }
+ }
+
+ const addSelectionForGroupId = (groupId: GroupId) => {
+ const groupElements = getElementsInGroup(elementsMap, groupId);
+ const [x1, y1, x2, y2] = getCommonBounds(groupElements);
+ selections.push({
+ angle: 0,
+ x1,
+ x2,
+ y1,
+ y2,
+ selectionColors: [oc.black],
+ dashed: true,
+ cx: x1 + (x2 - x1) / 2,
+ cy: y1 + (y2 - y1) / 2,
+ activeEmbeddable: false,
+ });
+ };
+
+ for (const groupId of getSelectedGroupIds(appState)) {
+ // TODO: support multiplayer selected group IDs
+ addSelectionForGroupId(groupId);
+ }
+
+ if (appState.editingGroupId) {
+ addSelectionForGroupId(appState.editingGroupId);
+ }
+
+ selections.forEach((selection) =>
+ renderSelectionBorder(context, appState, selection),
+ );
+ }
+ // Paint resize transformHandles
+ context.save();
+ context.translate(appState.scrollX, appState.scrollY);
+
+ if (selectedElements.length === 1) {
+ context.fillStyle = oc.white;
+ const transformHandles = getTransformHandles(
+ selectedElements[0],
+ appState.zoom,
+ elementsMap,
+ "mouse", // when we render we don't know which pointer type so use mouse,
+ getOmitSidesForDevice(device),
+ );
+ if (
+ !appState.viewModeEnabled &&
+ showBoundingBox &&
+ // do not show transform handles when text is being edited
+ !isTextElement(appState.editingTextElement) &&
+ // do not show transform handles when image is being cropped
+ !appState.croppingElementId
+ ) {
+ renderTransformHandles(
+ context,
+ renderConfig,
+ appState,
+ transformHandles,
+ selectedElements[0].angle,
+ );
+ }
+
+ if (appState.croppingElementId && !appState.isCropping) {
+ const croppingElement = elementsMap.get(appState.croppingElementId);
+
+ if (croppingElement && isImageElement(croppingElement)) {
+ renderCropHandles(
+ context,
+ renderConfig,
+ appState,
+ croppingElement,
+ elementsMap,
+ );
+ }
+ }
+ } else if (selectedElements.length > 1 && !appState.isRotating) {
+ const dashedLinePadding =
+ (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
+ context.fillStyle = oc.white;
+ const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
+ const initialLineDash = context.getLineDash();
+ context.setLineDash([2 / appState.zoom.value]);
+ const lineWidth = context.lineWidth;
+ context.lineWidth = 1 / appState.zoom.value;
+ context.strokeStyle = selectionColor;
+ strokeRectWithRotation(
+ context,
+ x1 - dashedLinePadding,
+ y1 - dashedLinePadding,
+ x2 - x1 + dashedLinePadding * 2,
+ y2 - y1 + dashedLinePadding * 2,
+ (x1 + x2) / 2,
+ (y1 + y2) / 2,
+ 0,
+ );
+ context.lineWidth = lineWidth;
+ context.setLineDash(initialLineDash);
+ const transformHandles = getTransformHandlesFromCoords(
+ [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
+ 0 as Radians,
+ appState.zoom,
+ "mouse",
+ isFrameSelected
+ ? { ...getOmitSidesForDevice(device), rotation: true }
+ : getOmitSidesForDevice(device),
+ );
+ if (selectedElements.some((element) => !element.locked)) {
+ renderTransformHandles(
+ context,
+ renderConfig,
+ appState,
+ transformHandles,
+ 0,
+ );
+ }
+ }
+ context.restore();
+ }
+
+ appState.searchMatches.forEach(({ id, focus, matchedLines }) => {
+ const element = elementsMap.get(id);
+
+ if (element && isTextElement(element)) {
+ const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ true,
+ );
+
+ context.save();
+ if (appState.theme === THEME.LIGHT) {
+ if (focus) {
+ context.fillStyle = "rgba(255, 124, 0, 0.4)";
+ } else {
+ context.fillStyle = "rgba(255, 226, 0, 0.4)";
+ }
+ } else if (focus) {
+ context.fillStyle = "rgba(229, 82, 0, 0.4)";
+ } else {
+ context.fillStyle = "rgba(99, 52, 0, 0.4)";
+ }
+
+ context.translate(appState.scrollX, appState.scrollY);
+ context.translate(cx, cy);
+ context.rotate(element.angle);
+
+ matchedLines.forEach((matchedLine) => {
+ context.fillRect(
+ elementX1 + matchedLine.offsetX - cx,
+ elementY1 + matchedLine.offsetY - cy,
+ matchedLine.width,
+ matchedLine.height,
+ );
+ });
+
+ context.restore();
+ }
+ });
+
+ renderSnaps(context, appState);
+
+ context.restore();
+
+ renderRemoteCursors({
+ context,
+ renderConfig,
+ appState,
+ normalizedWidth,
+ normalizedHeight,
+ });
+
+ // Paint scrollbars
+ let scrollBars;
+ if (renderConfig.renderScrollbars) {
+ scrollBars = getScrollBars(
+ visibleElements,
+ normalizedWidth,
+ normalizedHeight,
+ appState,
+ );
+
+ context.save();
+ context.fillStyle = SCROLLBAR_COLOR;
+ context.strokeStyle = "rgba(255,255,255,0.8)";
+ [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => {
+ if (scrollBar) {
+ roundRect(
+ context,
+ scrollBar.x,
+ scrollBar.y,
+ scrollBar.width,
+ scrollBar.height,
+ SCROLLBAR_WIDTH / 2,
+ );
+ }
+ });
+ context.restore();
+ }
+
+ return {
+ scrollBars,
+ atLeastOneVisibleElement: visibleElements.length > 0,
+ elementsMap,
+ };
+};
+
+/** throttled to animation framerate */
+export const renderInteractiveSceneThrottled = throttleRAF(
+ (config: InteractiveSceneRenderConfig) => {
+ const ret = _renderInteractiveScene(config);
+ config.callback?.(ret);
+ },
+ { trailing: true },
+);
+
+/**
+ * Interactive scene is the ui-canvas where we render bounding boxes, selections
+ * and other ui stuff.
+ */
+export const renderInteractiveScene = <
+ U extends typeof _renderInteractiveScene,
+ T extends boolean = false,
+>(
+ renderConfig: InteractiveSceneRenderConfig,
+ throttle?: T,
+): T extends true ? void : ReturnType<U> => {
+ if (throttle) {
+ renderInteractiveSceneThrottled(renderConfig);
+ return undefined as T extends true ? void : ReturnType<U>;
+ }
+ const ret = _renderInteractiveScene(renderConfig);
+ renderConfig.callback(ret);
+ return ret as T extends true ? void : ReturnType<U>;
+};
diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts
new file mode 100644
index 0000000..d93469c
--- /dev/null
+++ b/packages/excalidraw/renderer/renderElement.ts
@@ -0,0 +1,1070 @@
+import type {
+ ExcalidrawElement,
+ ExcalidrawTextElement,
+ NonDeletedExcalidrawElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawImageElement,
+ ExcalidrawTextElementWithContainer,
+ ExcalidrawFrameLikeElement,
+ NonDeletedSceneElementsMap,
+ ElementsMap,
+} from "../element/types";
+import {
+ isTextElement,
+ isLinearElement,
+ isFreeDrawElement,
+ isInitializedImageElement,
+ isArrowElement,
+ hasBoundTextElement,
+ isMagicFrameElement,
+ isImageElement,
+} from "../element/typeChecks";
+import { getElementAbsoluteCoords } from "../element/bounds";
+import type { RoughCanvas } from "roughjs/bin/canvas";
+
+import type {
+ StaticCanvasRenderConfig,
+ RenderableElementsMap,
+ InteractiveCanvasRenderConfig,
+} from "../scene/types";
+import { distance, getFontString, isRTL } from "../utils";
+import rough from "roughjs/bin/rough";
+import type {
+ AppState,
+ StaticCanvasAppState,
+ Zoom,
+ InteractiveCanvasAppState,
+ ElementsPendingErasure,
+ PendingExcalidrawElements,
+} from "../types";
+import { getDefaultAppState } from "../appState";
+import {
+ BOUND_TEXT_PADDING,
+ DEFAULT_REDUCED_GLOBAL_ALPHA,
+ ELEMENT_READY_TO_ERASE_OPACITY,
+ FRAME_STYLE,
+ MIME_TYPES,
+ THEME,
+} from "../constants";
+import type { StrokeOptions } from "perfect-freehand";
+import { getStroke } from "perfect-freehand";
+import {
+ getBoundTextElement,
+ getContainerCoords,
+ getContainerElement,
+ getBoundTextMaxHeight,
+ getBoundTextMaxWidth,
+} from "../element/textElement";
+import { LinearElementEditor } from "../element/linearElementEditor";
+
+import { getContainingFrame } from "../frame";
+import { ShapeCache } from "../scene/ShapeCache";
+import { getVerticalOffset } from "../fonts";
+import { isRightAngleRads } from "@excalidraw/math";
+import { getCornerRadius } from "../shapes";
+import { getUncroppedImageElement } from "../element/cropElement";
+import { getLineHeightInPx } from "../element/textMeasurements";
+
+// using a stronger invert (100% vs our regular 93%) and saturate
+// as a temp hack to make images in dark theme look closer to original
+// color scheme (it's still not quite there and the colors look slightly
+// desatured, alas...)
+export const IMAGE_INVERT_FILTER =
+ "invert(100%) hue-rotate(180deg) saturate(1.25)";
+
+const defaultAppState = getDefaultAppState();
+
+const isPendingImageElement = (
+ element: ExcalidrawElement,
+ renderConfig: StaticCanvasRenderConfig,
+) =>
+ isInitializedImageElement(element) &&
+ !renderConfig.imageCache.has(element.fileId);
+
+const shouldResetImageFilter = (
+ element: ExcalidrawElement,
+ renderConfig: StaticCanvasRenderConfig,
+ appState: StaticCanvasAppState,
+) => {
+ return (
+ appState.theme === THEME.DARK &&
+ isInitializedImageElement(element) &&
+ !isPendingImageElement(element, renderConfig) &&
+ renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
+ );
+};
+
+const getCanvasPadding = (element: ExcalidrawElement) => {
+ switch (element.type) {
+ case "freedraw":
+ return element.strokeWidth * 12;
+ case "text":
+ return element.fontSize / 2;
+ default:
+ return 20;
+ }
+};
+
+export const getRenderOpacity = (
+ element: ExcalidrawElement,
+ containingFrame: ExcalidrawFrameLikeElement | null,
+ elementsPendingErasure: ElementsPendingErasure,
+ pendingNodes: Readonly<PendingExcalidrawElements> | null,
+ globalAlpha: number = 1,
+) => {
+ // multiplying frame opacity with element opacity to combine them
+ // (e.g. frame 50% and element 50% opacity should result in 25% opacity)
+ let opacity =
+ (((containingFrame?.opacity ?? 100) * element.opacity) / 10000) *
+ globalAlpha;
+
+ // if pending erasure, multiply again to combine further
+ // (so that erasing always results in lower opacity than original)
+ if (
+ elementsPendingErasure.has(element.id) ||
+ (pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
+ (containingFrame && elementsPendingErasure.has(containingFrame.id))
+ ) {
+ opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
+ }
+
+ return opacity;
+};
+
+export interface ExcalidrawElementWithCanvas {
+ element: ExcalidrawElement | ExcalidrawTextElement;
+ canvas: HTMLCanvasElement;
+ theme: AppState["theme"];
+ scale: number;
+ angle: number;
+ zoomValue: AppState["zoom"]["value"];
+ canvasOffsetX: number;
+ canvasOffsetY: number;
+ boundTextElementVersion: number | null;
+ imageCrop: ExcalidrawImageElement["crop"] | null;
+ containingFrameOpacity: number;
+ boundTextCanvas: HTMLCanvasElement;
+}
+
+const cappedElementCanvasSize = (
+ element: NonDeletedExcalidrawElement,
+ elementsMap: ElementsMap,
+ zoom: Zoom,
+): {
+ width: number;
+ height: number;
+ scale: number;
+} => {
+ // these limits are ballpark, they depend on specific browsers and device.
+ // We've chosen lower limits to be safe. We might want to change these limits
+ // based on browser/device type, if we get reports of low quality rendering
+ // on zoom.
+ //
+ // ~ safari mobile canvas area limit
+ const AREA_LIMIT = 16777216;
+ // ~ safari width/height limit based on developer.mozilla.org.
+ const WIDTH_HEIGHT_LIMIT = 32767;
+
+ const padding = getCanvasPadding(element);
+
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const elementWidth =
+ isLinearElement(element) || isFreeDrawElement(element)
+ ? distance(x1, x2)
+ : element.width;
+ const elementHeight =
+ isLinearElement(element) || isFreeDrawElement(element)
+ ? distance(y1, y2)
+ : element.height;
+
+ let width = elementWidth * window.devicePixelRatio + padding * 2;
+ let height = elementHeight * window.devicePixelRatio + padding * 2;
+
+ let scale: number = zoom.value;
+
+ // rescale to ensure width and height is within limits
+ if (
+ width * scale > WIDTH_HEIGHT_LIMIT ||
+ height * scale > WIDTH_HEIGHT_LIMIT
+ ) {
+ scale = Math.min(WIDTH_HEIGHT_LIMIT / width, WIDTH_HEIGHT_LIMIT / height);
+ }
+
+ // rescale to ensure canvas area is within limits
+ if (width * height * scale * scale > AREA_LIMIT) {
+ scale = Math.sqrt(AREA_LIMIT / (width * height));
+ }
+
+ width = Math.floor(width * scale);
+ height = Math.floor(height * scale);
+
+ return { width, height, scale };
+};
+
+const generateElementCanvas = (
+ element: NonDeletedExcalidrawElement,
+ elementsMap: NonDeletedSceneElementsMap,
+ zoom: Zoom,
+ renderConfig: StaticCanvasRenderConfig,
+ appState: StaticCanvasAppState,
+): ExcalidrawElementWithCanvas | null => {
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d")!;
+ const padding = getCanvasPadding(element);
+
+ const { width, height, scale } = cappedElementCanvasSize(
+ element,
+ elementsMap,
+ zoom,
+ );
+
+ if (!width || !height) {
+ return null;
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+
+ let canvasOffsetX = -100;
+ let canvasOffsetY = 0;
+
+ if (isLinearElement(element) || isFreeDrawElement(element)) {
+ const [x1, y1] = getElementAbsoluteCoords(element, elementsMap);
+
+ canvasOffsetX =
+ element.x > x1
+ ? distance(element.x, x1) * window.devicePixelRatio * scale
+ : 0;
+
+ canvasOffsetY =
+ element.y > y1
+ ? distance(element.y, y1) * window.devicePixelRatio * scale
+ : 0;
+
+ context.translate(canvasOffsetX, canvasOffsetY);
+ }
+
+ context.save();
+ context.translate(padding * scale, padding * scale);
+ context.scale(
+ window.devicePixelRatio * scale,
+ window.devicePixelRatio * scale,
+ );
+
+ const rc = rough.canvas(canvas);
+
+ // in dark theme, revert the image color filter
+ if (shouldResetImageFilter(element, renderConfig, appState)) {
+ context.filter = IMAGE_INVERT_FILTER;
+ }
+
+ drawElementOnCanvas(element, rc, context, renderConfig, appState);
+
+ context.restore();
+
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ const boundTextCanvas = document.createElement("canvas");
+ const boundTextCanvasContext = boundTextCanvas.getContext("2d")!;
+
+ if (isArrowElement(element) && boundTextElement) {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ // Take max dimensions of arrow canvas so that when canvas is rotated
+ // the arrow doesn't get clipped
+ const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
+ boundTextCanvas.width =
+ maxDim * window.devicePixelRatio * scale + padding * scale * 10;
+ boundTextCanvas.height =
+ maxDim * window.devicePixelRatio * scale + padding * scale * 10;
+ boundTextCanvasContext.translate(
+ boundTextCanvas.width / 2,
+ boundTextCanvas.height / 2,
+ );
+ boundTextCanvasContext.rotate(element.angle);
+ boundTextCanvasContext.drawImage(
+ canvas!,
+ -canvas.width / 2,
+ -canvas.height / 2,
+ canvas.width,
+ canvas.height,
+ );
+
+ const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
+ boundTextElement,
+ elementsMap,
+ );
+
+ boundTextCanvasContext.rotate(-element.angle);
+ const offsetX = (boundTextCanvas.width - canvas!.width) / 2;
+ const offsetY = (boundTextCanvas.height - canvas!.height) / 2;
+ const shiftX =
+ boundTextCanvas.width / 2 -
+ (boundTextCx - x1) * window.devicePixelRatio * scale -
+ offsetX -
+ padding * scale;
+
+ const shiftY =
+ boundTextCanvas.height / 2 -
+ (boundTextCy - y1) * window.devicePixelRatio * scale -
+ offsetY -
+ padding * scale;
+ boundTextCanvasContext.translate(-shiftX, -shiftY);
+ // Clear the bound text area
+ boundTextCanvasContext.clearRect(
+ -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
+ window.devicePixelRatio *
+ scale,
+ -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
+ window.devicePixelRatio *
+ scale,
+ (boundTextElement.width + BOUND_TEXT_PADDING * 2) *
+ window.devicePixelRatio *
+ scale,
+ (boundTextElement.height + BOUND_TEXT_PADDING * 2) *
+ window.devicePixelRatio *
+ scale,
+ );
+ }
+
+ return {
+ element,
+ canvas,
+ theme: appState.theme,
+ scale,
+ zoomValue: zoom.value,
+ canvasOffsetX,
+ canvasOffsetY,
+ boundTextElementVersion:
+ getBoundTextElement(element, elementsMap)?.version || null,
+ containingFrameOpacity:
+ getContainingFrame(element, elementsMap)?.opacity || 100,
+ boundTextCanvas,
+ angle: element.angle,
+ imageCrop: isImageElement(element) ? element.crop : null,
+ };
+};
+
+export const DEFAULT_LINK_SIZE = 14;
+
+const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
+IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
+ `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
+)}`;
+
+const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
+IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
+ `<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
+)}`;
+
+const drawImagePlaceholder = (
+ element: ExcalidrawImageElement,
+ context: CanvasRenderingContext2D,
+) => {
+ context.fillStyle = "#E7E7E7";
+ context.fillRect(0, 0, element.width, element.height);
+
+ const imageMinWidthOrHeight = Math.min(element.width, element.height);
+
+ const size = Math.min(
+ imageMinWidthOrHeight,
+ Math.min(imageMinWidthOrHeight * 0.4, 100),
+ );
+
+ context.drawImage(
+ element.status === "error"
+ ? IMAGE_ERROR_PLACEHOLDER_IMG
+ : IMAGE_PLACEHOLDER_IMG,
+ element.width / 2 - size / 2,
+ element.height / 2 - size / 2,
+ size,
+ size,
+ );
+};
+
+const drawElementOnCanvas = (
+ element: NonDeletedExcalidrawElement,
+ rc: RoughCanvas,
+ context: CanvasRenderingContext2D,
+ renderConfig: StaticCanvasRenderConfig,
+ appState: StaticCanvasAppState,
+) => {
+ switch (element.type) {
+ case "rectangle":
+ case "iframe":
+ case "embeddable":
+ case "diamond":
+ case "ellipse": {
+ context.lineJoin = "round";
+ context.lineCap = "round";
+ rc.draw(ShapeCache.get(element)!);
+ break;
+ }
+ case "arrow":
+ case "line": {
+ context.lineJoin = "round";
+ context.lineCap = "round";
+
+ ShapeCache.get(element)!.forEach((shape) => {
+ rc.draw(shape);
+ });
+ break;
+ }
+ case "freedraw": {
+ // Draw directly to canvas
+ context.save();
+ context.fillStyle = element.strokeColor;
+
+ const path = getFreeDrawPath2D(element) as Path2D;
+ const fillShape = ShapeCache.get(element);
+
+ if (fillShape) {
+ rc.draw(fillShape);
+ }
+
+ context.fillStyle = element.strokeColor;
+ context.fill(path);
+
+ context.restore();
+ break;
+ }
+ case "image": {
+ const img = isInitializedImageElement(element)
+ ? renderConfig.imageCache.get(element.fileId)?.image
+ : undefined;
+ if (img != null && !(img instanceof Promise)) {
+ if (element.roundness && context.roundRect) {
+ context.beginPath();
+ context.roundRect(
+ 0,
+ 0,
+ element.width,
+ element.height,
+ getCornerRadius(Math.min(element.width, element.height), element),
+ );
+ context.clip();
+ }
+
+ const { x, y, width, height } = element.crop
+ ? element.crop
+ : {
+ x: 0,
+ y: 0,
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ };
+
+ context.drawImage(
+ img,
+ x,
+ y,
+ width,
+ height,
+ 0 /* hardcoded for the selection box*/,
+ 0,
+ element.width,
+ element.height,
+ );
+ } else {
+ drawImagePlaceholder(element, context);
+ }
+ break;
+ }
+ default: {
+ if (isTextElement(element)) {
+ const rtl = isRTL(element.text);
+ const shouldTemporarilyAttach = rtl && !context.canvas.isConnected;
+ if (shouldTemporarilyAttach) {
+ // to correctly render RTL text mixed with LTR, we have to append it
+ // to the DOM
+ document.body.appendChild(context.canvas);
+ }
+ context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
+ context.save();
+ context.font = getFontString(element);
+ context.fillStyle = element.strokeColor;
+ context.textAlign = element.textAlign as CanvasTextAlign;
+
+ // Canvas does not support multiline text by default
+ const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
+
+ const horizontalOffset =
+ element.textAlign === "center"
+ ? element.width / 2
+ : element.textAlign === "right"
+ ? element.width
+ : 0;
+
+ const lineHeightPx = getLineHeightInPx(
+ element.fontSize,
+ element.lineHeight,
+ );
+
+ const verticalOffset = getVerticalOffset(
+ element.fontFamily,
+ element.fontSize,
+ lineHeightPx,
+ );
+
+ for (let index = 0; index < lines.length; index++) {
+ context.fillText(
+ lines[index],
+ horizontalOffset,
+ index * lineHeightPx + verticalOffset,
+ );
+ }
+ context.restore();
+ if (shouldTemporarilyAttach) {
+ context.canvas.remove();
+ }
+ } else {
+ throw new Error(`Unimplemented type ${element.type}`);
+ }
+ }
+ }
+};
+
+export const elementWithCanvasCache = new WeakMap<
+ ExcalidrawElement,
+ ExcalidrawElementWithCanvas
+>();
+
+const generateElementWithCanvas = (
+ element: NonDeletedExcalidrawElement,
+ elementsMap: NonDeletedSceneElementsMap,
+ renderConfig: StaticCanvasRenderConfig,
+ appState: StaticCanvasAppState,
+) => {
+ const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom;
+ const prevElementWithCanvas = elementWithCanvasCache.get(element);
+ const shouldRegenerateBecauseZoom =
+ prevElementWithCanvas &&
+ prevElementWithCanvas.zoomValue !== zoom.value &&
+ !appState?.shouldCacheIgnoreZoom;
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ const boundTextElementVersion = boundTextElement?.version || null;
+ const imageCrop = isImageElement(element) ? element.crop : null;
+
+ const containingFrameOpacity =
+ getContainingFrame(element, elementsMap)?.opacity || 100;
+
+ if (
+ !prevElementWithCanvas ||
+ shouldRegenerateBecauseZoom ||
+ prevElementWithCanvas.theme !== appState.theme ||
+ prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
+ prevElementWithCanvas.imageCrop !== imageCrop ||
+ prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity ||
+ // since we rotate the canvas when copying from cached canvas, we don't
+ // regenerate the cached canvas. But we need to in case of labels which are
+ // cached alongside the arrow, and we want the labels to remain unrotated
+ // with respect to the arrow.
+ (isArrowElement(element) &&
+ boundTextElement &&
+ element.angle !== prevElementWithCanvas.angle)
+ ) {
+ const elementWithCanvas = generateElementCanvas(
+ element,
+ elementsMap,
+ zoom,
+ renderConfig,
+ appState,
+ );
+
+ if (!elementWithCanvas) {
+ return null;
+ }
+
+ elementWithCanvasCache.set(element, elementWithCanvas);
+
+ return elementWithCanvas;
+ }
+ return prevElementWithCanvas;
+};
+
+const drawElementFromCanvas = (
+ elementWithCanvas: ExcalidrawElementWithCanvas,
+ context: CanvasRenderingContext2D,
+ renderConfig: StaticCanvasRenderConfig,
+ appState: StaticCanvasAppState,
+ allElementsMap: NonDeletedSceneElementsMap,
+) => {
+ const element = elementWithCanvas.element;
+ const padding = getCanvasPadding(element);
+ const zoom = elementWithCanvas.scale;
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
+ const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
+ const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
+
+ context.save();
+ context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
+
+ const boundTextElement = getBoundTextElement(element, allElementsMap);
+
+ if (isArrowElement(element) && boundTextElement) {
+ const offsetX =
+ (elementWithCanvas.boundTextCanvas.width -
+ elementWithCanvas.canvas!.width) /
+ 2;
+ const offsetY =
+ (elementWithCanvas.boundTextCanvas.height -
+ elementWithCanvas.canvas!.height) /
+ 2;
+ context.translate(cx, cy);
+ context.drawImage(
+ elementWithCanvas.boundTextCanvas,
+ (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
+ (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
+ elementWithCanvas.boundTextCanvas.width / zoom,
+ elementWithCanvas.boundTextCanvas.height / zoom,
+ );
+ } else {
+ // we translate context to element center so that rotation and scale
+ // originates from the element center
+ context.translate(cx, cy);
+
+ context.rotate(element.angle);
+
+ if (
+ "scale" in elementWithCanvas.element &&
+ !isPendingImageElement(element, renderConfig)
+ ) {
+ context.scale(
+ elementWithCanvas.element.scale[0],
+ elementWithCanvas.element.scale[1],
+ );
+ }
+
+ // revert afterwards we don't have account for it during drawing
+ context.translate(-cx, -cy);
+
+ context.drawImage(
+ elementWithCanvas.canvas!,
+ (x1 + appState.scrollX) * window.devicePixelRatio -
+ (padding * elementWithCanvas.scale) / elementWithCanvas.scale,
+ (y1 + appState.scrollY) * window.devicePixelRatio -
+ (padding * elementWithCanvas.scale) / elementWithCanvas.scale,
+ elementWithCanvas.canvas!.width / elementWithCanvas.scale,
+ elementWithCanvas.canvas!.height / elementWithCanvas.scale,
+ );
+
+ if (
+ import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
+ "true" &&
+ hasBoundTextElement(element)
+ ) {
+ const textElement = getBoundTextElement(
+ element,
+ allElementsMap,
+ ) as ExcalidrawTextElementWithContainer;
+ const coords = getContainerCoords(element);
+ context.strokeStyle = "#c92a2a";
+ context.lineWidth = 3;
+ context.strokeRect(
+ (coords.x + appState.scrollX) * window.devicePixelRatio,
+ (coords.y + appState.scrollY) * window.devicePixelRatio,
+ getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
+ getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
+ );
+ }
+ }
+ context.restore();
+
+ // Clear the nested element we appended to the DOM
+};
+
+export const renderSelectionElement = (
+ element: NonDeletedExcalidrawElement,
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+ selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
+) => {
+ context.save();
+ context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
+ context.fillStyle = "rgba(0, 0, 200, 0.04)";
+
+ // render from 0.5px offset to get 1px wide line
+ // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
+ // TODO can be be improved by offseting to the negative when user selects
+ // from right to left
+ const offset = 0.5 / appState.zoom.value;
+
+ context.fillRect(offset, offset, element.width, element.height);
+ context.lineWidth = 1 / appState.zoom.value;
+ context.strokeStyle = selectionColor;
+ context.strokeRect(offset, offset, element.width, element.height);
+
+ context.restore();
+};
+
+export const renderElement = (
+ element: NonDeletedExcalidrawElement,
+ elementsMap: RenderableElementsMap,
+ allElementsMap: NonDeletedSceneElementsMap,
+ rc: RoughCanvas,
+ context: CanvasRenderingContext2D,
+ renderConfig: StaticCanvasRenderConfig,
+ appState: StaticCanvasAppState,
+) => {
+ const reduceAlphaForSelection =
+ appState.openDialog?.name === "elementLinkSelector" &&
+ !appState.selectedElementIds[element.id] &&
+ !appState.hoveredElementIds[element.id];
+
+ context.globalAlpha = getRenderOpacity(
+ element,
+ getContainingFrame(element, elementsMap),
+ renderConfig.elementsPendingErasure,
+ renderConfig.pendingFlowchartNodes,
+ reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
+ );
+
+ switch (element.type) {
+ case "magicframe":
+ case "frame": {
+ if (appState.frameRendering.enabled && appState.frameRendering.outline) {
+ context.save();
+ context.translate(
+ element.x + appState.scrollX,
+ element.y + appState.scrollY,
+ );
+ context.fillStyle = "rgba(0, 0, 200, 0.04)";
+
+ context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
+ context.strokeStyle = FRAME_STYLE.strokeColor;
+
+ // TODO change later to only affect AI frames
+ if (isMagicFrameElement(element)) {
+ context.strokeStyle =
+ appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
+ }
+
+ if (FRAME_STYLE.radius && context.roundRect) {
+ context.beginPath();
+ context.roundRect(
+ 0,
+ 0,
+ element.width,
+ element.height,
+ FRAME_STYLE.radius / appState.zoom.value,
+ );
+ context.stroke();
+ context.closePath();
+ } else {
+ context.strokeRect(0, 0, element.width, element.height);
+ }
+
+ context.restore();
+ }
+ break;
+ }
+ case "freedraw": {
+ // TODO investigate if we can do this in situ. Right now we need to call
+ // beforehand because math helpers (such as getElementAbsoluteCoords)
+ // rely on existing shapes
+ ShapeCache.generateElementShape(element, null);
+
+ if (renderConfig.isExporting) {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const cx = (x1 + x2) / 2 + appState.scrollX;
+ const cy = (y1 + y2) / 2 + appState.scrollY;
+ const shiftX = (x2 - x1) / 2 - (element.x - x1);
+ const shiftY = (y2 - y1) / 2 - (element.y - y1);
+ context.save();
+ context.translate(cx, cy);
+ context.rotate(element.angle);
+ context.translate(-shiftX, -shiftY);
+ drawElementOnCanvas(element, rc, context, renderConfig, appState);
+ context.restore();
+ } else {
+ const elementWithCanvas = generateElementWithCanvas(
+ element,
+ allElementsMap,
+ renderConfig,
+ appState,
+ );
+ if (!elementWithCanvas) {
+ return;
+ }
+
+ drawElementFromCanvas(
+ elementWithCanvas,
+ context,
+ renderConfig,
+ appState,
+ allElementsMap,
+ );
+ }
+
+ break;
+ }
+ case "rectangle":
+ case "diamond":
+ case "ellipse":
+ case "line":
+ case "arrow":
+ case "image":
+ case "text":
+ case "iframe":
+ case "embeddable": {
+ // TODO investigate if we can do this in situ. Right now we need to call
+ // beforehand because math helpers (such as getElementAbsoluteCoords)
+ // rely on existing shapes
+ ShapeCache.generateElementShape(element, renderConfig);
+ if (renderConfig.isExporting) {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const cx = (x1 + x2) / 2 + appState.scrollX;
+ const cy = (y1 + y2) / 2 + appState.scrollY;
+ let shiftX = (x2 - x1) / 2 - (element.x - x1);
+ let shiftY = (y2 - y1) / 2 - (element.y - y1);
+ if (isTextElement(element)) {
+ const container = getContainerElement(element, elementsMap);
+ if (isArrowElement(container)) {
+ const boundTextCoords =
+ LinearElementEditor.getBoundTextElementPosition(
+ container,
+ element as ExcalidrawTextElementWithContainer,
+ elementsMap,
+ );
+ shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1);
+ shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1);
+ }
+ }
+ context.save();
+ context.translate(cx, cy);
+
+ if (shouldResetImageFilter(element, renderConfig, appState)) {
+ context.filter = "none";
+ }
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+
+ if (isArrowElement(element) && boundTextElement) {
+ const tempCanvas = document.createElement("canvas");
+
+ const tempCanvasContext = tempCanvas.getContext("2d")!;
+
+ // Take max dimensions of arrow canvas so that when canvas is rotated
+ // the arrow doesn't get clipped
+ const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
+ const padding = getCanvasPadding(element);
+ tempCanvas.width =
+ maxDim * appState.exportScale + padding * 10 * appState.exportScale;
+ tempCanvas.height =
+ maxDim * appState.exportScale + padding * 10 * appState.exportScale;
+
+ tempCanvasContext.translate(
+ tempCanvas.width / 2,
+ tempCanvas.height / 2,
+ );
+ tempCanvasContext.scale(appState.exportScale, appState.exportScale);
+
+ // Shift the canvas to left most point of the arrow
+ shiftX = element.width / 2 - (element.x - x1);
+ shiftY = element.height / 2 - (element.y - y1);
+
+ tempCanvasContext.rotate(element.angle);
+ const tempRc = rough.canvas(tempCanvas);
+
+ tempCanvasContext.translate(-shiftX, -shiftY);
+
+ drawElementOnCanvas(
+ element,
+ tempRc,
+ tempCanvasContext,
+ renderConfig,
+ appState,
+ );
+
+ tempCanvasContext.translate(shiftX, shiftY);
+
+ tempCanvasContext.rotate(-element.angle);
+
+ // Shift the canvas to center of bound text
+ const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
+ boundTextElement,
+ elementsMap,
+ );
+ const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
+ const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
+ tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
+
+ // Clear the bound text area
+ tempCanvasContext.clearRect(
+ -boundTextElement.width / 2,
+ -boundTextElement.height / 2,
+ boundTextElement.width,
+ boundTextElement.height,
+ );
+ context.scale(1 / appState.exportScale, 1 / appState.exportScale);
+ context.drawImage(
+ tempCanvas,
+ -tempCanvas.width / 2,
+ -tempCanvas.height / 2,
+ tempCanvas.width,
+ tempCanvas.height,
+ );
+ } else {
+ context.rotate(element.angle);
+
+ if (element.type === "image") {
+ // note: scale must be applied *after* rotating
+ context.scale(element.scale[0], element.scale[1]);
+ }
+
+ context.translate(-shiftX, -shiftY);
+ drawElementOnCanvas(element, rc, context, renderConfig, appState);
+ }
+
+ context.restore();
+ // not exporting → optimized rendering (cache & render from element
+ // canvases)
+ } else {
+ const elementWithCanvas = generateElementWithCanvas(
+ element,
+ allElementsMap,
+ renderConfig,
+ appState,
+ );
+
+ if (!elementWithCanvas) {
+ return;
+ }
+
+ const currentImageSmoothingStatus = context.imageSmoothingEnabled;
+
+ if (
+ // do not disable smoothing during zoom as blurry shapes look better
+ // on low resolution (while still zooming in) than sharp ones
+ !appState?.shouldCacheIgnoreZoom &&
+ // angle is 0 -> always disable smoothing
+ (!element.angle ||
+ // or check if angle is a right angle in which case we can still
+ // disable smoothing without adversely affecting the result
+ // We need less-than comparison because of FP artihmetic
+ isRightAngleRads(element.angle))
+ ) {
+ // Disabling smoothing makes output much sharper, especially for
+ // text. Unless for non-right angles, where the aliasing is really
+ // terrible on Chromium.
+ //
+ // Note that `context.imageSmoothingQuality="high"` has almost
+ // zero effect.
+ //
+ context.imageSmoothingEnabled = false;
+ }
+
+ if (
+ element.id === appState.croppingElementId &&
+ isImageElement(elementWithCanvas.element) &&
+ elementWithCanvas.element.crop !== null
+ ) {
+ context.save();
+ context.globalAlpha = 0.1;
+
+ const uncroppedElementCanvas = generateElementCanvas(
+ getUncroppedImageElement(elementWithCanvas.element, elementsMap),
+ allElementsMap,
+ appState.zoom,
+ renderConfig,
+ appState,
+ );
+
+ if (uncroppedElementCanvas) {
+ drawElementFromCanvas(
+ uncroppedElementCanvas,
+ context,
+ renderConfig,
+ appState,
+ allElementsMap,
+ );
+ }
+
+ context.restore();
+ }
+
+ drawElementFromCanvas(
+ elementWithCanvas,
+ context,
+ renderConfig,
+ appState,
+ allElementsMap,
+ );
+
+ // reset
+ context.imageSmoothingEnabled = currentImageSmoothingStatus;
+ }
+ break;
+ }
+ default: {
+ // @ts-ignore
+ throw new Error(`Unimplemented type ${element.type}`);
+ }
+ }
+
+ context.globalAlpha = 1;
+};
+
+export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
+
+export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
+ const svgPathData = getFreeDrawSvgPath(element);
+ const path = new Path2D(svgPathData);
+ pathsCache.set(element, path);
+ return path;
+}
+
+export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
+ return pathsCache.get(element);
+}
+
+export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
+ // If input points are empty (should they ever be?) return a dot
+ const inputPoints = element.simulatePressure
+ ? element.points
+ : element.points.length
+ ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
+ : [[0, 0, 0.5]];
+
+ // Consider changing the options for simulated pressure vs real pressure
+ const options: StrokeOptions = {
+ simulatePressure: element.simulatePressure,
+ size: element.strokeWidth * 4.25,
+ thinning: 0.6,
+ smoothing: 0.5,
+ streamline: 0.5,
+ easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
+ last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
+ };
+
+ return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
+}
+
+function med(A: number[], B: number[]) {
+ return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
+}
+
+// Trim SVG path data so number are each two decimal points. This
+// improves SVG exports, and prevents rendering errors on points
+// with long decimals.
+const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
+
+function getSvgPathFromStroke(points: number[][]): string {
+ if (!points.length) {
+ return "";
+ }
+
+ const max = points.length - 1;
+
+ return points
+ .reduce(
+ (acc, point, i, arr) => {
+ if (i === max) {
+ acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
+ } else {
+ acc.push(point, med(point, arr[i + 1]));
+ }
+ return acc;
+ },
+ ["M", points[0], "Q"],
+ )
+ .join(" ")
+ .replace(TO_FIXED_PRECISION, "$1");
+}
diff --git a/packages/excalidraw/renderer/renderNewElementScene.ts b/packages/excalidraw/renderer/renderNewElementScene.ts
new file mode 100644
index 0000000..caa7f58
--- /dev/null
+++ b/packages/excalidraw/renderer/renderNewElementScene.ts
@@ -0,0 +1,66 @@
+import type { NewElementSceneRenderConfig } from "../scene/types";
+import { throttleRAF } from "../utils";
+import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
+import { renderElement } from "./renderElement";
+
+const _renderNewElementScene = ({
+ canvas,
+ rc,
+ newElement,
+ elementsMap,
+ allElementsMap,
+ scale,
+ appState,
+ renderConfig,
+}: NewElementSceneRenderConfig) => {
+ if (canvas) {
+ const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
+ canvas,
+ scale,
+ );
+
+ const context = bootstrapCanvas({
+ canvas,
+ scale,
+ normalizedWidth,
+ normalizedHeight,
+ });
+
+ // Apply zoom
+ context.save();
+ context.scale(appState.zoom.value, appState.zoom.value);
+
+ if (newElement && newElement.type !== "selection") {
+ renderElement(
+ newElement,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ } else {
+ context.clearRect(0, 0, normalizedWidth, normalizedHeight);
+ }
+ }
+};
+
+export const renderNewElementSceneThrottled = throttleRAF(
+ (config: NewElementSceneRenderConfig) => {
+ _renderNewElementScene(config);
+ },
+ { trailing: true },
+);
+
+export const renderNewElementScene = (
+ renderConfig: NewElementSceneRenderConfig,
+ throttle?: boolean,
+) => {
+ if (throttle) {
+ renderNewElementSceneThrottled(renderConfig);
+ return;
+ }
+
+ _renderNewElementScene(renderConfig);
+};
diff --git a/packages/excalidraw/renderer/renderSnaps.ts b/packages/excalidraw/renderer/renderSnaps.ts
new file mode 100644
index 0000000..878e8e5
--- /dev/null
+++ b/packages/excalidraw/renderer/renderSnaps.ts
@@ -0,0 +1,207 @@
+import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
+import { THEME } from "../constants";
+import type { PointSnapLine, PointerSnapLine } from "../snapping";
+import type { InteractiveCanvasAppState } from "../types";
+
+const SNAP_COLOR_LIGHT = "#ff6b6b";
+const SNAP_COLOR_DARK = "#ff0000";
+const SNAP_WIDTH = 1;
+const SNAP_CROSS_SIZE = 2;
+
+export const renderSnaps = (
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+) => {
+ if (!appState.snapLines.length) {
+ return;
+ }
+
+ // in dark mode, we need to adjust the color to account for color inversion.
+ // Don't change if zen mode, because we draw only crosses, we want the
+ // colors to be more visible
+ const snapColor =
+ appState.theme === THEME.LIGHT || appState.zenModeEnabled
+ ? SNAP_COLOR_LIGHT
+ : SNAP_COLOR_DARK;
+ // in zen mode make the cross more visible since we don't draw the lines
+ const snapWidth =
+ (appState.zenModeEnabled ? SNAP_WIDTH * 1.5 : SNAP_WIDTH) /
+ appState.zoom.value;
+
+ context.save();
+ context.translate(appState.scrollX, appState.scrollY);
+
+ for (const snapLine of appState.snapLines) {
+ if (snapLine.type === "pointer") {
+ context.lineWidth = snapWidth;
+ context.strokeStyle = snapColor;
+
+ drawPointerSnapLine(snapLine, context, appState);
+ } else if (snapLine.type === "gap") {
+ context.lineWidth = snapWidth;
+ context.strokeStyle = snapColor;
+
+ drawGapLine(
+ snapLine.points[0],
+ snapLine.points[1],
+ snapLine.direction,
+ appState,
+ context,
+ );
+ } else if (snapLine.type === "points") {
+ context.lineWidth = snapWidth;
+ context.strokeStyle = snapColor;
+ drawPointsSnapLine(snapLine, context, appState);
+ }
+ }
+
+ context.restore();
+};
+
+const drawPointsSnapLine = (
+ pointSnapLine: PointSnapLine,
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+) => {
+ if (!appState.zenModeEnabled) {
+ const firstPoint = pointSnapLine.points[0];
+ const lastPoint = pointSnapLine.points[pointSnapLine.points.length - 1];
+
+ drawLine(firstPoint, lastPoint, context);
+ }
+
+ for (const point of pointSnapLine.points) {
+ drawCross(point, appState, context);
+ }
+};
+
+const drawPointerSnapLine = (
+ pointerSnapLine: PointerSnapLine,
+ context: CanvasRenderingContext2D,
+ appState: InteractiveCanvasAppState,
+) => {
+ drawCross(pointerSnapLine.points[0], appState, context);
+ if (!appState.zenModeEnabled) {
+ drawLine(pointerSnapLine.points[0], pointerSnapLine.points[1], context);
+ }
+};
+
+const drawCross = <Point extends LocalPoint | GlobalPoint>(
+ [x, y]: Point,
+ appState: InteractiveCanvasAppState,
+ context: CanvasRenderingContext2D,
+) => {
+ context.save();
+ const size =
+ (appState.zenModeEnabled ? SNAP_CROSS_SIZE * 1.5 : SNAP_CROSS_SIZE) /
+ appState.zoom.value;
+ context.beginPath();
+
+ context.moveTo(x - size, y - size);
+ context.lineTo(x + size, y + size);
+
+ context.moveTo(x + size, y - size);
+ context.lineTo(x - size, y + size);
+
+ context.stroke();
+ context.restore();
+};
+
+const drawLine = <Point extends LocalPoint | GlobalPoint>(
+ from: Point,
+ to: Point,
+ context: CanvasRenderingContext2D,
+) => {
+ context.beginPath();
+ context.lineTo(from[0], from[1]);
+ context.lineTo(to[0], to[1]);
+ context.stroke();
+};
+
+const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
+ from: Point,
+ to: Point,
+ direction: "horizontal" | "vertical",
+ appState: InteractiveCanvasAppState,
+ context: CanvasRenderingContext2D,
+) => {
+ // a horizontal gap snap line
+ // |–––––––||–––––––|
+ // ^ ^ ^ ^
+ // \ \ \ \
+ // (1) (2) (3) (4)
+
+ const FULL = 8 / appState.zoom.value;
+ const HALF = FULL / 2;
+ const QUARTER = FULL / 4;
+
+ if (direction === "horizontal") {
+ const halfPoint = [(from[0] + to[0]) / 2, from[1]];
+ // (1)
+ if (!appState.zenModeEnabled) {
+ drawLine(
+ pointFrom(from[0], from[1] - FULL),
+ pointFrom(from[0], from[1] + FULL),
+ context,
+ );
+ }
+
+ // (3)
+ drawLine(
+ pointFrom(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
+ pointFrom(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
+ context,
+ );
+ drawLine(
+ pointFrom(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
+ pointFrom(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
+ context,
+ );
+
+ if (!appState.zenModeEnabled) {
+ // (4)
+ drawLine(
+ pointFrom(to[0], to[1] - FULL),
+ pointFrom(to[0], to[1] + FULL),
+ context,
+ );
+
+ // (2)
+ drawLine(from, to, context);
+ }
+ } else {
+ const halfPoint = [from[0], (from[1] + to[1]) / 2];
+ // (1)
+ if (!appState.zenModeEnabled) {
+ drawLine(
+ pointFrom(from[0] - FULL, from[1]),
+ pointFrom(from[0] + FULL, from[1]),
+ context,
+ );
+ }
+
+ // (3)
+ drawLine(
+ pointFrom(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
+ pointFrom(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
+ context,
+ );
+ drawLine(
+ pointFrom(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
+ pointFrom(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
+ context,
+ );
+
+ if (!appState.zenModeEnabled) {
+ // (4)
+ drawLine(
+ pointFrom(to[0] - FULL, to[1]),
+ pointFrom(to[0] + FULL, to[1]),
+ context,
+ );
+
+ // (2)
+ drawLine(from, to, context);
+ }
+ }
+};
diff --git a/packages/excalidraw/renderer/roundRect.ts b/packages/excalidraw/renderer/roundRect.ts
new file mode 100644
index 0000000..bbb9830
--- /dev/null
+++ b/packages/excalidraw/renderer/roundRect.ts
@@ -0,0 +1,41 @@
+/**
+ * https://stackoverflow.com/a/3368118
+ * Draws a rounded rectangle using the current state of the canvas.
+ * @param {CanvasRenderingContext2D} context
+ * @param {Number} x The top left x coordinate
+ * @param {Number} y The top left y coordinate
+ * @param {Number} width The width of the rectangle
+ * @param {Number} height The height of the rectangle
+ * @param {Number} radius The corner radius
+ */
+export const roundRect = (
+ context: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number,
+ strokeColor?: string,
+) => {
+ context.beginPath();
+ context.moveTo(x + radius, y);
+ context.lineTo(x + width - radius, y);
+ context.quadraticCurveTo(x + width, y, x + width, y + radius);
+ context.lineTo(x + width, y + height - radius);
+ context.quadraticCurveTo(
+ x + width,
+ y + height,
+ x + width - radius,
+ y + height,
+ );
+ context.lineTo(x + radius, y + height);
+ context.quadraticCurveTo(x, y + height, x, y + height - radius);
+ context.lineTo(x, y + radius);
+ context.quadraticCurveTo(x, y, x + radius, y);
+ context.closePath();
+ context.fill();
+ if (strokeColor) {
+ context.strokeStyle = strokeColor;
+ }
+ context.stroke();
+};
diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts
new file mode 100644
index 0000000..90ed8af
--- /dev/null
+++ b/packages/excalidraw/renderer/staticScene.ts
@@ -0,0 +1,480 @@
+import { FRAME_STYLE } from "../constants";
+import { getElementAbsoluteCoords } from "../element";
+
+import {
+ elementOverlapsWithFrame,
+ getTargetFrame,
+ shouldApplyFrameClip,
+} from "../frame";
+import {
+ isEmbeddableElement,
+ isIframeLikeElement,
+ isTextElement,
+} from "../element/typeChecks";
+import { renderElement } from "../renderer/renderElement";
+import { createPlaceholderEmbeddableLabel } from "../element/embeddable";
+import type { StaticCanvasAppState, Zoom } from "../types";
+import type {
+ ElementsMap,
+ ExcalidrawFrameLikeElement,
+ NonDeletedExcalidrawElement,
+} from "../element/types";
+import type {
+ StaticCanvasRenderConfig,
+ StaticSceneRenderConfig,
+} from "../scene/types";
+import {
+ EXTERNAL_LINK_IMG,
+ ELEMENT_LINK_IMG,
+ getLinkHandleFromCoords,
+} from "../components/hyperlink/helpers";
+import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
+import { throttleRAF } from "../utils";
+import { getBoundTextElement } from "../element/textElement";
+import { isElementLink } from "../element/elementLink";
+
+const GridLineColor = {
+ Bold: "#dddddd",
+ Regular: "#e5e5e5",
+} as const;
+
+const strokeGrid = (
+ context: CanvasRenderingContext2D,
+ /** grid cell pixel size */
+ gridSize: number,
+ /** setting to 1 will disble bold lines */
+ gridStep: number,
+ scrollX: number,
+ scrollY: number,
+ zoom: Zoom,
+ width: number,
+ height: number,
+) => {
+ const offsetX = (scrollX % gridSize) - gridSize;
+ const offsetY = (scrollY % gridSize) - gridSize;
+
+ const actualGridSize = gridSize * zoom.value;
+
+ const spaceWidth = 1 / zoom.value;
+
+ context.save();
+
+ // Offset rendering by 0.5 to ensure that 1px wide lines are crisp.
+ // We only do this when zoomed to 100% because otherwise the offset is
+ // fractional, and also visibly offsets the elements.
+ // We also do this per-axis, as each axis may already be offset by 0.5.
+ if (zoom.value === 1) {
+ context.translate(offsetX % 1 ? 0 : 0.5, offsetY % 1 ? 0 : 0.5);
+ }
+
+ // vertical lines
+ for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
+ const isBold =
+ gridStep > 1 && Math.round(x - scrollX) % (gridStep * gridSize) === 0;
+ // don't render regular lines when zoomed out and they're barely visible
+ if (!isBold && actualGridSize < 10) {
+ continue;
+ }
+
+ const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
+ context.lineWidth = lineWidth;
+ const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
+
+ context.beginPath();
+ context.setLineDash(isBold ? [] : lineDash);
+ context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
+ context.moveTo(x, offsetY - gridSize);
+ context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
+ context.stroke();
+ }
+
+ for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
+ const isBold =
+ gridStep > 1 && Math.round(y - scrollY) % (gridStep * gridSize) === 0;
+ if (!isBold && actualGridSize < 10) {
+ continue;
+ }
+
+ const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
+ context.lineWidth = lineWidth;
+ const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
+
+ context.beginPath();
+ context.setLineDash(isBold ? [] : lineDash);
+ context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
+ context.moveTo(offsetX - gridSize, y);
+ context.lineTo(Math.ceil(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),
+ );
+};
+
+type LinkIconCanvas = HTMLCanvasElement & { zoom: number };
+
+const linkIconCanvasCache: {
+ regularLink: LinkIconCanvas | null;
+ elementLink: LinkIconCanvas | null;
+} = {
+ regularLink: null,
+ elementLink: null,
+};
+
+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);
+
+ const canvasKey = isElementLink(element.link)
+ ? "elementLink"
+ : "regularLink";
+
+ let linkCanvas = linkIconCanvasCache[canvasKey];
+
+ if (!linkCanvas || linkCanvas.zoom !== appState.zoom.value) {
+ linkCanvas = Object.assign(document.createElement("canvas"), {
+ zoom: appState.zoom.value,
+ });
+ linkCanvas.width = width * window.devicePixelRatio * appState.zoom.value;
+ linkCanvas.height =
+ height * window.devicePixelRatio * appState.zoom.value;
+ linkIconCanvasCache[canvasKey] = linkCanvas;
+
+ const linkCanvasCacheContext = linkCanvas.getContext("2d")!;
+ linkCanvasCacheContext.scale(
+ window.devicePixelRatio * appState.zoom.value,
+ window.devicePixelRatio * appState.zoom.value,
+ );
+ linkCanvasCacheContext.fillStyle = "#fff";
+ linkCanvasCacheContext.fillRect(0, 0, width, height);
+
+ if (canvasKey === "elementLink") {
+ linkCanvasCacheContext.drawImage(ELEMENT_LINK_IMG, 0, 0, width, height);
+ } else {
+ linkCanvasCacheContext.drawImage(
+ EXTERNAL_LINK_IMG,
+ 0,
+ 0,
+ width,
+ height,
+ );
+ }
+
+ linkCanvasCacheContext.restore();
+ }
+ context.drawImage(linkCanvas, 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) {
+ strokeGrid(
+ context,
+ appState.gridSize,
+ appState.gridStep,
+ 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),
+ );
+ }
+ });
+
+ const inFrameGroupsMap = new Map<string, boolean>();
+
+ // Paint visible elements
+ visibleElements
+ .filter((el) => !isIframeLikeElement(el))
+ .forEach((element) => {
+ try {
+ const frameId = element.frameId || appState.frameToHighlight?.id;
+
+ if (
+ isTextElement(element) &&
+ element.containerId &&
+ elementsMap.has(element.containerId)
+ ) {
+ // will be rendered with the container
+ return;
+ }
+
+ context.save();
+
+ if (
+ frameId &&
+ appState.frameRendering.enabled &&
+ appState.frameRendering.clip
+ ) {
+ const frame = getTargetFrame(element, elementsMap, appState);
+ if (
+ frame &&
+ shouldApplyFrameClip(
+ element,
+ frame,
+ appState,
+ elementsMap,
+ inFrameGroupsMap,
+ )
+ ) {
+ frameClip(frame, context, renderConfig, appState);
+ }
+ renderElement(
+ element,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ } else {
+ renderElement(
+ element,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ }
+
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ renderElement(
+ boundTextElement,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ }
+
+ context.restore();
+
+ if (!isExporting) {
+ renderLinkIcon(element, context, appState, elementsMap);
+ }
+ } catch (error: any) {
+ console.error(
+ error,
+ element.id,
+ element.x,
+ element.y,
+ element.width,
+ element.height,
+ );
+ }
+ });
+
+ // 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 &&
+ shouldApplyFrameClip(
+ element,
+ frame,
+ appState,
+ elementsMap,
+ inFrameGroupsMap,
+ )
+ ) {
+ frameClip(frame, context, renderConfig, appState);
+ }
+ render();
+ context.restore();
+ } else {
+ render();
+ }
+ } catch (error: any) {
+ console.error(error);
+ }
+ });
+
+ // render pending nodes for flowcharts
+ renderConfig.pendingFlowchartNodes?.forEach((element) => {
+ try {
+ renderElement(
+ element,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ } catch (error) {
+ 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);
+};
diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts
new file mode 100644
index 0000000..b14faf7
--- /dev/null
+++ b/packages/excalidraw/renderer/staticSvgScene.ts
@@ -0,0 +1,744 @@
+import type { Drawable } from "roughjs/bin/core";
+import type { 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, hashString } from "../element";
+import {
+ createPlaceholderEmbeddableLabel,
+ getEmbedLink,
+} from "../element/embeddable";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import {
+ getBoundTextElement,
+ getContainerElement,
+} from "../element/textElement";
+import {
+ isArrowElement,
+ isIframeLikeElement,
+ isInitializedImageElement,
+ isTextElement,
+} from "../element/typeChecks";
+import type {
+ ExcalidrawElement,
+ ExcalidrawTextElementWithContainer,
+ NonDeletedExcalidrawElement,
+} from "../element/types";
+import { getContainingFrame } from "../frame";
+import { ShapeCache } from "../scene/ShapeCache";
+import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
+import type { AppState, BinaryFiles } from "../types";
+import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
+import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
+import { getVerticalOffset } from "../fonts";
+import { getCornerRadius, isPathALoop } from "../shapes";
+import { getUncroppedWidthAndHeight } from "../element/cropElement";
+import { getLineHeightInPx } from "../element/textMeasurements";
+
+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 { reuseImages = true } = renderConfig;
+
+ let symbolId = `image-${fileData.id}`;
+
+ let uncroppedWidth = element.width;
+ let uncroppedHeight = element.height;
+ if (element.crop) {
+ ({ width: uncroppedWidth, height: uncroppedHeight } =
+ getUncroppedWidthAndHeight(element));
+
+ symbolId = `image-crop-${fileData.id}-${hashString(
+ `${uncroppedWidth}x${uncroppedHeight}`,
+ )}`;
+ }
+
+ if (!reuseImages) {
+ symbolId = `image-${element.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("href", fileData.dataURL);
+ image.setAttribute("preserveAspectRatio", "none");
+
+ if (element.crop || !reuseImages) {
+ image.setAttribute("width", `${uncroppedWidth}`);
+ image.setAttribute("height", `${uncroppedHeight}`);
+ } else {
+ image.setAttribute("width", "100%");
+ image.setAttribute("height", "100%");
+ }
+
+ symbol.appendChild(image);
+
+ (root.querySelector("defs") || 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);
+ }
+
+ let normalizedCropX = 0;
+ let normalizedCropY = 0;
+
+ if (element.crop) {
+ const { width: uncroppedWidth, height: uncroppedHeight } =
+ getUncroppedWidthAndHeight(element);
+ normalizedCropX =
+ element.crop.x / (element.crop.naturalWidth / uncroppedWidth);
+ normalizedCropY =
+ element.crop.y / (element.crop.naturalHeight / uncroppedHeight);
+ }
+
+ const adjustedCenterX = cx + normalizedCropX;
+ const adjustedCenterY = cy + normalizedCropY;
+
+ use.setAttribute("width", `${width + normalizedCropX}`);
+ use.setAttribute("height", `${height + normalizedCropY}`);
+ 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) {
+ use.setAttribute(
+ "transform",
+ `translate(${adjustedCenterX} ${adjustedCenterY}) scale(${
+ element.scale[0]
+ } ${
+ element.scale[1]
+ }) translate(${-adjustedCenterX} ${-adjustedCenterY})`,
+ );
+ }
+
+ const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
+
+ if (element.crop) {
+ const mask = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
+ mask.setAttribute("id", `mask-image-crop-${element.id}`);
+ mask.setAttribute("fill", "#fff");
+ const maskRect = svgRoot.ownerDocument!.createElementNS(
+ SVG_NS,
+ "rect",
+ );
+
+ maskRect.setAttribute("x", `${normalizedCropX}`);
+ maskRect.setAttribute("y", `${normalizedCropY}`);
+ maskRect.setAttribute("width", `${width}`);
+ maskRect.setAttribute("height", `${height}`);
+
+ mask.appendChild(maskRect);
+ root.appendChild(mask);
+ g.setAttribute("mask", `url(#${mask.id})`);
+ }
+
+ g.appendChild(use);
+ g.setAttribute(
+ "transform",
+ `translate(${offsetX - normalizedCropX} ${
+ offsetY - normalizedCropY
+ }) rotate(${degree} ${adjustedCenterX} ${adjustedCenterY})`,
+ );
+
+ 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 verticalOffset = getVerticalOffset(
+ element.fontFamily,
+ element.fontSize,
+ lineHeightPx,
+ );
+ 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 + verticalOffset}`);
+ 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", "alphabetic");
+ 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) {
+ if (
+ isTextElement(element) &&
+ element.containerId &&
+ elementsMap.has(element.containerId)
+ ) {
+ // will be rendered with the container
+ return;
+ }
+
+ try {
+ renderElementToSvg(
+ element,
+ elementsMap,
+ rsvg,
+ svgRoot,
+ files,
+ element.x + renderConfig.offsetX,
+ element.y + renderConfig.offsetY,
+ renderConfig,
+ );
+
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ renderElementToSvg(
+ boundTextElement,
+ elementsMap,
+ rsvg,
+ svgRoot,
+ files,
+ boundTextElement.x + renderConfig.offsetX,
+ boundTextElement.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);
+ }
+ }
+ });
+};