From ea7c702cfce515e83cff4ea73b8432b306c438d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Tue, 27 Aug 2024 19:46:00 +0200 Subject: [PATCH] feat: Visual debugger (#8344) Add visual debugger to the Excalidraw app (only). --- excalidraw-app/App.tsx | 39 ++- excalidraw-app/app_constants.ts | 1 + excalidraw-app/components/AppFooter.tsx | 42 ++-- excalidraw-app/components/AppMainMenu.tsx | 20 ++ excalidraw-app/components/DebugCanvas.tsx | 293 ++++++++++++++++++++++ packages/excalidraw/element/typeChecks.ts | 24 +- packages/excalidraw/visualdebug.ts | 157 ++++++++++++ 7 files changed, 555 insertions(+), 21 deletions(-) create mode 100644 excalidraw-app/components/DebugCanvas.tsx create mode 100644 packages/excalidraw/visualdebug.ts diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 69d621129..0076eead1 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -120,6 +120,11 @@ import { import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; import { getPreferredLanguage } from "./app-language/language-detector"; import { useAppLangCode } from "./app-language/language-state"; +import DebugCanvas, { + debugRenderer, + isVisualDebuggerEnabled, + loadSavedDebugState, +} from "./components/DebugCanvas"; import { AIComponents } from "./components/AI"; polyfill(); @@ -337,6 +342,8 @@ const ExcalidrawWrapper = () => { resolvablePromise(); } + const debugCanvasRef = useRef(null); + useEffect(() => { trackEvent("load", "frame", getFrame()); // Delayed so that the app has a time to load the latest SW @@ -362,6 +369,23 @@ const ExcalidrawWrapper = () => { migrationAdapter: LibraryLocalStorageMigrationAdapter, }); + const [, forceRefresh] = useState(false); + + useEffect(() => { + if (import.meta.env.DEV) { + const debugState = loadSavedDebugState(); + + if (debugState.enabled && !window.visualDebug) { + window.visualDebug = { + data: [], + }; + } else { + delete window.visualDebug; + } + forceRefresh((prev) => !prev); + } + }, [excalidrawAPI]); + useEffect(() => { if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) { return; @@ -622,6 +646,11 @@ const ExcalidrawWrapper = () => { } }); } + + // Render the debug scene if the debug canvas is available + if (debugCanvasRef.current && excalidrawAPI) { + debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio); + } }; const [latestShareableLink, setLatestShareableLink] = useState( @@ -820,6 +849,7 @@ const ExcalidrawWrapper = () => { isCollabEnabled={!isCollabDisabled} theme={appTheme} setTheme={(theme) => setAppTheme(theme)} + refresh={() => forceRefresh((prev) => !prev)} /> { )} - + excalidrawAPI?.refresh()} /> {excalidrawAPI && } @@ -1077,6 +1107,13 @@ const ExcalidrawWrapper = () => { }, ]} /> + {isVisualDebuggerEnabled() && excalidrawAPI && ( + + )} ); diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index f4b56496d..1dc6c6f46 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -40,6 +40,7 @@ export const STORAGE_KEYS = { LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_COLLAB: "excalidraw-collab", LOCAL_STORAGE_THEME: "excalidraw-theme", + LOCAL_STORAGE_DEBUG: "excalidraw-debug", VERSION_DATA_STATE: "version-dataState", VERSION_FILES: "version-files", diff --git a/excalidraw-app/components/AppFooter.tsx b/excalidraw-app/components/AppFooter.tsx index 624873218..ea8152a25 100644 --- a/excalidraw-app/components/AppFooter.tsx +++ b/excalidraw-app/components/AppFooter.tsx @@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index"; import { EncryptedIcon } from "./EncryptedIcon"; import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink"; import { isExcalidrawPlusSignedUser } from "../app_constants"; +import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas"; -export const AppFooter = React.memo(() => { - return ( -
-
- {isExcalidrawPlusSignedUser ? ( - - ) : ( - - )} -
-
- ); -}); +export const AppFooter = React.memo( + ({ onChange }: { onChange: () => void }) => { + return ( +
+
+ {isVisualDebuggerEnabled() && } + {isExcalidrawPlusSignedUser ? ( + + ) : ( + + )} +
+
+ ); + }, +); diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index eb3f24caf..04bddedef 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -2,11 +2,13 @@ import React from "react"; import { loginIcon, ExcalLogo, + eyeIcon, } from "../../packages/excalidraw/components/icons"; import type { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; import { isExcalidrawPlusSignedUser } from "../app_constants"; import { LanguageList } from "../app-language/LanguageList"; +import { saveDebugState } from "./DebugCanvas"; export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; @@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{ isCollabEnabled: boolean; theme: Theme | "system"; setTheme: (theme: Theme | "system") => void; + refresh: () => void; }> = React.memo((props) => { return ( @@ -50,6 +53,23 @@ export const AppMainMenu: React.FC<{ > {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} + {import.meta.env.DEV && ( + { + if (window.visualDebug) { + delete window.visualDebug; + saveDebugState({ enabled: false }); + } else { + window.visualDebug = { data: [] }; + saveDebugState({ enabled: true }); + } + props?.refresh(); + }} + > + Visual Debug + + )} { + 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 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 render = ( + frame: DebugElement[], + context: CanvasRenderingContext2D, + appState: AppState, +) => { + frame.forEach((el) => { + switch (true) { + case isLineSegment(el.data): + renderLine(context, appState.zoom.value, el.data, el.color); + break; + } + }); +}; + +const _debugRenderer = ( + canvas: HTMLCanvasElement, + appState: AppState, + 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); + + 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, scale: number) => { + _debugRenderer(canvas, appState, 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 = forwardRef( + ({ appState, scale }, ref) => { + const { width, height } = appState; + + const canvasRef = useRef(null); + useImperativeHandle( + ref, + () => canvasRef.current, + [canvasRef], + ); + + return ( + + Debug Canvas + + ); + }, +); + +export default DebugCanvas; diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 9c1d6913d..78f1a458a 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -1,7 +1,9 @@ +import type { LineSegment } from "../../utils"; import { ROUNDNESS } from "../constants"; -import type { ElementOrToolType } from "../types"; +import type { ElementOrToolType, Point } from "../types"; import type { MarkNonNullable } from "../utility-types"; import { assertNever } from "../utils"; +import type { Bounds } from "./bounds"; import type { ExcalidrawElement, ExcalidrawTextElement, @@ -322,3 +324,23 @@ export const isFixedPointBinding = ( ): binding is FixedPointBinding => { return binding.fixedPoint != null; }; + +// TODO: Move this to @excalidraw/math +export const isPoint = (point: unknown): point is Point => + Array.isArray(point) && point.length === 2; + +// TODO: Move this to @excalidraw/math +export const isBounds = (box: unknown): box is Bounds => + Array.isArray(box) && + box.length === 4 && + typeof box[0] === "number" && + typeof box[1] === "number" && + typeof box[2] === "number" && + typeof box[3] === "number"; + +// TODO: Move this to @excalidraw/math +export const isLineSegment = (segment: unknown): segment is LineSegment => + Array.isArray(segment) && + segment.length === 2 && + isPoint(segment[0]) && + isPoint(segment[0]); diff --git a/packages/excalidraw/visualdebug.ts b/packages/excalidraw/visualdebug.ts new file mode 100644 index 000000000..f6ab8f744 --- /dev/null +++ b/packages/excalidraw/visualdebug.ts @@ -0,0 +1,157 @@ +import type { LineSegment } from "../utils"; +import type { BoundingBox, Bounds } from "./element/bounds"; +import { isBounds, isLineSegment } from "./element/typeChecks"; +import type { Point } from "./types"; + +// The global data holder to collect the debug operations +declare global { + interface Window { + visualDebug?: { + data: DebugElement[][]; + currentFrame?: number; + }; + } +} + +export type DebugElement = { + color: string; + data: LineSegment; + permanent: boolean; +}; + +export const debugDrawLine = ( + segment: LineSegment | LineSegment[], + opts?: { + color?: string; + permanent?: boolean; + }, +) => { + (isLineSegment(segment) ? [segment] : segment).forEach((data) => + addToCurrentFrame({ + color: opts?.color ?? "red", + data, + permanent: !!opts?.permanent, + }), + ); +}; + +export const debugDrawPoint = ( + point: Point, + opts?: { + color?: string; + permanent?: boolean; + fuzzy?: boolean; + }, +) => { + const xOffset = opts?.fuzzy ? Math.random() * 3 : 0; + const yOffset = opts?.fuzzy ? Math.random() * 3 : 0; + + debugDrawLine( + [ + [point[0] + xOffset - 10, point[1] + yOffset - 10], + [point[0] + xOffset + 10, point[1] + yOffset + 10], + ], + { + color: opts?.color ?? "cyan", + permanent: opts?.permanent, + }, + ); + debugDrawLine( + [ + [point[0] + xOffset - 10, point[1] + yOffset + 10], + [point[0] + xOffset + 10, point[1] + yOffset - 10], + ], + { + color: opts?.color ?? "cyan", + permanent: opts?.permanent, + }, + ); +}; + +export const debugDrawBoundingBox = ( + box: BoundingBox | BoundingBox[], + opts?: { + color?: string; + permanent?: boolean; + }, +) => { + (Array.isArray(box) ? box : [box]).forEach((bbox) => + debugDrawLine( + [ + [ + [bbox.minX, bbox.minY], + [bbox.maxX, bbox.minY], + ], + [ + [bbox.maxX, bbox.minY], + [bbox.maxX, bbox.maxY], + ], + [ + [bbox.maxX, bbox.maxY], + [bbox.minX, bbox.maxY], + ], + [ + [bbox.minX, bbox.maxY], + [bbox.minX, bbox.minY], + ], + ], + { + color: opts?.color ?? "cyan", + permanent: opts?.permanent, + }, + ), + ); +}; + +export const debugDrawBounds = ( + box: Bounds | Bounds[], + opts?: { + color: string; + permanent: boolean; + }, +) => { + (isBounds(box) ? [box] : box).forEach((bbox) => + debugDrawLine( + [ + [ + [bbox[0], bbox[1]], + [bbox[2], bbox[1]], + ], + [ + [bbox[2], bbox[1]], + [bbox[2], bbox[3]], + ], + [ + [bbox[2], bbox[3]], + [bbox[0], bbox[3]], + ], + [ + [bbox[0], bbox[3]], + [bbox[0], bbox[1]], + ], + ], + { + color: opts?.color ?? "green", + permanent: opts?.permanent, + }, + ), + ); +}; + +export const debugCloseFrame = () => { + window.visualDebug?.data.push([]); +}; + +export const debugClear = () => { + if (window.visualDebug?.data) { + window.visualDebug.data = []; + } +}; + +const addToCurrentFrame = (element: DebugElement) => { + if (window.visualDebug?.data && window.visualDebug.data.length === 0) { + window.visualDebug.data[0] = []; + } + window.visualDebug?.data && + window.visualDebug.data[window.visualDebug.data.length - 1].push(element); +};