Skip to content

Architecture

This page condenses ADR 0008 (docs/decisions/0008-editor-package.md). The ADR holds the reasoning.

History and arrow-following need to know what changed, not what the user did. Shapes and bindings are immutable records, so an unchanged record is the same object. Each document also lists the ids written since it was derived from its base. Together they answer “what changed” in time proportional to the change, not to the document.

A feature is a pure function that returns the next Doc. It never builds a patch and never thinks about arrows.

Value In Editor Undoable
Doc Yes Yes
Selection Yes Recorded with each edit, never undone alone
History Yes No
Camera No No

Editor is { doc, selection, history }. The camera is a separate value. A gesture keeps the editor it started from and commits from it on release. If the camera were inside Editor, a zoom during a drag would be rolled back on release.

  1. A gesture starts from a clean base editor, whose document has no touched ids.
  2. Each pointer move builds next from base.doc with the document helpers, which record the ids they change in touched. preview(base, next) settles it for drawing.
  3. On release, commit(base, next) settles next, diffs it against base.doc and pushes one history entry. The committed document is clean again.
  4. undo applies the entry’s inverted patch. It does not settle again, because the patch already holds the settled arrows.

diff and changedShapeIds read only the touched ids, so the change detection behind each preview frame and each commit is O(changes), not O(shapes). settle also scans the bindings, so it is O(changes + bindings), and it returns at once when nothing changed. Writing a record copies the map it lives in. Persistent maps would remove that copy if it ever shows up in a profile.

  • Docs come only from the helpers. Doc is branded, so a feature cannot build one that skips touched. Start from EMPTY_DOC, fromShapeDocument or an editor’s doc.
  • next derives from base.doc. diff(before, after) trusts that every change in after went through a helper since before.
  • No-op writes keep identity. updateShapes keeps the old object when the new one is data-equal, so a no-op edit pushes no entry and the drawing cache keeps hitting.
  • Committed documents are settled. Settling every id of a committed document again changes nothing. A property test checks this.

The app shell converts each pointer event to page space with the camera and passes it to the session. The session is the only routing point:

  1. Press. pointerDown begins a gesture for the current tool. A gesture record holds only pointer data and ids minted once at begin, such as the new shape’s id. The session keeps the committed editor as the base.
  2. Move. pointerMove asks the gesture’s feature for the next document from the base and stores preview(base, next). Frames never build on each other, so errors do not accumulate.
  3. Release. pointerUp commits from the base once. A drag of any length is one entry.

cancel drops the gesture, and the base is shown again. perform cancels before it runs a command, so undo during a drag cannot mix the two.

Module Job
doc.ts Doc and the helpers that write it
patch.ts diff, apply, invert
settle.ts Bindings and bound arrows after any edit
editor.ts Editor, preview, commit, undo, redo, select, load
camera.ts Screen and page conversion, zoom about a point, culling
input.ts Pointer and Tool
env.ts EditorEnv, the injected id and seed ports
gesture.ts The closed Gesture union and the routing of each kind to its feature
session.ts Session and the pointer functions
features/*.ts One file per feature: its gesture record, its begin and its step functions

A new pointer feature adds its file in features/, one member to Gesture and GestureKind, and one branch in each routing function of gesture.ts. A new command adds its file and needs no routing: the shell runs it with perform.