Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/canvas/browser/main.tsx

Raw
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<T>(value: Response): Promise<T> {
	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<string, unknown>),
					elementId: ref((binding as Record<string, unknown>)["elementId"]),
				};
		}
		if (Array.isArray(element["boundElements"]))
			element["boundElements"] = element["boundElements"].map((binding) =>
				binding && typeof binding === "object" && "id" in binding
					? {
							...(binding as Record<string, unknown>),
							id: ref((binding as Record<string, unknown>)["id"]),
						}
					: binding,
			);
		return element;
	});
}

async function nativeAdditions(draw: CanvasDraw): Promise<CanvasElement[]> {
	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<typeof convertToExcalidrawElements>[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<HTMLCanvasElement>(
		".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<ExcalidrawImperativeAPI | null>(null);
	const canonical = useRef<CanvasState>(emptyState());
	const editorFingerprint = useRef("");
	const suppressedFingerprint = useRef<string | null>(null);
	const queued = useRef<QueuedScene | null>(null);
	const saving = useRef<QueuedScene | null>(null);
	const dirty = useRef(false);
	const conflicted = useRef(false);
	const pointerDown = useRef(false);
	const editingText = useRef(false);
	const localEpoch = useRef(0);
	const flight = useRef<Flight | null>(null);
	const collaborators = useRef(new Map<SocketId, Collaborator>());
	const debounce = useRef<number | undefined>(undefined);
	const [state, setState] = useState<CanvasState | null>(null);
	const [error, setError] = useState<string | null>(
		token ? null : "Canvas link is missing its access token.",
	);
	const [connection, setConnection] = useState("connecting");
	const [connectionAttempt, setConnectionAttempt] = useState(0);
	const [connectionError, setConnectionError] = useState<string | null>(null);
	const [conflict, setConflict] = useState<string | null>(null);
	const retryTimer = useRef<number | undefined>(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<string | null>(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<ActionResponse> => {
			if (!token) throw new Error("Canvas access token is unavailable.");
			return response<ActionResponse>(
				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<void> => {
		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<void> => {
			let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
			try {
				setConnection("connecting");
				install(
					await response<CanvasState>(
						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<void> => {
			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<void> => {
		if (!recoveryChoice || recovering || saving.current || !api.current) return;
		setRecovering(true);
		try {
			const latest = await response<CanvasState>(
				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 (
			<main className="loading">
				<p>{error ?? connectionError ?? "Loading canvas…"}</p>
				{token && connection === "disconnected" ? (
					<button
						type="button"
						onClick={() => setConnectionAttempt((value) => value + 1)}
					>
						Reconnect
					</button>
				) : null}
			</main>
		);
	return (
		<main className="canvas" data-connection={connection}>
			<Excalidraw
				initialData={{
					elements: state.elements as unknown as readonly ExcalidrawElement[],
				}}
				excalidrawAPI={setApi}
				onChange={onChange}
				onPointerDown={() => {
					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 },
				}}
			>
				<MainMenu>
					{state.notice ? (
						<>
							<MainMenu.Group title="Pi">
								<MainMenu.Item
									icon={<CanvasIcon name="notification" />}
									onSelect={openNotice}
								>
									Latest Pi notification
								</MainMenu.Item>
							</MainMenu.Group>
							<MainMenu.Separator />
						</>
					) : null}
					<MainMenu.Group title="Canvas">
						<MainMenu.Item
							icon={<CanvasIcon name="download" />}
							onSelect={download}
						>
							Download drawing
						</MainMenu.Item>
						<MainMenu.DefaultItems.SaveAsImage />
						<MainMenu.DefaultItems.ChangeCanvasBackground />
						<MainMenu.DefaultItems.ClearCanvas />
						<MainMenu.DefaultItems.ToggleTheme />
					</MainMenu.Group>
					<MainMenu.DefaultItems.Help />
				</MainMenu>
				<Sidebar
					name="pi-notifications"
					className="pi-sidebar"
					onStateChange={onSidebarStateChange}
				>
					<Sidebar.Header>Pi notifications</Sidebar.Header>
					<section aria-label="Latest Pi notification" className="pi-notice">
						<button
							type="button"
							className="pi-icon-button"
							aria-label="Close notification"
							onClick={() =>
								api.current?.toggleSidebar({
									name: "pi-notifications",
									force: false,
								})
							}
						>
							<CanvasIcon name="close" />
						</button>
						<pre>{state.notice?.text ?? ""}</pre>
					</section>
				</Sidebar>
			</Excalidraw>
			<div className="pi-footer" role="status" aria-label="Pi sharing status">
				Pi · {connection} ·{" "}
				{saveStatus === "shared"
					? "Shared with Pi"
					: saveStatus === "saving"
						? "Sharing…"
						: "Not shared with Pi"}
				{state.notice ? (
					<button
						type="button"
						className="pi-icon-button pi-notification-trigger"
						aria-label="Latest Pi notification"
						title="Latest Pi notification"
						onClick={openNotice}
					>
						<CanvasIcon name="notification" />
						{noticeUnread ? (
							<span className="notification-unread-dot" aria-hidden="true" />
						) : null}
					</button>
				) : null}
				{error && !conflict ? ` · ${error}` : ""}
				{connectionError ? ` · ${connectionError}` : ""}
				{connection === "disconnected" ? (
					<button
						type="button"
						onClick={() => setConnectionAttempt((value) => value + 1)}
					>
						Reconnect
					</button>
				) : null}
			</div>
			{saveStatus === "blocked" || saveStatus === "retrying" ? (
				<section className="conflict" aria-label="Drawing sharing">
					<strong>Not shared with Pi</strong>
					<p>
						{saveStatus === "retrying"
							? "Connection interrupted. Retrying sharing automatically…"
							: conflict}
					</p>
					{error ? <p>{error}</p> : null}
					<p>Pi cannot see your latest edits until sharing resumes.</p>
					{saveStatus === "blocked" ? (
						<>
							<div className="recovery-actions">
								<button
									type="button"
									disabled={recovering || saving.current !== null}
									onClick={() =>
										setRecoveryChoice({
											kind: "mine",
											revision: canonical.current.revision,
											epoch: localEpoch.current,
										})
									}
								>
									Keep my drawing
								</button>
								<button
									type="button"
									disabled={recovering || saving.current !== null}
									onClick={() =>
										setRecoveryChoice({
											kind: "shared",
											revision: canonical.current.revision,
											epoch: localEpoch.current,
										})
									}
								>
									Use shared drawing
								</button>
								<button type="button" onClick={download}>
									Save copy
								</button>
							</div>
							{recoveryChoice ? (
								<div className="recovery-confirmation">
									<p>
										{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."}
									</p>
									<div className="recovery-actions">
										<button
											type="button"
											disabled={recovering}
											onClick={() => void recover()}
										>
											{recovering
												? "Updating…"
												: recoveryChoice.kind === "mine"
													? "Replace shared drawing"
													: "Discard my edits"}
										</button>
										<button
											type="button"
											disabled={recovering}
											onClick={() => setRecoveryChoice(null)}
										>
											Cancel
										</button>
									</div>
								</div>
							) : null}
						</>
					) : null}
				</section>
			) : null}
		</main>
	);
}

const root = document.getElementById("root");
if (!root) throw new Error("Canvas root is missing.");
createRoot(root).render(<App />);