Whiteboard

Infinite Freeform-style whiteboards with notes, text, shapes, comments, live presence cursors, frames, presentation, export, minimap navigation, grouping, layout tools, connectors, drawing, images, per-user persistence, and native agent tools.

What it does

whiteboard is a bundled iframe module for visual thinking on an infinite canvas. Its boards home shows saved boards with scene previews, inline rename, duplication, and deletion. Opening a board provides pan and zoom navigation plus sticky notes, text boxes, geometric shapes, attached straight/elbow connector arrows, freehand drawing, and pasted, dropped, uploaded, or AI-generated images. The Bild tool opens a compact menu: Bild hochladen keeps the existing file picker flow, while Bild generieren arms placement mode. The next canvas click creates a local-only 480 x 360 placeholder with an anchored prompt card; submitting the prompt shows generation progress and retryable errors, then replaces the placeholder with a selected, embedded data-URL image only after generation succeeds. The card also offers an optional model selector that defaults to the server-configured image model and remembers the last selection locally. Escape or the close action cancels the draft, and only one generation placeholder can be active at a time.

Selecting exactly one unlocked image exposes Bild bearbeiten in the compact selection toolbar and the item context menu. It opens the same anchored prompt/model card in edit mode. Generated images reuse their stored assetId; uploaded or legacy embedded images are first imported through the authenticated generated- image import API, and the returned id is retained for the retry. The edited asset is fetched, downscaled, and fitted proportionally inside the previous image box before one atomic board mutation replaces the source, geometry, and assetId. Position context, rotation, and z-order remain intact, and undo restores the complete previous image. Failed imports, edits, downloads, or processing leave the board item unchanged; changing the selection while a request is running also discards the result.

The editor supports selection and marquee selection, movement, z-order, duplication, deletion, color palettes, keyboard shortcuts, bounded undo/redo, and debounced autosave. Moving and resizing snaps item left, centre, right, top, middle, and bottom lines to nearby items within a zoom-normalized 6 px screen threshold. Pink world-space alignment guides show the matched extents; holding Alt/Option temporarily disables snapping. Every unrotated, unlocked item type, including notes, text boxes, and images, exposes all eight resize handles; the four corner handles change width and height together, and holding Shift while dragging a corner preserves the item's aspect ratio. A single unlocked selection also exposes a rotation handle; Shift constrains rotation to 15 degree steps. Resize handles are intentionally hidden while rotation is non-zero because resize deltas are still axis-aligned rather than transformed into the item's local rotated coordinate space. Selection chrome renders in a sibling overlay frame, so item content can remain clipped without swallowing the selection ring or controls.

Command/Ctrl+G assigns one fresh shared groupId to the current selection; Command/Ctrl+Shift+G removes the selected group metadata. A normal click on any member selects the complete group, and a marquee that touches one member expands to the complete group. Movement, rotation, deletion, locking, z-order changes, and style paste therefore operate on the group selection. Double-click enters one member for individual selection and text editing; Escape restores the full group selection. Groups are deliberately flat: groupId is optional string metadata and nested groups are out of scope.

Selections of two or more items expose a compact layout popover and matching right-click actions for left, horizontal-centre, right, top, vertical-centre, and bottom alignment against the complete selection bounds. Selections of three or more also support equal horizontal or vertical edge-to-edge distribution while keeping the two outer items fixed. Locked items contribute to the reference bounds and spacing but are not moved. Every item moved by a layout command reuses the connector re-route path, so attached arrows stay connected.

The Comment tool drops a fixed 36 x 36 numbered pin at the clicked canvas position and opens its thread. Creating the original comment persists a normal scene item with type: "comment"; replies, resolved state, authorship, and timestamps therefore reuse the board's existing JSONB persistence, row-locked ops, revision polling, and sharing rules rather than a parallel comment store. The thread popover shows the original text and up to 50 replies, accepts 4,000 characters per entry, lets any user with board access reply or resolve/reopen, and exposes deletion only to the original author. Resolved pins dim, and the header filter can hide or show them. Comments have fixed geometry and cannot be resized or rotated. They are also excluded from grouping, align/distribute commands, style copy/paste targets, and connector attachment.

Comment authorization is enforced in the module handler as well as the UI. Creation and new replies replace all client-supplied author, authorId, and createdAt values with identity and time derived server-side from context.user. Existing comment/reply authorship is preserved on updates, and comment deletion is rejected server-side unless the acting user matches the stored original authorId; this applies to ops batches, single-item deletion, and whole-scene replacement.

