Skip to content

Architecture

Status: partly implemented. The math primitives, ids, frames, the box drawables (rectangle, ellipse, diamond) with geometry and hit testing, and frame resize are implemented, along with the line, freehand, text and arrow drawables. See Math primitives, Ids and frames, Drawables and geometry, Resize, Points drawables, Text and Arrows. The Shape record, Result, fractional z-order with its reorder operations, selection bounds, picking and arrow bindings are implemented too. See The Shape record, Results, Z-order, Selection and picking and Bindings. Parsing of untrusted input is implemented as well. See Parsing. Everything else below is a proposal, and its type sketches may change.

  • One interaction model. Every shape is a rectangle with corner and edge handles. Move, resize and rotate are written once.
  • Pluggable content. Rectangle, ellipse, diamond, line, freehand, text and arrow are adapters behind one interface.
  • Pure domain. No DOM, no framework, no rendering backend, no mutable state. The package builds against lib: es2023 only, so DOM types are not even available.
  • Serializable. Every value is plain JSON-compatible data, so undo/redo, persistence and sync are straightforward.

Rendering (Canvas, SVG, roughjs), input handling, the camera and its UI, and storage are out of scope. They will live in separate packages or in the app and consume the geometry this package produces.

Term Meaning
Shape An entity on the canvas: identity + frame + drawable + style + z-index.
Frame The rotated rectangle that contains the drawable. It is what the user selects and drags.
Drawable The content inside a frame, tagged by kind.
Adapter Stateless behaviour for one drawable kind. It maps frame operations to drawable data and data to geometry.
Geometry Derived, read-only description used for rendering and hit testing: outline, path segments, bounds.
Handle A grab point. Every frame has 8 resize handles and 1 rotate handle. An adapter may add its own handles, such as line vertices or arrow ends.
Binding A record that attaches an arrow terminal to another shape.
Page / Local / Screen space The three coordinate systems. Each one has its own branded point type.
Shape
├── id: ShapeId branded string
├── frame: Frame { x, y, width, height, rotation } page space
├── drawable: Drawable discriminated union on `kind`
├── index: ZIndex fractional index string
└── style: Style stroke, fill, roughness, opacity, seed

Shapes carry no sync or persistence metadata (versions, nonces, timestamps). Layers built on top of this package wrap the records if they need that.

Ids are branded strings created with crypto.randomUUID(), which is native in browsers and Node. It adds 0 bytes to the bundle, compared with about 130 bytes for nanoid. Generating an id is the package’s only impure operation. Every other function receives ids as input, which keeps the operations pure and the tests deterministic.

type Frame = {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly rotation: Radians;
};
type LinePoints = readonly [LocalPoint, LocalPoint, ...LocalPoint[]];
type FreehandSample = { readonly point: LocalPoint; readonly pressure: number };
type FreehandSamples = readonly [FreehandSample, ...FreehandSample[]];
type Drawable =
| { readonly kind: "rectangle"; readonly cornerRadius: number }
| { readonly kind: "ellipse" }
| { readonly kind: "diamond" }
| { readonly kind: "line"; readonly points: LinePoints }
| { readonly kind: "freehand"; readonly samples: FreehandSamples }
| {
readonly kind: "text";
readonly text: string;
readonly sizing: "auto" | "fixed-width";
readonly fontSize: number;
readonly fontFamily: "hand-drawn" | "sans" | "mono";
readonly align: "left" | "center" | "right";
}
| {
readonly kind: "arrow";
readonly start: LocalPoint;
readonly end: LocalPoint;
readonly bend: number;
readonly startHead: Arrowhead;
readonly endHead: Arrowhead;
};
  • Freehand samples: each sample holds a point and its pressure. The first sketch kept points and pressures as parallel arrays, which could disagree in length. One list of samples makes that state unrepresentable.
  • Minimum points: LinePoints needs at least two points and FreehandSamples at least one, so an empty drawable cannot be built.
  • Frame conventions: (x, y) is the top-left corner before rotation, and rotation is about the frame’s centre. Width and height are always ≥ 0. A flip is applied to the content and never stored.
type DrawableAdapter<K extends Drawable["kind"]> = {
readonly kind: K;
create(frame: Frame, env: AdapterEnv): DrawableOf<K>;
geometry(frame: Frame, drawable: DrawableOf<K>): Geometry;
resize(drawable: DrawableOf<K>, change: FrameChange, env: AdapterEnv): Fitted<K>;
handles(frame: Frame, drawable: DrawableOf<K>): readonly Handle[];
dragHandle(drawable: DrawableOf<K>, drag: HandleDrag, env: AdapterEnv): Fitted<K>;
};
type AdapterEnv = { readonly measureText: (line: string, font: Font) => number };
type Fitted<K> = { readonly frame: Frame; readonly drawable: DrawableOf<K> };
  • Registry: adapters are collected in a registry keyed by kind. The rest of the system calls adapterFor(shape.drawable.kind) and never switches on kind.
  • Exhaustiveness: the registry is typed { [K in Kind]: DrawableAdapter<K> }, so adding a kind without its adapter is a compile error.
