Files
upstream-excalidraw/packages/excalidraw/renderer/staticScene.ts
2026-09-16 05:31:04 +05:00

546 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
};