The Frame tool drag-places a titled background region. Frames always render behind ordinary items, independent of stored z-order; their label above the top-left corner is click-to-rename. The Frames panel lists all regions and zooms to one on click. Frame membership is computed rather than stored: an item's centre belongs to a frame when it lies inside the frame rectangle. Moving a frame moves its currently contained, unlocked items, while deleting a frame removes only the region and leaves its contents in place.

The editor header provides localized Export and Present actions. PNG export serializes the current selection when one exists, otherwise the complete scene, including embedded data-URL images and the selected board background. It rasterizes that SVG through an offscreen canvas at a requested 2x scale. A 32-megapixel pixel budget reduces the scale proportionally for very large boards instead of attempting an unsafe canvas allocation; either side is also capped at 16,384 pixels. Before encoding, the exporter reads one pixel back from the canvas; a security exception is reported as a localized unsafe-image error, and toBlob is never called on a canvas that failed that taint check. Downloads use a filesystem-safe version of the board title. PDF export uses the same scene serializer and a dedicated print-only, landscape, fit-to-page layout; it honestly opens the browser print dialog, where the user selects Save as PDF, rather than shipping or hand-rolling a PDF library. The sanitized board title is also used as the print document's suggested filename.

Present takes a read-only snapshot and steps through frames in stored board order. Each frame is centred and zoomed to fit with a short transform transition. Right Arrow, Down Arrow, or Space advances; Left Arrow or Up Arrow goes back; Escape exits. The compact previous/next controls and position counter auto-hide after inactivity. The module requests browser fullscreen from the initiating click, but the same distraction-free fixed overlay remains active if the Fullscreen API is unavailable or the request is rejected. A board without frames shows a localized explanation and does not enter presentation. Because the presentation renders a snapshot in its own pointer-inert layer and routes presentation keystrokes before editor shortcuts, presenting cannot mutate the board.

A collapsible minimap sits at the editor's lower-right edge. It reduces each scene item to a filled rectangle, adds the current viewport outline, and maps click or drag positions back to board pan coordinates. Pan and zoom update the viewport continuously, while scene/viewport SVG rebuilds are throttled to one per 180 ms (about 5.5 updates per second) during pointer movement to keep large boards cheap. The collapsed preference is stored in localStorage; both reads and writes are guarded so storage restrictions in the module iframe do not break the editor.

The compact selection toolbar keeps the primary item-colour swatches and conditionally adds text formatting for notes, text boxes, and shapes: a stepped font-size ladder, bold, italic, left/centre/right alignment, and a separate text-colour palette. Shape selections additionally expose a fill palette with a transparent No fill option; the existing primary color remains the shape border colour. Mixed bold/italic selections show an unpressed toggle and clicking applies the style to every unlocked text-bearing item. Optional formatting fields are not materialized until the user changes them, so existing boards retain their exact scene JSON and rendering defaults.

Right-clicking inside the canvas opens a localized, keyboard-navigable menu at the pointer. Item menus provide copy, duplicate, style copy/paste, z-order, lock/unlock, and delete; a locked target exposes only copy, style copy, and unlock. Empty-canvas menus provide paste, select all, and zoom to fit. The menu flips away from viewport edges and closes on Escape, outside click, scrolling, or zooming. Right-clicks outside the canvas keep the browser's native context menu.

Items can be locked or unlocked from the selection toolbar. Locked items remain directly selectable so the same control can unlock them, but show only the selection ring and cannot be moved, nudged, resized, rotated, deleted, text-edited, recolored, restyled, or marquee-selected. Delete and cut skip locked items, and an all-locked delete reports a localized error. Standard Command/Ctrl+C, V, and X shortcuts exchange a versioned plain-text JSON envelope with the system clipboard, so item selections can move between boards and tabs. Pastes assign new ids, offset the copies, select the result, and remap connector endpoints only when their referenced items were copied too; other endpoints become free. The prospective scene is checked against the 15 MB limit before the paste is committed. Command/Ctrl+Alt+C copies only one item's visual style in module state, and Command/Ctrl+Alt+V applies color/font/stroke and text formatting plus same-type shape fill/type or connector-route style to the current unlocked selection. Both style actions are also available in the selection toolbar and item context menu.

