I9 · Part 2 · How it runs
The whiteboard
An infinite SVG board built on plain arrays, pure functions and one history stack.
12On this page
The shape of it
The whiteboard at /whiteboard is two layers. lib/whiteboard holds five
modules of plain TypeScript with no React in them: model.ts (the element type, hit
testing, arrows, history and cleaning), geometry.ts (turning an element into SVG
parts), storage.ts, exporter.ts and templates.ts.
app/whiteboard holds the React side, and most of that is one component:
Board.tsx, at 1,851 lines, owns the camera, the gestures, the selection and the
keyboard. The page loads it through next/dynamic with ssr: false,
because nothing about a board can be rendered without the reader's browser storage.
The element model
A board is an array of El objects, and the array order is the stacking order. Every
element has an id, a kind, a box (x, y, w,
h) and a style. Lines, arrows and pen strokes also carry
points, stored relative to x and y. Arrows carry
start and end, the ids of the shapes they are bound to. Two optional
flags, locked and group, hold the rest.
| What | Count | Which |
|---|---|---|
| Element kinds | 15 | pen, line, arrow, rect, ellipse, diamond, text, sticky, image, and six extra shapes: triangle, hexagon, star, cylinder, parallelogram, cloud |
| Tools | 19 | 13 in the dock, with the six extra shapes behind one shapes button |
| Arrow heads | 5 | none, arrow, triangle, dot, bar |
| Dash styles | 3 | solid, dashed, dotted |
The highlighter and the laser are tools, not kinds. A highlighter stroke is a wide pen stroke at 35% opacity, and the laser adds nothing to the array: its trail fades after 700 ms.
From element to SVG
describe(el, palette) in geometry.ts turns one element into a list of
Part objects, each a tag name (path, rect,
ellipse, polygon, image or text) and its
attributes. A cylinder is two paths. An arrow is a smoothed path plus a part for each head. A
label becomes a text part with its lines already wrapped.
ElementView maps parts to JSX, and it is wrapped in memo. That matters
because every edit in the model returns a new array but reuses the objects it did not change. When
a reader drags three shapes across a board of 500, moveEls creates three new objects
and 497 of the ElementViews see the same prop and skip rendering.
The same describe() drives export. On screen the palette is
var(--wb-ink) and var(--wb-paper), so a theme change recolours the
board with no re-render. For export, Board.tsx reads the computed values of those
two variables and passes real colours, and partToSvg writes strings instead of JSX.
Because there is only one drawing function, an exported board always matches the screen.
Hit testing and arrows
hitTest checks lines by distance to each segment, with a reach of the tolerance plus
half the stroke width. Ellipses use the ellipse equation, and polygon shapes use a ray-casting
inside test plus a near-the-outline test, so clicking the empty corner of a star's box misses it.
elementAt walks the array from the end, so the topmost element wins. The tolerance
is 6 / zoom, which keeps the grab distance the same on screen at any zoom.
An arrow is bound when its end is dropped on a shape, and bindTarget uses twice the
normal tolerance for that. routeArrows then puts each bound end on the outline of its
shape, facing the other end. edgeToward works out that point by intersecting a line
from the centre with the polygon's edges, the ellipse, or the box, and leaves a 6-unit gap. It
takes a set of changed ids and only recomputes arrows touching them. Moving an arrow on its own
detaches it, and deleting a shape nulls the ends that pointed at it.
History
History is { past, present, future }, three arrays of whole boards.
commit pushes the present onto past, trims it to 100 entries and clears
future. Undo and redo move one board between the arrays. Keeping whole boards is
cheap here because of structural sharing: two neighbouring states share every element that did
not change.
Three rules keep the stack honest. A gesture writes a draft and commits once when it ends.
apply skips a commit when the new array holds the same objects as the present, so a
click that moves nothing leaves no empty undo step. And the opacity slider goes through
applyCoalesced, which folds changes to the same selection that arrive within 900 ms
into one entry.
Gestures
- Frame throttling.
onPointerMovestores the latest position and schedules onerequestAnimationFrame. However many events a 120 Hz pen fires, the board steps once per frame.finish()runs any pending step before committing, so the final position is never lost. - Pinch. Active pointers live in a
Map. When there are two, the zoom is the starting zoom times the ratio of the distances, and the camera is solved so that the world point under the starting midpoint stays under the current midpoint. - Wheel. A non-passive listener zooms by
exp(-deltaY × 0.01)around the cursor when Ctrl or Cmd is held, which is also what a trackpad pinch sends, and pans otherwise. Zoom is clamped between 0.1 and 5, and the buttons step by 1.2. - Pen strokes. Points closer than
1.5 / zoomto the last one are dropped while drawing, and the finished stroke is simplified with Ramer–Douglas–Peucker at0.6 / zoom.
Storage and untrusted input
Each board is its own key, groundwork:board:<id>, beside an index at
groundwork:boards, the last board opened and the page preferences. Changes are saved
400 ms after they stop, and a pagehide or visibilitychange listener
saves at once, so closing the tab inside that window loses nothing. If storage is full, the board
says so and suggests exporting or removing images.
Everything read back goes through sanitizeEls, including the reader's own storage,
imported JSON and share links. It keeps only known kinds, drops duplicate ids, clamps numbers to
±10,000,000, width to 0.5–60, opacity to 0.05–1 and font size to 6–400,
cuts strings, drops non-finite points, accepts an image only if its source starts with
data:image/, and unbinds arrows whose target is missing. How many elements it keeps
depends on the source.
| Source | Elements kept |
|---|---|
| A saved board | 50,000 |
| An imported JSON file | 5,000 |
| A share link | 2,000 |
Export and share links
SVG export fits a view box to the union of the elements' bounds, with 32 units of padding. PNG
export draws that SVG into an Image, waits for decode(), and paints it
onto a canvas at twice the size. JSON export writes
{ type: "groundwork-board", version: 1, name, elements }. When something is
selected, PNG and SVG export only the selection.
A share link puts the whole board in the URL fragment, after #board=. The board is
turned into JSON, compressed with the browser's CompressionStream("deflate-raw")
and written as base64url. Nothing goes to a server, because a fragment is never sent in a
request. Images are left out and the reader is told how many, and a link longer than 60,000
characters is refused with a suggestion to export JSON. Opening a link always creates a new
board named with a "(shared)" suffix, then clears the fragment, so a shared link cannot
overwrite anything.
Templates, pages and tints
templates.ts has six templates: flowchart, system design, kanban, mind map, a 2
× 2 matrix and a retro. Each builds its elements with bound arrows, then
placeTemplate routes the arrows and centres the result in the view.
Paper.tsx defines 19 page layouts, counting blank, grouped under Dots, Grid, Lines
and Technical. Each is an SVG
<pattern> whose stroke width is divided by the zoom so its lines stay hairline,
and each has a minZoom below which the pattern is hidden rather than drawn as noise.
Eight tints sit alongside. "Theme" follows the site theme, and the other seven (white, cream,
kraft, mint, blueprint, chalkboard and night) set --wb-paper, --wb-ink
and --wb-line on the board's root element.
Lock, group and align
A locked element can still be selected, but moveEls, removeEls and the
align functions skip it. Duplicates come out unlocked. Grouping stamps a shared group id, and
expandGroups turns a click on one member into a selection of all of them.
There are six align options (left, centre, right, top, middle, bottom) and two distribute
options. Distributing needs at least three elements and leaves bound arrows alone, since
routeArrows places those.
The context menu and the drawer
Right-clicking opens ContextMenu.tsx, a role="menu" list that stays
inside the board, focuses its first button, moves with the arrow keys, and closes on Escape, an
outside click or the window losing focus. Its items depend on whether anything is selected.
The menu button opens BoardMenu.tsx, a drawer rendered through a portal with the
board list, the site's appearance picker, and export, import, share and clear.
Performance
Above 150 elements the render stops drawing what cannot be seen. It keeps elements whose bounds
overlap the visible area plus a margin of 200 / zoom, and always keeps the
selection and the element being edited. Below 150 it draws everything.
Culling limits how many elements are drawn, memoization how many re-render, and frame throttling how often either happens. In an earlier measurement in headless Chromium, a board of 2,000 elements kept frames at 16.7 ms at the 95th percentile.
A canvas would draw faster at very large counts, but it would have to reimplement text layout,
hit regions, focus and crisp scaling. SVG gets those from the browser, and CSS variables recolour
it for free. The model is kept separate from the drawing, and every element goes through
describe() into plain parts, so a canvas renderer could consume the same parts later
if a board ever outgrows SVG.
Finished reading?
0 of 26 chapters read in this series