How this is builtGroundworkHow this is built
15 / 26 written chapters

I9 · Part 2 · How it runs

The whiteboard

An infinite SVG board built on plain arrays, pure functions and one history stack.

9 min read1 diagram12 sections
12On this page
  1. The shape of it
  2. The element model
  3. From element to SVG
  4. Hit testing and arrows
  5. History
  6. Gestures
  7. Storage and untrusted input
  8. Export and share links
  9. Templates, pages and tints
  10. Lock, group and align
  11. The context menu and the drawer
  12. Performance

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.

Pointer events move, pinch, wheel rAF step one per frame Draft live copy, no history History one commit per gesture 400 ms debounce flush on pagehide draft ?? present localStorage one key per board Cull only above 150 ElementView memo, describe() SVG on screen
A drag never touches the history stack until the pointer lifts. Everything between is a draft that renders but cannot be undone, which is why one drag is one undo step.

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.

WhatCountWhich
Element kinds15pen, line, arrow, rect, ellipse, diamond, text, sticky, image, and six extra shapes: triangle, hexagon, star, cylinder, parallelogram, cloud
Tools1913 in the dock, with the six extra shapes behind one shapes button
Arrow heads5none, arrow, triangle, dot, bar
Dash styles3solid, 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. onPointerMove stores the latest position and schedules one requestAnimationFrame. 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 / zoom to the last one are dropped while drawing, and the finished stroke is simplified with Ramer–Douglas–Peucker at 0.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.

SourceElements kept
A saved board50,000
An imported JSON file5,000
A share link2,000

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.

Why SVG and not a canvas

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.