Files
m3e-canvas/web/CanvasPanel.tsx
T
pastilhasandClaude Fable 5 a0349cc7a3 the officer shell: designs per user, and the handoff desk
manifest, schema, router and two panels. the editor is hosted and
saving; the last step is not finished and the readme says so.

designs are rows scoped per user, with userId in every WHERE rather
than checked after the read — another person's design is 'not found',
so the id space cannot be probed. the Doc is stored whole as jsonb
because upstream adds fields to it constantly and none of them should
be a migration here.

the handoff is the reason the plugin exists and it is the part that
does not work: /api/chat has NO session-creation route, a session is
constructed by the browser opening a websocket, so a plugin has nothing
to POST to. onExport returns false and the clipboard still runs, which
is upstream's own behaviour — degraded, not broken. docs/handoff.md
names the one platform change that would finish it, and argues it
should be generic rather than ours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:16:35 +01:00

163 lines
6.5 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { useClient } from "hooks/useClient";
import { Canvas, HostProvider, effectivePrompt, titleOf, type Doc, type HostAdapter } from "./adapter";
import "./vendor/globals.css";
/**
* The editor, hosted.
*
* This file is the whole of Officer's contribution to the drawing half: it hands the vendored editor
* a place to keep documents and somewhere for the prompt to go, and otherwise stays out of the way.
*
* ── Saving ──
*
* Upstream calls `saveDoc` on every change — every drag, every colour, every keystroke in a label. That
* is right for `localStorage` and wrong for a database, so writes are debounced and the newest state is
* held in a ref rather than in the dependency list. Putting the document in a dependency array would
* rebuild the adapter on every stroke, and the editor takes the adapter from context at mount.
*
* ── Which document ──
*
* `?doc=<id>` in the URL, per `docs/navigation-audit.md`: selection is addressable, survives a reload,
* and can be linked to. Not a channel — a channel would make "which design am I looking at" a thing you
* cannot bookmark.
*/
const SAVE_DEBOUNCE_MS = 1200;
type DocumentRow = { id: number; title: string; doc: Partial<Doc>; ui: unknown | null };
export const CanvasPanel = () => {
const client = useClient();
// useClient() rebuilds its verbs every render, so it must never sit in a dependency list.
const clientRef = useRef(client);
clientRef.current = client;
const [params, setParams] = useSearchParams();
const requested = params.get("doc");
const [row, setRow] = useState<DocumentRow | null>(null);
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
/** The newest document, outside React state — see the note above on why. */
const latest = useRef<{ doc: Partial<Doc> | null; ui: unknown }>({ doc: null, ui: null });
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const rowId = useRef<number | null>(null);
// ── load, or start a new one ────────────────────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
setReady(false);
(async () => {
try {
const found = requested
? await clientRef.current.get<DocumentRow>(`/m3e-canvas/documents/${requested}`)
: await clientRef.current.get<DocumentRow | null>("/m3e-canvas/documents/latest");
if (cancelled) return;
rowId.current = found?.id ?? null;
latest.current = { doc: found?.doc ?? null, ui: found?.ui ?? null };
setRow(found ?? null);
setError(null);
} catch {
if (!cancelled) setError("Could not open that design.");
} finally {
// `ready` gates the editor's first render: mounting it before the document has arrived would
// let it start empty and then immediately save that emptiness over the real one.
if (!cancelled) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [requested]);
// ── saving ──────────────────────────────────────────────────────────────────────────────────────
const flush = useCallback(async () => {
const { doc, ui } = latest.current;
if (!doc) return;
const body = { title: titleOf(doc), doc, ui };
try {
if (rowId.current === null) {
const created = await clientRef.current.post<DocumentRow>("/m3e-canvas/documents", body);
rowId.current = created.id;
// Put the new id in the URL so a reload returns to this design rather than to "latest".
setParams((p) => { p.set("doc", String(created.id)); return p; }, { replace: true });
} else {
await clientRef.current.put(`/m3e-canvas/documents/${rowId.current}`, body);
}
setError(null);
} catch {
setError("Changes are not being saved.");
}
}, [setParams]);
const schedule = useCallback(() => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => void flush(), SAVE_DEBOUNCE_MS);
}, [flush]);
// A pending save must not be lost to an unmount — closing the panel is exactly when the last edit
// is most likely to be sitting in the timer.
useEffect(
() => () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
void flush();
},
[flush],
);
// ── the handoff, which is the point of the whole plugin ─────────────────────────────────────────
const handoff = useCallback((prompt: string, doc: Doc | null) => {
// Fire and forget: the export must not wait on a session starting, and a failure here should not
// stop the clipboard from being a working fallback. Returning false does exactly that.
void clientRef.current
.post("/m3e-canvas/_handoff", { prompt, title: titleOf(doc ?? latest.current.doc) })
.catch(() => setError("Could not hand that to the agent — it is on your clipboard instead."));
return false;
}, []);
// Built once. The editor reads this from context at mount, so an identity change mid-edit would
// remount it and lose the in-progress design.
const host = useMemo<HostAdapter>(
() => ({
loadDoc: () => latest.current.doc,
saveDoc: (doc) => {
latest.current.doc = doc;
schedule();
},
loadUi: () => latest.current.ui,
saveUi: (ui) => {
latest.current.ui = ui;
schedule();
},
onExport: handoff,
}),
[schedule, handoff],
);
if (!ready) return <Centred>Opening…</Centred>;
return (
<div className="relative size-full">
{error ? (
<div className="absolute inset-x-0 top-0 z-50 bg-destructive/90 px-3 py-1.5 text-center text-xs text-destructive-foreground">
{error}
</div>
) : null}
<HostProvider value={host}>
<Canvas key={row?.id ?? "new"} />
</HostProvider>
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">{children}</div>
);