From ab786a78619cf5fdb4114e73e4f7aeb83a03ab51 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sun, 31 May 2026 12:17:18 +0000 Subject: [PATCH] refactor: split app shell --- src/App.tsx | 644 +++------------------------------------ src/DrawingSidebar.tsx | 110 +++++++ src/EditorCanvas.tsx | 61 ++++ src/useDrawingSession.ts | 475 +++++++++++++++++++++++++++++ src/useThemeTokenSync.ts | 63 ++++ 5 files changed, 757 insertions(+), 596 deletions(-) create mode 100644 src/DrawingSidebar.tsx create mode 100644 src/EditorCanvas.tsx create mode 100644 src/useDrawingSession.ts create mode 100644 src/useThemeTokenSync.ts diff --git a/src/App.tsx b/src/App.tsx index 9a8a62a..fb56cc2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,507 +1,29 @@ -import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css"; -import { Excalidraw } from "@excalidraw/excalidraw"; -import type { - AppState, - BinaryFiles, - ExcalidrawInitialDataState, -} from "@excalidraw/excalidraw/types"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "./shared"; - -type DrawingResponse = DrawingMeta & ScenePayload; -type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta }; -type UpdateResponse = { ok: true; drawing: DrawingMeta }; - -class RequestError extends Error { - constructor( - readonly status: number, - readonly body: ApiErrorBody, - ) { - super(body.error ?? `Request failed: ${status}`); - } -} - -const EXCALIDRAW_THEME_TOKEN_MAP = [ - ["--island-bg-color", "--app-island-bg"], - ["--sidebar-bg-color", "--app-sidebar-bg"], - ["--sidebar-border-color", "--app-sidebar-border"], - ["--sidebar-shadow", "--app-sidebar-shadow"], - ["--color-surface-lowest", "--app-surface-lowest"], - ["--color-surface-low", "--app-surface-low"], - ["--color-surface-primary-container", "--app-selected-bg"], - ["--color-on-surface", "--app-text-color"], - ["--color-on-primary-container", "--app-selected-text-color"], - ["--color-disabled", "--app-disabled-color"], - ["--input-bg-color", "--app-input-bg"], - ["--input-border-color", "--app-input-border"], - ["--input-label-color", "--app-input-color"], - ["--default-border-color", "--app-button-border"], - ["--button-hover-bg", "--app-button-hover-bg"], - ["--button-active-bg", "--app-button-active-bg"], - ["--button-active-border", "--app-button-active-border"], - ["--overlay-bg-color", "--app-overlay-bg"], -] as const; - -function pathToId(pathname = window.location.pathname): string | null { - const match = pathname.match(/^\/d\/([^/]+)$/); - return match?.[1] ?? null; -} - -function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] { - return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); -} - -function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] { - const filtered = drawings.filter((item) => item.id !== drawing.id); - return sortDrawings([drawing, ...filtered]); -} - -function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState { - return scene as ExcalidrawInitialDataState; -} - -function sceneFromEditor( - elements: readonly unknown[], - appState: AppState, - files: BinaryFiles, -): ScenePayload { - return { - elements: [...elements], - appState: appState as unknown as Record, - files: files as unknown as Record, - }; -} - -async function requestJson(url: string, init?: RequestInit): Promise { - const response = await fetch(url, { - ...init, - headers: { - ...(init?.body ? { "content-type": "application/json" } : {}), - ...init?.headers, - }, - }); - - const body = (await response.json()) as T & ApiErrorBody; - if (!response.ok) { - throw new RequestError(response.status, body); - } - - return body; -} - -function isConflictError(error: unknown): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } { - return error instanceof RequestError && error.status === 409 && error.body.drawing !== undefined; -} - -function DrawerIcon() { - return ( - - ); -} +import { useCallback, useEffect, useRef, useState } from "react"; +import { DrawingSidebar, DrawingsToggle } from "./DrawingSidebar"; +import { EditorCanvas } from "./EditorCanvas"; +import { useDrawingSession } from "./useDrawingSession"; +import { useThemeTokenSync } from "./useThemeTokenSync"; export function App() { - const [drawings, setDrawings] = useState([]); - const [activeId, setActiveId] = useState(pathToId()); - const [activeTitle, setActiveTitle] = useState(DEFAULT_TITLE); - const [scene, setScene] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [toastMessage, setToastMessage] = useState(null); - - const currentIdRef = useRef(activeId); - const currentTitleRef = useRef(activeTitle); - const latestSceneRef = useRef(null); - const ignoreChangeRef = useRef(false); const appShellRef = useRef(null); - const timeoutRef = useRef(null); - const inFlightSaveRef = useRef | null>(null); - const loadVersionRef = useRef(0); - const loadedRevisionRef = useRef(null); - const loadDrawingRef = useRef<(drawingId: string) => Promise>(async () => {}); - const themeSyncFrameRef = useRef(null); - const toastTimeoutRef = useRef(null); - const [editorReloadNonce, setEditorReloadNonce] = useState(0); - - const activeDrawing = useMemo( - () => drawings.find((drawing) => drawing.id === activeId) ?? null, - [activeId, drawings], - ); - - const refreshList = useCallback(async () => { - const list = await requestJson("/api/drawings", { method: "GET" }); - setDrawings(sortDrawings(list)); - return list; - }, []); - - const clearPendingSaveTimeout = useCallback(() => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }, []); - - const saveScene = useCallback(async (isManual = false) => { - const drawingId = currentIdRef.current; - const nextScene = latestSceneRef.current; - const expectedRevision = loadedRevisionRef.current; - if (!drawingId || !nextScene) { - return; - } - setError(null); - - if (isManual) { - setToastMessage("Saving..."); - if (toastTimeoutRef.current !== null) { - clearTimeout(toastTimeoutRef.current); - toastTimeoutRef.current = null; - } - } - - const promise = requestJson(`/api/drawings/${drawingId}`, { - method: "PUT", - body: JSON.stringify({ ...nextScene, expectedRevision }), - }) - .then((body) => { - setDrawings((current) => replaceMeta(current, body.drawing)); - if (currentIdRef.current === drawingId) { - loadedRevisionRef.current = body.drawing.revision; - } - if (isManual) { - setToastMessage("Saved"); - if (toastTimeoutRef.current !== null) { - clearTimeout(toastTimeoutRef.current); - } - toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000); - } - }) - .catch((saveError: unknown) => { - if (isConflictError(saveError)) { - clearPendingSaveTimeout(); - setError(null); - setDrawings((current) => replaceMeta(current, saveError.body.drawing)); - if (isManual) { - setToastMessage("Reloading latest..."); - if (toastTimeoutRef.current !== null) { - clearTimeout(toastTimeoutRef.current); - toastTimeoutRef.current = null; - } - } - return loadDrawingRef.current(drawingId); - } - - setError(saveError instanceof Error ? saveError.message : "Save failed"); - if (isManual) { - setToastMessage("Save failed"); - if (toastTimeoutRef.current !== null) { - clearTimeout(toastTimeoutRef.current); - } - toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000); - } - throw saveError; - }) - .finally(() => { - inFlightSaveRef.current = null; - }); - - inFlightSaveRef.current = promise; - await promise; - }, [clearPendingSaveTimeout]); - - const flushPendingSave = useCallback(async () => { - if (timeoutRef.current !== null) { - clearPendingSaveTimeout(); - await saveScene(); - return; - } - - if (inFlightSaveRef.current) { - await inFlightSaveRef.current; - } - }, [clearPendingSaveTimeout, saveScene]); - - const triggerManualSave = useCallback(async () => { - if (timeoutRef.current !== null) { - clearPendingSaveTimeout(); - } - if (inFlightSaveRef.current) { - await inFlightSaveRef.current; - } - await saveScene(true); - }, [clearPendingSaveTimeout, saveScene]); - - const scheduleSave = useCallback(() => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = window.setTimeout(() => { - timeoutRef.current = null; - void saveScene(); - }, 30000); - }, [saveScene]); - - const loadDrawing = useCallback( - async (drawingId: string) => { - const version = ++loadVersionRef.current; - setLoading(true); - setError(null); - - try { - const [list, drawing] = await Promise.all([ - requestJson("/api/drawings", { method: "GET" }), - requestJson(`/api/drawings/${drawingId}`, { method: "GET" }), - ]); - - if (version !== loadVersionRef.current) { - return; - } - - const nextScene = { - elements: drawing.elements, - appState: drawing.appState, - files: drawing.files, - }; - - ignoreChangeRef.current = true; - currentIdRef.current = drawing.id; - currentTitleRef.current = drawing.title; - latestSceneRef.current = nextScene; - loadedRevisionRef.current = drawing.revision; - - setDrawings(replaceMeta(list, drawing)); - setActiveId(drawing.id); - setActiveTitle(drawing.title); - setScene(nextScene); - setEditorReloadNonce((current) => current + 1); - } catch (loadError) { - const list = await refreshList(); - if (list.length === 0) { - const created = await requestJson("/api/drawings", { method: "POST" }); - window.history.replaceState(null, "", `/d/${created.id}`); - await loadDrawing(created.id); - return; - } - - const fallback = list[0]; - if (fallback && fallback.id !== drawingId) { - window.history.replaceState(null, "", `/d/${fallback.id}`); - await loadDrawing(fallback.id); - return; - } - - setError(loadError instanceof Error ? loadError.message : "Failed to load drawing"); - } finally { - if (version === loadVersionRef.current) { - setLoading(false); - } - } - }, - [refreshList], - ); - - loadDrawingRef.current = loadDrawing; - - const navigateToDrawing = useCallback( - async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => { - const replace = options?.replace ?? false; - const flush = options?.flush ?? true; - - if (drawingId === currentIdRef.current && latestSceneRef.current) { - if (replace) { - window.history.replaceState(null, "", `/d/${drawingId}`); - } - return; - } - - if (flush) { - await flushPendingSave(); - } - - if (replace) { - window.history.replaceState(null, "", `/d/${drawingId}`); - } else { - window.history.pushState(null, "", `/d/${drawingId}`); - } - - await loadDrawing(drawingId); - }, - [flushPendingSave, loadDrawing], - ); - - const createDrawing = useCallback(async () => { - await flushPendingSave(); - const created = await requestJson("/api/drawings", { method: "POST" }); - await navigateToDrawing(created.id, { flush: false }); - }, [flushPendingSave, navigateToDrawing]); - - const deleteDrawing = useCallback( - async (drawingId: string) => { - if (!window.confirm("Delete this drawing?")) { - return; - } - - if (drawingId === currentIdRef.current) { - await flushPendingSave(); - } - - const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, { - method: "DELETE", - }); - - if (drawingId === currentIdRef.current && response.nextId) { - await navigateToDrawing(response.nextId, { replace: true, flush: false }); - } else { - await refreshList(); - } - }, - [flushPendingSave, navigateToDrawing, refreshList], - ); - - const handleTitleSubmit = useCallback(async () => { - const drawingId = currentIdRef.current; - if (!drawingId) { - return; - } - - try { - const body = await requestJson(`/api/drawings/${drawingId}`, { - method: "PUT", - body: JSON.stringify({ - title: currentTitleRef.current, - expectedRevision: loadedRevisionRef.current, - }), - }); - - currentTitleRef.current = body.drawing.title; - loadedRevisionRef.current = body.drawing.revision; - setActiveTitle(body.drawing.title); - setDrawings((current) => replaceMeta(current, body.drawing)); - } catch (renameError) { - if (isConflictError(renameError)) { - clearPendingSaveTimeout(); - setDrawings((current) => replaceMeta(current, renameError.body.drawing)); - await loadDrawingRef.current(drawingId); - return; - } - - setError(renameError instanceof Error ? renameError.message : "Rename failed"); - } - }, [clearPendingSaveTimeout]); - - const syncThemeTokens = useCallback(() => { - const appShell = appShellRef.current; - const excalidrawRoot = appShell?.querySelector(".excalidraw"); - if (!appShell || !excalidrawRoot) { - return; - } - - const computedStyles = window.getComputedStyle(excalidrawRoot); - for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) { - const value = computedStyles.getPropertyValue(sourceToken).trim(); - if (value) { - appShell.style.setProperty(targetToken, value); - } - } - }, []); - - const scheduleThemeTokenSync = useCallback(() => { - if (themeSyncFrameRef.current !== null) { - window.cancelAnimationFrame(themeSyncFrameRef.current); - } - - themeSyncFrameRef.current = window.requestAnimationFrame(() => { - themeSyncFrameRef.current = null; - syncThemeTokens(); - }); - }, [syncThemeTokens]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { - e.preventDefault(); - e.stopPropagation(); - void triggerManualSave(); - } - }; - window.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => { - window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }; - }, [triggerManualSave]); - - useEffect(() => { - const currentPathId = pathToId(); - if (currentPathId) { - void loadDrawing(currentPathId); - } else { - window.location.replace("/"); - } - - const onPopState = () => { - const nextId = pathToId(); - if (nextId) { - void navigateToDrawing(nextId, { replace: true, flush: true }); - } - }; - - window.addEventListener("popstate", onPopState); - return () => { - window.removeEventListener("popstate", onPopState); - }; - }, [loadDrawing, navigateToDrawing]); - - useEffect(() => { - const checkForExternalChanges = async () => { - try { - const list = await refreshList(); - const drawingId = currentIdRef.current; - const loadedRevision = loadedRevisionRef.current; - const serverDrawing = drawingId ? list.find((drawing) => drawing.id === drawingId) : null; - - if (serverDrawing && loadedRevision !== null && serverDrawing.revision > loadedRevision) { - clearPendingSaveTimeout(); - await loadDrawingRef.current(serverDrawing.id); - } - } catch { - // Focus checks only discover external edits; direct save/load requests report failures. - } - }; - - const handleFocus = () => { - void checkForExternalChanges(); - }; - - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") { - void checkForExternalChanges(); - } - }; - - window.addEventListener("focus", handleFocus); - document.addEventListener("visibilitychange", handleVisibilityChange); - - return () => { - window.removeEventListener("focus", handleFocus); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, [clearPendingSaveTimeout, refreshList]); - - useEffect(() => { - return () => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - - if (themeSyncFrameRef.current !== null) { - window.cancelAnimationFrame(themeSyncFrameRef.current); - } - }; - }, []); + const scheduleThemeTokenSync = useThemeTokenSync(appShellRef); + const { + drawings, + activeId, + activeTitle, + scene, + loading, + error, + toastMessage, + editorReloadNonce, + setActiveTitle, + submitTitle, + handleSceneChange, + selectDrawing, + createDrawing, + deleteDrawing, + } = useDrawingSession(); useEffect(() => { if (!scene || loading) { @@ -537,16 +59,16 @@ export function App() { }, []); const handleSelectDrawing = useCallback( - async (drawingId: string) => { + (drawingId: string) => { setIsSidebarOpen(false); - await navigateToDrawing(drawingId); + void selectDrawing(drawingId); }, - [navigateToDrawing], + [selectDrawing], ); - const handleCreateDrawing = useCallback(async () => { + const handleCreateDrawing = useCallback(() => { setIsSidebarOpen(false); - await createDrawing(); + void createDrawing(); }, [createDrawing]); return ( @@ -558,101 +80,31 @@ export function App() { )}
- +
- {loading || !scene ? ( -
{error ?? "Loading..."}
- ) : ( -
- { - const nextScene = sceneFromEditor(elements, appState, files); - latestSceneRef.current = nextScene; - scheduleThemeTokenSync(); - - if (ignoreChangeRef.current) { - ignoreChangeRef.current = false; - return; - } - - scheduleSave(); - }} - /> -
- )} +
-
void deleteDrawing(drawingId)} + onTitleChange={setActiveTitle} + onTitleSubmit={() => void submitTitle()} /> -
); } diff --git a/src/DrawingSidebar.tsx b/src/DrawingSidebar.tsx new file mode 100644 index 0000000..30bf915 --- /dev/null +++ b/src/DrawingSidebar.tsx @@ -0,0 +1,110 @@ +import { type DrawingMeta } from "./shared"; + +type DrawingSidebarProps = { + open: boolean; + drawings: DrawingMeta[]; + activeId: string | null; + activeTitle: string; + onClose: () => void; + onCreate: () => void; + onSelect: (drawingId: string) => void; + onDelete: (drawingId: string) => void; + onTitleChange: (title: string) => void; + onTitleSubmit: () => void; +}; + +function DrawerIcon() { + return ( + + ); +} + +export function DrawingsToggle({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +export function DrawingSidebar({ + open, + drawings, + activeId, + activeTitle, + onClose, + onCreate, + onSelect, + onDelete, + onTitleChange, + onTitleSubmit, +}: DrawingSidebarProps) { + return ( + <> +
+ + + ); +} diff --git a/src/EditorCanvas.tsx b/src/EditorCanvas.tsx new file mode 100644 index 0000000..cc561e9 --- /dev/null +++ b/src/EditorCanvas.tsx @@ -0,0 +1,61 @@ +import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css"; +import { Excalidraw } from "@excalidraw/excalidraw"; +import type { + AppState, + BinaryFiles, + ExcalidrawInitialDataState, +} from "@excalidraw/excalidraw/types"; +import { type ScenePayload } from "./shared"; + +type EditorCanvasProps = { + activeId: string | null; + scene: ScenePayload | null; + loading: boolean; + error: string | null; + editorReloadNonce: number; + onSceneChange: (scene: ScenePayload) => void; + onEditorActivity: () => void; +}; + +function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState { + return scene as ExcalidrawInitialDataState; +} + +function sceneFromEditor( + elements: readonly unknown[], + appState: AppState, + files: BinaryFiles, +): ScenePayload { + return { + elements: [...elements], + appState: appState as unknown as Record, + files: files as unknown as Record, + }; +} + +export function EditorCanvas({ + activeId, + scene, + loading, + error, + editorReloadNonce, + onSceneChange, + onEditorActivity, +}: EditorCanvasProps) { + if (loading || !scene) { + return
{error ?? "Loading..."}
; + } + + return ( +
+ { + onEditorActivity(); + onSceneChange(sceneFromEditor(elements, appState, files)); + }} + /> +
+ ); +} diff --git a/src/useDrawingSession.ts b/src/useDrawingSession.ts new file mode 100644 index 0000000..24cbce8 --- /dev/null +++ b/src/useDrawingSession.ts @@ -0,0 +1,475 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "./shared"; + +type DrawingResponse = DrawingMeta & ScenePayload; +type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta }; +type UpdateResponse = { ok: true; drawing: DrawingMeta }; + +class RequestError extends Error { + constructor( + readonly status: number, + readonly body: ApiErrorBody, + ) { + super(body.error ?? `Request failed: ${status}`); + } +} + +function pathToId(pathname = window.location.pathname): string | null { + const match = pathname.match(/^\/d\/([^/]+)$/); + return match?.[1] ?? null; +} + +function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] { + return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] { + const filtered = drawings.filter((item) => item.id !== drawing.id); + return sortDrawings([drawing, ...filtered]); +} + +async function requestJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { + ...(init?.body ? { "content-type": "application/json" } : {}), + ...init?.headers, + }, + }); + + const body = (await response.json()) as T & ApiErrorBody; + if (!response.ok) { + throw new RequestError(response.status, body); + } + + return body; +} + +function fetchDrawingList() { + return requestJson("/api/drawings", { method: "GET" }); +} + +function isConflictError(error: unknown): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } { + return error instanceof RequestError && error.status === 409 && error.body.drawing !== undefined; +} + +function sceneFromDrawing(drawing: DrawingResponse): ScenePayload { + return { + elements: drawing.elements, + appState: drawing.appState, + files: drawing.files, + }; +} + +export function useDrawingSession() { + const [drawings, setDrawings] = useState([]); + const [activeId, setActiveId] = useState(pathToId()); + const [activeTitle, setActiveTitleState] = useState(DEFAULT_TITLE); + const [scene, setScene] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [toastMessage, setToastMessage] = useState(null); + const [editorReloadNonce, setEditorReloadNonce] = useState(0); + + const currentIdRef = useRef(activeId); + const currentTitleRef = useRef(activeTitle); + const latestSceneRef = useRef(null); + const ignoreChangeRef = useRef(false); + const saveTimeoutRef = useRef(null); + const toastTimeoutRef = useRef(null); + const inFlightSaveRef = useRef | null>(null); + const loadVersionRef = useRef(0); + const loadedRevisionRef = useRef(null); + + const clearPendingSaveTimeout = useCallback(() => { + if (saveTimeoutRef.current !== null) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + }, []); + + const showToast = useCallback((message: string | null, timeoutMs?: number) => { + if (toastTimeoutRef.current !== null) { + clearTimeout(toastTimeoutRef.current); + toastTimeoutRef.current = null; + } + + setToastMessage(message); + if (message !== null && timeoutMs !== undefined) { + toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), timeoutMs); + } + }, []); + + const refreshList = useCallback(async () => { + const list = await fetchDrawingList(); + setDrawings(sortDrawings(list)); + return list; + }, []); + + const loadDrawing = useCallback( + async (drawingId: string) => { + const version = ++loadVersionRef.current; + setLoading(true); + setError(null); + + try { + const [list, drawing] = await Promise.all([ + requestJson("/api/drawings", { method: "GET" }), + requestJson(`/api/drawings/${drawingId}`, { method: "GET" }), + ]); + + if (version !== loadVersionRef.current) { + return; + } + + const nextScene = sceneFromDrawing(drawing); + + ignoreChangeRef.current = true; + currentIdRef.current = drawing.id; + currentTitleRef.current = drawing.title; + latestSceneRef.current = nextScene; + loadedRevisionRef.current = drawing.revision; + + setDrawings(replaceMeta(list, drawing)); + setActiveId(drawing.id); + setActiveTitleState(drawing.title); + setScene(nextScene); + setEditorReloadNonce((current) => current + 1); + } catch (loadError) { + if (version !== loadVersionRef.current) { + return; + } + + const list = await fetchDrawingList(); + if (version !== loadVersionRef.current) { + return; + } + setDrawings(sortDrawings(list)); + + if (list.length === 0) { + const created = await requestJson("/api/drawings", { method: "POST" }); + if (version !== loadVersionRef.current) { + return; + } + + window.history.replaceState(null, "", `/d/${created.id}`); + await loadDrawing(created.id); + return; + } + + const fallback = list[0]; + if (fallback && fallback.id !== drawingId) { + window.history.replaceState(null, "", `/d/${fallback.id}`); + await loadDrawing(fallback.id); + return; + } + + setError(loadError instanceof Error ? loadError.message : "Failed to load drawing"); + } finally { + if (version === loadVersionRef.current) { + setLoading(false); + } + } + }, + [], + ); + + const saveScene = useCallback( + async (isManual = false) => { + const drawingId = currentIdRef.current; + const nextScene = latestSceneRef.current; + const expectedRevision = loadedRevisionRef.current; + if (!drawingId || !nextScene) { + return; + } + setError(null); + + if (isManual) { + showToast("Saving..."); + } + + const promise = requestJson(`/api/drawings/${drawingId}`, { + method: "PUT", + body: JSON.stringify({ ...nextScene, expectedRevision }), + }) + .then((body) => { + setDrawings((current) => replaceMeta(current, body.drawing)); + if (currentIdRef.current === drawingId) { + loadedRevisionRef.current = body.drawing.revision; + } + if (isManual) { + showToast("Saved", 2000); + } + }) + .catch((saveError: unknown) => { + if (isConflictError(saveError)) { + clearPendingSaveTimeout(); + setError(null); + setDrawings((current) => replaceMeta(current, saveError.body.drawing)); + if (isManual) { + showToast("Reloading latest..."); + } + return loadDrawing(drawingId); + } + + setError(saveError instanceof Error ? saveError.message : "Save failed"); + if (isManual) { + showToast("Save failed", 2000); + } + throw saveError; + }) + .finally(() => { + inFlightSaveRef.current = null; + }); + + inFlightSaveRef.current = promise; + await promise; + }, + [clearPendingSaveTimeout, loadDrawing, showToast], + ); + + const flushPendingSave = useCallback(async () => { + if (saveTimeoutRef.current !== null) { + clearPendingSaveTimeout(); + await saveScene(); + return; + } + + if (inFlightSaveRef.current) { + await inFlightSaveRef.current; + } + }, [clearPendingSaveTimeout, saveScene]); + + const triggerManualSave = useCallback(async () => { + if (saveTimeoutRef.current !== null) { + clearPendingSaveTimeout(); + } + if (inFlightSaveRef.current) { + await inFlightSaveRef.current; + } + await saveScene(true); + }, [clearPendingSaveTimeout, saveScene]); + + const scheduleSave = useCallback(() => { + if (saveTimeoutRef.current !== null) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = window.setTimeout(() => { + saveTimeoutRef.current = null; + void saveScene(); + }, 30000); + }, [saveScene]); + + const navigateToDrawing = useCallback( + async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => { + const replace = options?.replace ?? false; + const flush = options?.flush ?? true; + + if (drawingId === currentIdRef.current && latestSceneRef.current) { + if (replace) { + window.history.replaceState(null, "", `/d/${drawingId}`); + } + return; + } + + if (flush) { + await flushPendingSave(); + } + + if (replace) { + window.history.replaceState(null, "", `/d/${drawingId}`); + } else { + window.history.pushState(null, "", `/d/${drawingId}`); + } + + await loadDrawing(drawingId); + }, + [flushPendingSave, loadDrawing], + ); + + const createDrawing = useCallback(async () => { + await flushPendingSave(); + const created = await requestJson("/api/drawings", { method: "POST" }); + await navigateToDrawing(created.id, { flush: false }); + }, [flushPendingSave, navigateToDrawing]); + + const deleteDrawing = useCallback( + async (drawingId: string) => { + if (!window.confirm("Delete this drawing?")) { + return; + } + + if (drawingId === currentIdRef.current) { + await flushPendingSave(); + } + + const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, { + method: "DELETE", + }); + + if (drawingId === currentIdRef.current && response.nextId) { + await navigateToDrawing(response.nextId, { replace: true, flush: false }); + } else { + await refreshList(); + } + }, + [flushPendingSave, navigateToDrawing, refreshList], + ); + + const setActiveTitle = useCallback((title: string) => { + currentTitleRef.current = title; + setActiveTitleState(title); + }, []); + + const submitTitle = useCallback(async () => { + const drawingId = currentIdRef.current; + if (!drawingId) { + return; + } + + try { + const body = await requestJson(`/api/drawings/${drawingId}`, { + method: "PUT", + body: JSON.stringify({ + title: currentTitleRef.current, + expectedRevision: loadedRevisionRef.current, + }), + }); + + currentTitleRef.current = body.drawing.title; + loadedRevisionRef.current = body.drawing.revision; + setActiveTitleState(body.drawing.title); + setDrawings((current) => replaceMeta(current, body.drawing)); + } catch (renameError) { + if (isConflictError(renameError)) { + clearPendingSaveTimeout(); + setDrawings((current) => replaceMeta(current, renameError.body.drawing)); + await loadDrawing(drawingId); + return; + } + + setError(renameError instanceof Error ? renameError.message : "Rename failed"); + } + }, [clearPendingSaveTimeout, loadDrawing]); + + const handleSceneChange = useCallback( + (nextScene: ScenePayload) => { + latestSceneRef.current = nextScene; + + if (ignoreChangeRef.current) { + ignoreChangeRef.current = false; + return; + } + + scheduleSave(); + }, + [scheduleSave], + ); + + const selectDrawing = useCallback( + async (drawingId: string) => { + await navigateToDrawing(drawingId); + }, + [navigateToDrawing], + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + e.stopPropagation(); + void triggerManualSave(); + } + }; + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }; + }, [triggerManualSave]); + + useEffect(() => { + const currentPathId = pathToId(); + if (currentPathId) { + void loadDrawing(currentPathId); + } else { + window.location.replace("/"); + } + + const onPopState = () => { + const nextId = pathToId(); + if (nextId) { + void navigateToDrawing(nextId, { replace: true, flush: true }); + } + }; + + window.addEventListener("popstate", onPopState); + return () => { + window.removeEventListener("popstate", onPopState); + }; + }, [loadDrawing, navigateToDrawing]); + + useEffect(() => { + const checkForExternalChanges = async () => { + try { + const list = await refreshList(); + const drawingId = currentIdRef.current; + const loadedRevision = loadedRevisionRef.current; + const serverDrawing = drawingId ? list.find((drawing) => drawing.id === drawingId) : null; + + if (serverDrawing && loadedRevision !== null && serverDrawing.revision > loadedRevision) { + clearPendingSaveTimeout(); + await loadDrawing(serverDrawing.id); + } + } catch { + // Focus checks only discover external edits; direct save/load requests report failures. + } + }; + + const handleFocus = () => { + void checkForExternalChanges(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + void checkForExternalChanges(); + } + }; + + window.addEventListener("focus", handleFocus); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.removeEventListener("focus", handleFocus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [clearPendingSaveTimeout, loadDrawing, refreshList]); + + useEffect(() => { + return () => { + if (saveTimeoutRef.current !== null) { + clearTimeout(saveTimeoutRef.current); + } + if (toastTimeoutRef.current !== null) { + clearTimeout(toastTimeoutRef.current); + } + }; + }, []); + + return { + drawings, + activeId, + activeTitle, + scene, + loading, + error, + toastMessage, + editorReloadNonce, + setActiveTitle, + submitTitle, + handleSceneChange, + selectDrawing, + createDrawing, + deleteDrawing, + }; +} diff --git a/src/useThemeTokenSync.ts b/src/useThemeTokenSync.ts new file mode 100644 index 0000000..b21ff5b --- /dev/null +++ b/src/useThemeTokenSync.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, type RefObject } from "react"; + +const EXCALIDRAW_THEME_TOKEN_MAP = [ + ["--island-bg-color", "--app-island-bg"], + ["--sidebar-bg-color", "--app-sidebar-bg"], + ["--sidebar-border-color", "--app-sidebar-border"], + ["--sidebar-shadow", "--app-sidebar-shadow"], + ["--color-surface-lowest", "--app-surface-lowest"], + ["--color-surface-low", "--app-surface-low"], + ["--color-surface-primary-container", "--app-selected-bg"], + ["--color-on-surface", "--app-text-color"], + ["--color-on-primary-container", "--app-selected-text-color"], + ["--color-disabled", "--app-disabled-color"], + ["--input-bg-color", "--app-input-bg"], + ["--input-border-color", "--app-input-border"], + ["--input-label-color", "--app-input-color"], + ["--default-border-color", "--app-button-border"], + ["--button-hover-bg", "--app-button-hover-bg"], + ["--button-active-bg", "--app-button-active-bg"], + ["--button-active-border", "--app-button-active-border"], + ["--overlay-bg-color", "--app-overlay-bg"], +] as const; + +export function useThemeTokenSync(appShellRef: RefObject) { + const themeSyncFrameRef = useRef(null); + + const syncThemeTokens = useCallback(() => { + const appShell = appShellRef.current; + const excalidrawRoot = appShell?.querySelector(".excalidraw"); + if (!appShell || !excalidrawRoot) { + return; + } + + const computedStyles = window.getComputedStyle(excalidrawRoot); + for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) { + const value = computedStyles.getPropertyValue(sourceToken).trim(); + if (value) { + appShell.style.setProperty(targetToken, value); + } + } + }, [appShellRef]); + + const scheduleThemeTokenSync = useCallback(() => { + if (themeSyncFrameRef.current !== null) { + window.cancelAnimationFrame(themeSyncFrameRef.current); + } + + themeSyncFrameRef.current = window.requestAnimationFrame(() => { + themeSyncFrameRef.current = null; + syncThemeTokens(); + }); + }, [syncThemeTokens]); + + useEffect(() => { + return () => { + if (themeSyncFrameRef.current !== null) { + window.cancelAnimationFrame(themeSyncFrameRef.current); + } + }; + }, []); + + return scheduleThemeTokenSync; +}