Rich text / foldkit-richtext

Transforms

Normalization runs as a registry of transforms. Each is a pure function of the document plus what the transaction touched, and returns the new document with the position steps and identity bookkeeping that go with it:

ts
const DropEmptyRuns: RichText.Transform = {
  name: 'dropEmptyRuns',
  apply: (document, { dirtyNodes }) => ({ document: next, steps, removedNodes, dirtyNodes, textChanged }),
}

RichText.apply(state, transaction, [DropEmptyRuns])
RichText.apply(state, transaction) // defaultTransforms: mergeAdjacentRuns

Two rules make a loop over them safe: a transform is deterministic and idempotent on normalized state, so the loop knows it has settled when a pass changes nothing. A pass that keeps changing the document is refused with UnstableNormalization after MAX_NORMALIZATION_PASSES rather than spinning.

Three constraints follow from replay and from positions:

  • A transform cannot mint identities. It may merge, move, or remove, never create — so "ensure an empty Document has a Paragraph" belongs to a command that mints, not to a transform.
  • Removing a run that positions address requires a step. Report a RelocateStep (or a collapse) for it, or the selection can be left dangling.
  • Only what the transaction touched is in scope. dirtyNodes is how a transform stays incremental; the shipped merge uses it and leaves every other block alone.

apply folds a transform's report into the same ChangeSet and positionMap it was already building, and maps the selection through the transform's steps, so a caller cannot tell whether a change came from an operation or from normalization.