import { CaptureUpdateAction, convertToExcalidrawElements, Excalidraw, MainMenu, Sidebar, serializeAsJSON, } from "@excalidraw/excalidraw"; import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; import type { AppState, Collaborator, ExcalidrawImperativeAPI, SocketId, } from "@excalidraw/excalidraw/types"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { documentFingerprint as fingerprint } from "../src/document.ts"; import type { CanvasAction, CanvasDraw, CanvasElement, CanvasState, CanvasViewport, } from "../src/store.ts"; import { CanvasIcon } from "./icons.tsx"; declare global { interface Window { EXCALIDRAW_ASSET_PATH?: string; } } window.EXCALIDRAW_ASSET_PATH = "/browser/"; const PI_SOCKET = "pi-canvas" as SocketId; const CLIENT_ID = crypto.randomUUID(); type ActionResponse = { ok: true; state: CanvasState }; type QueuedScene = { elements: CanvasElement[]; revision: number }; type Flight = { requestId: string; owns: boolean }; function emptyState(): CanvasState { return { revision: 0, elements: [], pendingDraw: null, }; } function accessToken(): string | null { const fragment = location.hash.slice(1); if (fragment) { sessionStorage.setItem("pi-canvas-token", fragment); history.replaceState(null, "", location.pathname); return fragment; } return sessionStorage.getItem("pi-canvas-token"); } class HttpError extends Error { constructor( readonly status: number, message: string, ) { super(message); } } async function response(value: Response): Promise { const body: unknown = await value.json(); if (!value.ok) throw new HttpError( value.status, typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" ? body.error : `Request failed (${value.status}).`, ); return body as T; } function errorText(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } function isUnsafeMermaid(definition: string): boolean { return ( !definition.trim() || definition.length > 12_000 || definition.split("\n").length > 400 || /%%\{|^\s*---|\b(?:click|href|link|image)\b|<\/?[a-z]|https?:\/\//imu.test( definition, ) ); } function remapMermaid( elements: CanvasElement[], requestId: string, x: number, y: number, ): CanvasElement[] { if (!elements.length || elements.length > 300) throw new Error( "Mermaid conversion produced an unsupported number of elements.", ); const left = Math.min(...elements.map((element) => Number(element.x) || 0)); const top = Math.min(...elements.map((element) => Number(element.y) || 0)); const ids = new Map( elements.map((element, index) => [ element.id, `${requestId}-mermaid-${index}`, ]), ); const ref = (value: unknown): unknown => typeof value === "string" ? (ids.get(value) ?? value) : value; return elements.map((source) => { const element = structuredClone(source) as CanvasElement; const id = ids.get(source.id); if (id === undefined) throw new Error("Mermaid conversion lost an element ID."); element.id = id; element.x = (Number(source.x) || 0) - left + x; element.y = (Number(source.y) || 0) - top + y; for (const key of ["containerId", "frameId"]) element[key] = ref(element[key]); for (const key of ["startBinding", "endBinding"]) { const binding = element[key]; if (binding && typeof binding === "object" && "elementId" in binding) element[key] = { ...(binding as Record), elementId: ref((binding as Record)["elementId"]), }; } if (Array.isArray(element["boundElements"])) element["boundElements"] = element["boundElements"].map((binding) => binding && typeof binding === "object" && "id" in binding ? { ...(binding as Record), id: ref((binding as Record)["id"]), } : binding, ); return element; }); } async function nativeAdditions(draw: CanvasDraw): Promise { if (draw.mermaid) { if (draw.additions.length || draw.updates.length || draw.deleteIds.length) throw new Error( "Mermaid drawing cannot include normal drawing operations.", ); if (isUnsafeMermaid(draw.mermaid.definition)) throw new Error("Mermaid definition is unsafe or exceeds canvas limits."); const { parseMermaidToExcalidraw } = await import( "@excalidraw/mermaid-to-excalidraw" ); const parsed = await parseMermaidToExcalidraw(draw.mermaid.definition, { startOnLoad: false, maxEdges: 200, maxTextSize: 12_000, }); if ( parsed.files || !parsed.elements.length || parsed.elements.some( (element) => (element as { type?: unknown }).type === "image" || "fileId" in element, ) ) throw new Error( "Mermaid conversion produced an unsupported image or file.", ); return remapMermaid( convertToExcalidrawElements(parsed.elements, { regenerateIds: false, }) as unknown as CanvasElement[], draw.id, draw.mermaid.x, draw.mermaid.y, ); } const additions = convertToExcalidrawElements( draw.additions as Parameters[0], { regenerateIds: false }, ) as unknown as CanvasElement[]; if (additions.length > 300) throw new Error("Drawing contains too many native elements."); const sources = new Map( draw.additions.map((element) => [element.id, element]), ); for (const addition of additions) { const source = sources.get(addition.id); // Conversion nudges arrow endpoints for binding, even without any bindings. // Keep explicitly requested unbound paths exact for prepare validation. if ( addition.type === "arrow" && !addition["startBinding"] && !addition["endBinding"] && !source?.["start"] && !source?.["end"] && Array.isArray(source?.["points"]) ) addition["points"] = structuredClone(source["points"]); } return additions; } function requestedDocument( draw: CanvasDraw, state: CanvasState, additions: CanvasElement[], ): CanvasElement[] { const updates = new Map( draw.updates.map((update) => [update.id, update.changes]), ); const deletes = new Set(draw.deleteIds); return [ ...state.elements.map((element) => { if (deletes.has(element.id)) return { ...element, isDeleted: true }; const changes = updates.get(element.id); return changes ? { ...element, ...changes } : element; }), ...additions, ]; } function captureViewport(editor: ExcalidrawImperativeAPI): CanvasViewport { // Copy the static layer: viewport clipping, pan and zoom are already rendered. // Selection handles and collaborators live on the separate interactive layer. const source = document.querySelector( ".excalidraw canvas.static", ); if (!source?.width || !source.height) throw new Error("Canvas viewport is unavailable."); const scale = Math.min(1, 1024 / Math.max(source.width, source.height)); const image = document.createElement("canvas"); image.width = Math.max(1, Math.round(source.width * scale)); image.height = Math.max(1, Math.round(source.height * scale)); const ctx = image.getContext("2d"); if (!ctx) throw new Error("Canvas viewport capture is unavailable."); ctx.fillStyle = editor.getAppState().viewBackgroundColor; ctx.fillRect(0, 0, image.width, image.height); ctx.drawImage(source, 0, 0, image.width, image.height); let url = image.toDataURL("image/png"); if (url.length > 256 * 1024) url = image.toDataURL("image/jpeg", 0.8); const data = url.slice(url.indexOf(",") + 1); if (!data.length || data.length > 256 * 1024) throw new Error("Canvas viewport image exceeds size limit."); const app = editor.getAppState(); return { data, mimeType: url.startsWith("data:image/png;") ? "image/png" : "image/jpeg", imageWidth: image.width, imageHeight: image.height, width: source.clientWidth, height: source.clientHeight, scrollX: app.scrollX, scrollY: app.scrollY, zoom: app.zoom.value, }; } function App() { const token = useMemo(accessToken, []); const api = useRef(null); const canonical = useRef(emptyState()); const editorFingerprint = useRef(""); const suppressedFingerprint = useRef(null); const queued = useRef(null); const saving = useRef(null); const dirty = useRef(false); const conflicted = useRef(false); const pointerDown = useRef(false); const editingText = useRef(false); const localEpoch = useRef(0); const flight = useRef(null); const collaborators = useRef(new Map()); const debounce = useRef(undefined); const [state, setState] = useState(null); const [error, setError] = useState( token ? null : "Canvas link is missing its access token.", ); const [connection, setConnection] = useState("connecting"); const [connectionAttempt, setConnectionAttempt] = useState(0); const [connectionError, setConnectionError] = useState(null); const [conflict, setConflict] = useState(null); const retryTimer = useRef(undefined); const retries = useRef(0); const [saveStatus, setSaveStatus] = useState< "shared" | "saving" | "retrying" | "blocked" >("shared"); const [recoveryChoice, setRecoveryChoice] = useState<{ kind: "mine" | "shared"; revision: number; epoch: number; } | null>(null); const [recovering, setRecovering] = useState(false); const [seenNoticeId, setSeenNoticeId] = useState(null); const noticeUnread = Boolean( state?.notice && state.notice.id !== seenNoticeId, ); const onSidebarStateChange = useCallback( (next: { name: string } | null): void => { if (next?.name === "pi-notifications") { const notice = canonical.current.notice; if (notice) setSeenNoticeId(notice.id); } }, [], ); const request = useCallback( async ( action: CanvasAction, keepalive = false, ): Promise => { if (!token) throw new Error("Canvas access token is unavailable."); return response( await fetch("/api/action", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(action), keepalive, signal: AbortSignal.timeout(10000), }), ); }, [token], ); const applyEditor = useCallback( ( elements: CanvasElement[], capture: (typeof CaptureUpdateAction)[keyof typeof CaptureUpdateAction], ): void => { const next = fingerprint(elements); if (!api.current || next === editorFingerprint.current) return; suppressedFingerprint.current = next; editorFingerprint.current = next; api.current.updateScene({ elements: elements as unknown as readonly ExcalidrawElement[], captureUpdate: capture, }); }, [], ); const install = useCallback( (next: CanvasState, apply = true): void => { if (next.revision < canonical.current.revision) return; const priorRevision = canonical.current.revision; canonical.current = next; setState(next); if (!apply || flight.current) return; if ( dirty.current || saving.current || pointerDown.current || editingText.current ) { const acknowledgesSave = saving.current !== null && next.revision === saving.current.revision + 1 && fingerprint(next.elements) === fingerprint(saving.current.elements); if ( next.revision > priorRevision && dirty.current && !acknowledgesSave ) { conflicted.current = true; setConflict( "Remote drawing changed. Your drawing is still in this tab.", ); setSaveStatus("blocked"); setRecoveryChoice(null); } return; } applyEditor(next.elements, CaptureUpdateAction.NEVER); }, [applyEditor], ); const saveScene = useCallback(async (): Promise => { if ( saving.current || conflicted.current || retryTimer.current !== undefined || !queued.current ) return; const draft = queued.current; queued.current = null; saving.current = draft; setSaveStatus("saving"); try { let viewport: CanvasViewport | undefined; let viewportError: string | null = null; try { if (api.current) viewport = captureViewport(api.current); } catch (reason) { viewportError = errorText( reason, "Viewport capture failed; saving drawing without image.", ); } const result = await request({ kind: "scene", revision: draft.revision, // Deleted shapes belong to local undo history, not the shared document. elements: draft.elements.filter((element) => !element.isDeleted), ...(viewport ? { viewport } : {}), }).catch((reason: unknown) => { // A lost HTTP response is not a conflict when the stream already acknowledged this save. const latest = canonical.current; if ( latest.revision === draft.revision + 1 && fingerprint(latest.elements) === fingerprint(draft.elements) ) return { ok: true as const, state: latest }; throw reason; }); install(result.state, false); const nextDraft = queued.current as QueuedScene | null; if (nextDraft) { queued.current = { ...nextDraft, revision: result.state.revision }; dirty.current = true; } else dirty.current = false; retries.current = 0; if (!conflicted.current) { setSaveStatus(nextDraft ? "saving" : "shared"); setError(viewportError); } } catch (reason) { queued.current ??= draft; const message = errorText( reason, "Drawing update failed. Local edits remain in browser.", ); setError(message); const retryable = !(reason instanceof HttpError) || reason.status === 408 || reason.status === 429 || reason.status >= 500; if (retryable && !conflicted.current && retries.current < 3) { retries.current++; setSaveStatus("retrying"); retryTimer.current = window.setTimeout( () => { retryTimer.current = undefined; void saveScene(); }, 250 * 2 ** retries.current, ); } else { conflicted.current = true; setSaveStatus("blocked"); setConflict( reason instanceof HttpError && reason.status === 409 ? "Shared drawing changed. Your drawing is still in this tab." : reason instanceof HttpError && reason.status === 413 ? "Drawing is too large to share. Save a copy, then remove some shapes or simplify strokes." : "Sharing failed. Your drawing is still in this tab.", ); } } finally { saving.current = null; if ( queued.current && !conflicted.current && retryTimer.current === undefined ) void saveScene(); } }, [install, request]); const scheduleSave = useCallback((): void => { if (debounce.current !== undefined) window.clearTimeout(debounce.current); debounce.current = window.setTimeout(() => { debounce.current = undefined; void saveScene(); }, 200); }, [saveScene]); const onChange = useCallback( (elements: readonly ExcalidrawElement[], appState: AppState): void => { editingText.current = appState.editingTextElement !== null; const next = fingerprint(elements as unknown as CanvasElement[]); if (suppressedFingerprint.current === next) { suppressedFingerprint.current = null; return; } if (next === editorFingerprint.current) return; editorFingerprint.current = next; dirty.current = true; setSaveStatus( conflicted.current ? "blocked" : retryTimer.current !== undefined ? "retrying" : "saving", ); setRecoveryChoice(null); localEpoch.current++; queued.current = { elements: structuredClone(elements) as unknown as CanvasElement[], revision: canonical.current.revision, }; scheduleSave(); }, [scheduleSave], ); const setApi = useCallback((value: ExcalidrawImperativeAPI): void => { api.current = value; editorFingerprint.current = fingerprint( value.getSceneElementsIncludingDeleted() as unknown as CanvasElement[], ); }, []); const canApplyDraw = useCallback( (draw: CanvasDraw, epoch: number): boolean => { return ( canonical.current.pendingDraw?.id === draw.id && canonical.current.revision === draw.baseRevision && !dirty.current && !saving.current && !pointerDown.current && !editingText.current && localEpoch.current === epoch ); }, [], ); const canApplyAcceptedDraw = useCallback( (draw: CanvasDraw, accepted: CanvasState, epoch: number): boolean => canonical.current.revision === accepted.revision && accepted.revision === draw.baseRevision + 1 && canonical.current.pendingDraw === null && !dirty.current && !saving.current && !pointerDown.current && !editingText.current && localEpoch.current === epoch, [], ); const cancelOwnedFlight = useCallback( (requestId?: string, keepalive = false): void => { const active = flight.current; if (!active?.owns || (requestId && active.requestId !== requestId)) return; flight.current = null; void request( { kind: "cancel", requestId: active.requestId, clientId: CLIENT_ID }, keepalive, ).catch(() => undefined); }, [request], ); useEffect(() => { if (!token) return; setConnectionError(connectionAttempt > 0 ? "Reconnecting…" : null); let closed = false; const controller = new AbortController(); let reconnectTimer: number | undefined; let reconnects = 0; const connect = async (): Promise => { let reader: ReadableStreamDefaultReader | undefined; try { setConnection("connecting"); install( await response( await fetch("/api/state", { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.any([ controller.signal, AbortSignal.timeout(10000), ]), }), ), ); const events = await fetch("/api/events", { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }); if (!events.ok || !events.body) throw new HttpError(events.status, "Canvas event stream failed."); setConnection("connected"); setConnectionError(null); reader = events.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!closed) { const chunk = await reader.read(); if (chunk.done) throw new Error("Canvas event stream disconnected."); buffer += decoder.decode(chunk.value, { stream: true }); const records = buffer.split("\n\n"); buffer = records.pop() ?? ""; for (const record of records) { const data = record .split("\n") .find((line) => line.startsWith("data: ")) ?.slice(6); if (data) install(JSON.parse(data) as CanvasState); } } } catch (reason) { if (!closed) { setConnection("disconnected"); setConnectionError(errorText(reason, "Canvas disconnected.")); if ( !( reason instanceof HttpError && reason.status < 500 && reason.status !== 408 && reason.status !== 429 ) && reconnects < 3 ) { reconnects++; reconnectTimer = window.setTimeout( () => void connect(), 500 * 2 ** reconnects, ); } } } finally { if (reader) { await reader.cancel().catch(() => undefined); reader.releaseLock(); } } }; void connect(); return () => { closed = true; controller.abort(); if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); }; }, [connectionAttempt, install, token]); const drawId = state?.pendingDraw?.id; useEffect(() => { if ( !drawId || !api.current || flight.current || dirty.current || saving.current ) return; const draw = canonical.current.pendingDraw; if (!draw || draw.id !== drawId || draw.clientId !== null) return; const epoch = localEpoch.current; flight.current = { requestId: draw.id, owns: false }; let disposed = false; const run = async (): Promise => { try { const additions = await nativeAdditions(draw); if (disposed || !canApplyDraw(draw, epoch)) return; const prepared = await request({ kind: "prepare", requestId: draw.id, clientId: CLIENT_ID, additions, }); flight.current = { requestId: draw.id, owns: true }; install(prepared.state, false); if (disposed || !canApplyDraw(draw, epoch)) { cancelOwnedFlight(draw.id); return; } const committed = await request({ kind: "commit", requestId: draw.id, clientId: CLIENT_ID, revision: draw.baseRevision, elements: requestedDocument(draw, canonical.current, additions), }); install(committed.state, false); flight.current = null; if (disposed || !canApplyAcceptedDraw(draw, committed.state, epoch)) { if (dirty.current) { conflicted.current = true; setConflict( "Pi drawing changed. Your drawing is still in this tab.", ); setSaveStatus("blocked"); setRecoveryChoice(null); } return; } applyEditor(committed.state.elements, CaptureUpdateAction.IMMEDIATELY); } catch (reason) { const pending = canonical.current.pendingDraw; if ( !flight.current?.owns && pending?.id === draw.id && pending.clientId === null ) void request({ kind: "cancel", requestId: draw.id, clientId: CLIENT_ID, }).catch(() => undefined); cancelOwnedFlight(draw.id); if (!disposed) setError(errorText(reason, "Drawing conversion or commit failed.")); } finally { if (flight.current?.requestId === draw.id) flight.current = null; } }; void run(); return () => { disposed = true; }; }, [ applyEditor, canApplyAcceptedDraw, canApplyDraw, cancelOwnedFlight, drawId, install, request, ]); useEffect(() => { const onPageHide = (): void => cancelOwnedFlight(undefined, true); addEventListener("pagehide", onPageHide); return () => { removeEventListener("pagehide", onPageHide); cancelOwnedFlight(undefined, true); }; }, [cancelOwnedFlight]); useEffect(() => { const warn = (event: BeforeUnloadEvent): void => { if (!dirty.current && !saving.current && !flight.current) return; event.preventDefault(); event.returnValue = ""; }; addEventListener("beforeunload", warn); return () => { removeEventListener("beforeunload", warn); if (debounce.current !== undefined) window.clearTimeout(debounce.current); if (retryTimer.current !== undefined) window.clearTimeout(retryTimer.current); }; }, []); const setFocus = useCallback((ids: string[]): void => { if (!api.current) return; if (ids.length) collaborators.current.set(PI_SOCKET, { username: "Pi", selectedElementIds: Object.fromEntries(ids.map((id) => [id, true])), color: { background: "#dbe4ff", stroke: "#4263eb" }, }); else collaborators.current.delete(PI_SOCKET); api.current.updateScene({ collaborators: new Map(collaborators.current) }); }, []); useEffect(() => { setFocus(state?.focusIds ?? []); }, [setFocus, state?.focusIds]); const download = useCallback((): void => { if (!api.current) return; const data = serializeAsJSON( api.current.getSceneElementsIncludingDeleted(), api.current.getAppState(), api.current.getFiles(), "local", ); const url = URL.createObjectURL( new Blob([data], { type: "application/json" }), ); const link = document.createElement("a"); link.href = url; link.download = "canvas-local.excalidraw"; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1_000); }, []); const openNotice = useCallback((): void => { const notice = canonical.current.notice; if (notice) setSeenNoticeId(notice.id); api.current?.toggleSidebar({ name: "pi-notifications", force: true, }); }, []); const recover = useCallback(async (): Promise => { if (!recoveryChoice || recovering || saving.current || !api.current) return; setRecovering(true); try { const latest = await response( await fetch("/api/state", { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(10000), }), ); install(latest, false); if ( latest.revision !== recoveryChoice.revision || localEpoch.current !== recoveryChoice.epoch || pointerDown.current || editingText.current ) throw new Error( "Drawing changed again or is being edited. Review your choice again.", ); if (retryTimer.current !== undefined) window.clearTimeout(retryTimer.current); retryTimer.current = undefined; retries.current = 0; conflicted.current = false; setConflict(null); setError(null); setRecoveryChoice(null); if (recoveryChoice.kind === "mine") { dirty.current = true; queued.current = { revision: latest.revision, elements: structuredClone( api.current.getSceneElementsIncludingDeleted(), ) as unknown as CanvasElement[], }; await saveScene(); } else { queued.current = null; dirty.current = false; setSaveStatus("shared"); applyEditor(latest.elements, CaptureUpdateAction.NEVER); } } catch (reason) { setRecoveryChoice(null); setError( errorText( reason, "Sharing recovery failed. Your drawing is still in this tab.", ), ); } finally { setRecovering(false); } }, [applyEditor, install, recovering, recoveryChoice, saveScene, token]); if (!state) return (

{error ?? connectionError ?? "Loading canvas…"}

{token && connection === "disconnected" ? ( ) : null}
); return (
{ pointerDown.current = true; }} onPointerUp={() => { pointerDown.current = false; }} validateEmbeddable={() => false} onLinkOpen={(_element, event) => event.preventDefault()} UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false, export: {}, saveAsImage: true, toggleTheme: true, changeViewBackgroundColor: true, clearCanvas: true, }, tools: { image: false }, }} > {state.notice ? ( <> } onSelect={openNotice} > Latest Pi notification ) : null} } onSelect={download} > Download drawing Pi notifications
{state.notice?.text ?? ""}
Pi · {connection} ·{" "} {saveStatus === "shared" ? "Shared with Pi" : saveStatus === "saving" ? "Sharing…" : "Not shared with Pi"} {state.notice ? ( ) : null} {error && !conflict ? ` · ${error}` : ""} {connectionError ? ` · ${connectionError}` : ""} {connection === "disconnected" ? ( ) : null}
{saveStatus === "blocked" || saveStatus === "retrying" ? (
Not shared with Pi

{saveStatus === "retrying" ? "Connection interrupted. Retrying sharing automatically…" : conflict}

{error ?

{error}

: null}

Pi cannot see your latest edits until sharing resumes.

{saveStatus === "blocked" ? ( <>
{recoveryChoice ? (

{recoveryChoice.kind === "mine" ? "Replace shared drawing with this tab's drawing? Other-tab or Pi edits will be replaced." : "Discard this tab's unsaved edits and use shared drawing? Save a copy first if you need them."}

) : null} ) : null}
) : null}
); } const root = document.getElementById("root"); if (!root) throw new Error("Canvas root is missing."); createRoot(root).render();