Store whiteboards as Excalidraw elements¶
Status: accepted
Amends ADR 0120's whiteboard schema, image transforms and connector rendering. Its host authority, Yjs synchronization, attributed transactions, editor isolation and document editor are unchanged.
The whiteboard was a clean-room imitation of Excalidraw, inherited from the
reference template: Excalidraw's tools, hand-drawn sloppiness, hachure fills
and sticky notes, with its own shape record (box, stroke, rough,
from/to). The user wanted Excalidraw's interchange: open and save
.excalidraw files, use .excalidrawlib libraries including the public
collection, and turn hand-drawn strokes into clean shapes. A private record
made each of these a lossy translation.
Board content is now Excalidraw elements: Excalidraw's types, field names and
values in the Yjs elements map, and its image files in a files map.
Rendering follows Excalidraw's renderer, using roughjs and perfect-freehand
with the element's seed, Excalidraw's text metrics and its fonts. The editor
UI remains mevedel's, because its comments, area questions, presence,
movement previews, assistant highlights and Revert sit on that UI, and the
board was not a pain point; only the data layer and its drawing changed.
Decisions¶
- Excalidraw's own reconciliation fields are derived, not stored.
version,versionNonce,updated,isDeletedandboundElementsexist for Excalidraw's collaboration; Yjs already merges concurrent edits. Export generates them; a patch carrying them is refused. Absent optional fields take Excalidraw's defaults,seedderives from the id and an element without anindexdraws on top, so the model writes only what it means. - References are stored in one direction. A label stores
containerId, an arrow itsstartBinding/endBinding. Excalidraw also stores the reverseboundElements; under concurrent edits two independent lists can disagree, so they are derived. A reference to a removed element is drawn unbound and dropped on export instead of failing validation, because rejecting it would reject an unrelated writer's update after a concurrent delete. - Displayed geometry is derived. Bound arrow ends (orbit and inside modes) and label wrapping and placement are computed from current shapes in the browser, host PNGs and exports alike, so stored arrow points and label positions can never go stale. Text metrics come from advance-width tables of the bundled fonts, not the browser, so every client and the host wrap a label at the same places.
- Images live in content-addressed files. An image element names a
fileId; bytes are stored once infiles. Contributions then record image element changes without image bytes, which removes the history growth that saturated ADR 0120's boards. The host prunes files that no element and no retained contribution references, so Revert can still restore a deleted image. The model adds an image throughSharedEdit'simageaction, which sends the bytes to the host outside the contribution and stores them under the same content-addressed id; the contribution still records only the element. - Crops are Excalidraw fields. Boards store
crop,scale(flips) andangle, not ADR 0120's renderedimageEditPNG; documents keepimageEdit. The.excalidrawfile keeps the original so a crop stays editable. An SVG download re-renders a cropped image to its visible pixels, preserving ADR 0120's rule that a download does not reveal what a crop hides. - The native whiteboard file is
.excalidraw.mevedel-editable-1remains for documents. Import applies Excalidraw's restore rules: legacy fields migrate, invisible and unknown elements are dropped, unusable values fall back to defaults. Unsupported image types become placeholders with a visible note. - The cylinder became a library item. Excalidraw has no cylinder type. Keeping one as a private type would break files opened elsewhere, so the database shape is a built-in group of an ellipse and lines.
- Element libraries belong to the host. A directory of
.excalidrawlibfiles,mevedel-shared-library-directory, holds the personal library and installed ones; mevedel ships a built-in library. They are offered to writable editors in every room and to the model. Excalidraw keeps its library in the browser, apart from scenes; a host directory gives the same separation while surviving browsers and sessions. Emacs fetches the public catalog from libraries.excalidraw.com because the editor's sandbox has no network access; it fetches only that catalog's library files. Items stay opaque JSON in Emacs; inserting one validates it like any other edit. - The model inserts library items by reference. Reading
shared://librarylists items asLIBRARY/ITEM-ID, itssheet.pngshows them numbered, andSharedEditinsertplaces one with fresh identities in the helper. Copying an item's elements into a patch would make the model handle ids, groups and bindings that the editor's insertion already remaps. - Shape recognition is an editor tool. Excalidraw's moment-based recognizer turns a stroke drawn with the autoshape tool into a rectangle, diamond, ellipse, arrow or line, or keeps it as freedraw. The result is an ordinary element saved through the normal path, so Undo and collaboration need nothing new.
Considered options¶
- Translate only at the file boundary. Keeping the private record and converting on import/export was the smallest change, but every Excalidraw feature the record lacked was lost on import, and libraries would have needed the same translation.
- Embed Excalidraw's React editor. It brings its whole editor, but not the layers mevedel depends on: it has no Yjs or host-authoritative mode, its export needs a browser so the host still needs its own renderer, it loads assets from a CDN that the editor's CSP forbids, and comments, area questions and previews would need re-integration on its API. The element model chosen here is also the first step if that ever changes.
- Port Excalidraw's interaction in full. Most of its specification describes tools, handles and snapping. Cherry-picking interaction (groups and entering them, locking, arrowheads, fonts, transform and point handles, arrow types) where it helped was cheaper than cloning it.
Consequences¶
Existing whiteboards no longer open; there is no migration. The editor bundle
grows by the fonts and roughjs; the editor CSP gains font-src data:, so the
relay must be rebuilt and redeployed. Constant-width freedraw approximates
Excalidraw's laser-pointer stroke with perfect-freehand, and sticky notes omit
the lifted corner and date footer. Elbow arrows take a fixed orthogonal route
between the facing sides of their shapes while it is clear, and otherwise an
A* search over lines along the padded edges of the shapes in the way, from
and to any side of the bound shapes, where a bend costs as much as the
distance between the ends, so few bends beat a shorter zigzag. Excalidraw's
own router only avoids the arrow's two shapes; this one avoids every shape,
except one enclosing an end's shape, such as a lane. Every render,
including each drag frame, resolves the routes again, so a clear route costs
one box test per shape, and a detour is reused while every box it depended on
is unchanged and nothing blocks it.
Decision history¶
Elbow arrows first took the fixed route alone, assuming obstacle avoidance
would come from porting Excalidraw's A* router when a board needed it. Reading
that router (packages/element/src/elbowArrow.ts) showed it builds obstacles
from the start and end shapes only, so a port would still cross every other
shape. Routing around all shapes is our own search; keeping the fixed route
whenever it is clear leaves unobstructed arrows exactly as they were. A detour
kept to the facing sides overlapped the row's own arrowheads and took four
bends where leaving by another side takes two, so detours choose their sides.
On a 10×8 board with 80 elbow arrows, a drag frame resolves in about 1.2 ms
(0.4 ms with the fixed route); searching every frame without reuse took 4.5 ms.
The first version kept one personal library file, mevedel-shared-library-file,
and "Add all to my library" merged public libraries into it. In use, the user
wanted a few public libraries installed on the host and always available, as
separate collections. The library directory replaces the single file;
installing writes the library as its own file, which can also be removed
whole. Excalidraw's own libraries never travelled with scenes either, so
nothing was lost by keeping them off the board.
The first version let the model reference existing board files but never add
one: no authorized source of bytes existed, and sending bytes through a
patch would have brought back the history growth the content-addressed files
removed. Both reasons stopped holding. An image sent as one host action is
stored once, and the model can already read files and resources under the
ordinary read permissions, so the image action takes its source through that
same authority instead of accepting inline data. Trying to draw a graph
natively worked, but a model that could produce a plot or screenshot with
Python or a browser had no way to put it on the board.