snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
import { isRenderThrottlingEnabled } from "../reactUtils";
export type Animation<R extends object> = (params: {
deltaTime: number;
state?: R;
}) => R | null | undefined;
type AnimationRecord = {
animation: Animation<any>;
lastTime: number;
state: any;
};
export class AnimationController {
private static scheduledFrame:
| { id: ReturnType<typeof requestAnimationFrame>; type: "raf" }
| { id: ReturnType<typeof setTimeout>; type: "timeout" }
| null = null;
private static animations = new Map<string, AnimationRecord>();
static start<R extends object>(key: string, animation: Animation<R>) {
if (AnimationController.animations.has(key)) {
return;
}
const record: AnimationRecord = {
animation,
lastTime: 0,
state: undefined,
};
AnimationController.animations.set(key, record);
let initialState: R | null | undefined;
try {
initialState = animation({
deltaTime: 0,
state: undefined,
});
} catch (error) {
if (AnimationController.animations.get(key) === record) {
AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
}
throw error;
}
// The initial callback may synchronously cancel this animation or replace
// it with another animation under the same key. Never resurrect or
// overwrite it after control returns.
if (AnimationController.animations.get(key) !== record) {
return;
}
if (!initialState) {
AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
return;
}
record.state = initialState;
AnimationController.scheduleNextFrame();
}
private static scheduleNextFrame() {
if (AnimationController.scheduledFrame) {
return;
}
if (isRenderThrottlingEnabled()) {
AnimationController.scheduledFrame = {
id: requestAnimationFrame(AnimationController.tick),
type: "raf",
};
} else {
AnimationController.scheduledFrame = {
id: setTimeout(AnimationController.tick, 0),
type: "timeout",
};
}
}
private static cancelScheduledFrame() {
if (!AnimationController.scheduledFrame) {
return;
}
if (AnimationController.scheduledFrame.type === "raf") {
cancelAnimationFrame(AnimationController.scheduledFrame.id);
} else {
clearTimeout(AnimationController.scheduledFrame.id);
}
AnimationController.scheduledFrame = null;
}
private static cancelScheduledFrameIfIdle() {
if (AnimationController.animations.size > 0) {
return false;
}
AnimationController.cancelScheduledFrame();
return true;
}
private static tick() {
AnimationController.scheduledFrame = null;
if (AnimationController.animations.size > 0) {
// A callback may synchronously add, cancel, or replace animations. Work
// from the frame's starting set so newly started animations begin on the
// next frame and every record runs at most once per tick.
const animations = [...AnimationController.animations];
for (const [key, animation] of animations) {
if (AnimationController.animations.get(key) !== animation) {
continue;
}
const now = performance.now();
const deltaTime =
animation.lastTime === 0 ? 0 : now - animation.lastTime;
const state = animation.animation({
deltaTime,
state: animation.state,
});
// The callback may have cancelled or replaced itself. Only the record
// that was invoked is allowed to update or remove its registration.
if (AnimationController.animations.get(key) !== animation) {
continue;
}
if (!state) {
AnimationController.animations.delete(key);
if (AnimationController.cancelScheduledFrameIfIdle()) {
return;
}
} else {
animation.lastTime = now;
animation.state = state;
}
}
if (AnimationController.cancelScheduledFrameIfIdle()) {
return;
}
AnimationController.scheduleNextFrame();
}
}
static running(key: string) {
return AnimationController.animations.has(key);
}
static cancel(key: string) {
AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
}
static reset() {
AnimationController.animations.clear();
AnimationController.cancelScheduledFrame();
}
}

View File

