refactor: split app shell

This commit is contained in:
2026-05-31 12:17:18 +00:00
parent 6911033c1a
commit ab786a7861
5 changed files with 757 additions and 596 deletions
+48 -596
View File
@@ -1,507 +1,29 @@
import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css"; import { useCallback, useEffect, useRef, useState } from "react";
import { Excalidraw } from "@excalidraw/excalidraw"; import { DrawingSidebar, DrawingsToggle } from "./DrawingSidebar";
import type { import { EditorCanvas } from "./EditorCanvas";
AppState, import { useDrawingSession } from "./useDrawingSession";
BinaryFiles, import { useThemeTokenSync } from "./useThemeTokenSync";
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<string, unknown>,
files: files as unknown as Record<string, unknown>,
};
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
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 (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
export function App() { export function App() {
const [drawings, setDrawings] = useState<DrawingMeta[]>([]);
const [activeId, setActiveId] = useState<string | null>(pathToId());
const [activeTitle, setActiveTitle] = useState(DEFAULT_TITLE);
const [scene, setScene] = useState<ScenePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const currentIdRef = useRef<string | null>(activeId);
const currentTitleRef = useRef(activeTitle);
const latestSceneRef = useRef<ScenePayload | null>(null);
const ignoreChangeRef = useRef(false);
const appShellRef = useRef<HTMLDivElement | null>(null); const appShellRef = useRef<HTMLDivElement | null>(null);
const timeoutRef = useRef<number | null>(null); const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
const inFlightSaveRef = useRef<Promise<void> | null>(null); const {
const loadVersionRef = useRef(0); drawings,
const loadedRevisionRef = useRef<number | null>(null); activeId,
const loadDrawingRef = useRef<(drawingId: string) => Promise<void>>(async () => {}); activeTitle,
const themeSyncFrameRef = useRef<number | null>(null); scene,
const toastTimeoutRef = useRef<number | null>(null); loading,
const [editorReloadNonce, setEditorReloadNonce] = useState(0); error,
toastMessage,
const activeDrawing = useMemo( editorReloadNonce,
() => drawings.find((drawing) => drawing.id === activeId) ?? null, setActiveTitle,
[activeId, drawings], submitTitle,
); handleSceneChange,
selectDrawing,
const refreshList = useCallback(async () => { createDrawing,
const list = await requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }); deleteDrawing,
setDrawings(sortDrawings(list)); } = useDrawingSession();
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<UpdateResponse>(`/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<DrawingMeta[]>("/api/drawings", { method: "GET" }),
requestJson<DrawingResponse>(`/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<DrawingResponse>("/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<DrawingResponse>("/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<UpdateResponse>(`/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<HTMLElement>(".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);
}
};
}, []);
useEffect(() => { useEffect(() => {
if (!scene || loading) { if (!scene || loading) {
@@ -537,16 +59,16 @@ export function App() {
}, []); }, []);
const handleSelectDrawing = useCallback( const handleSelectDrawing = useCallback(
async (drawingId: string) => { (drawingId: string) => {
setIsSidebarOpen(false); setIsSidebarOpen(false);
await navigateToDrawing(drawingId); void selectDrawing(drawingId);
}, },
[navigateToDrawing], [selectDrawing],
); );
const handleCreateDrawing = useCallback(async () => { const handleCreateDrawing = useCallback(() => {
setIsSidebarOpen(false); setIsSidebarOpen(false);
await createDrawing(); void createDrawing();
}, [createDrawing]); }, [createDrawing]);
return ( return (
@@ -558,101 +80,31 @@ export function App() {
)} )}
<main className="editor-shell"> <main className="editor-shell">
<div className="app-actions"> <div className="app-actions">
<button <DrawingsToggle onClick={openSidebar} />
type="button"
className="secondary-button app-actions-toggle"
onClick={openSidebar}
aria-label="Open drawings"
>
<DrawerIcon />
<span className="sr-only">Open drawings</span>
</button>
</div> </div>
{loading || !scene ? ( <EditorCanvas
<div className="editor-loading">{error ?? "Loading..."}</div> activeId={activeId}
) : ( scene={scene}
<div className="editor-frame"> loading={loading}
<Excalidraw error={error}
key={`${activeDrawing?.id ?? activeId}:${editorReloadNonce}`} editorReloadNonce={editorReloadNonce}
initialData={sceneToInitialData(scene)} onSceneChange={handleSceneChange}
onChange={(elements, appState, files) => { onEditorActivity={scheduleThemeTokenSync}
const nextScene = sceneFromEditor(elements, appState, files); />
latestSceneRef.current = nextScene;
scheduleThemeTokenSync();
if (ignoreChangeRef.current) {
ignoreChangeRef.current = false;
return;
}
scheduleSave();
}}
/>
</div>
)}
</main> </main>
<div <DrawingSidebar
className={isSidebarOpen ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"} open={isSidebarOpen}
onClick={closeSidebar} drawings={drawings}
aria-hidden={!isSidebarOpen} activeId={activeId}
activeTitle={activeTitle}
onClose={closeSidebar}
onCreate={handleCreateDrawing}
onSelect={handleSelectDrawing}
onDelete={(drawingId) => void deleteDrawing(drawingId)}
onTitleChange={setActiveTitle}
onTitleSubmit={() => void submitTitle()}
/> />
<aside className={isSidebarOpen ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!isSidebarOpen}>
<div className="sidebar-header">
<h1>excali-box</h1>
<div className="sidebar-actions">
<button type="button" className="primary-button" onClick={() => void handleCreateDrawing()}>
New
</button>
<button type="button" className="icon-button" onClick={closeSidebar} aria-label="Close drawings">
×
</button>
</div>
</div>
<div className="drawing-list">
{drawings.map((drawing) => (
<div
key={drawing.id}
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
>
{drawing.id === activeId ? (
<div className="drawing-link">
<input
className="title-input"
value={activeTitle}
onChange={(event) => {
currentTitleRef.current = event.target.value;
setActiveTitle(event.target.value);
}}
onBlur={() => void handleTitleSubmit()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</div>
) : (
<button
type="button"
className="drawing-link"
onClick={() => void handleSelectDrawing(drawing.id)}
>
<span className="drawing-title">{drawing.title}</span>
</button>
)}
<button
type="button"
className="icon-button"
onClick={() => void deleteDrawing(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
</div>
))}
</div>
</aside>
</div> </div>
); );
} }
+110
View File
@@ -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 (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
export function DrawingsToggle({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
className="secondary-button app-actions-toggle"
onClick={onClick}
aria-label="Open drawings"
>
<DrawerIcon />
<span className="sr-only">Open drawings</span>
</button>
);
}
export function DrawingSidebar({
open,
drawings,
activeId,
activeTitle,
onClose,
onCreate,
onSelect,
onDelete,
onTitleChange,
onTitleSubmit,
}: DrawingSidebarProps) {
return (
<>
<div
className={open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
onClick={onClose}
aria-hidden={!open}
/>
<aside className={open ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!open}>
<div className="sidebar-header">
<h1>excali-box</h1>
<div className="sidebar-actions">
<button type="button" className="primary-button" onClick={onCreate}>
New
</button>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close drawings">
×
</button>
</div>
</div>
<div className="drawing-list">
{drawings.map((drawing) => (
<div
key={drawing.id}
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
>
{drawing.id === activeId ? (
<div className="drawing-link">
<input
className="title-input"
value={activeTitle}
onChange={(event) => onTitleChange(event.target.value)}
onBlur={onTitleSubmit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</div>
) : (
<button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}>
<span className="drawing-title">{drawing.title}</span>
</button>
)}
<button
type="button"
className="icon-button"
onClick={() => onDelete(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
</div>
))}
</div>
</aside>
</>
);
}
+61
View File
@@ -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<string, unknown>,
files: files as unknown as Record<string, unknown>,
};
}
export function EditorCanvas({
activeId,
scene,
loading,
error,
editorReloadNonce,
onSceneChange,
onEditorActivity,
}: EditorCanvasProps) {
if (loading || !scene) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
}
return (
<div className="editor-frame">
<Excalidraw
key={`${activeId}:${editorReloadNonce}`}
initialData={sceneToInitialData(scene)}
onChange={(elements, appState, files) => {
onEditorActivity();
onSceneChange(sceneFromEditor(elements, appState, files));
}}
/>
</div>
);
}
+475
View File
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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<DrawingMeta[]>("/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<DrawingMeta[]>([]);
const [activeId, setActiveId] = useState<string | null>(pathToId());
const [activeTitle, setActiveTitleState] = useState(DEFAULT_TITLE);
const [scene, setScene] = useState<ScenePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [editorReloadNonce, setEditorReloadNonce] = useState(0);
const currentIdRef = useRef<string | null>(activeId);
const currentTitleRef = useRef(activeTitle);
const latestSceneRef = useRef<ScenePayload | null>(null);
const ignoreChangeRef = useRef(false);
const saveTimeoutRef = useRef<number | null>(null);
const toastTimeoutRef = useRef<number | null>(null);
const inFlightSaveRef = useRef<Promise<void> | null>(null);
const loadVersionRef = useRef(0);
const loadedRevisionRef = useRef<number | null>(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<DrawingMeta[]>("/api/drawings", { method: "GET" }),
requestJson<DrawingResponse>(`/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<DrawingResponse>("/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<UpdateResponse>(`/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<DrawingResponse>("/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<UpdateResponse>(`/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,
};
}
+63
View File
@@ -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<HTMLDivElement | null>) {
const themeSyncFrameRef = useRef<number | null>(null);
const syncThemeTokens = useCallback(() => {
const appShell = appShellRef.current;
const excalidrawRoot = appShell?.querySelector<HTMLElement>(".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;
}