ソースを参照

feat: Visual debugger (#8344)

Add visual debugger to the Excalidraw app (only).
Márk Tolmács 1 年間 前
コミット
ea7c702cfc

+ 38 - 1
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<ExcalidrawInitialDataState | null>();
   }
 
+  const debugCanvasRef = useRef<HTMLCanvasElement>(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<string | null>(
@@ -820,6 +849,7 @@ const ExcalidrawWrapper = () => {
           isCollabEnabled={!isCollabDisabled}
           theme={appTheme}
           setTheme={(theme) => setAppTheme(theme)}
+          refresh={() => forceRefresh((prev) => !prev)}
         />
         <AppWelcomeScreen
           onCollabDialogOpen={onCollabDialogOpen}
@@ -845,7 +875,7 @@ const ExcalidrawWrapper = () => {
             </OverwriteConfirmDialog.Action>
           )}
         </OverwriteConfirmDialog>
-        <AppFooter />
+        <AppFooter onChange={() => excalidrawAPI?.refresh()} />
         {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
 
         <TTDDialogTrigger />
@@ -1077,6 +1107,13 @@ const ExcalidrawWrapper = () => {
             },
           ]}
         />
+        {isVisualDebuggerEnabled() && excalidrawAPI && (
+          <DebugCanvas
+            appState={excalidrawAPI.getAppState()}
+            scale={window.devicePixelRatio}
+            ref={debugCanvasRef}
+          />
+        )}
       </Excalidraw>
     </div>
   );

+ 1 - 0
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",
 

+ 23 - 19
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 (
-    <Footer>
-      <div
-        style={{
-          display: "flex",
-          gap: ".5rem",
-          alignItems: "center",
-        }}
-      >
-        {isExcalidrawPlusSignedUser ? (
-          <ExcalidrawPlusAppLink />
-        ) : (
-          <EncryptedIcon />
-        )}
-      </div>
-    </Footer>
-  );
-});
+export const AppFooter = React.memo(
+  ({ onChange }: { onChange: () => void }) => {
+    return (
+      <Footer>
+        <div
+          style={{
+            display: "flex",
+            gap: ".5rem",
+            alignItems: "center",
+          }}
+        >
+          {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
+          {isExcalidrawPlusSignedUser ? (
+            <ExcalidrawPlusAppLink />
+          ) : (
+            <EncryptedIcon />
+          )}
+        </div>
+      </Footer>
+    );
+  },
+);

+ 20 - 0
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 (
     <MainMenu>
@@ -50,6 +53,23 @@ export const AppMainMenu: React.FC<{
       >
         {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
       </MainMenu.ItemLink>
+      {import.meta.env.DEV && (
+        <MainMenu.Item
+          icon={eyeIcon}
+          onClick={() => {
+            if (window.visualDebug) {
+              delete window.visualDebug;
+              saveDebugState({ enabled: false });
+            } else {
+              window.visualDebug = { data: [] };
+              saveDebugState({ enabled: true });
+            }
+            props?.refresh();
+          }}
+        >
+          Visual Debug
+        </MainMenu.Item>
+      )}
       <MainMenu.Separator />
       <MainMenu.DefaultItems.ToggleTheme
         allowSystemTheme

+ 293 - 0
excalidraw-app/components/DebugCanvas.tsx

@@ -0,0 +1,293 @@
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+import { type AppState } from "../../packages/excalidraw/types";
+import { throttleRAF } from "../../packages/excalidraw/utils";
+import type { LineSegment } from "../../packages/utils";
+import {
+  bootstrapCanvas,
+  getNormalizedCanvasDimensions,
+} from "../../packages/excalidraw/renderer/helpers";
+import type { DebugElement } from "../../packages/excalidraw/visualdebug";
+import {
+  ArrowheadArrowIcon,
+  CloseIcon,
+  TrashIcon,
+} from "../../packages/excalidraw/components/icons";
+import { STORAGE_KEYS } from "../app_constants";
+import { isLineSegment } from "../../packages/excalidraw/element/typeChecks";
+
+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 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 (
+    <>
+      <button
+        className="ToolIcon_type_button"
+        data-testid="debug-forward"
+        aria-label="Move forward"
+        type="button"
+        onClick={trashFrames}
+      >
+        <div
+          className="ToolIcon__icon"
+          aria-hidden="true"
+          aria-disabled="false"
+        >
+          {TrashIcon}
+        </div>
+      </button>
+      <button
+        className="ToolIcon_type_button"
+        data-testid="debug-forward"
+        aria-label="Move forward"
+        type="button"
+        onClick={moveBackward}
+      >
+        <div
+          className="ToolIcon__icon"
+          aria-hidden="true"
+          aria-disabled="false"
+        >
+          <ArrowheadArrowIcon flip />
+        </div>
+      </button>
+      <button
+        className="ToolIcon_type_button"
+        data-testid="debug-forward"
+        aria-label="Move forward"
+        type="button"
+        onClick={reset}
+      >
+        <div
+          className="ToolIcon__icon"
+          aria-hidden="true"
+          aria-disabled="false"
+        >
+          {CloseIcon}
+        </div>
+      </button>
+      <button
+        className="ToolIcon_type_button"
+        data-testid="debug-backward"
+        aria-label="Move backward"
+        type="button"
+        onClick={moveForward}
+      >
+        <div
+          className="ToolIcon__icon"
+          aria-hidden="true"
+          aria-disabled="false"
+        >
+          <ArrowheadArrowIcon />
+        </div>
+      </button>
+    </>
+  );
+};
+
+interface DebugCanvasProps {
+  appState: AppState;
+  scale: number;
+}
+
+const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
+  ({ appState, scale }, ref) => {
+    const { width, height } = appState;
+
+    const canvasRef = useRef<HTMLCanvasElement>(null);
+    useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
+      ref,
+      () => canvasRef.current,
+      [canvasRef],
+    );
+
+    return (
+      <canvas
+        style={{
+          width,
+          height,
+          position: "absolute",
+          zIndex: 2,
+          pointerEvents: "none",
+        }}
+        width={width * scale}
+        height={height * scale}
+        ref={canvasRef}
+      >
+        Debug Canvas
+      </canvas>
+    );
+  },
+);
+
+export default DebugCanvas;

+ 23 - 1
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]);

+ 157 - 0
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);
+};