Family Kinds Source of truth On resize
Box rectangle, ellipse, diamond the frame nothing to do; geometry follows the frame
Points line, freehand local points scale points by (sx, sy); mirror on flip
Text text text + font + sizing side handle → fixed width, re-wrap; corner → scale font
Connector arrow endpoints + bend scale ends and curve midpoint; resolveArrow re-attaches bound ends

For points and connectors, any edit to the points re-fits the frame to their bounds. This is why resize and dragHandle return a Fitted pair instead of only the drawable.

Why points are local, not normalized 0–1

Section titled “Why points are local, not normalized 0–1”

We considered normalized coordinates (0–1 inside the frame), which would make resizing free. They break for degenerate frames: a horizontal line has height 0, so the division is undefined, and a flip would also have to be stored somewhere. Both tldraw and Excalidraw store local units and scale on resize, and we follow them.

Space Origin Type
Screen viewport top-left, CSS pixels ScreenPoint
Page infinite canvas origin PagePoint
Local frame top-left, unrotated LocalPoint

The three point types are branded, so passing a ScreenPoint where a PagePoint is expected fails to compile. Conversions go through Mat (2D affine):

  • frameToPage(frame): Mat handles local → page.
  • The camera { x, y, zoom } handles page ↔ screen.
type Binding = {
readonly id: BindingId;
readonly arrowId: ShapeId;
readonly terminal: "start" | "end";
readonly targetId: ShapeId;
readonly anchor: NormalizedPoint;
};
  • Storage: a binding is its own record, not a field on the arrow, so deleting either side is a simple filter (removeBindingsFor).
  • Anchor: a branded NormalizedPoint in (0..1, 0..1) inside the target’s frame, so it survives the target being moved, resized or rotated.
  • Targeting: bindingTarget picks the smallest non-arrow shape whose outline is near the point or whose inside holds it.
  • Resolution: resolveArrow moves each bound end to its anchor’s page point, then clips it back along the curve to the target’s outline. arrowsToResolve lists the arrows a change affects. Resolution keeps bend and is idempotent.
  • Left to the app: unbinding when the arrow itself is dragged, and snapping anchors to the centre or edges.

See Bindings.

index is a fractional index string (e.g. "a0", "a0V"), so moving a shape forward or back rewrites one record only. Implemented with the fractional-indexing package, wrapped in one module so nothing else imports it. It is the package’s only runtime dependency. See Z-order.

All operations are pure functions: (state, intent) → state. There are no setters and no classes holding state.

  • translate(shape, delta)
  • resize(shape, handle, pointer, options) — options: aspect lock, from-centre
  • rotate(shape, angle, pivot)
  • editHandle(shape, handleId, pointer)
  • hitTest(frame, geometry, point, options) — one shape’s outline against a point
  • pickShape(shapes, point, options) — the shape a click selects. See Selection and picking.
  • bounds(shapes) — the axis-aligned box around a selection
  • bringToFront, sendToBack, bringForward, sendBackward — reorder a selection, returning patches
  • At the boundary: loading a file or pasting goes through parseShapeDocument, parseShape or parseBinding, each taking unknown and returning a Result<_, ParseError>.
  • Inside the domain: types guarantee validity, so no defensive checks are needed.
  • No throwing: failures are values. A ParseError holds the path of the first bad value and a closed reason.
  • Shape of the checks: the first error wins, unknown fields are dropped, and a document with one bad record is rejected whole. A document also has to agree with itself: no duplicate ids, and every binding joins an arrow to a non-arrow shape in the document.
  • Left to the app: JSON.parse failures and file versioning.

See Parsing.

  • Unit tests per adapter, mainly on resize: negative scale (flip), rotated frames, zero-size frames, aspect lock.
  • Property tests for round-trips, such as pageToLocal(localToPage(p)) ≈ p, and for z-index ordering.
  • Geometry snapshots for each kind.
  1. Runtime dependencies: keep the bundle small.
    • The only runtime dependency allowed is fractional-indexing. It landed with z-order.
    • Ids come from crypto.randomUUID.
    • Parsing is hand-written; there is no schema library.
  2. Rendering: not part of this package. Drawing is the next package, built on the geometry this one produces. Style fields such as roughness and seed were first placed outside Shape. ADR 0005 (docs/decisions/0005-hand-drawn-canvas-renderer.md) moved them onto Shape, as style, so they persist and copy with the shape.
  3. Groups and containers: later. No parentId for now.
  4. Sync and persistence: out of scope. The package models shapes and nothing built on top of them.
  5. Text measuring: the package has no DOM, so measuring is injected.
    • A MeasureText port returns the advance width of one unbroken line. Callers bundle it as an AdapterEnv and pass it as a last argument to create, resize and dragHandle.
    • Every adapter call takes the env, and callers always pass it. Adapters that do not measure declare shorter functions, which TypeScript accepts, so there is one adapter shape.
    • create returns a drawable only, so it cannot fit the frame to a text. editText does that.
    • Layout is a pure function over the measurer. Line height is a constant, 1.25 times the font size.