Images support non-destructive cropping. Double-click an unrotated, unlocked image or use the localized Crop action in its selection toolbar/context menu to show the full source with the outside region dimmed. Eight handles resize the normalized crop window, dragging inside pans it, and Shift preserves the current aspect ratio on corner handles. Enter or clicking outside commits one undoable mutation; Escape cancels; Reset crop removes the field. Committing shrinks the item itself to the crop rectangle: it cuts the outside pixels away without rescaling the retained pixels, so their on-screen size and position stay unchanged. Reset restores the full image at that natural scale and anchors it so the previously visible region does not jump. Resizing the item outside crop mode keeps and rescales the crop. The original src is never changed or re-encoded: this keeps every crop re-editable/resettable without quality loss or a second image copy under the scene's roughly 15 MB ceiling. Canvas, presentation, PDF, and PNG/SVG export all render the same cropped source window.

Hand-drawn arrows are auto-straightened or smoothed on release, near-45-degree directions snap to the exact angle, and the arrowhead follows a stabilized end tangent instead of the final raw pointer sample. In select mode, hovering an item exposes four edge anchor dots. Dragging from an anchor creates a connector whose target end attaches when released over another item or stays free on empty canvas. Attached endpoints reroute live when a referenced item moves or resizes and after remote merges; deleting a referenced item preserves the arrow and turns that endpoint into its last free world point. A selected arrow exposes draggable endpoint handles plus a localized straight/elbow route toggle. Computed connectors are not passed through hand-drawn beautification, and elbow shafts render as unsmoothed orthogonal segments so their corners remain square. It posts live page context (view, boardId, boardTitle, and boardCount) to the host so chat agents can resolve references such as “this board.” Agent mutations emit the standard module refresh topic; an open board refetches when there are no local edits, while dirty local state wins and is saved again.

How to open it

  • Web: /modules/whiteboard
  • Apple clients: native "Whiteboard" section below Notizen in the side menu (clients/apple/ClapilotApple/Sources/Clapilot/Views/WhiteboardView.swift + WhiteboardEditorView.swift) — a fully native SwiftUI boards list and pan/zoom canvas editor against the same module API, with an iPhone full-screen editor push and an iPad/macOS list/editor split pane whose board list can be collapsed from the sidebar toggle in the editor header (the collapsed state persists per device, and the list reappears automatically when no board is selected). It decodes and renders the same non-destructive image crop, while malformed/missing crops fall back to the full image; web-locked items remain selectable but are not draggable, resizable, text-editable, recolorable, reordered, duplicated, or deleted. Board-home thumbnails remain simple grey image rectangles and therefore need no crop-specific path. It reports moduleSlug: "whiteboard" live context (currentBoardId, boardTitle) so agent chat resolves "this board" natively too. Native iOS/macOS renders numbered comment pins with the same FDS/navy visual treatment as web but deliberately keeps comments read-only: opening threads, replying, resolving, filtering, and deleting are web-only in this change.
  • Manifest: bundled-modules/whiteboard/module.json
  • Module API base: /api/modules/whiteboard/api
  • Storage: PostgreSQL table whiteboard_boards, strictly scoped by signed-in user_id

The module uses the normal iframe renderer and the host-injected window.__CLAPILOT_MODULE_CONTEXT__. All visible copy lives in the module's German, English, and Italian dictionary.

Scene format

The server stores the client-owned scene as JSONB and validates its outer shape and size (maximum about 15 MB):

{
  "items": [
    {
      "id": "item-id",
      "type": "note",
      "x": 80,
      "y": 80,
      "w": 220,
      "h": 180,
      "z": 1,
      "text": "Idea",
      "color": "yellow"
    }
  ],
  "background": "dots"
}

An image may add "crop": { "x": 0.1, "y": 0, "w": 0.8, "h": 0.6 }. These are normalized coordinates on the original src; all four values must be finite and inside 0..1, width/height must be positive, and the window must stay inside the source. Invalid crops and crops on non-image items are dropped. An absent field means the legacy full-image rendering exactly.

Supported item types are note, text, shape, draw, image, arrow, frame, and comment. Shapes support rect, rounded, ellipse, diamond, triangle, star, and line. Draw and arrow items use relative points: [[x,y], ...]; those points are rendered scaled to the item's w and h, so resizing a stroke scales its visible geometry. Images use a downscaled image data URL in src and may carry a generated-image asset identifier in the image-only assetId field for successive AI edits. Common optional fields include rotation, strict-boolean locked, optional string groupId, text, color, fontSize, and strokeWidth. Frames use title, x, y, w, and h; their default size is 640 x 400, title input is capped at 180 characters, and containment is never serialized. Text-bearing items may also store strict-boolean bold/italic, align: "left"|"center"|"right", and a palette-name textColor (or "none"). Shapes may additionally store a palette-name fill or "none"; no fill field is accepted for other item types. When absent, these additive fields preserve the original defaults: notes/text align left, shape overlays align centre, text colour keeps its previous derived value, and shape fill remains the tint derived from primary color.

