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=` 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; 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(null); const [error, setError] = useState(null); const [ready, setReady] = useState(false); /** The newest document, outside React state — see the note above on why. */ const latest = useRef<{ doc: Partial | null; ui: unknown }>({ doc: null, ui: null }); const saveTimer = useRef | null>(null); const rowId = useRef(null); // ── load, or start a new one ──────────────────────────────────────────────────────────────────── useEffect(() => { let cancelled = false; setReady(false); (async () => { try { const found = requested ? await clientRef.current.get(`/m3e-canvas/documents/${requested}`) : await clientRef.current.get("/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("/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( () => ({ 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 Opening…; return (
{error ? (
{error}
) : null}
); }; const Centred = ({ children }: { children: React.ReactNode }) => (
{children}
);