Steps

The atomic document transformations.

Once every edit must be invertible, traceable, and serializable, the primitive operation gets narrow. It cannot be "mutate the doc however you like," because that is not invertible. A Step is what remains: a small, named document transformation with serializable data and methods for apply, invert, and map.

Worked example

The step that inserts a character at position 12:

const inserted = {
  content: fragment([textNode("a")]),
  openStart: 0,
  openEnd: 0,
};

const step: ReplaceRangeStep = createReplaceRangeStep(
  range(pos(12), pos(12)),
  inserted,
);

const result: StepResult = step.apply(doc);

The result is deliberately small: a new document, a mapping from old positions to new positions, and optional removedSpans when mark-removal needs exact restoration evidence.

A transaction is a readonly Step[] applied in order. Each step exposes an invert(docBefore) method that returns an inverse step; the inverse of a transaction is the inverses in reverse order.

Step union

From packages/core/src/transaction.ts:

type Step =
  | ReplaceRangeStep
  | AddMarkStep
  | RemoveMarkStep
  | InsertFragmentStep
  | SplitBlockStep
  | JoinBlocksStep
  | SetBlockTypeStep
  | SetMediaWidthStep
  | SetMediaAlignmentStep
  | SetMediaIndentStep
  | CompositeStep;

Every step extends StepBase. The structural steps use NodePath (readonly number[]), not Pos. The text steps use Pos (a UTF-16 offset) — see Types.

CompositeStep groups sub-steps when a faithful inverse cannot be expressed as a single primitive — for example, a set-block-type that must also re-apply attrs. It is an internal step artifact; the stable transaction JSON format serializes the outer supported step shapes, not arbitrary closures or method bodies.

Why every step is invertible

  • Inverses are reproducible. Given stateBefore.doc, each step's invert reconstructs its inverse without extra bookkeeping.
  • Inverses compose. The inverse of a transaction is its steps' inverses in reverse order.
  • History is data. A HistoryEntry holds the original transaction plus stateBefore and stateAfter; the current undo path restores the snapshot rather than replaying inverses, but the inverse step information is still derivable from the stored transaction.

What step.apply returns

StepResult carries the new document, the Mapping, and optional removedSpans for steps that removed mark coverage. The inverse step is produced separately by step.invert(docBefore).

API

interface StepBase {
  readonly kind: StepKind;
  apply(doc: Doc): StepResult;
  invert(docBefore: Doc): Step;
  map(mapping: Mapping): Step | null;
}

applyTransaction(doc: Doc, transaction: Transaction): TransactionResult;

applyTransaction lives in packages/core/src/steps/steps-apply.ts.