Comment items use the common id, x, y, and z fields plus fixed server-owned w: 36 and h: 36. Their additional fields are text, author, authorId, strict-boolean resolved, createdAt, and replies. Each reply is { id, text, author, authorId, createdAt }; the sanitizer caps the list at 50 and every original or reply body at 4,000 characters. Comment sanitization drops rotation, lock, group, shape, connector, and style metadata. Client authorship is accepted only for sanitizing already-persisted round trips; every acting create or reply path overwrites it from the authenticated server context.

Arrow items may additionally store start and end, each either absent/null or { "itemId": "...", "anchor": "n"|"e"|"s"|"w"|"auto" }, plus route: "straight"|"elbow". These fields are additive editor metadata: points, x, y, w, and h remain the authoritative rendered geometry and are recomputed whenever an attachment or referenced box changes. That keeps Apple clients and board previews compatible without a migration. Connector boxes use an 8 px minimum on both axes to match server sanitization. The item sanitizer preserves valid connector metadata only on arrows, defaults a missing or invalid attachment anchor to auto, and drops malformed attachments/routes defensively. Ops batches, guarded whole-scene PATCH writes, and single-item PATCH writes all converge on this same sanitizer; whole-scene replace does not use a separate round-trip format.

The Apple renderer draws frames first regardless of z-order, using a transparent rounded rectangle with a thin FDS-accent border and the title above its top-left corner. Frames are excluded from native hit testing so they cannot mask interactive items inside them or fall through to the unknown-image placeholder.

Module API

Method and routePurpose
GET /healthVerify module API and schema readiness
GET /boardsList accessible boards (own plus team-shared) with title, item count, sharing metadata, and timestamps
POST /boardsCreate an empty board with optional title
GET /boards/:idRead one accessible board and its scene. With ?sinceRevision=N it returns {"unchanged":true,"revision":N,...} when nothing changed — the cheap live-sync poll
PATCH /boards/:idRename, replace the full scene, and/or set visibility (private/team, owner-only). An optional baseRevision turns the scene replace into a guarded write: on mismatch the API returns 409 {"error":"revision_conflict","board":{...}} with the current board
POST /boards/:id/opsApply up to 200 item-level ops ({"op":"upsert","item":{...}} / {"op":"delete","id":"..."}) in one row-locked transaction; returns {revision, updatedAt, itemCount}. This is what the web and Apple editors autosave through
GET /boards/:id/presenceOpen an authenticated SSE stream of other active users on this board as {userId, name, colour, x, y, ts} snapshots plus removed ids
POST /boards/:id/presencePublish the authenticated caller's current world-space cursor; identity, name, colour, and timestamp are server-derived and never persisted
POST /boards/:id/duplicateCopy a board and its scene for the current user
DELETE /boards/:idPermanently delete one board (owner-only)
POST /boards/:id/itemsAdd one item; assigns id/z and a free position when omitted
PATCH /boards/:id/items/:itemIdMerge-patch one item
DELETE /boards/:id/items/:itemIdRemove one item

Board access is user_id = context.user.id OR visibility = 'team'; deleting a board and changing its visibility stay owner-only. Board JSON includes revision, visibility, isOwner, and (for shared boards) ownerName. Every scene mutation — full PATCH, ops batch, or single-item route — runs as a row-locked (SELECT … FOR UPDATE) read-modify-write and bumps revision by one, so concurrent editors and agent runs cannot lose each other's items. Unknown routes return 404 {"error":"Unknown endpoint"}. The bundled migration runner serializes schema initialization with a PostgreSQL advisory transaction lock and records 001_create_whiteboard_schema.sql and 002_sharing_and_revision.sql in module_schema_migrations.

Live multi-user editing

Sharing is board-level: the owner toggles Team sharing from the editor header (web: the person icon next to the title; Apple: the same toggle in the native editor header). Team-shared boards appear in every user's board list labeled with the owner's name; teammates can edit items and rename, but cannot delete or unshare.