@@ -0,0 +1,90 @@
import { COLOR_WHITE } from "@excalidraw/common";
import { bootstrapCanvas } from "./helpers";
const setup = () => {
const canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 100;
const context = canvas.getContext("2d")!;
const clearRect = vi.spyOn(context, "clearRect");
const fillRect = vi.spyOn(context, "fillRect");
return { canvas, context, clearRect, fillRect };
};
const run = (viewBackgroundColor: unknown) => {
const { canvas, context, clearRect, fillRect } = setup();
bootstrapCanvas({
canvas,
scale: 1,
normalizedWidth: 200,
normalizedHeight: 100,
viewBackgroundColor: viewBackgroundColor as string,
});
return { context, clearRect, fillRect };
};
describe("bootstrapCanvas background painting", () => {
it("skips clearRect for an opaque hex color (fill fully repaints)", () => {
const { clearRect, fillRect } = run("#ffffff");
expect(clearRect).not.toHaveBeenCalled();
expect(fillRect).toHaveBeenCalledTimes(1);
});
it("skips clearRect for a 3-digit opaque hex color", () => {
const { clearRect, fillRect } = run("#fff");
expect(clearRect).not.toHaveBeenCalled();
expect(fillRect).toHaveBeenCalledTimes(1);
});
it("clears for a hex color with alpha (#RGBA / #RRGGBBAA)", () => {
expect(run("#ffff").clearRect).toHaveBeenCalledTimes(1);
expect(run("#ffffff80").clearRect).toHaveBeenCalledTimes(1);
});
it("clears and skips fill for the transparent keyword", () => {
const { clearRect, fillRect } = run("transparent");
expect(clearRect).toHaveBeenCalledTimes(1);
expect(fillRect).not.toHaveBeenCalled();
});
it("clears for rgba()/hsla() colors and still fills", () => {
const rgba = run("rgba(255, 0, 0, 0.5)");
expect(rgba.clearRect).toHaveBeenCalledTimes(1);
expect(rgba.fillRect).toHaveBeenCalledTimes(1);
});
// the ghosting bug (#10931): a corrupted value must never leave the prior
// frame on screen — we always clear when we can't prove the color is opaque
it("clears for a corrupted color value to prevent ghosting", () => {
expect(run("0000").clearRect).toHaveBeenCalledTimes(1);
expect(run("asdfgh").clearRect).toHaveBeenCalledTimes(1);
});
it("falls back to white when the color is rejected by the canvas", () => {
const { canvas, context } = setup();
// simulate a stale fillStyle left over from a previous frame's drawing
context.fillStyle = "#ff0000";
let fillStyleAtFillTime = "";
vi.spyOn(context, "fillRect").mockImplementation(() => {
fillStyleAtFillTime = context.fillStyle as string;
});
bootstrapCanvas({
canvas,
scale: 1,
normalizedWidth: 200,
normalizedHeight: 100,
viewBackgroundColor: "not-a-color",
});
// not the stale red — the seeded default
expect(fillStyleAtFillTime).toBe(COLOR_WHITE);
});
it("clears for a non-string background", () => {
const { clearRect, fillRect } = run(undefined);
expect(clearRect).toHaveBeenCalledTimes(1);
expect(fillRect).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,157 @@
import { COLOR_WHITE, THEME, applyDarkModeFilter } from "@excalidraw/common";
import type { StaticCanvasRenderConfig } from "../scene/types";
import type { AppState, StaticCanvasAppState } from "../types";
export const DEFAULT_SELECTION_COLOR = "#6965db";
/**
* Returns the theme's selection color (`--color-selection`), read from the
* computed style of any element inside the editor container so that host
* overrides are respected. Falls back to the default when unavailable.
*/
export const getSelectionColor = (element: Element | null | undefined) =>
(element &&
getComputedStyle(element).getPropertyValue("--color-selection").trim()) ||
DEFAULT_SELECTION_COLOR;
export const fillCircle = (
context: CanvasRenderingContext2D,
cx: number,
cy: number,
radius: number,
stroke: boolean,
fill = true,
) => {
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
if (fill) {
context.fill();
}
if (stroke) {
context.stroke();
}
};
/**
* The scroll the renderers draw at: the real one, rounded to whole device
* pixels. Panning writes a fractional scroll (pointer delta ÷ zoom), and an
* element's cached bitmap blitted at a fractional device offset gets
* resampled — so as the fraction drifts under a pan, every element pulses
* between crisp and soft. A whole-pixel scroll moves the scene the way a
* browser scrolls a page: each element keeps its own, constant sub-pixel
* phase and a pan never changes how anything is filtered. The difference
* stays under half a device pixel, so hit-testing and DOM overlays keep
* using the real scroll. Identity when nothing needs to change.
*/
export const snapScrollToDevicePixels = <
T extends { scrollX: number; scrollY: number; zoom: { value: number } },
>(
appState: T,
scale: number,
): T => {
// device pixels per scene unit
const devicePixels = appState.zoom.value * scale;
if (!(devicePixels > 0)) {
return appState;
}
const scrollX = Math.round(appState.scrollX * devicePixels) / devicePixels;
const scrollY = Math.round(appState.scrollY * devicePixels) / devicePixels;
return scrollX === appState.scrollX && scrollY === appState.scrollY
? appState
: { ...appState, scrollX, scrollY };
};
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);
// Paint background
if (typeof viewBackgroundColor === "string") {
// An opaque fill repaints every pixel, so clearRect would be redundant.
// For anything else — transparency, or a value we can't be certain about
// (e.g. corrupted persisted state like "0000") — clear first so the
// previous frame can't bleed through.
//
// We skip opaque #RRGGBB and #RGB hex colors as a quick optimization.
const isOpaque = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(viewBackgroundColor);
if (!isOpaque) {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
if (viewBackgroundColor !== "transparent") {
context.save();
// The canvas silently ignores an invalid fillStyle, which would leave a
// stale color from a previous draw. Seed a sane default so corrupted
// values fall back to white instead of painting garbage.
context.fillStyle = COLOR_WHITE;
context.fillStyle = applyDarkModeFilter(
viewBackgroundColor,
theme === THEME.DARK,
);
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
}
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
return context;
};
export const strokeRectWithRotation_simple = (
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();
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
import { throttleRAF } from "@excalidraw/common";
import {
getTargetFrame,
isInvisiblySmallElement,
renderElement,
shouldApplyFrameClip,
} from "@excalidraw/element";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
snapScrollToDevicePixels,
} from "./helpers";
import { frameClip } from "./staticScene";
import type { NewElementSceneRenderConfig } from "../scene/types";
const _renderNewElementScene = ({
canvas,
rc,
newElement,
elementsMap,
allElementsMap,
scale,
appState: unsnappedAppState,
renderConfig,
}: NewElementSceneRenderConfig) => {
if (canvas) {
// the same whole-device-pixel scroll the static scene draws at
const appState = snapScrollToDevicePixels(unsnappedAppState, scale);
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
});
context.save();
// Apply zoom
context.scale(appState.zoom.value, appState.zoom.value);
if (newElement && newElement.type !== "selection") {
// e.g. when creating arrows and we're still below the arrow drag distance
// threshold
// (for now we skip render only with elements while we're creating to be
// safe)
if (isInvisiblySmallElement(newElement)) {
return;
}
const frameId = newElement.frameId || appState.frameToHighlight?.id;
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
const frame = getTargetFrame(newElement, elementsMap, appState);
if (
frame &&
shouldApplyFrameClip(newElement, frame, appState, elementsMap)
) {
frameClip(frame, context, renderConfig, appState);
}
}
const renderTargetElement = newElement;
renderElement(
renderTargetElement,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.restore();
}
};
export const renderNewElementSceneThrottled = throttleRAF(
(config: NewElementSceneRenderConfig) => {
_renderNewElementScene(config);
},
);
export const renderNewElementScene = (
renderConfig: NewElementSceneRenderConfig,
throttle?: boolean,
) => {
if (throttle) {
renderNewElementSceneThrottled(renderConfig);
return;
}
_renderNewElementScene(renderConfig);
};

View File

@@ -0,0 +1,213 @@
import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
import { THEME } from "@excalidraw/common";
import type { PointSnapLine, PointerSnapLine } from "../snapping";
import type { InteractiveCanvasAppState } from "../types";
const SNAP_COLOR_LIGHT = "#ff6b6b";
// dark-mode colors are the equivalents of what the former CSS inversion filter
// produced (see `applyDarkModeFilter`)
const SNAP_COLOR_DARK = "#ff9090";
const SNAP_COLOR_DARK_ZEN = "#da5b5b";
const SNAP_WIDTH = 1;
const SNAP_CROSS_SIZE = 2;
export const renderSnaps = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
) => {
if (!appState.snapLines.length) {
return;
}
// in zen mode we draw only crosses, so use a different color to keep them
// visible
const snapColor =
appState.theme === THEME.LIGHT
? SNAP_COLOR_LIGHT
: appState.zenModeEnabled
? SNAP_COLOR_DARK_ZEN
: 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);
}
}
};

