repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/canvas/browser/main.tsx
Rawimport {
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 />);