Live sync is revision-cursor polling (about every 2.5 s while a board is open and the tab/app is active):

  1. Editors autosave through POST /boards/:id/ops, sending only the items that changed locally relative to the last server-acknowledged scene (plus deletes). The response carries the new revision.
  2. Each poll calls GET /boards/:id?sinceRevision=<cursor>; unchanged answers are a single-row check.
  3. When the revision moved, the client merges at item level: remote items are adopted except those with unsaved local edits (local wins until its next flush), locally deleted items stay deleted, and unsaved local additions are kept. Undo/redo history restarts after a remote merge so undo cannot delete teammates' items.

Conflict semantics are last-write-wins per item — fine for whiteboards, where two people rarely edit the same sticky simultaneously. There is no CRDT and no per-property merging.

Live cursor presence is intentionally separate from that scene transport. The web editor opens GET /boards/:id/presence as a real text/event-stream response from the bundled module handler and publishes pointer movement to POST /boards/:id/presence at no more than about 10 updates per second. Both routes apply the same board-read access check (user_id = context.user.id OR visibility = 'team'), and the server derives identity, display name, stable per-user colour, and timestamp from context.user. Entries live only in an in-process map keyed by board id, expire about five seconds after their last update, and are emitted as removals; the client fades removed world-space labelled pointers out. The stream and publishing stop when the board closes, the tab becomes hidden, or the module page is unloaded.

This presence map is single-process only. With multiple app replicas, users see only peers connected to the same replica; it is not cluster-safe and does not use PostgreSQL or another shared broker. Production currently runs one app container, so this limitation is acceptable today. A multi-replica deployment must add a shared ephemeral fan-out mechanism before claiming cross-replica presence.

Agent tools

ToolPurpose
whiteboard_list_boardsList the current user's boards
whiteboard_create_boardCreate an empty titled board
whiteboard_get_boardReturn title, counts by type, compact item ids/text/shape/color/geometry/connector points, and arrow start/end/route metadata without image data URLs
whiteboard_add_itemAdd a note, text, shape, frame, drawing, image, or arrow; omitted position auto-places it. Frames accept a capped title plus geometry; membership remains client-computed. The canonical input is item: { type, text, x, y, w, h, ... }, while the proxy safely normalizes common local-model aliases. Empty text/note items and images without a usable source are rejected. For type: "image", optional imagePrompt generates and embeds a data URL; a same-user generated-image API URL returned by images_generate is resolved and embedded too. Freehand drawings and arrows accept a points polyline plus strokeWidth. Connector arrows accept start/end as {itemId, anchor} or a bare item-id string and route as straight/elbow; common from/to/source/target, attachment, item-id, and orthogonal-route aliases are normalized. The proxy loads the current board, rejects malformed or dangling references clearly, and computes authoritative connector geometry rather than trusting agent points. UML and flowcharts use editable shapes with text and attached arrows, not box-drawing text or a flattened image unless explicitly requested
whiteboard_update_itemMerge-patch one item by id; common content/position/size aliases are normalized. Image patches may set a validated normalized crop (plus cropRect/snake-case/image aliases) or clear it with null/false without changing src. Arrow start/end/route updates resolve references and recompute the polyline against the current board
whiteboard_remove_itemRemove one item by id
whiteboard_rename_boardRename a board
whiteboard_delete_boardPermanently delete a board after an explicit user request

The tools are defined in services/clapilot-agent/src/tool-definitions.mjs, executed through the authenticated Next.js tool proxy, and available through the generated clapilot-cli whiteboard skill.

The web editor renders a localized unavailable-source placeholder for legacy or corrupt image elements instead of exposing the browser's broken-image icon.

Limits

  • Durable scene sync remains polling-based (~2.5 s cadence) with per-item last-write-wins and no per-property CRDT merging. Cursor presence is ephemeral SSE and single-process only; it does not change scene polling.
  • Sharing is all-or-nothing per board (team visibility); there are no per-user invites or read-only shares.
  • PDF output is intentionally delegated to the browser print dialog; the module does not generate a PDF file directly. Rotated items can be moved and rotated, but web resize handles stay disabled until rotation is reset to zero.
  • PNG export requests 2x output and automatically lowers that scale when needed to stay within its 32-megapixel canvas budget.
  • Uploaded and web-generated images are embedded as data URLs. Browser-side images are downscaled to a maximum 1600 px long edge; the upload/processed-image limit is 10 MB. Agent-generated image downloads are capped at 8 MB before embedding.
  • A scene write larger than about 15 MB is rejected with HTTP 413.