Document Model

The persistent tree the pipeline transforms.

An editor needs a document that can survive a thousand tiny edits per second, show two versions of itself for a diff view, and let subscribers notice exactly which subtrees changed. A mutable tree cannot do any of those cleanly. A persistent tree can.

The document is an immutable structure of nodes. Every transaction produces a new root; unchanged subtrees share memory with the previous one. Observers hold references to old roots without pinning deep copies.

Worked example

A document with one paragraph containing a mark run:

doc (branch)
└── paragraph (branch)
    ├── text "Hello " (leaf)
    └── text "world" (leaf, marks: [strong])

Paths into that tree:

  • [] — the root.
  • [0] — the paragraph.
  • [0, 1] — the bold "world" leaf.

Positions into that tree (Pos, UTF-16 offsets) count across the whole document: the caret "between the space and the w" is pos(6).

Nodes and marks

  • RopeNode — either a BranchNode or a TextLeafNode. Branch nodes carry a NodeTypeName, an Attrs bag, child nodes, cached text length, and cached height. Text leaves carry a string, marks, text length, and height 0.
  • Marks — inline annotations identified by MarkTypeName with an Attrs bag. Marks are flat on a text leaf; they do not nest as tree wrappers. SpineEditor ships eleven mark types: strong, em, underline, strikethrough, subscript, superscript, link, fontFamily, fontSize, textColor, highlightColor.

Fragments and slices

Fragment is a contiguous run of document content used by paste and insertFragment. Slicing helpers live in packages/core/src/doc/ (doc-fragment.ts, doc-slice.ts). A paste payload converts to a Fragment before it enters the planner.

Why persistent

  • Structural sharing. A one-character insert in a 10,000-block document rebuilds one block and one path up to the root, not 10,000 blocks.
  • Safe observation. Subscribers receive (nextState, previousState) and can compare references for unchanged subtrees.
  • History is cheap. HistoryEntry holds references to stateBefore.doc and stateAfter.doc — not deep copies.

API

Construction and traversal live in @spine-editor/core/doc. Most consumers do not build documents directly; they build them with schema-aware helpers (createSchema, repairDoc) and then let the pipeline transform them.

const resolved = resolve(doc, pos(6));

resolve walks a document position to the enclosing path and text leaf. See packages/core/src/doc/ for the full surface.