diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/renderer/staticScene.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/renderer/staticScene.ts')
| -rw-r--r-- | packages/excalidraw/renderer/staticScene.ts | 480 |
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); +}; |
