Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 788ba0fa01083b300ad50913
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Editor.tsx
// The board itself: the Excalidraw editor, kept in step with everyone else on the board.
// Loaded only on board pages, so the landing page stays light.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Excalidraw, MainMenu, WelcomeScreen, CaptureUpdateAction, convertToExcalidrawElements, restoreElements, useHandleLibrary, getCommonBounds } from "@excalidraw/excalidraw";
import type { LibraryPersistenceAdapter } from "@excalidraw/excalidraw/data/library";
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { ExcalidrawImperativeAPI, NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import "@excalidraw/excalidraw/index.css";
import { useQuery } from "@bounded-sh/client";
import { BoardSync, loadElements, type SaveState } from "./lib/sync";
import { seedElements } from "./lib/seed";
import { createBoard } from "./lib/boards";
import { myName, setMyName } from "./lib/mine";
import { explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { toast } from "./ui";
import { ShareDialog } from "./ShareDialog";
import { COLORS, parseSeed, type Seed } from "../functions/lib/canvas";
export interface BoardDoc { title: string; editOn: boolean; viewOn: boolean; seed: string; createdBy: string; createdAt: number }
interface Loaded { elements: ExcalidrawElement[]; fromServer: ExcalidrawElement[]; latest: number; seed: Seed | null }
// The shape library (the book icon) is kept on this device.
const LIBRARY_KEY = "canvas:library";
const library: LibraryPersistenceAdapter = {
load: () => {
try { return { libraryItems: JSON.parse(localStorage.getItem(LIBRARY_KEY) || "[]") }; } catch { return null; }
},
save: ({ libraryItems }) => { localStorage.setItem(LIBRARY_KEY, JSON.stringify(libraryItems)); },
};
/** Shows the whole board between the toolbars, zoomed out if it does not fit (never past 100%). */
function fitBoard(api: ExcalidrawImperativeAPI) {
const elements = api.getSceneElements();
if (!elements.length) return;
const [x1, y1, x2, y2] = getCommonBounds(elements);
const { width, height } = api.getAppState();
const side = 24, top = 80, bottom = 84;
const w = width - side * 2;
const h = height - top - bottom;
const zoom = Math.max(0.1, Math.min(1, w / Math.max(1, x2 - x1), h / Math.max(1, y2 - y1)));
api.updateScene({
appState: { zoom: { value: zoom as NormalizedZoomValue }, scrollX: (side + w / 2) / zoom - (x1 + x2) / 2, scrollY: (top + h / 2) / zoom - (y1 + y2) / 2 },
captureUpdate: CaptureUpdateAction.NEVER,
});
}
const SAVE_TEXT: Record<SaveState, string> = { saved: "Saved", saving: "Saving", offline: "Not saved yet", readonly: "View only" };
export default function Editor({ boardId, uid, owner, editKey, initial, onGone }: {
boardId: string; uid: string; owner: boolean; editKey: string | null; initial: BoardDoc; onGone: () => void;
}) {
const { data: live } = useQuery(`boards/${boardId}`) as { data: BoardDoc | null | undefined };
const board = live === undefined ? initial : live;
const [keyRejected, setKeyRejected] = useState(false);
const canEdit = Boolean(board) && (owner || (Boolean(editKey) && board!.editOn && !keyRejected));
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [error, setError] = useState("");
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
const [save, setSave] = useState<SaveState>("saved");
const [sharing, setSharing] = useState(false);
const [name, setName] = useState(myName);
const nameRef = useRef(name);
nameRef.current = name;
const syncRef = useRef<BoardSync | null>(null);
const canEditRef = useRef(canEdit);
canEditRef.current = canEdit;
useHandleLibrary({ excalidrawAPI: api, adapter: library });
useEffect(() => { if (live === null) onGone(); }, [live, onGone]);
useEffect(() => { document.title = `${board?.title || "Untitled board"} | Canvas`; }, [board?.title]);
useEffect(() => {
let cancelled = false;
loadElements(boardId).then(({ elements, latest }) => {
if (cancelled) return;
const seed = elements.length ? null : parseSeed(initial.seed);
let drawn = elements;
if (seed) {
try { drawn = seedElements(seed); } catch { drawn = []; }
}
setLoaded({ elements: restoreElements(drawn, null, { repairBindings: true }), fromServer: elements, latest, seed: drawn.length ? seed : null });
}).catch((err) => { if (!cancelled) setError(explain(err)); });
return () => { cancelled = true; };
}, [boardId]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!api || !loaded) return;
let cancelled = false;
const sync = new BoardSync(boardId, uid, canEditRef.current, () => nameRef.current, {
onSave: setSave,
onDenied: () => {
setKeyRejected(true);
toast("This edit link no longer works. The owner turned editing off or made a new link.");
},
onNotice: toast,
});
(async () => {
let seeded: ExcalidrawElement[] = [];
if (loaded.seed) {
// Text is sized with the board's hand-drawn font, which the editor starts loading as it opens.
// Draw the starting elements again once it has loaded, before anything is saved.
await new Promise((r) => setTimeout(r, 0));
await Promise.all([document.fonts.load("20px Excalifont"), document.fonts.ready]).catch(() => null);
if (cancelled) return;
seeded = seedElements(loaded.seed);
api.updateScene({ elements: seeded, captureUpdate: CaptureUpdateAction.NEVER });
}
if (cancelled) return;
syncRef.current = sync;
fitBoard(api);
await sync.start(api, loaded.fromServer, loaded.latest);
if (seeded.length) sync.addSeed(seeded);
})().catch((err) => toast(`Live updates did not start. ${explain(err)} Reload the page to try again.`));
const online = () => sync.retry();
const leave = () => sync.leave();
window.addEventListener("online", online);
window.addEventListener("pagehide", leave);
return () => {
cancelled = true;
window.removeEventListener("online", online);
window.removeEventListener("pagehide", leave);
syncRef.current = null;
void sync.stop();
};
}, [api, loaded]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const sync = syncRef.current;
if (!sync) return;
sync.canEdit = canEdit;
setSave(canEdit ? "saved" : "readonly");
if (canEdit) sync.retry();
}, [canEdit]);
const addNote = useCallback(() => {
if (!api) return;
const s = api.getAppState();
const x = -s.scrollX + s.width / 2 / s.zoom.value - 100;
const y = -s.scrollY + s.height / 2 / s.zoom.value - 100;
const [note] = convertToExcalidrawElements([{
type: "rectangle", x, y, width: 200, height: 200, backgroundColor: COLORS.yellow, fillStyle: "solid",
strokeColor: "transparent", roughness: 0, roundness: { type: 3 },
}]);
api.updateScene({
elements: [...api.getSceneElementsIncludingDeleted(), note],
appState: { selectedElementIds: { [note.id]: true } },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
toast("Note added. Double-click it, or press Enter, to write on it.");
}, [api]);
const newBoard = useCallback(async () => {
try {
navigate(await createBoard("Untitled board", null));
} catch (err) {
toast(explain(err));
}
}, []);
const initialData = useMemo(() => (loaded ? { elements: loaded.elements } : null), [loaded]); // eslint-disable-line react-hooks/exhaustive-deps
const actions = (
<>
<span className={`save-state save-${save}`} role="status">
{SAVE_TEXT[save]}
{save === "offline" && <button type="button" className="link-btn" onClick={() => syncRef.current?.retry()}>Retry</button>}
</span>
{canEdit && (
<button type="button" className="board-btn" onClick={addNote} title="Add a sticky note" aria-label="Add a sticky note">
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 3.5h12a.5.5 0 0 1 .5.5v8L12 16.5H4a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5Z M12 16.5V12h4.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /></svg>
<span className="board-btn-text">Note</span>
</button>
)}
<button type="button" className="board-btn board-btn-ink" onClick={() => setSharing(true)}>Share</button>
</>
);
if (error) return <div className="wrap page-pad narrow"><p className="notice notice-error">{error}</p></div>;
if (!initialData) return <div className="board-loading"><span className="spinner" /> Opening the board...</div>;
return (
<div className="board">
<Excalidraw
initialData={initialData}
excalidrawAPI={setApi}
isCollaborating
viewModeEnabled={!canEdit}
name={board?.title || "Untitled board"}
aiEnabled={false}
validateEmbeddable={false}
UIOptions={{
canvasActions: { loadScene: false, clearCanvas: false, saveToActiveFile: false, export: { saveFileToDisk: true }, saveAsImage: true, toggleTheme: true, changeViewBackgroundColor: true },
tools: { image: canEdit },
}}
onChange={(elements, appState, files) => syncRef.current?.onLocalChange(elements, appState, files)}
onPointerUpdate={({ pointer, button }) => syncRef.current?.pointer(pointer.x, pointer.y, pointer.tool, button === "down")}
renderTopRightUI={(isMobile) => (!isMobile ? <div className="board-actions">{actions}</div> : sharing ? null : createPortal(<div className="board-actions board-actions-mobile">{actions}</div>, document.body))}
>
<MainMenu>
<MainMenu.ItemLink href="/" onSelect={(e) => { e.preventDefault(); navigate("/"); }}>Canvas home</MainMenu.ItemLink>
<MainMenu.Item onSelect={() => void newBoard()}>New board</MainMenu.Item>
<MainMenu.Item onSelect={() => setSharing(true)}>Share and links</MainMenu.Item>
<MainMenu.Separator />
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
<WelcomeScreen>
<WelcomeScreen.Hints.MenuHint>Export, save a copy and help</WelcomeScreen.Hints.MenuHint>
<WelcomeScreen.Hints.ToolbarHint>{canEdit ? "Pick a tool and start drawing" : "This board is view only"}</WelcomeScreen.Hints.ToolbarHint>
<WelcomeScreen.Hints.HelpHint />
<WelcomeScreen.Center>
<WelcomeScreen.Center.Heading>
{canEdit ? "Your board is ready. Everyone with the edit link draws here with you, live." : "This board is empty for now. It updates live when someone draws."}
</WelcomeScreen.Center.Heading>
<WelcomeScreen.Center.Menu>
<WelcomeScreen.Center.MenuItem onSelect={() => setSharing(true)}>Share the board</WelcomeScreen.Center.MenuItem>
<WelcomeScreen.Center.MenuItemHelp />
</WelcomeScreen.Center.Menu>
</WelcomeScreen.Center>
</WelcomeScreen>
</Excalidraw>
{sharing && board && (
<ShareDialog
boardId={boardId} board={board} owner={owner} canEdit={canEdit} editKey={editKey}
name={name} onName={(n) => { setName(n); setMyName(n); }}
onClose={() => setSharing(false)}
onDeleting={async () => { const sync = syncRef.current; syncRef.current = null; await sync?.stop(); }}
/>
)}
</div>
);
}
Change history
Point OpenApps links to openapps.free
Bind AGENT_ADDRESS in policy constants
Use functions.openapps.xyz alias, add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync