Compare commits

..

2 Commits

Author SHA1 Message Date
ruinivist ff2fee02ff fix: use inequality instead of greater than for timestamp check
Addresses PR feedback to rely on timestamp inequality since it is more robust to clock issues and timestamp resolution when saving on the backend.
2026-05-31 10:58:45 +00:00
ruinivist 0d0d4b8a85 fix: reload drawing when edited externally by MCP
Adds an active polling and reload mechanism. It tracks the `updatedAt` timestamp of the current drawing and polls `refreshList` every 5 seconds. If the server's drawing is newer, it clears pending autosaves and reloads the drawing to reflect the external changes.

Also tracks when a title save is in progress to avoid reloading when the current user renamed the drawing.
2026-05-31 01:21:08 +00:00
24 changed files with 806 additions and 1340 deletions
+4 -4
View File
@@ -3,11 +3,11 @@
"type": "module",
"private": true,
"scripts": {
"dev": "bun --hot src/server/dev-server.tsx",
"dev": "bun --hot src/dev-server.tsx",
"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/client/index.html",
"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:client": "bun build --target=browser --production --outdir ./dist/public ./src/index.html",
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server.tsx",
"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",
"test": "bun test",
"typecheck": "tsc --noEmit"
+600
View File
@@ -0,0 +1,600 @@
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 loadedUpdatedAtRef = useRef<string | null>(null);
const inFlightTitleSaveRef = useRef<Promise<void> | 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) => {
loadedUpdatedAtRef.current = body.drawing.updatedAt;
setDrawings((current) => replaceMeta(current, body.drawing));
if (isManual) {
setToastMessage("Saved");
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000);
}
})
.catch((saveError: unknown) => {
setError(saveError instanceof Error ? saveError.message : "Save failed");
if (isManual) {
setToastMessage("Save failed");
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000);
}
throw saveError;
})
.finally(() => {
inFlightSaveRef.current = null;
});
inFlightSaveRef.current = promise;
await promise;
}, []);
const flushPendingSave = useCallback(async () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
await saveScene();
return;
}
if (inFlightSaveRef.current) {
await inFlightSaveRef.current;
}
}, [saveScene]);
const triggerManualSave = useCallback(async () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (inFlightSaveRef.current) {
await inFlightSaveRef.current;
}
await saveScene(true);
}, [saveScene]);
const scheduleSave = useCallback(() => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
void saveScene();
}, 30000);
}, [saveScene]);
const loadDrawing = useCallback(
async (drawingId: string) => {
const version = ++loadVersionRef.current;
setLoading(true);
setError(null);
try {
const [list, drawing] = await Promise.all([
requestJson<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;
loadedUpdatedAtRef.current = drawing.updatedAt;
setDrawings(sortDrawings(list));
setActiveId(drawing.id);
setActiveTitle(drawing.title);
setScene(nextScene);
} catch (loadError) {
const list = await refreshList();
if (list.length === 0) {
const created = await requestJson<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;
}
const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
method: "PUT",
body: JSON.stringify({ title: currentTitleRef.current }),
}).then((body) => {
currentTitleRef.current = body.drawing.title;
loadedUpdatedAtRef.current = body.drawing.updatedAt;
setActiveTitle(body.drawing.title);
setDrawings((current) => replaceMeta(current, body.drawing));
}).catch((renameError) => {
setError(renameError instanceof Error ? renameError.message : "Rename failed");
}).finally(() => {
inFlightTitleSaveRef.current = null;
});
inFlightTitleSaveRef.current = promise;
await promise;
}, []);
const syncThemeTokens = useCallback(() => {
const appShell = appShellRef.current;
const excalidrawRoot = appShell?.querySelector<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(() => {
const interval = window.setInterval(() => {
void refreshList();
}, 5000);
return () => {
window.clearInterval(interval);
};
}, [refreshList]);
useEffect(() => {
if (
activeDrawing &&
loadedUpdatedAtRef.current &&
activeDrawing.updatedAt !== loadedUpdatedAtRef.current &&
!inFlightSaveRef.current &&
!inFlightTitleSaveRef.current
) {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
void loadDrawing(activeDrawing.id);
}
}, [activeDrawing, loadDrawing]);
useEffect(() => {
if (!scene || loading) {
return;
}
scheduleThemeTokenSync();
}, [activeId, loading, scene, scheduleThemeTokenSync]);
useEffect(() => {
if (!isSidebarOpen) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsSidebarOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [isSidebarOpen]);
const openSidebar = useCallback(() => {
setIsSidebarOpen(true);
}, []);
const closeSidebar = useCallback(() => {
setIsSidebarOpen(false);
}, []);
const handleSelectDrawing = useCallback(
async (drawingId: string) => {
setIsSidebarOpen(false);
await navigateToDrawing(drawingId);
},
[navigateToDrawing],
);
const handleCreateDrawing = useCallback(async () => {
setIsSidebarOpen(false);
await createDrawing();
}, [createDrawing]);
return (
<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>
);
}
+82
View File
@@ -0,0 +1,82 @@
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();
});
});
+17 -52
View File
@@ -1,6 +1,5 @@
import { type DrawingStore } from "./db";
import { type DrawingMeta } from "../core/shared";
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "./scene";
type RouteRequest = Request & {
params?: Record<string, string>;
@@ -15,37 +14,10 @@ function errorResponse(error: unknown): Response {
return json({ ok: false, error: error.message }, { status: error.status });
}
if (error instanceof Error && error.name === "AbortError") {
return json({ ok: false, error: "Request aborted" }, { status: 499 });
}
console.error(error);
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) {
return {
health() {
@@ -60,7 +32,10 @@ export function createApi(store: DrawingStore) {
const drawing = store.createDrawing();
return json(
{
...toMeta(drawing),
id: drawing.id,
title: drawing.title,
createdAt: drawing.createdAt,
updatedAt: drawing.updatedAt,
...drawing.scene,
},
{ status: 201 },
@@ -68,7 +43,6 @@ export function createApi(store: DrawingStore) {
},
getDrawing(request: RouteRequest) {
try {
const id = request.params?.id;
const drawing = id ? store.getDrawing(id) : null;
@@ -77,12 +51,12 @@ export function createApi(store: DrawingStore) {
}
return json({
...toMeta(drawing),
id: drawing.id,
title: drawing.title,
createdAt: drawing.createdAt,
updatedAt: drawing.updatedAt,
...drawing.scene,
});
} catch (error) {
return errorResponse(error);
}
},
async updateDrawing(request: RouteRequest) {
@@ -99,38 +73,29 @@ export function createApi(store: DrawingStore) {
Object.hasOwn(raw, "elements") ||
Object.hasOwn(raw, "appState") ||
Object.hasOwn(raw, "files");
const expectedRevision = parseExpectedRevision(raw);
if (!hasTitle && !hasScene) {
throw new HttpError(400, "Nothing to update");
}
const scene = hasScene ? parseSceneText(text) : undefined;
const result = store.updateDrawing(id, {
const updated = store.updateDrawing(id, {
scene,
title: hasTitle ? normalizeTitle(raw.title) : undefined,
expectedRevision,
});
if (!result.ok && result.reason === "not_found") {
if (!updated) {
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({
ok: true,
drawing: toMeta(updated),
drawing: {
id: updated.id,
title: updated.title,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
},
});
} catch (error) {
return errorResponse(error);
-110
View File
@@ -1,110 +0,0 @@
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>
);
}
-110
View File
@@ -1,110 +0,0 @@
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>
</>
);
}
-61
View File
@@ -1,61 +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 { 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>
);
}
-451
View File
@@ -1,451 +0,0 @@
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,
};
}
-63
View File
@@ -1,63 +0,0 @@
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;
}
+50
View File
@@ -0,0 +1,50 @@
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);
});
});
+22 -94
View File
@@ -2,8 +2,8 @@ import { Database } from "bun:sqlite";
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "../core/shared";
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene";
type DrawingRow = {
id: string;
@@ -11,16 +11,10 @@ type DrawingRow = {
data: string;
createdAt: string;
updatedAt: string;
revision: number;
};
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>;
function createId(): string {
@@ -50,7 +44,6 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
title: row.title,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
revision: row.revision,
};
}
@@ -59,17 +52,9 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | 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 {
...readMeta(row)!,
scene,
scene: coerceStoredScene(JSON.parse(row.data)),
};
}
@@ -85,8 +70,7 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
title TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
revision INTEGER NOT NULL DEFAULT 0
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
@@ -98,8 +82,7 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
id,
title,
created_at AS createdAt,
updated_at AS updatedAt,
revision
updated_at AS updatedAt
FROM drawings
ORDER BY updated_at DESC, created_at DESC
`);
@@ -110,8 +93,7 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
title,
data,
created_at AS createdAt,
updated_at AS updatedAt,
revision
updated_at AS updatedAt
FROM drawings
WHERE id = ?1
`);
@@ -123,40 +105,22 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
const updateSceneQuery = db.query(`
UPDATE drawings
SET data = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
SET data = ?2, updated_at = CURRENT_TIMESTAMP
WHERE id = ?1
`);
const updateTitleQuery = db.query(`
UPDATE drawings
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
SET title = ?2, updated_at = CURRENT_TIMESTAMP
WHERE id = ?1
`);
const updateBothQuery = db.query(`
UPDATE drawings
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP
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(`
DELETE FROM drawings
WHERE id = ?1
@@ -184,64 +148,28 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
return getLatestDrawing() ?? createDrawing();
}
function updateDrawing(
id: string,
update: { scene?: ScenePayload; title?: string; expectedRevision?: number },
): DrawingUpdateResult {
function updateDrawing(id: string, update: { scene?: ScenePayload; title?: string }): DrawingRecord | null {
const existing = getDrawing(id);
if (!existing) {
return null;
}
const hasScene = update.scene !== 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) {
return { ok: true, drawing: existing };
return existing;
}
const nextTitle = hasTitle ? normalizeTitle(update.title) : existingRow.title;
const nextScene = hasScene ? JSON.stringify(update.scene) : existingRow.data;
const titleChanged = nextTitle !== existingRow.title;
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);
if (hasScene && hasTitle) {
updateBothQuery.run(id, normalizeTitle(update.title), JSON.stringify(update.scene));
} else if (hasScene) {
updateSceneQuery.run(id, JSON.stringify(update.scene));
} else {
changes =
update.expectedRevision === undefined
? Number(updateTitleQuery.run(id, nextTitle).changes)
: Number(updateTitleExpectedQuery.run(id, nextTitle, update.expectedRevision).changes);
updateTitleQuery.run(id, normalizeTitle(update.title));
}
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 };
return getDrawing(id);
}
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
@@ -1,5 +1,5 @@
import index from "../client/index.html";
import { createServer } from "./http-server";
import index from "./index.html";
import { createServer } from "./server";
export function createDevServer() {
const server = createServer();
+1 -1
View File
@@ -6,7 +6,7 @@
<!-- to resolve the hs and css from root -->
<base href="/" />
<title>excali-box</title>
<script type="module" src="./main.tsx"></script>
<script type="module" src="./client.tsx"></script>
</head>
<body>
<div id="root"></div>
@@ -2,9 +2,9 @@ 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 { type ScenePayload } from "../core/shared";
import { createDrawingStore, type DrawingStore } from "../server/db";
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
import { createDrawingStore, type DrawingStore } from "./db";
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./mcp-server";
import { type ScenePayload } from "./shared";
const cleanup: Array<{ dir: string; store: DrawingStore }> = [];
@@ -54,10 +54,8 @@ describe("mcp tool handlers", () => {
expect(result).toEqual({
id: result.id,
title: "explicit",
revision: 1,
url: `http://example.test/d/${result.id}`,
});
expect(drawing?.revision).toBe(1);
expect(drawing?.scene).toEqual(scene);
});
@@ -71,7 +69,6 @@ describe("mcp tool handlers", () => {
expect(drawing).toMatchObject({
id: result.id,
title: "read",
revision: 1,
url: `http://example.test/d/${result.id}`,
scene,
});
@@ -86,32 +83,18 @@ describe("mcp tool handlers", () => {
files: {},
};
const replaced = tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
expect(store.listDrawings()).toHaveLength(1);
expect(replaced.revision).toBe(2);
expect(store.getDrawing(result.id)?.title).toBe("replaced");
expect(store.getDrawing(result.id)?.revision).toBe(2);
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", () => {
const { store, tools } = createTempTools();
const result = tools.create_drawing({ title: "patch", scene: testScene() });
const patched = tools.patch_drawing({
tools.patch_drawing({
id: result.id,
patch: [
{ op: "replace", path: "/elements/0/x", value: 42 },
@@ -119,8 +102,6 @@ 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 }]);
});
+6 -12
View File
@@ -1,9 +1,9 @@
import { applyPatch, type Operation } from "fast-json-patch";
import { createMcpHandler } from "mcp-handler";
import { z } from "zod";
import { normalizeScene, normalizeTitle } from "../core/scene";
import { type ScenePayload } from "../core/shared";
import { createDrawingStore, type DrawingStore } from "../server/db";
import { createDrawingStore, type DrawingStore } from "./db";
import { normalizeScene, normalizeTitle } from "./scene";
import { type ScenePayload } from "./shared";
const sceneSchema = z.object({
elements: z.array(z.unknown()),
@@ -75,11 +75,10 @@ function notFound(id: string): never {
throw new Error(`Drawing not found: ${id}`);
}
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string; revision: number }) {
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string }) {
return {
id: drawing.id,
title: drawing.title,
revision: drawing.revision,
url: drawingUrl(publicBaseUrl, drawing.id),
};
}
@@ -89,12 +88,8 @@ function updateExistingDrawing(
id: string,
update: { scene?: ScenePayload; title?: string },
) {
const result = store.updateDrawing(id, update);
if (!result.ok) {
return notFound(id);
}
return result.drawing;
const updated = store.updateDrawing(id, update);
return updated ?? notFound(id);
}
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload {
@@ -120,7 +115,6 @@ export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string
title: drawing.title,
createdAt: drawing.createdAt,
updatedAt: drawing.updatedAt,
revision: drawing.revision,
url: drawingUrl(publicBaseUrl, drawing.id),
scene: drawing.scene,
};
+8
View File
@@ -87,3 +87,11 @@ export function parseJsonBody<T = unknown>(text: string): T {
export function parseSceneText(text: string): ScenePayload {
return normalizeScene(parseJsonBody(text));
}
export function coerceStoredScene(input: unknown): ScenePayload {
try {
return normalizeScene(input);
} catch {
return emptyScene();
}
}
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDrawingStore, type DrawingStore } from "./db";
import { createServer } from "./http-server";
import { createServer } from "./server";
const cleanup: string[] = [];
const stores: DrawingStore[] = [];
-208
View File
@@ -1,208 +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");
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();
});
});
-138
View File
@@ -1,138 +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.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);
});
});
-1
View File
@@ -9,7 +9,6 @@ export type DrawingMeta = {
title: string;
createdAt: string;
updatedAt: string;
revision: number;
};
export type DrawingRecord = DrawingMeta & {