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(); 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(); // 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); } }); // 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); };