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 reportsmoduleSlug: "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-inuser_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 route | Purpose |
|---|---|
GET /health | Verify module API and schema readiness |
GET /boards | List accessible boards (own plus team-shared) with title, item count, sharing metadata, and timestamps |
POST /boards | Create an empty board with optional title |
GET /boards/:id | Read 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/:id | Rename, 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/ops | Apply 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/presence | Open 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/presence | Publish the authenticated caller's current world-space cursor; identity, name, colour, and timestamp are server-derived and never persisted |
POST /boards/:id/duplicate | Copy a board and its scene for the current user |
DELETE /boards/:id | Permanently delete one board (owner-only) |
POST /boards/:id/items | Add one item; assigns id/z and a free position when omitted |
PATCH /boards/:id/items/:itemId | Merge-patch one item |
DELETE /boards/:id/items/:itemId | Remove 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):
- 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 newrevision. - Each poll calls
GET /boards/:id?sinceRevision=<cursor>;unchangedanswers are a single-row check. - 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
| Tool | Purpose |
|---|---|
whiteboard_list_boards | List the current user's boards |
whiteboard_create_board | Create an empty titled board |
whiteboard_get_board | Return 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_item | Add 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_item | Merge-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_item | Remove one item by id |
whiteboard_rename_board | Rename a board |
whiteboard_delete_board | Permanently 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 (
teamvisibility); 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.
