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; 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 & { error?: string }; if (!response.ok) { throw new Error(body.error ?? `Request failed: ${response.status}`); } return body; } function DrawerIcon() { return ( ); } 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 themeSyncFrameRef = useRef(null); const toastTimeoutRef = useRef(null); const loadedUpdatedAtRef = useRef(null); const inFlightTitleSaveRef = useRef | null>(null); 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 saveScene = useCallback(async (isManual = false) => { const drawingId = currentIdRef.current; const nextScene = latestSceneRef.current; if (!drawingId || !nextScene) { return; } setError(null); if (isManual) { setToastMessage("Saving..."); if (toastTimeoutRef.current !== null) { clearTimeout(toastTimeoutRef.current); toastTimeoutRef.current = null; } } const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, { method: "PUT", body: JSON.stringify(nextScene), }) .then((body) => { loadedUpdatedAtRef.current = body.drawing.updatedAt; setDrawings((current) => replaceMeta(current, body.drawing)); if (isManual) { setToastMessage("Saved"); if (toastTimeoutRef.current !== null) { clearTimeout(toastTimeoutRef.current); } toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000); } }) .catch((saveError: unknown) => { 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; }, []); const flushPendingSave = useCallback(async () => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; await saveScene(); return; } if (inFlightSaveRef.current) { await inFlightSaveRef.current; } }, [saveScene]); const triggerManualSave = useCallback(async () => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } if (inFlightSaveRef.current) { await inFlightSaveRef.current; } await saveScene(true); }, [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; loadedUpdatedAtRef.current = drawing.updatedAt; setDrawings(sortDrawings(list)); setActiveId(drawing.id); setActiveTitle(drawing.title); setScene(nextScene); } 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], ); 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; } const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, { method: "PUT", body: JSON.stringify({ title: currentTitleRef.current }), }).then((body) => { currentTitleRef.current = body.drawing.title; loadedUpdatedAtRef.current = body.drawing.updatedAt; setActiveTitle(body.drawing.title); setDrawings((current) => replaceMeta(current, body.drawing)); }).catch((renameError) => { setError(renameError instanceof Error ? renameError.message : "Rename failed"); }).finally(() => { inFlightTitleSaveRef.current = null; }); inFlightTitleSaveRef.current = promise; await promise; }, []); 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(() => { return () => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } if (themeSyncFrameRef.current !== null) { window.cancelAnimationFrame(themeSyncFrameRef.current); } }; }, []); useEffect(() => { const interval = window.setInterval(() => { void refreshList(); }, 5000); return () => { window.clearInterval(interval); }; }, [refreshList]); useEffect(() => { if ( activeDrawing && loadedUpdatedAtRef.current && activeDrawing.updatedAt > loadedUpdatedAtRef.current && !inFlightSaveRef.current && !inFlightTitleSaveRef.current ) { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } void loadDrawing(activeDrawing.id); } }, [activeDrawing, loadDrawing]); useEffect(() => { if (!scene || loading) { return; } scheduleThemeTokenSync(); }, [activeId, loading, scene, scheduleThemeTokenSync]); useEffect(() => { if (!isSidebarOpen) { return; } const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setIsSidebarOpen(false); } }; window.addEventListener("keydown", onKeyDown); return () => { window.removeEventListener("keydown", onKeyDown); }; }, [isSidebarOpen]); const openSidebar = useCallback(() => { setIsSidebarOpen(true); }, []); const closeSidebar = useCallback(() => { setIsSidebarOpen(false); }, []); const handleSelectDrawing = useCallback( async (drawingId: string) => { setIsSidebarOpen(false); await navigateToDrawing(drawingId); }, [navigateToDrawing], ); const handleCreateDrawing = useCallback(async () => { setIsSidebarOpen(false); await createDrawing(); }, [createDrawing]); return (
{toastMessage && (
{toastMessage}
)}
{loading || !scene ? (
{error ?? "Loading..."}
) : (
{ const nextScene = sceneFromEditor(elements, appState, files); latestSceneRef.current = nextScene; scheduleThemeTokenSync(); if (ignoreChangeRef.current) { ignoreChangeRef.current = false; return; } scheduleSave(); }} />
)}
); }