Architecture
The pipeline that turns user actions into committed state.
The problem: in most editors, a keystroke, a mouse drag, a clipboard paste, a test harness call, and a programmatic command all take different paths to the document. Each path eventually mutates something, and the bugs cluster where they meet. Undo has to second-guess what happened; tracing only sees whichever layer it was bolted onto.
SpineEditor collapses semantic edits into one path. Browser input, tests, commands, and plugin commands produce an Intent; planner plugins can then add supported behavior inside the same pipeline.
The six stages
Intent → Plan → Transaction → Apply → Normalize → Commit
- Intent — a JSON-safe description of what should happen (
{ kind: "typeText", text: "a" }). No DOM, no positions in pixels, no mutation. - Plan — the planner reads the current doc and selection and compiles the intent into a
PlanofPlanOps. The planner does not mutate. - Transaction — the plan lowers into a
Transactionwith aTransactionIdand areadonly Step[]of apply-time document transformations. - Apply —
applyTransactionruns the steps against the immutable doc, producing a new doc and aMappingfrom old to new positions. - Normalize — a sequence of
NormalizerRules repairs structural invariants. Running it twice is the same as running it once. - Commit — the engine swaps in the new
EditorState, records history when the transaction should be undoable, and calls everyStateSubscriber.
A concrete dispatch
const engine = createEditorEngine({
initialState,
planner: createPlanner(),
});
const result = engine.dispatch({ kind: "typeText", text: "a" });
When the edit applies, result carries the committed state plus the artifacts that produced it:
| Field | What it tells you |
|---|---|
applied | Whether the intent produced an observable commit. |
stateBefore / stateAfter | The immutable state before and after the commit. |
transaction | The atomic transaction that was applied. |
mapping | The position mapping produced by apply. |
normalization | The normalizer result for the applied document. |
historyEntry | Present when the transaction is recorded for undo/redo. |
With a tracer attached, the same call emits, in order:
intentReceived
intentKind: "typeText"
intentJSON: { kind: "typeText", text: "a" }
planCompiled
planId
intentKind: "typeText"
opCount: 1
transactionApplied
transactionId
stepCount: 1
history
action: "push"
normalized events may appear between transactionApplied and history when a normalizer rule changes the applied document.
Contract priorities
- Determinism. Same state plus same intent yields the same plan, same transaction, same normalized doc, same commit. No wall-clock time in canonical JSON.
- Stable discriminants.
Intent["kind"],Step["kind"],Selection["kind"], andTraceEvent["kind"]are the API boundary. - JSON-safe artifacts. Intents, transaction JSON, and trace events round-trip through canonical JSON.
- Explicit extension points. Plugins contribute planner rules and commands through resolvePlugins. They do not monkey-patch.
Package boundaries
@spine-editor/core— headless. Zero DOM, zero framework dependency.@spine-editor/dom— browser runtime. Reads events, renders DOM, owns the selection overlay.