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

IntentPlanTransactionApplyNormalizeCommit
  1. Intent — a JSON-safe description of what should happen ({ kind: "typeText", text: "a" }). No DOM, no positions in pixels, no mutation.
  2. Plan — the planner reads the current doc and selection and compiles the intent into a Plan of PlanOps. The planner does not mutate.
  3. Transaction — the plan lowers into a Transaction with a TransactionId and a readonly Step[] of apply-time document transformations.
  4. ApplyapplyTransaction runs the steps against the immutable doc, producing a new doc and a Mapping from old to new positions.
  5. Normalize — a sequence of NormalizerRules repairs structural invariants. Running it twice is the same as running it once.
  6. Commit — the engine swaps in the new EditorState, records history when the transaction should be undoable, and calls every StateSubscriber.

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:

FieldWhat it tells you
appliedWhether the intent produced an observable commit.
stateBefore / stateAfterThe immutable state before and after the commit.
transactionThe atomic transaction that was applied.
mappingThe position mapping produced by apply.
normalizationThe normalizer result for the applied document.
historyEntryPresent 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.

See EditorEngine.dispatch.

Contract priorities

  1. Determinism. Same state plus same intent yields the same plan, same transaction, same normalized doc, same commit. No wall-clock time in canonical JSON.
  2. Stable discriminants. Intent["kind"], Step["kind"], Selection["kind"], and TraceEvent["kind"] are the API boundary.
  3. JSON-safe artifacts. Intents, transaction JSON, and trace events round-trip through canonical JSON.
  4. 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.