snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
545
packages/excalidraw/renderer/staticScene.ts
Normal file
545
packages/excalidraw/renderer/staticScene.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user