aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/renderer/staticScene.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/renderer/staticScene.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/renderer/staticScene.ts')
-rw-r--r--packages/excalidraw/renderer/staticScene.ts480
1 files changed, 480 insertions, 0 deletions
diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts
new file mode 100644
index 0000000..90ed8af
--- /dev/null
+++ b/packages/excalidraw/renderer/staticScene.ts
@@ -0,0 +1,480 @@
+import { FRAME_STYLE } from "../constants";
+import { getElementAbsoluteCoords } from "../element";
+
+import {
+ elementOverlapsWithFrame,
+ getTargetFrame,
+ shouldApplyFrameClip,
+} from "../frame";
+import {
+ isEmbeddableElement,
+ isIframeLikeElement,
+ isTextElement,
+} from "../element/typeChecks";
+import { renderElement } from "../renderer/renderElement";
+import { createPlaceholderEmbeddableLabel } from "../element/embeddable";
+import type { StaticCanvasAppState, Zoom } from "../types";
+import type {
+ ElementsMap,
+ ExcalidrawFrameLikeElement,
+ NonDeletedExcalidrawElement,
+} from "../element/types";
+import type {
+ StaticCanvasRenderConfig,
+ StaticSceneRenderConfig,
+} from "../scene/types";
+import {
+ EXTERNAL_LINK_IMG,
+ ELEMENT_LINK_IMG,
+ getLinkHandleFromCoords,
+} from "../components/hyperlink/helpers";
+import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
+import { throttleRAF } from "../utils";
+import { getBoundTextElement } from "../element/textElement";
+import { isElementLink } from "../element/elementLink";
+
+const GridLineColor = {
+ Bold: "#dddddd",
+ Regular: "#e5e5e5",
+} as const;
+
+const strokeGrid = (
+ context: CanvasRenderingContext2D,
+ /** grid cell pixel size */
+ gridSize: number,
+ /** setting to 1 will disble bold lines */
+ gridStep: number,
+ scrollX: number,
+ scrollY: number,
+ zoom: Zoom,
+ width: number,
+ height: number,
+) => {
+ const offsetX = (scrollX % gridSize) - gridSize;
+ const offsetY = (scrollY % gridSize) - gridSize;
+
+ const actualGridSize = gridSize * zoom.value;
+
+ const spaceWidth = 1 / zoom.value;
+
+ context.save();
+
+ // Offset rendering by 0.5 to ensure that 1px wide lines are crisp.
+ // We only do this when zoomed to 100% because otherwise the offset is
+ // fractional, and also visibly offsets the elements.
+ // We also do this per-axis, as each axis may already be offset by 0.5.
+ if (zoom.value === 1) {
+ context.translate(offsetX % 1 ? 0 : 0.5, offsetY % 1 ? 0 : 0.5);
+ }
+
+ // vertical lines
+ for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
+ const isBold =
+ gridStep > 1 && Math.round(x - scrollX) % (gridStep * gridSize) === 0;
+ // don't render regular lines when zoomed out and they're barely visible
+ if (!isBold && actualGridSize < 10) {
+ continue;
+ }
+
+ const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
+ context.lineWidth = lineWidth;
+ const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
+
+ context.beginPath();
+ context.setLineDash(isBold ? [] : lineDash);
+ context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
+ context.moveTo(x, offsetY - gridSize);
+ context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
+ context.stroke();
+ }
+
+ for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
+ const isBold =
+ gridStep > 1 && Math.round(y - scrollY) % (gridStep * gridSize) === 0;
+ if (!isBold && actualGridSize < 10) {
+ continue;
+ }
+
+ const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
+ context.lineWidth = lineWidth;
+ const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
+
+ context.beginPath();
+ context.setLineDash(isBold ? [] : lineDash);
+ context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
+ context.moveTo(offsetX - gridSize, y);
+ context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y);
+ context.stroke();
+ }
+ context.restore();
+};
+
+const frameClip = (
+ frame: ExcalidrawFrameLikeElement,
+ context: CanvasRenderingContext2D,
+ renderConfig: StaticCanvasRenderConfig,
+ appState: StaticCanvasAppState,
+) => {
+ context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
+ context.beginPath();
+ if (context.roundRect) {
+ context.roundRect(
+ 0,
+ 0,
+ frame.width,
+ frame.height,
+ FRAME_STYLE.radius / appState.zoom.value,
+ );
+ } else {
+ context.rect(0, 0, frame.width, frame.height);
+ }
+ context.clip();
+ context.translate(
+ -(frame.x + appState.scrollX),
+ -(frame.y + appState.scrollY),
+ );
+};
+
+type LinkIconCanvas = HTMLCanvasElement & { zoom: number };
+
+const linkIconCanvasCache: {
+ regularLink: LinkIconCanvas | null;
+ elementLink: LinkIconCanvas | null;
+} = {
+ regularLink: null,
+ elementLink: null,
+};
+
+const renderLinkIcon = (
+ element: NonDeletedExcalidrawElement,
+ context: CanvasRenderingContext2D,
+ appState: StaticCanvasAppState,
+ elementsMap: ElementsMap,
+) => {
+ if (element.link && !appState.selectedElementIds[element.id]) {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const [x, y, width, height] = getLinkHandleFromCoords(
+ [x1, y1, x2, y2],
+ element.angle,
+ appState,
+ );
+ const centerX = x + width / 2;
+ const centerY = y + height / 2;
+ context.save();
+ context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
+ context.rotate(element.angle);
+
+ const canvasKey = isElementLink(element.link)
+ ? "elementLink"
+ : "regularLink";
+
+ let linkCanvas = linkIconCanvasCache[canvasKey];
+
+ if (!linkCanvas || linkCanvas.zoom !== appState.zoom.value) {
+ linkCanvas = Object.assign(document.createElement("canvas"), {
+ zoom: appState.zoom.value,
+ });
+ linkCanvas.width = width * window.devicePixelRatio * appState.zoom.value;
+ linkCanvas.height =
+ height * window.devicePixelRatio * appState.zoom.value;
+ linkIconCanvasCache[canvasKey] = linkCanvas;
+
+ const linkCanvasCacheContext = linkCanvas.getContext("2d")!;
+ linkCanvasCacheContext.scale(
+ window.devicePixelRatio * appState.zoom.value,
+ window.devicePixelRatio * appState.zoom.value,
+ );
+ linkCanvasCacheContext.fillStyle = "#fff";
+ linkCanvasCacheContext.fillRect(0, 0, width, height);
+
+ if (canvasKey === "elementLink") {
+ linkCanvasCacheContext.drawImage(ELEMENT_LINK_IMG, 0, 0, width, height);
+ } else {
+ linkCanvasCacheContext.drawImage(
+ EXTERNAL_LINK_IMG,
+ 0,
+ 0,
+ width,
+ height,
+ );
+ }
+
+ linkCanvasCacheContext.restore();
+ }
+ context.drawImage(linkCanvas, x - centerX, y - centerY, width, height);
+ context.restore();
+ }
+};
+const _renderStaticScene = ({
+ canvas,
+ rc,
+ elementsMap,
+ allElementsMap,
+ visibleElements,
+ scale,
+ appState,
+ renderConfig,
+}: StaticSceneRenderConfig) => {
+ if (canvas === null) {
+ return;
+ }
+
+ const { renderGrid = true, isExporting } = renderConfig;
+
+ const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
+ canvas,
+ scale,
+ );
+
+ const context = bootstrapCanvas({
+ canvas,
+ scale,
+ normalizedWidth,
+ normalizedHeight,
+ theme: appState.theme,
+ isExporting,
+ viewBackgroundColor: appState.viewBackgroundColor,
+ });
+
+ // Apply zoom
+ context.scale(appState.zoom.value, appState.zoom.value);
+
+ // Grid
+ if (renderGrid) {
+ strokeGrid(
+ context,
+ appState.gridSize,
+ appState.gridStep,
+ appState.scrollX,
+ appState.scrollY,
+ appState.zoom,
+ normalizedWidth / appState.zoom.value,
+ normalizedHeight / appState.zoom.value,
+ );
+ }
+
+ const groupsToBeAddedToFrame = new Set<string>();
+
+ visibleElements.forEach((element) => {
+ if (
+ element.groupIds.length > 0 &&
+ appState.frameToHighlight &&
+ appState.selectedElementIds[element.id] &&
+ (elementOverlapsWithFrame(
+ element,
+ appState.frameToHighlight,
+ elementsMap,
+ ) ||
+ element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId)))
+ ) {
+ element.groupIds.forEach((groupId) =>
+ groupsToBeAddedToFrame.add(groupId),
+ );
+ }
+ });
+
+ const inFrameGroupsMap = new Map<string, boolean>();
+
+ // Paint visible elements
+ visibleElements
+ .filter((el) => !isIframeLikeElement(el))
+ .forEach((element) => {
+ try {
+ const frameId = element.frameId || appState.frameToHighlight?.id;
+
+ if (
+ isTextElement(element) &&
+ element.containerId &&
+ elementsMap.has(element.containerId)
+ ) {
+ // will be rendered with the container
+ return;
+ }
+
+ context.save();
+
+ if (
+ frameId &&
+ appState.frameRendering.enabled &&
+ appState.frameRendering.clip
+ ) {
+ const frame = getTargetFrame(element, elementsMap, appState);
+ if (
+ frame &&
+ shouldApplyFrameClip(
+ element,
+ frame,
+ appState,
+ elementsMap,
+ inFrameGroupsMap,
+ )
+ ) {
+ frameClip(frame, context, renderConfig, appState);
+ }
+ renderElement(
+ element,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ } else {
+ renderElement(
+ element,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ }
+
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ renderElement(
+ boundTextElement,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ }
+
+ context.restore();
+
+ if (!isExporting) {
+ renderLinkIcon(element, context, appState, elementsMap);
+ }
+ } catch (error: any) {
+ console.error(
+ error,
+ element.id,
+ element.x,
+ element.y,
+ element.width,
+ element.height,
+ );
+ }
+ });
+
+ // render embeddables on top
+ visibleElements
+ .filter((el) => isIframeLikeElement(el))
+ .forEach((element) => {
+ try {
+ const render = () => {
+ renderElement(
+ element,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+
+ if (
+ isIframeLikeElement(element) &&
+ (isExporting ||
+ (isEmbeddableElement(element) &&
+ renderConfig.embedsValidationStatus.get(element.id) !==
+ true)) &&
+ element.width &&
+ element.height
+ ) {
+ const label = createPlaceholderEmbeddableLabel(element);
+ renderElement(
+ label,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ }
+ if (!isExporting) {
+ renderLinkIcon(element, context, appState, elementsMap);
+ }
+ };
+ // - when exporting the whole canvas, we DO NOT apply clipping
+ // - when we are exporting a particular frame, apply clipping
+ // if the containing frame is not selected, apply clipping
+ const frameId = element.frameId || appState.frameToHighlight?.id;
+
+ if (
+ frameId &&
+ appState.frameRendering.enabled &&
+ appState.frameRendering.clip
+ ) {
+ context.save();
+
+ const frame = getTargetFrame(element, elementsMap, appState);
+
+ if (
+ frame &&
+ shouldApplyFrameClip(
+ element,
+ frame,
+ appState,
+ elementsMap,
+ inFrameGroupsMap,
+ )
+ ) {
+ frameClip(frame, context, renderConfig, appState);
+ }
+ render();
+ context.restore();
+ } else {
+ render();
+ }
+ } catch (error: any) {
+ console.error(error);
+ }
+ });
+
+ // render pending nodes for flowcharts
+ renderConfig.pendingFlowchartNodes?.forEach((element) => {
+ try {
+ renderElement(
+ element,
+ elementsMap,
+ allElementsMap,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
+ } catch (error) {
+ console.error(error);
+ }
+ });
+};
+
+/** throttled to animation framerate */
+export const renderStaticSceneThrottled = throttleRAF(
+ (config: StaticSceneRenderConfig) => {
+ _renderStaticScene(config);
+ },
+ { trailing: true },
+);
+
+/**
+ * Static scene is the non-ui canvas where we render elements.
+ */
+export const renderStaticScene = (
+ renderConfig: StaticSceneRenderConfig,
+ throttle?: boolean,
+) => {
+ if (throttle) {
+ renderStaticSceneThrottled(renderConfig);
+ return;
+ }
+
+ _renderStaticScene(renderConfig);
+};