View File

@@ -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();
};

View File

@@ -0,0 +1,545 @@
import {
applyDarkModeFilter,
COLOR_WHITE,
FRAME_STYLE,
THEME,
throttleRAF,
} from "@excalidraw/common";
import { isElementLink } from "@excalidraw/element";
import { createPlaceholderEmbeddableLabel } from "@excalidraw/element";
import { getBoundTextElement } from "@excalidraw/element";
import {
isEmbeddableElement,
isIframeLikeElement,
isTextElement,
} from "@excalidraw/element";
import {
elementOverlapsWithFrame,
getTargetFrame,
shouldApplyFrameClip,
} from "@excalidraw/element";
import {
getRenderElementWithPositionOverride,
resolveElementRenderState,
renderElement,
} from "@excalidraw/element";
import { getElementAbsoluteCoords } from "@excalidraw/element";
import type { ElementRenderState } from "@excalidraw/element";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import {
EXTERNAL_LINK_IMG,
ELEMENT_LINK_IMG,
getLinkHandleFromCoords,
} from "../components/hyperlink/helpers";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
snapScrollToDevicePixels,
} from "./helpers";
import type {
StaticCanvasRenderConfig,
StaticSceneRenderConfig,
} from "../scene/types";
import type { StaticCanvasAppState, Zoom } from "../types";
const GridLineColor = {
[THEME.LIGHT]: {
bold: "#dddddd",
regular: "#e5e5e5",
},
[THEME.DARK]: {
bold: applyDarkModeFilter("#dddddd"),
regular: applyDarkModeFilter("#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,
theme: StaticCanvasRenderConfig["theme"],
width: number,
height: number,
scale: number,
) => {
const offsetX = (scrollX % gridSize) - gridSize;
const offsetY = (scrollY % gridSize) - gridSize;
const actualGridSize = gridSize * zoom.value;
const spaceWidth = 1 / zoom.value;
// scene units → device pixels
const devicePixels = zoom.value * scale;
// A line at least a device pixel wide is drawn a whole number of device
// pixels wide and centered to cover them exactly — on the half pixel when
// the count is odd. Straddling a pixel boundary renders it as two lighter
// pixels, which is how the grid looked at every zoom but 100%. Thinner
// lines (zoomed far out) keep their sub-pixel width: their lightness is
// the point.
const snap = (position: number, maxWidthInCssPixels: number) => {
// a line is `min(1 / zoom, max)` scene units wide; computed straight in
// device pixels, so `(1 / zoom) × zoom` never lands just under 1
const widthInDevicePixels = Math.min(
scale,
maxWidthInCssPixels * devicePixels,
);
if (widthInDevicePixels < 1) {
return { position, lineWidth: widthInDevicePixels / devicePixels };
}
const wholeWidth = Math.round(widthInDevicePixels);
const center = wholeWidth % 2 ? 0.5 : 0;
return {
position:
(Math.round(position * devicePixels - center) + center) / devicePixels,
lineWidth: wholeWidth / devicePixels,
};
};
context.save();
// 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 { position, lineWidth } = snap(x, isBold ? 4 : 1);
context.lineWidth = lineWidth;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.beginPath();
context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold
? GridLineColor[theme].bold
: GridLineColor[theme].regular;
context.moveTo(position, offsetY - gridSize);
context.lineTo(position, 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 { position, lineWidth } = snap(y, isBold ? 4 : 1);
context.lineWidth = lineWidth;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.beginPath();
context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold
? GridLineColor[theme].bold
: GridLineColor[theme].regular;
context.moveTo(offsetX - gridSize, position);
context.lineTo(Math.ceil(offsetX + width + gridSize * 2), position);
context.stroke();
}
context.restore();
};
export 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,
renderState: ElementRenderState,
) => {
if (element.link && !appState.selectedElementIds[element.id]) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y, width, height] = getLinkHandleFromCoords(
[
x1 + renderState.offset.x,
y1 + renderState.offset.y,
x2 + renderState.offset.x,
y2 + renderState.offset.y,
],
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,
);
// Seed a sane default so a corrupted color (silently rejected by the
// canvas) falls back to white instead of a stale fillStyle.
linkCanvasCacheContext.fillStyle = COLOR_WHITE;
linkCanvasCacheContext.fillStyle =
appState.viewBackgroundColor || COLOR_WHITE;
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.globalAlpha = renderState.opacity;
context.drawImage(linkCanvas, x - centerX, y - centerY, width, height);
context.restore();
}
};
const _renderStaticScene = ({
canvas,
rc,
elementsMap,
allElementsMap,
visibleElements,
scale,
appState: unsnappedAppState,
renderConfig,
}: StaticSceneRenderConfig) => {
if (canvas === null) {
return;
}
const { renderGrid = true, isExporting } = renderConfig;
// export draws vectors, not cached bitmaps — nothing to keep on the grid
const appState = isExporting
? unsnappedAppState
: snapScrollToDevicePixels(unsnappedAppState, scale);
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,
renderConfig.theme,
normalizedWidth / appState.zoom.value,
normalizedHeight / appState.zoom.value,
scale,
);
}
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>();
const getRenderState = (element: ExcalidrawElement) =>
resolveElementRenderState(
element,
elementsMap,
renderConfig,
allElementsMap,
);
const clipElementToFrame = (
element: NonDeletedExcalidrawElement,
renderState: ElementRenderState,
) => {
if (
!(element.frameId || appState.frameToHighlight?.id) ||
!appState.frameRendering.enabled ||
!appState.frameRendering.clip
) {
return;
}
const targetFrame = getTargetFrame(element, elementsMap, appState);
if (!targetFrame) {
return;
}
const frameState = getRenderState(targetFrame);
const frame = getRenderElementWithPositionOverride(
targetFrame,
frameState.offset,
);
const isTranslated = (state: ElementRenderState) =>
state.offset.x !== 0 || state.offset.y !== 0;
if (
(element.frameId === frame.id &&
(isTranslated(renderState) || isTranslated(frameState))) ||
shouldApplyFrameClip(
getRenderElementWithPositionOverride(element, renderState.offset),
frame,
appState,
elementsMap,
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState);
}
};
// Paint visible elements
visibleElements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
try {
if (
isTextElement(element) &&
element.containerId &&
elementsMap.has(element.containerId)
) {
// will be rendered with the container
return;
}
context.save();
const boundTextElement = getBoundTextElement(element, elementsMap);
const renderState = getRenderState(element);
clipElementToFrame(element, renderState);
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
renderState,
);
if (boundTextElement) {
renderElement(
boundTextElement,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
context.restore();
if (!isExporting && renderConfig.renderLinks !== false) {
renderLinkIcon(element, context, appState, elementsMap, renderState);
}
} 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 renderState = getRenderState(element);
context.save();
clipElementToFrame(element, renderState);
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
renderState,
);
if (
isIframeLikeElement(element) &&
(isExporting ||
(isEmbeddableElement(element) &&
renderConfig.embedsValidationStatus.get(element.id) !== true)) &&
element.width &&
element.height
) {
const label = {
...createPlaceholderEmbeddableLabel(element),
// Synthetic visual: resolve overrides and frame opacity through
// its owner, without creating another animation target.
id: element.id,
frameId: element.frameId,
};
renderElement(
label,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
if (!isExporting && renderConfig.renderLinks !== false) {
renderLinkIcon(element, context, appState, elementsMap, renderState);
}
context.restore();
} 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);
},
);
/**
* Static scene is the non-ui canvas where we render elements.
*/
export const renderStaticScene = (
renderConfig: StaticSceneRenderConfig,
throttle?: boolean,
) => {
if (throttle) {
renderStaticSceneThrottled(renderConfig);
return;
}
_renderStaticScene(renderConfig);
};

