import { ArrowheadArrowIcon, CloseIcon, TrashIcon, } from "@excalidraw/excalidraw/components/icons"; import { bootstrapCanvas, getNormalizedCanvasDimensions, } from "@excalidraw/excalidraw/renderer/helpers"; import { type AppState } from "@excalidraw/excalidraw/types"; import { arrayToMap, invariant, throttleRAF } from "@excalidraw/common"; import { useCallback } from "react"; import { getGlobalFixedPointForBindableElement, isArrowElement, isBindableElement, } from "@excalidraw/element"; import { isLineSegment, type GlobalPoint, type LineSegment, } from "@excalidraw/math"; import { isCurve } from "@excalidraw/math/curve"; import React from "react"; import type { Curve } from "@excalidraw/math"; import type { DebugElement } from "@excalidraw/common"; import type { ElementsMap, ExcalidrawArrowElement, ExcalidrawBindableElement, FixedPointBinding, OrderedExcalidrawElement, } from "@excalidraw/element/types"; import { STORAGE_KEYS } from "../app_constants"; const renderLine = ( context: CanvasRenderingContext2D, zoom: number, segment: LineSegment, color: string, ) => { context.save(); context.strokeStyle = color; context.beginPath(); context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom); context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom); context.stroke(); context.restore(); }; const renderCubicBezier = ( context: CanvasRenderingContext2D, zoom: number, [start, control1, control2, end]: Curve, color: string, ) => { context.save(); context.strokeStyle = color; context.beginPath(); context.moveTo(start[0] * zoom, start[1] * zoom); context.bezierCurveTo( control1[0] * zoom, control1[1] * zoom, control2[0] * zoom, control2[1] * zoom, end[0] * zoom, end[1] * zoom, ); context.stroke(); context.restore(); }; const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { context.strokeStyle = "#888"; context.save(); context.beginPath(); context.moveTo(-10 * zoom, -10 * zoom); context.lineTo(10 * zoom, 10 * zoom); context.moveTo(10 * zoom, -10 * zoom); context.lineTo(-10 * zoom, 10 * zoom); context.stroke(); context.save(); }; const _renderBinding = ( context: CanvasRenderingContext2D, binding: FixedPointBinding, elementsMap: ElementsMap, zoom: number, width: number, height: number, color: string, ) => { if (!binding.fixedPoint) { console.warn("Binding must have a fixedPoint"); return; } const bindable = elementsMap.get( binding.elementId, ) as ExcalidrawBindableElement; const [x, y] = getGlobalFixedPointForBindableElement( binding.fixedPoint, bindable, elementsMap, ); context.save(); context.strokeStyle = color; context.lineWidth = 1; context.beginPath(); context.moveTo(x * zoom, y * zoom); context.bezierCurveTo( x * zoom - width, y * zoom - height, x * zoom - width, y * zoom + height, x * zoom, y * zoom, ); context.stroke(); context.restore(); }; const _renderBindableBinding = ( binding: FixedPointBinding, context: CanvasRenderingContext2D, elementsMap: ElementsMap, zoom: number, width: number, height: number, color: string, ) => { const bindable = elementsMap.get( binding.elementId, ) as ExcalidrawBindableElement; if (!binding.fixedPoint) { console.warn("Binding must have a fixedPoint"); return; } const [x, y] = getGlobalFixedPointForBindableElement( binding.fixedPoint, bindable, elementsMap, ); context.save(); context.strokeStyle = color; context.lineWidth = 1; context.beginPath(); context.moveTo(x * zoom, y * zoom); context.bezierCurveTo( x * zoom + width, y * zoom + height, x * zoom + width, y * zoom - height, x * zoom, y * zoom, ); context.stroke(); context.restore(); }; const renderBindings = ( context: CanvasRenderingContext2D, elements: readonly OrderedExcalidrawElement[], zoom: number, ) => { const elementsMap = arrayToMap(elements); const dim = 16; elements.forEach((element) => { if (element.isDeleted) { return; } if (isArrowElement(element)) { if (element.startBinding) { invariant( elementsMap .get(element.startBinding.elementId) ?.boundElements?.find((e) => e.id === element.id), "Missing record in boundElements for arrow", ); _renderBinding( context, element.startBinding, elementsMap, zoom, dim, dim, "red", ); } if (element.endBinding) { _renderBinding( context, element.endBinding, elementsMap, zoom, dim, dim, "red", ); } } if (isBindableElement(element) && element.boundElements?.length) { element.boundElements.forEach((boundElement) => { if (boundElement.type !== "arrow") { return; } const arrow = elementsMap.get( boundElement.id, ) as ExcalidrawArrowElement; if (arrow && arrow.startBinding?.elementId === element.id) { _renderBindableBinding( arrow.startBinding, context, elementsMap, zoom, dim, dim, "green", ); } if (arrow && arrow.endBinding?.elementId === element.id) { _renderBindableBinding( arrow.endBinding, context, elementsMap, zoom, dim, dim, "green", ); } }); } }); }; const render = ( frame: DebugElement[], context: CanvasRenderingContext2D, appState: AppState, ) => { frame.forEach((el: DebugElement) => { switch (true) { case isLineSegment(el.data): renderLine( context, appState.zoom.value, el.data as LineSegment, el.color, ); break; case isCurve(el.data): renderCubicBezier( context, appState.zoom.value, el.data as Curve, el.color, ); break; default: throw new Error(`Unknown element type ${JSON.stringify(el)}`); } }); }; const _debugRenderer = ( canvas: HTMLCanvasElement, appState: AppState, elements: readonly OrderedExcalidrawElement[], scale: number, ) => { const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( canvas, scale, ); const context = bootstrapCanvas({ canvas, scale, normalizedWidth, normalizedHeight, viewBackgroundColor: "transparent", }); // Apply zoom context.save(); context.translate( appState.scrollX * appState.zoom.value, appState.scrollY * appState.zoom.value, ); renderOrigin(context, appState.zoom.value); renderBindings(context, elements, appState.zoom.value); if ( window.visualDebug?.currentFrame && window.visualDebug?.data && window.visualDebug.data.length > 0 ) { // Render only one frame const [idx] = debugFrameData(); render(window.visualDebug.data[idx], context, appState); } else { // Render all debug frames window.visualDebug?.data.forEach((frame) => { render(frame, context, appState); }); } if (window.visualDebug) { window.visualDebug!.data = window.visualDebug?.data.map((frame) => frame.filter((el) => el.permanent), ) ?? []; } }; const debugFrameData = (): [number, number] => { const currentFrame = window.visualDebug?.currentFrame ?? 0; const frameCount = window.visualDebug?.data.length ?? 0; if (frameCount > 0) { return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0]; } return [0, 0]; }; export const saveDebugState = (debug: { enabled: boolean }) => { try { localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_DEBUG, JSON.stringify(debug), ); } catch (error: any) { console.error(error); } }; export const debugRenderer = throttleRAF( ( canvas: HTMLCanvasElement, appState: AppState, elements: readonly OrderedExcalidrawElement[], scale: number, ) => { _debugRenderer(canvas, appState, elements, scale); }, { trailing: true }, ); export const loadSavedDebugState = () => { let debug; try { const savedDebugState = localStorage.getItem( STORAGE_KEYS.LOCAL_STORAGE_DEBUG, ); if (savedDebugState) { debug = JSON.parse(savedDebugState) as { enabled: boolean }; } } catch (error: any) { console.error(error); } return debug ?? { enabled: false }; }; export const isVisualDebuggerEnabled = () => Array.isArray(window.visualDebug?.data); export const DebugFooter = ({ onChange }: { onChange: () => void }) => { const moveForward = useCallback(() => { if ( !window.visualDebug?.currentFrame || isNaN(window.visualDebug?.currentFrame ?? -1) ) { window.visualDebug!.currentFrame = 0; } window.visualDebug!.currentFrame += 1; onChange(); }, [onChange]); const moveBackward = useCallback(() => { if ( !window.visualDebug?.currentFrame || isNaN(window.visualDebug?.currentFrame ?? -1) || window.visualDebug?.currentFrame < 1 ) { window.visualDebug!.currentFrame = 1; } window.visualDebug!.currentFrame -= 1; onChange(); }, [onChange]); const reset = useCallback(() => { window.visualDebug!.currentFrame = undefined; onChange(); }, [onChange]); const trashFrames = useCallback(() => { if (window.visualDebug) { window.visualDebug.currentFrame = undefined; window.visualDebug.data = []; } onChange(); }, [onChange]); return ( <> ); }; interface DebugCanvasProps { appState: AppState; scale: number; } const DebugCanvas = React.forwardRef( ({ appState, scale }, ref) => { const { width, height } = appState; return ( Debug Canvas ); }, ); export default DebugCanvas;