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,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);
};