canvas-free.openapps.xyz

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.
Owned app · rev 93fdaafeda16
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.
← All files
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
93fdaafeda · 1 day ago
Bind AGENT_ADDRESS in policy constants
bf8f43b096 · 1 day ago
Use functions.openapps.xyz alias, add OpenApp contribution strip
56f1a381b2 · 1 day ago
Bounded source sync
9ec707aeb8 · 2 days ago
Bounded source sync
5645481c23 · 2 days ago
Bounded source sync
c38fe71874 · 2 days ago
Bounded source sync
28c0350b40 · 2 days ago