Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41cbd393bd | |||
| 8aa53ed3a8 | |||
| 65ecc8bfa4 | |||
| fcfde9f862 | |||
| f349617427 | |||
| d7a0a5d076 | |||
| 83191055bd | |||
| ab786a7861 | |||
| 6911033c1a | |||
| c13c137e24 |
+4
-4
@@ -3,11 +3,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --hot src/dev-server.tsx",
|
"dev": "bun --hot src/server/dev-server.tsx",
|
||||||
"build": "rm -rf dist && bun run build:client && bun run build:server && bun run build:mcp",
|
"build": "rm -rf dist && bun run build:client && bun run build:server && bun run build:mcp",
|
||||||
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/index.html",
|
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/client/index.html",
|
||||||
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server.tsx",
|
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server/http-server.tsx",
|
||||||
"build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp-server.ts",
|
"build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp/server.ts",
|
||||||
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
|
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
-566
@@ -1,566 +0,0 @@
|
|||||||
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<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 & { error?: string };
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(body.error ?? `Request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
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 [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 timeoutRef = useRef<number | null>(null);
|
|
||||||
const inFlightSaveRef = useRef<Promise<void> | null>(null);
|
|
||||||
const loadVersionRef = useRef(0);
|
|
||||||
const themeSyncFrameRef = useRef<number | null>(null);
|
|
||||||
const toastTimeoutRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const activeDrawing = useMemo(
|
|
||||||
() => drawings.find((drawing) => drawing.id === activeId) ?? null,
|
|
||||||
[activeId, drawings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
|
||||||
const list = await requestJson<DrawingMeta[]>("/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) => {
|
|
||||||
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<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;
|
|
||||||
|
|
||||||
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<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],
|
|
||||||
);
|
|
||||||
|
|
||||||
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<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ title: currentTitleRef.current }),
|
|
||||||
});
|
|
||||||
|
|
||||||
currentTitleRef.current = body.drawing.title;
|
|
||||||
setActiveTitle(body.drawing.title);
|
|
||||||
setDrawings((current) => replaceMeta(current, body.drawing));
|
|
||||||
} catch (renameError) {
|
|
||||||
setError(renameError instanceof Error ? renameError.message : "Rename failed");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current !== null) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (themeSyncFrameRef.current !== null) {
|
|
||||||
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="app-shell" ref={appShellRef}>
|
|
||||||
{toastMessage && (
|
|
||||||
<div className="toast-overlay" aria-live="polite">
|
|
||||||
{toastMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<main className="editor-shell">
|
|
||||||
<div className="app-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary-button app-actions-toggle"
|
|
||||||
onClick={openSidebar}
|
|
||||||
aria-label="Open drawings"
|
|
||||||
>
|
|
||||||
<DrawerIcon />
|
|
||||||
<span className="sr-only">Open drawings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading || !scene ? (
|
|
||||||
<div className="editor-loading">{error ?? "Loading..."}</div>
|
|
||||||
) : (
|
|
||||||
<div className="editor-frame">
|
|
||||||
<Excalidraw
|
|
||||||
key={activeDrawing?.id ?? activeId}
|
|
||||||
initialData={sceneToInitialData(scene)}
|
|
||||||
onChange={(elements, appState, files) => {
|
|
||||||
const nextScene = sceneFromEditor(elements, appState, files);
|
|
||||||
latestSceneRef.current = nextScene;
|
|
||||||
scheduleThemeTokenSync();
|
|
||||||
|
|
||||||
if (ignoreChangeRef.current) {
|
|
||||||
ignoreChangeRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleSave();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
<div
|
|
||||||
className={isSidebarOpen ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
|
|
||||||
onClick={closeSidebar}
|
|
||||||
aria-hidden={!isSidebarOpen}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createApi } from "./api";
|
|
||||||
import { createDrawingStore } from "./db";
|
|
||||||
|
|
||||||
function withApi() {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
|
|
||||||
const store = createDrawingStore(join(dir, "test.sqlite"));
|
|
||||||
const api = createApi(store);
|
|
||||||
|
|
||||||
return {
|
|
||||||
api,
|
|
||||||
cleanup() {
|
|
||||||
store.close();
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("api", () => {
|
|
||||||
test("creates a drawing with dark theme by default", async () => {
|
|
||||||
const { api, cleanup } = withApi();
|
|
||||||
|
|
||||||
const created = await api.createDrawing().json();
|
|
||||||
|
|
||||||
expect(created.appState.theme).toBe("dark");
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 400 for invalid JSON", async () => {
|
|
||||||
const { api, cleanup } = withApi();
|
|
||||||
const drawing = await api.createDrawing().json();
|
|
||||||
|
|
||||||
const response = await api.updateDrawing(
|
|
||||||
Object.assign(
|
|
||||||
new Request(`http://local/api/drawings/${drawing.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: "{",
|
|
||||||
}),
|
|
||||||
{ params: { id: drawing.id } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 404 for missing drawing", () => {
|
|
||||||
const { api, cleanup } = withApi();
|
|
||||||
|
|
||||||
const response = api.getDrawing(
|
|
||||||
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updates a drawing scene", async () => {
|
|
||||||
const { api, cleanup } = withApi();
|
|
||||||
const created = await api.createDrawing().json();
|
|
||||||
|
|
||||||
const response = await api.updateDrawing(
|
|
||||||
Object.assign(
|
|
||||||
new Request(`http://local/api/drawings/${created.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
elements: [{ id: "shape" }],
|
|
||||||
appState: {},
|
|
||||||
files: {},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
{ params: { id: created.id } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
|
||||||
|
import { EditorCanvas } from "./components/EditorCanvas";
|
||||||
|
import { useDrawingSession } from "./hooks/useDrawingSession";
|
||||||
|
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const appShellRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
|
||||||
|
const {
|
||||||
|
drawings,
|
||||||
|
activeId,
|
||||||
|
activeTitle,
|
||||||
|
scene,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
toastMessage,
|
||||||
|
editorReloadNonce,
|
||||||
|
setActiveTitle,
|
||||||
|
submitTitle,
|
||||||
|
handleSceneChange,
|
||||||
|
selectDrawing,
|
||||||
|
createDrawing,
|
||||||
|
deleteDrawing,
|
||||||
|
} = useDrawingSession();
|
||||||
|
|
||||||
|
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(
|
||||||
|
(drawingId: string) => {
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
void selectDrawing(drawingId);
|
||||||
|
},
|
||||||
|
[selectDrawing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateDrawing = useCallback(() => {
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
void createDrawing();
|
||||||
|
}, [createDrawing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell" ref={appShellRef}>
|
||||||
|
{toastMessage && (
|
||||||
|
<div className="toast-overlay" aria-live="polite">
|
||||||
|
{toastMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<main className="editor-shell">
|
||||||
|
<div className="app-actions">
|
||||||
|
<DrawingsToggle onClick={openSidebar} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorCanvas
|
||||||
|
activeId={activeId}
|
||||||
|
scene={scene}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
editorReloadNonce={editorReloadNonce}
|
||||||
|
onSceneChange={handleSceneChange}
|
||||||
|
onEditorActivity={scheduleThemeTokenSync}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<DrawingSidebar
|
||||||
|
open={isSidebarOpen}
|
||||||
|
drawings={drawings}
|
||||||
|
activeId={activeId}
|
||||||
|
activeTitle={activeTitle}
|
||||||
|
onClose={closeSidebar}
|
||||||
|
onCreate={handleCreateDrawing}
|
||||||
|
onSelect={handleSelectDrawing}
|
||||||
|
onDelete={(drawingId) => void deleteDrawing(drawingId)}
|
||||||
|
onTitleChange={setActiveTitle}
|
||||||
|
onTitleSubmit={() => void submitTitle()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { type DrawingMeta } from "../../core/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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 "../../core/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "../../core/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<!-- to resolve the hs and css from root -->
|
<!-- to resolve the hs and css from root -->
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<title>excali-box</title>
|
<title>excali-box</title>
|
||||||
<script type="module" src="./client.tsx"></script>
|
<script type="module" src="./main.tsx"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
@@ -87,11 +87,3 @@ export function parseJsonBody<T = unknown>(text: string): T {
|
|||||||
export function parseSceneText(text: string): ScenePayload {
|
export function parseSceneText(text: string): ScenePayload {
|
||||||
return normalizeScene(parseJsonBody(text));
|
return normalizeScene(parseJsonBody(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function coerceStoredScene(input: unknown): ScenePayload {
|
|
||||||
try {
|
|
||||||
return normalizeScene(input);
|
|
||||||
} catch {
|
|
||||||
return emptyScene();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ export type DrawingMeta = {
|
|||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
revision: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DrawingRecord = DrawingMeta & {
|
export type DrawingRecord = DrawingMeta & {
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createDrawingStore } from "./db";
|
|
||||||
|
|
||||||
const cleanup: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
while (cleanup.length > 0) {
|
|
||||||
const dir = cleanup.pop();
|
|
||||||
if (dir) {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function createTempStore() {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "excali-"));
|
|
||||||
cleanup.push(dir);
|
|
||||||
return createDrawingStore(join(dir, "test.sqlite"));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("drawing store", () => {
|
|
||||||
test("creates, updates, lists, and deletes drawings", () => {
|
|
||||||
const store = createTempStore();
|
|
||||||
const created = store.createDrawing();
|
|
||||||
|
|
||||||
expect(created.title).toBe("Untitled");
|
|
||||||
expect(created.scene.appState.theme).toBe("dark");
|
|
||||||
expect(store.listDrawings()).toHaveLength(1);
|
|
||||||
|
|
||||||
const updated = store.updateDrawing(created.id, {
|
|
||||||
title: "Flow",
|
|
||||||
scene: {
|
|
||||||
elements: [{ id: "one" }],
|
|
||||||
appState: { gridSize: 20 },
|
|
||||||
files: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updated?.title).toBe("Flow");
|
|
||||||
expect(updated?.scene.elements).toHaveLength(1);
|
|
||||||
|
|
||||||
const removed = store.deleteDrawing(created.id);
|
|
||||||
expect(removed.deleted).toBe(true);
|
|
||||||
expect(store.listDrawings()).toHaveLength(1);
|
|
||||||
expect(removed.next?.id).not.toBe(created.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createDrawingStore, type DrawingStore } from "./db";
|
import { type ScenePayload } from "../core/shared";
|
||||||
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./mcp-server";
|
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||||
import { type ScenePayload } from "./shared";
|
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
|
||||||
|
|
||||||
const cleanup: Array<{ dir: string; store: DrawingStore }> = [];
|
const cleanup: Array<{ dir: string; store: DrawingStore }> = [];
|
||||||
|
|
||||||
@@ -54,8 +54,10 @@ describe("mcp tool handlers", () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
title: "explicit",
|
title: "explicit",
|
||||||
|
revision: 1,
|
||||||
url: `http://example.test/d/${result.id}`,
|
url: `http://example.test/d/${result.id}`,
|
||||||
});
|
});
|
||||||
|
expect(drawing?.revision).toBe(1);
|
||||||
expect(drawing?.scene).toEqual(scene);
|
expect(drawing?.scene).toEqual(scene);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,6 +71,7 @@ describe("mcp tool handlers", () => {
|
|||||||
expect(drawing).toMatchObject({
|
expect(drawing).toMatchObject({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
title: "read",
|
title: "read",
|
||||||
|
revision: 1,
|
||||||
url: `http://example.test/d/${result.id}`,
|
url: `http://example.test/d/${result.id}`,
|
||||||
scene,
|
scene,
|
||||||
});
|
});
|
||||||
@@ -83,18 +86,32 @@ describe("mcp tool handlers", () => {
|
|||||||
files: {},
|
files: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
|
const replaced = tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
|
||||||
|
|
||||||
expect(store.listDrawings()).toHaveLength(1);
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
|
expect(replaced.revision).toBe(2);
|
||||||
expect(store.getDrawing(result.id)?.title).toBe("replaced");
|
expect(store.getDrawing(result.id)?.title).toBe("replaced");
|
||||||
|
expect(store.getDrawing(result.id)?.revision).toBe(2);
|
||||||
expect(store.getDrawing(result.id)?.scene).toEqual(nextScene);
|
expect(store.getDrawing(result.id)?.scene).toEqual(nextScene);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("replace_drawing keeps the revision when the scene is already stored", () => {
|
||||||
|
const { store, tools } = createTempTools();
|
||||||
|
const scene = testScene();
|
||||||
|
const result = tools.create_drawing({ title: "replace noop", scene });
|
||||||
|
|
||||||
|
const replaced = tools.replace_drawing({ id: result.id, scene });
|
||||||
|
|
||||||
|
expect(replaced.revision).toBe(1);
|
||||||
|
expect(store.getDrawing(result.id)?.revision).toBe(1);
|
||||||
|
expect(store.getDrawing(result.id)?.scene).toEqual(scene);
|
||||||
|
});
|
||||||
|
|
||||||
test("patch_drawing replaces an element property and removes an element", () => {
|
test("patch_drawing replaces an element property and removes an element", () => {
|
||||||
const { store, tools } = createTempTools();
|
const { store, tools } = createTempTools();
|
||||||
const result = tools.create_drawing({ title: "patch", scene: testScene() });
|
const result = tools.create_drawing({ title: "patch", scene: testScene() });
|
||||||
|
|
||||||
tools.patch_drawing({
|
const patched = tools.patch_drawing({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
patch: [
|
patch: [
|
||||||
{ op: "replace", path: "/elements/0/x", value: 42 },
|
{ op: "replace", path: "/elements/0/x", value: 42 },
|
||||||
@@ -102,6 +119,8 @@ describe("mcp tool handlers", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(patched.revision).toBe(2);
|
||||||
|
expect(store.getDrawing(result.id)?.revision).toBe(2);
|
||||||
expect(store.getDrawing(result.id)?.scene.elements).toEqual([{ id: "one", type: "rectangle", x: 42, y: 20 }]);
|
expect(store.getDrawing(result.id)?.scene.elements).toEqual([{ id: "one", type: "rectangle", x: 42, y: 20 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { applyPatch, type Operation } from "fast-json-patch";
|
import { applyPatch, type Operation } from "fast-json-patch";
|
||||||
import { createMcpHandler } from "mcp-handler";
|
import { createMcpHandler } from "mcp-handler";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createDrawingStore, type DrawingStore } from "./db";
|
import { normalizeScene, normalizeTitle } from "../core/scene";
|
||||||
import { normalizeScene, normalizeTitle } from "./scene";
|
import { type ScenePayload } from "../core/shared";
|
||||||
import { type ScenePayload } from "./shared";
|
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||||
|
|
||||||
const sceneSchema = z.object({
|
const sceneSchema = z.object({
|
||||||
elements: z.array(z.unknown()),
|
elements: z.array(z.unknown()),
|
||||||
@@ -75,10 +75,11 @@ function notFound(id: string): never {
|
|||||||
throw new Error(`Drawing not found: ${id}`);
|
throw new Error(`Drawing not found: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string }) {
|
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string; revision: number }) {
|
||||||
return {
|
return {
|
||||||
id: drawing.id,
|
id: drawing.id,
|
||||||
title: drawing.title,
|
title: drawing.title,
|
||||||
|
revision: drawing.revision,
|
||||||
url: drawingUrl(publicBaseUrl, drawing.id),
|
url: drawingUrl(publicBaseUrl, drawing.id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -88,8 +89,12 @@ function updateExistingDrawing(
|
|||||||
id: string,
|
id: string,
|
||||||
update: { scene?: ScenePayload; title?: string },
|
update: { scene?: ScenePayload; title?: string },
|
||||||
) {
|
) {
|
||||||
const updated = store.updateDrawing(id, update);
|
const result = store.updateDrawing(id, update);
|
||||||
return updated ?? notFound(id);
|
if (!result.ok) {
|
||||||
|
return notFound(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.drawing;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload {
|
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload {
|
||||||
@@ -115,6 +120,7 @@ export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string
|
|||||||
title: drawing.title,
|
title: drawing.title,
|
||||||
createdAt: drawing.createdAt,
|
createdAt: drawing.createdAt,
|
||||||
updatedAt: drawing.updatedAt,
|
updatedAt: drawing.updatedAt,
|
||||||
|
revision: drawing.revision,
|
||||||
url: drawingUrl(publicBaseUrl, drawing.id),
|
url: drawingUrl(publicBaseUrl, drawing.id),
|
||||||
scene: drawing.scene,
|
scene: drawing.scene,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createApi } from "./api";
|
||||||
|
import { createDrawingStore } from "./db";
|
||||||
|
|
||||||
|
function withApi() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
|
||||||
|
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||||
|
const api = createApi(store);
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
cleanup() {
|
||||||
|
store.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("api", () => {
|
||||||
|
test("creates a drawing with dark theme by default", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
expect(created.appState.theme).toBe("dark");
|
||||||
|
expect(created.revision).toBe(0);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 400 for invalid JSON", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const drawing = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${drawing.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: "{",
|
||||||
|
}),
|
||||||
|
{ params: { id: drawing.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 for missing drawing", () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
|
||||||
|
const response = api.getDrawing(
|
||||||
|
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates a drawing scene", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
expectedRevision: created.revision,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.drawing.revision).toBe(1);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not increment revisions for equivalent normalized scenes", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: { gridSize: 20 },
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: { gridSize: 20, selectedElementIds: { shape: true } },
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.drawing.revision).toBe(1);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates scene and title with a matching expected revision", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Synced",
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.drawing.title).toBe("Synced");
|
||||||
|
expect(body.drawing.revision).toBe(1);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 409 for stale expected revisions without overwriting", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "server" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflict = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "stale" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(conflict.status).toBe(409);
|
||||||
|
const body = await conflict.json();
|
||||||
|
expect(body).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Drawing changed externally",
|
||||||
|
drawing: {
|
||||||
|
id: created.id,
|
||||||
|
title: created.title,
|
||||||
|
createdAt: body.drawing.createdAt,
|
||||||
|
updatedAt: body.drawing.updatedAt,
|
||||||
|
revision: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = await api.getDrawing(
|
||||||
|
Object.assign(new Request(`http://local/api/drawings/${created.id}`), { params: { id: created.id } }),
|
||||||
|
).json();
|
||||||
|
expect(stored.elements).toEqual([{ id: "server" }]);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type DrawingStore } from "./db";
|
import { type DrawingStore } from "./db";
|
||||||
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "./scene";
|
import { type DrawingMeta } from "../core/shared";
|
||||||
|
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
|
||||||
|
|
||||||
type RouteRequest = Request & {
|
type RouteRequest = Request & {
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
@@ -22,6 +23,29 @@ function errorResponse(error: unknown): Response {
|
|||||||
return json({ ok: false, error: "Internal server error" }, { status: 500 });
|
return json({ ok: false, error: "Internal server error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toMeta(drawing: DrawingMeta): DrawingMeta {
|
||||||
|
return {
|
||||||
|
id: drawing.id,
|
||||||
|
title: drawing.title,
|
||||||
|
createdAt: drawing.createdAt,
|
||||||
|
updatedAt: drawing.updatedAt,
|
||||||
|
revision: drawing.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExpectedRevision(raw: Record<string, unknown>): number | undefined {
|
||||||
|
if (!Object.hasOwn(raw, "expectedRevision")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = raw.expectedRevision;
|
||||||
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw new HttpError(400, "expectedRevision must be a non-negative integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function createApi(store: DrawingStore) {
|
export function createApi(store: DrawingStore) {
|
||||||
return {
|
return {
|
||||||
health() {
|
health() {
|
||||||
@@ -36,10 +60,7 @@ export function createApi(store: DrawingStore) {
|
|||||||
const drawing = store.createDrawing();
|
const drawing = store.createDrawing();
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
id: drawing.id,
|
...toMeta(drawing),
|
||||||
title: drawing.title,
|
|
||||||
createdAt: drawing.createdAt,
|
|
||||||
updatedAt: drawing.updatedAt,
|
|
||||||
...drawing.scene,
|
...drawing.scene,
|
||||||
},
|
},
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
@@ -47,6 +68,7 @@ export function createApi(store: DrawingStore) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getDrawing(request: RouteRequest) {
|
getDrawing(request: RouteRequest) {
|
||||||
|
try {
|
||||||
const id = request.params?.id;
|
const id = request.params?.id;
|
||||||
const drawing = id ? store.getDrawing(id) : null;
|
const drawing = id ? store.getDrawing(id) : null;
|
||||||
|
|
||||||
@@ -55,12 +77,12 @@ export function createApi(store: DrawingStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
id: drawing.id,
|
...toMeta(drawing),
|
||||||
title: drawing.title,
|
|
||||||
createdAt: drawing.createdAt,
|
|
||||||
updatedAt: drawing.updatedAt,
|
|
||||||
...drawing.scene,
|
...drawing.scene,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateDrawing(request: RouteRequest) {
|
async updateDrawing(request: RouteRequest) {
|
||||||
@@ -77,29 +99,38 @@ export function createApi(store: DrawingStore) {
|
|||||||
Object.hasOwn(raw, "elements") ||
|
Object.hasOwn(raw, "elements") ||
|
||||||
Object.hasOwn(raw, "appState") ||
|
Object.hasOwn(raw, "appState") ||
|
||||||
Object.hasOwn(raw, "files");
|
Object.hasOwn(raw, "files");
|
||||||
|
const expectedRevision = parseExpectedRevision(raw);
|
||||||
|
|
||||||
if (!hasTitle && !hasScene) {
|
if (!hasTitle && !hasScene) {
|
||||||
throw new HttpError(400, "Nothing to update");
|
throw new HttpError(400, "Nothing to update");
|
||||||
}
|
}
|
||||||
|
|
||||||
const scene = hasScene ? parseSceneText(text) : undefined;
|
const scene = hasScene ? parseSceneText(text) : undefined;
|
||||||
const updated = store.updateDrawing(id, {
|
const result = store.updateDrawing(id, {
|
||||||
scene,
|
scene,
|
||||||
title: hasTitle ? normalizeTitle(raw.title) : undefined,
|
title: hasTitle ? normalizeTitle(raw.title) : undefined,
|
||||||
|
expectedRevision,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!result.ok && result.reason === "not_found") {
|
||||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result.ok && result.reason === "conflict") {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Drawing changed externally",
|
||||||
|
drawing: toMeta(result.drawing),
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = result.drawing;
|
||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
drawing: {
|
drawing: toMeta(updated),
|
||||||
id: updated.id,
|
|
||||||
title: updated.title,
|
|
||||||
createdAt: updated.createdAt,
|
|
||||||
updatedAt: updated.updatedAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createDrawingStore } from "./db";
|
||||||
|
|
||||||
|
const cleanup: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanup.length > 0) {
|
||||||
|
const dir = cleanup.pop();
|
||||||
|
if (dir) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTempStore() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "excali-"));
|
||||||
|
cleanup.push(dir);
|
||||||
|
return createDrawingStore(join(dir, "test.sqlite"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("drawing store", () => {
|
||||||
|
test("creates, updates, lists, and deletes drawings", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
|
||||||
|
expect(created.title).toBe("Untitled");
|
||||||
|
expect(created.revision).toBe(0);
|
||||||
|
expect(created.scene.appState.theme).toBe("dark");
|
||||||
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
|
expect(store.listDrawings()[0]?.revision).toBe(0);
|
||||||
|
|
||||||
|
const updated = store.updateDrawing(created.id, {
|
||||||
|
title: "Flow",
|
||||||
|
scene: {
|
||||||
|
elements: [{ id: "one" }],
|
||||||
|
appState: { gridSize: 20 },
|
||||||
|
files: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.ok).toBe(true);
|
||||||
|
expect(updated.ok ? updated.drawing.title : null).toBe("Flow");
|
||||||
|
expect(updated.ok ? updated.drawing.revision : null).toBe(1);
|
||||||
|
expect(updated.ok ? updated.drawing.scene.elements : []).toHaveLength(1);
|
||||||
|
|
||||||
|
const removed = store.deleteDrawing(created.id);
|
||||||
|
expect(removed.deleted).toBe(true);
|
||||||
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
|
expect(removed.next?.id).not.toBe(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects stale expected revisions without overwriting the scene", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
|
||||||
|
const first = store.updateDrawing(created.id, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
scene: {
|
||||||
|
elements: [{ id: "first" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
|
||||||
|
const stale = store.updateDrawing(created.id, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
scene: {
|
||||||
|
elements: [{ id: "stale" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stale.ok).toBe(false);
|
||||||
|
expect(stale.ok ? null : stale.reason).toBe("conflict");
|
||||||
|
expect(store.getDrawing(created.id)?.revision).toBe(1);
|
||||||
|
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the revision unchanged when the scene is already stored", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
const scene = {
|
||||||
|
elements: [{ id: "same" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = store.updateDrawing(created.id, { scene });
|
||||||
|
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||||
|
|
||||||
|
const repeated = store.updateDrawing(created.id, { scene });
|
||||||
|
expect(repeated.ok).toBe(true);
|
||||||
|
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
|
||||||
|
expect(store.getDrawing(created.id)?.revision).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts stale expected revisions when the scene is already stored", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
const scene = {
|
||||||
|
elements: [{ id: "same" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||||
|
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||||
|
|
||||||
|
const repeated = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||||
|
expect(repeated.ok).toBe(true);
|
||||||
|
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
|
||||||
|
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "same" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("increments the revision when the title changes but the scene does not", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
const scene = {
|
||||||
|
elements: [{ id: "same" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||||
|
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||||
|
|
||||||
|
const renamed = store.updateDrawing(created.id, { expectedRevision: 1, title: "Renamed", scene });
|
||||||
|
expect(renamed.ok).toBe(true);
|
||||||
|
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
|
||||||
|
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,8 @@ import { Database } from "bun:sqlite";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
|
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "../core/shared";
|
||||||
import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene";
|
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
|
||||||
|
|
||||||
type DrawingRow = {
|
type DrawingRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,10 +11,16 @@ type DrawingRow = {
|
|||||||
data: string;
|
data: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
revision: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
||||||
|
|
||||||
|
export type DrawingUpdateResult =
|
||||||
|
| { ok: true; drawing: DrawingRecord }
|
||||||
|
| { ok: false; reason: "not_found" }
|
||||||
|
| { ok: false; reason: "conflict"; drawing: DrawingRecord };
|
||||||
|
|
||||||
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
||||||
|
|
||||||
function createId(): string {
|
function createId(): string {
|
||||||
@@ -44,6 +50,7 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
|
|||||||
title: row.title,
|
title: row.title,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
|
revision: row.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +59,17 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scene: ScenePayload;
|
||||||
|
try {
|
||||||
|
scene = normalizeScene(JSON.parse(row.data));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown scene parsing error";
|
||||||
|
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...readMeta(row)!,
|
...readMeta(row)!,
|
||||||
scene: coerceStoredScene(JSON.parse(row.data)),
|
scene,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +85,8 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
||||||
@@ -82,7 +98,8 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
created_at AS createdAt,
|
created_at AS createdAt,
|
||||||
updated_at AS updatedAt
|
updated_at AS updatedAt,
|
||||||
|
revision
|
||||||
FROM drawings
|
FROM drawings
|
||||||
ORDER BY updated_at DESC, created_at DESC
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
`);
|
`);
|
||||||
@@ -93,7 +110,8 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
created_at AS createdAt,
|
created_at AS createdAt,
|
||||||
updated_at AS updatedAt
|
updated_at AS updatedAt,
|
||||||
|
revision
|
||||||
FROM drawings
|
FROM drawings
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
@@ -105,22 +123,40 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
|
|
||||||
const updateSceneQuery = db.query(`
|
const updateSceneQuery = db.query(`
|
||||||
UPDATE drawings
|
UPDATE drawings
|
||||||
SET data = ?2, updated_at = CURRENT_TIMESTAMP
|
SET data = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updateTitleQuery = db.query(`
|
const updateTitleQuery = db.query(`
|
||||||
UPDATE drawings
|
UPDATE drawings
|
||||||
SET title = ?2, updated_at = CURRENT_TIMESTAMP
|
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updateBothQuery = db.query(`
|
const updateBothQuery = db.query(`
|
||||||
UPDATE drawings
|
UPDATE drawings
|
||||||
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP
|
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const updateSceneExpectedQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET data = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
|
WHERE id = ?1 AND revision = ?3
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateTitleExpectedQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
|
WHERE id = ?1 AND revision = ?3
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateBothExpectedQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
|
||||||
|
WHERE id = ?1 AND revision = ?4
|
||||||
|
`);
|
||||||
|
|
||||||
const deleteQuery = db.query(`
|
const deleteQuery = db.query(`
|
||||||
DELETE FROM drawings
|
DELETE FROM drawings
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
@@ -148,28 +184,64 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
|||||||
return getLatestDrawing() ?? createDrawing();
|
return getLatestDrawing() ?? createDrawing();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDrawing(id: string, update: { scene?: ScenePayload; title?: string }): DrawingRecord | null {
|
function updateDrawing(
|
||||||
const existing = getDrawing(id);
|
id: string,
|
||||||
if (!existing) {
|
update: { scene?: ScenePayload; title?: string; expectedRevision?: number },
|
||||||
return null;
|
): DrawingUpdateResult {
|
||||||
}
|
|
||||||
|
|
||||||
const hasScene = update.scene !== undefined;
|
const hasScene = update.scene !== undefined;
|
||||||
const hasTitle = update.title !== undefined;
|
const hasTitle = update.title !== undefined;
|
||||||
|
const existingRow = getQuery.get(id) as DrawingRow | null | undefined;
|
||||||
|
|
||||||
|
if (!existingRow) {
|
||||||
|
return { ok: false, reason: "not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = readRow(existingRow)!;
|
||||||
if (!hasScene && !hasTitle) {
|
if (!hasScene && !hasTitle) {
|
||||||
return existing;
|
return { ok: true, drawing: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasScene && hasTitle) {
|
const nextTitle = hasTitle ? normalizeTitle(update.title) : existingRow.title;
|
||||||
updateBothQuery.run(id, normalizeTitle(update.title), JSON.stringify(update.scene));
|
const nextScene = hasScene ? JSON.stringify(update.scene) : existingRow.data;
|
||||||
} else if (hasScene) {
|
const titleChanged = nextTitle !== existingRow.title;
|
||||||
updateSceneQuery.run(id, JSON.stringify(update.scene));
|
const sceneChanged = nextScene !== existingRow.data;
|
||||||
|
|
||||||
|
if (!titleChanged && !sceneChanged) {
|
||||||
|
return { ok: true, drawing: existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.expectedRevision !== undefined && update.expectedRevision !== existingRow.revision) {
|
||||||
|
return { ok: false, reason: "conflict", drawing: existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
let changes: number;
|
||||||
|
if (sceneChanged && titleChanged) {
|
||||||
|
changes =
|
||||||
|
update.expectedRevision === undefined
|
||||||
|
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
|
||||||
|
: Number(updateBothExpectedQuery.run(id, nextTitle, nextScene, update.expectedRevision).changes);
|
||||||
|
} else if (sceneChanged) {
|
||||||
|
changes =
|
||||||
|
update.expectedRevision === undefined
|
||||||
|
? Number(updateSceneQuery.run(id, nextScene).changes)
|
||||||
|
: Number(updateSceneExpectedQuery.run(id, nextScene, update.expectedRevision).changes);
|
||||||
} else {
|
} else {
|
||||||
updateTitleQuery.run(id, normalizeTitle(update.title));
|
changes =
|
||||||
|
update.expectedRevision === undefined
|
||||||
|
? Number(updateTitleQuery.run(id, nextTitle).changes)
|
||||||
|
: Number(updateTitleExpectedQuery.run(id, nextTitle, update.expectedRevision).changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getDrawing(id);
|
const drawing = getDrawing(id);
|
||||||
|
if (changes > 0 && drawing) {
|
||||||
|
return { ok: true, drawing };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drawing) {
|
||||||
|
return { ok: false, reason: "not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, reason: "conflict", drawing };
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
|
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import index from "./index.html";
|
import index from "../client/index.html";
|
||||||
import { createServer } from "./server";
|
import { createServer } from "./http-server";
|
||||||
|
|
||||||
export function createDevServer() {
|
export function createDevServer() {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createDrawingStore, type DrawingStore } from "./db";
|
import { createDrawingStore, type DrawingStore } from "./db";
|
||||||
import { createServer } from "./server";
|
import { createServer } from "./http-server";
|
||||||
|
|
||||||
const cleanup: string[] = [];
|
const cleanup: string[] = [];
|
||||||
const stores: DrawingStore[] = [];
|
const stores: DrawingStore[] = [];
|
||||||
Reference in New Issue
Block a user