View File

@@ -0,0 +1,933 @@
import {
BOUND_TEXT_PADDING,
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT,
SVG_NS,
THEME,
DARK_THEME_FILTER,
getFontFamilyString,
isRTL,
isTestEnv,
getVerticalOffset,
applyDarkModeFilter,
MIME_TYPES,
STICKY_NOTE_EDGE_SHADOW_OPACITY,
STICKY_NOTE_EDGE_SHADOW_WIDTH,
STICKY_NOTE_FOOTER,
STICKY_NOTE_SHADOW_OPACITY,
} from "@excalidraw/common";
import { normalizeLink, toValidURL } from "@excalidraw/common";
import { hashString } from "@excalidraw/element";
import { getUncroppedWidthAndHeight } from "@excalidraw/element";
import {
createPlaceholderEmbeddableLabel,
getEmbedLink,
} from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import { getBoundTextElement, getContainerElement } from "@excalidraw/element";
import { getLineHeightInPx } from "@excalidraw/element";
import {
isArrowElement,
isIframeLikeElement,
isInitializedImageElement,
isTextElement,
} from "@excalidraw/element";
import { getContainingFrame } from "@excalidraw/element";
import { getCornerRadius, isPathALoop } from "@excalidraw/element";
import { ShapeCache } from "@excalidraw/element";
import {
getStickyNoteFooter,
getStickyNotePathCommands,
type StickyNotePathCommand,
} from "@excalidraw/element";
import { getElementAbsoluteCoords } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawTextElementWithContainer,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
import type { Drawable } from "roughjs/bin/core";
import type { RoughSVG } from "roughjs/bin/svg";
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: Readonly<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: Readonly<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 "stickynote": {
const getPathData = (commands: StickyNotePathCommand[]) =>
commands
.map((command) => {
if (command.type === "move") {
return `M ${command.point.x} ${command.point.y}`;
}
if (command.type === "line") {
return `L ${command.point.x} ${command.point.y}`;
}
return `Q ${command.control.x} ${command.control.y} ${command.point.x} ${command.point.y}`;
})
.join(" ")
.concat(" Z");
const createPath = (
commands: StickyNotePathCommand[],
fill: string,
fillOpacity?: number,
) => {
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
path.setAttribute("d", getPathData(commands));
path.setAttribute("fill", fill);
if (typeof fillOpacity !== "undefined") {
path.setAttribute("fill-opacity", `${fillOpacity}`);
}
path.setAttribute("stroke", "none");
return path;
};
const group = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
group.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (opacity !== 1) {
group.setAttribute("opacity", `${opacity}`);
}
const shadow = createPath(
getStickyNotePathCommands(element, { shadow: true }),
"#000",
STICKY_NOTE_SHADOW_OPACITY,
);
const commands = getStickyNotePathCommands(element);
const rect = createPath(
commands,
applyDarkModeFilter(
element.backgroundColor,
renderConfig.theme === THEME.DARK,
),
);
const clipPath = svgRoot.ownerDocument.createElementNS(
SVG_NS,
"clipPath",
);
clipPath.setAttribute("id", `sticky-note-clipPath-${element.id}`);
clipPath.setAttribute("clipPathUnits", "userSpaceOnUse");
clipPath.appendChild(createPath(commands, "#000"));
addToRoot(clipPath, element);
const edgeShadow = createPath(commands, "none");
edgeShadow.setAttribute("stroke", "#000");
edgeShadow.setAttribute(
"stroke-opacity",
`${STICKY_NOTE_EDGE_SHADOW_OPACITY}`,
);
edgeShadow.setAttribute(
"stroke-width",
`${STICKY_NOTE_EDGE_SHADOW_WIDTH * 2}`,
);
edgeShadow.setAttribute("clip-path", `url(#${clipPath.id})`);
group.appendChild(shadow);
group.appendChild(rect);
group.appendChild(edgeShadow);
const footer = getStickyNoteFooter(element);
if (footer) {
const dateText = svgRoot.ownerDocument.createElementNS(SVG_NS, "text");
dateText.setAttribute("x", `${footer.x}`);
dateText.setAttribute("y", `${footer.y}`);
dateText.setAttribute("font-family", STICKY_NOTE_FOOTER.fontFamily);
dateText.setAttribute("font-size", `${STICKY_NOTE_FOOTER.fontSize}px`);
// `text-anchor` is logical in SVG: pin the direction so an RTL host
// page can't flip the label to the left edge
dateText.setAttribute("text-anchor", "end");
dateText.setAttribute("direction", "ltr");
dateText.setAttribute(
"fill",
applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
),
);
dateText.setAttribute("fill-opacity", `${STICKY_NOTE_FOOTER.opacity}`);
dateText.textContent = footer.text;
group.appendChild(dateText);
}
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[group],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || group, element);
break;
}
case "rectangle":
case "diamond":
case "ellipse": {
const shape = ShapeCache.generateElementShape(element, renderConfig);
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 = 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;
// Pin the mask to user space; the default maskUnits="objectBoundingBox"
// collapses to zero area for axis-aligned arrows (zero-size bbox),
// hiding the whole line from SVG exports (#11439).
maskPath.setAttribute("maskUnits", "userSpaceOnUse");
maskPath.setAttribute("x", "0");
maskPath.setAttribute("y", "0");
maskPath.setAttribute("width", `${element.width + 100 + offsetX}`);
maskPath.setAttribute("height", `${element.height + 100 + offsetY}`);
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,
);
// the same padded hole the canvas renderers cut around the label
const maskX =
offsetX + boundTextCoords.x - element.x - BOUND_TEXT_PADDING;
const maskY =
offsetY + boundTextCoords.y - element.y - BOUND_TEXT_PADDING;
maskRectInvisible.setAttribute("x", maskX.toString());
maskRectInvisible.setAttribute("y", maskY.toString());
maskRectInvisible.setAttribute("fill", "#000");
maskRectInvisible.setAttribute(
"width",
`${boundText.width + BOUND_TEXT_PADDING * 2}`,
);
maskRectInvisible.setAttribute(
"height",
`${boundText.height + BOUND_TEXT_PADDING * 2}`,
);
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 wrapper = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
const shapes = ShapeCache.generateElementShape(element, renderConfig);
// always ordered as [background, stroke]
for (const shape of shapes) {
if (typeof shape === "string") {
// stroke (SVGPathString)
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
path.setAttribute(
"fill",
applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
),
);
path.setAttribute("d", shape);
wrapper.appendChild(path);
} else {
// background (Drawable)
const bgNode = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
// if children wrapped in <g>, unwrap it
if (bgNode.nodeName === "g") {
while (bgNode.firstChild) {
wrapper.appendChild(bgNode.firstChild);
}
} else {
wrapper.appendChild(bgNode);
}
}
}
if (opacity !== 1) {
wrapper.setAttribute("stroke-opacity", `${opacity}`);
wrapper.setAttribute("fill-opacity", `${opacity}`);
}
wrapper.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
wrapper.setAttribute("stroke", "none");
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[wrapper],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || wrapper, 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}`);
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 (
renderConfig.theme === THEME.DARK &&
fileData.mimeType === MIME_TYPES.svg
) {
g.setAttribute("filter", DARK_THEME_FILTER);
}
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}`;
clipPath.setAttribute("clipPathUnits", "userSpaceOnUse");
const clipRect = svgRoot.ownerDocument.createElementNS(
SVG_NS,
"rect",
);
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const clipOffsetX = element.crop ? normalizedCropX : 0;
const clipOffsetY = element.crop ? normalizedCropY : 0;
clipRect.setAttribute("x", `${clipOffsetX}`);
clipRect.setAttribute("y", `${clipOffsetY}`);
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",
applyDarkModeFilter(
FRAME_STYLE.strokeColor,
renderConfig.theme === THEME.DARK,
),
);
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",
applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
),
);
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?.isDeleted === false) {
renderElementToSvg(
boundTextElement as Readonly<NonDeletedExcalidrawElement>,
elementsMap,
rsvg,
svgRoot,
files,
boundTextElement.x + renderConfig.offsetX,
boundTextElement.y + renderConfig.offsetY,
renderConfig,
);
} else if (boundTextElement) {
// SAFETY: This should never happen, but log it just in case
console.error(
"[NONDELETED][INVARIANT] Skipped rendering deleted bound text element",
);
}
} 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);
}
}
});
};