Intent

A declarative description of an edit.

In a contentEditable-style editor, a keystroke is an event — a thing that already happened to a DOM node, whose semantics must now be reconstructed. That reconstruction is where most editor bugs live: the same keystroke means different things inside a list than inside a heading, and there is no stable name for either.

An Intent is that missing name. It is a JSON-safe value describing what should happen, before anything is committed. Substrate code, tests, command handlers, and API callers all produce the same shape, and the engine has one path for every one of them.

Worked example

Splitting a paragraph when the user presses Enter:

const intent: SplitBlockIntent = {
  kind: "splitBlock",
  blockPath: [0],
  splitChildIndex: 0,
  splitTextOffset: 4,
};

engine.dispatch(intent);

blockPath: [0] identifies the first block under the root. splitChildIndex: 0 points at that block's first child. Because splitTextOffset is present, the split happens inside that text child — four UTF-16 code units in — instead of between two child nodes.

The engine emits intentReceived with intentKind: "splitBlock" and the intent's canonical JSON before any document mutation is planned.

Why intents are data, not function calls

  • A substrate handler, a test, or a plugin produces the same shape. There is nothing the substrate can say that a test cannot.
  • Every dispatch is recordable. Tracing is a MemoryTraceSink away; replay feeds the recorded intents back into a fresh engine.
  • The planner is allowed to be context-sensitive. { kind: "typeText", text: "x" } with a range selection becomes a replace; with a caret it becomes an insert. Callers do not branch.
  • Intents never reference DOM nodes or pixel coordinates. A paste payload is a Fragment, not an HTMLElement.

Intent kinds

Declared in packages/core/src/intent.ts:

type Intent =
  | TypeTextIntent
  | DeleteIntent
  | ToggleMarkIntent
  | ApplyMarkIntent
  | RemoveMarkIntent
  | SetLinkIntent
  | SetSelectionIntent
  | SetSelectedBlockTypeIntent
  | SetSelectedBlockAlignmentIntent
  | SetSelectedListTypeIntent
  | UnwrapListIntent
  | SetListMarkerFontSizeIntent
  | SetListMarkerColorIntent
  | AdjustSelectedBlockIndentIntent
  | AdjustSelectedMediaIndentIntent
  | AdjustMediaRowIndentIntent
  | SetSelectedMediaWidthIntent
  | SetSelectedMediaAlignmentIntent
  | SetMediaRowAlignmentIntent
  | InsertFragmentIntent
  | SplitBlockIntent
  | JoinBlocksIntent
  | SetBlockTypeIntent
  | PasteIntent
  | InsertParagraphBreakIntent;

Every variant carries an optional meta?: IntentMeta (Readonly<Record<string, JsonValue>>) for trace tags or plugin-specific data. No variant carries DOM references.

API

interface SplitBlockIntent {
  readonly kind: "splitBlock";
  readonly blockPath: readonly number[];
  readonly splitChildIndex: number;
  readonly splitTextOffset?: number;
  readonly meta?: IntentMeta;
}

engine.dispatch(intent: Intent): DispatchResult;