From d76f7b1cbc3f7b9ce64ab48121d1639a4e7b3788 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 2 Apr 2026 10:46:02 +0000 Subject: [PATCH] feat: Console log overlay preference Signed-off-by: Mark Tolmacs --- excalidraw-app/App.tsx | 2 + excalidraw-app/app_constants.ts | 1 + excalidraw-app/components/AppMainMenu.tsx | 35 +++- excalidraw-app/components/DebugCanvas.tsx | 209 +++++++++++++++++++++- 4 files changed, 243 insertions(+), 4 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 011379a4c1..d4ca9566d6 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -136,6 +136,7 @@ import { useHandleAppTheme } from "./useHandleAppTheme"; import { getPreferredLanguage } from "./app-language/language-detector"; import { useAppLangCode } from "./app-language/language-state"; import DebugCanvas, { + ConsoleLogger, debugRenderer, isVisualDebuggerEnabled, loadSavedDebugState, @@ -1261,6 +1262,7 @@ const ExcalidrawWrapper = () => { ref={debugCanvasRef} /> )} + {isVisualDebuggerEnabled() && } ); diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index e4370e7986..8ba048d381 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -42,6 +42,7 @@ export const STORAGE_KEYS = { LOCAL_STORAGE_COLLAB: "excalidraw-collab", LOCAL_STORAGE_THEME: "excalidraw-theme", LOCAL_STORAGE_DEBUG: "excalidraw-debug", + LOCAL_STORAGE_DEBUG_CONSOLE: "excalidraw-debug-console", VERSION_DATA_STATE: "version-dataState", VERSION_FILES: "version-files", diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index a3f847385f..56964ecf9d 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -4,7 +4,8 @@ import { eyeIcon, } from "@excalidraw/excalidraw/components/icons"; import { MainMenu } from "@excalidraw/excalidraw/index"; -import React from "react"; +import DropdownMenuItemCheckbox from "@excalidraw/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox"; +import React, { useState } from "react"; import { isDevEnv } from "@excalidraw/common"; @@ -13,7 +14,29 @@ import type { Theme } from "@excalidraw/element/types"; import { LanguageList } from "../app-language/LanguageList"; import { isExcalidrawPlusSignedUser } from "../app_constants"; -import { saveDebugState } from "./DebugCanvas"; +import { + isVisualDebuggerEnabled, + loadConsoleLoggerState, + saveDebugState, + setConsoleLoggerEnabled, +} from "./DebugCanvas"; + +const ConsoleLoggerToggle = () => { + const [checked, setChecked] = useState(() => loadConsoleLoggerState()); + return ( + { + const next = !checked; + setChecked(next); + setConsoleLoggerEnabled(next); + event.preventDefault(); + }} + > + Show console log overlay + + ); +}; export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; @@ -77,7 +100,13 @@ export const AppMainMenu: React.FC<{ )} - + + ) : null + } + /> { export const isVisualDebuggerEnabled = () => Array.isArray(window.visualDebug?.data); +export const loadConsoleLoggerState = (): boolean => { + try { + const raw = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_DEBUG_CONSOLE); + if (raw !== null) { + return JSON.parse(raw) === true; + } + } catch {} + return false; +}; + +export const saveConsoleLoggerState = (enabled: boolean) => { + try { + localStorage.setItem( + STORAGE_KEYS.LOCAL_STORAGE_DEBUG_CONSOLE, + JSON.stringify(enabled), + ); + } catch {} +}; + +const CONSOLE_LOGGER_TOGGLE_EVENT = "excalidraw-debug-console-toggle"; + +export const setConsoleLoggerEnabled = (enabled: boolean) => { + saveConsoleLoggerState(enabled); + window.dispatchEvent( + new CustomEvent(CONSOLE_LOGGER_TOGGLE_EVENT, { detail: enabled }), + ); +}; + export const DebugFooter = ({ onChange }: { onChange: () => void }) => { const moveForward = useCallback(() => { if ( @@ -459,6 +487,7 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => { }, [onChange]); const reset = useCallback(() => { window.visualDebug!.currentFrame = undefined; + _clearLogsCallback?.(); onChange(); }, [onChange]); const trashFrames = useCallback(() => { @@ -466,6 +495,7 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => { window.visualDebug.currentFrame = undefined; window.visualDebug.data = []; } + _clearLogsCallback?.(); onChange(); }, [onChange]); @@ -563,4 +593,181 @@ const DebugCanvas = React.forwardRef( }, ); +type LogLevel = "log" | "info" | "warn" | "error"; + +interface LogEntry { + id: number; + level: LogLevel; + message: string; + timestamp: number; +} + +const LOG_COLORS: Record = { + log: "rgba(220,220,220,0.9)", + info: "rgba(100,180,255,0.9)", + warn: "rgba(255,200,60,0.9)", + error: "rgba(255,90,90,0.9)", +}; + +const MAX_LOGS = 500; +let logIdCounter = 0; +let _clearLogsCallback: (() => void) | null = null; + +export const ConsoleLogger = () => { + const [enabled, setEnabled] = useState(() => loadConsoleLoggerState()); + const [logs, setLogs] = useState([]); + const logsRef = useRef([]); + const scrollRef = useRef(null); + const dragState = useRef<{ startY: number; startScrollTop: number } | null>( + null, + ); + const isDragging = useRef(false); + + useEffect(() => { + const handler = (e: Event) => { + setEnabled((e as CustomEvent).detail); + }; + window.addEventListener(CONSOLE_LOGGER_TOGGLE_EVENT, handler); + return () => { + window.removeEventListener(CONSOLE_LOGGER_TOGGLE_EVENT, handler); + }; + }, []); + + useEffect(() => { + _clearLogsCallback = () => { + logsRef.current = []; + setLogs([]); + }; + return () => { + _clearLogsCallback = null; + }; + }, []); + + useEffect(() => { + const originals: Record void> = { + // eslint-disable-next-line no-console + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + const patch = (level: LogLevel) => { + // eslint-disable-next-line no-console + console[level] = (...args: unknown[]) => { + originals[level](...args); + const message = args + .map((a) => + typeof a === "object" ? JSON.stringify(a, null, 0) : String(a), + ) + .join(" "); + const entry: LogEntry = { + id: ++logIdCounter, + level, + message, + timestamp: Date.now(), + }; + logsRef.current = [...logsRef.current, entry].slice(-MAX_LOGS); + setLogs([...logsRef.current]); + if (!isDragging.current && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; + }; + + (["log", "info", "warn", "error"] as LogLevel[]).forEach(patch); + + return () => { + (["log", "info", "warn", "error"] as LogLevel[]).forEach((level) => { + // eslint-disable-next-line no-console + console[level] = originals[level]; + }); + }; + }, []); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + if (!scrollRef.current) { + return; + } + dragState.current = { + startY: e.clientY, + startScrollTop: scrollRef.current.scrollTop, + }; + scrollRef.current.setPointerCapture(e.pointerId); + isDragging.current = true; + e.preventDefault(); + }, []); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragState.current || !scrollRef.current) { + return; + } + const delta = dragState.current.startY - e.clientY; + scrollRef.current.scrollTop = dragState.current.startScrollTop + delta; + e.preventDefault(); + }, []); + + const onPointerUp = useCallback(() => { + dragState.current = null; + isDragging.current = false; + }, []); + + if (!enabled || logs.length === 0) { + return null; + } + + return ( +
+ {logs.map((entry) => ( +
+ + {entry.level.toUpperCase()} + + {entry.message} +
+ ))} +
+ ); +}; + export default DebugCanvas;