Plan
Intent becomes an ordered list of operations.
A single intent can mean different things depending on where the selection is. typeText over a range selection must delete the range before inserting. splitBlock inside a list item splits differently from inside a plain paragraph. If callers had to encode those branches themselves, commands, plugins, and runtimes would drift.
The planner owns that branching. It reads the current doc and selection, compiles the intent into an ordered list of PlanOps, and is done. It never mutates.
Worked example
An intent to split a paragraph:
const intent: SplitBlockIntent = {
kind: "splitBlock",
blockPath: [0],
splitChildIndex: 0,
splitTextOffset: 4,
};
const plan: Plan = planner.compile(intent, context);
The compiled plan keeps the original intent and exposes an ordered ops array. Tooling can inspect that array before anything is applied.
For that intent, the plan will contain a single SplitBlockOp:
const op: SplitBlockOp = {
kind: "splitBlock",
blockPath: [0],
splitChildIndex: 0,
splitTextOffset: 4,
};
After compile, the engine emits:
planCompiled
planId
intentKind: "splitBlock"
opCount: 1
then lowers the plan into a Transaction. Simple plans often lower one-to-one, but lowering is allowed to combine or expand operations as long as the final transaction stays atomic.
Why a plan, and not steps directly?
A PlanOp is a semantic instruction: "split this block here." A Step is an apply-time document transformation: "replace this range with this slice," "split this node," "add this mark."
For simple edits, the shapes can look nearly identical. The distinction matters when:
- Planner rules need to inspect, veto, replace, or append to other rules' output before lowering.
- One intent expands into several operations that must lower into one atomic transaction.
- Tracing needs both the semantic plan (
planCompiled) and the applied transaction (transactionApplied), so a recorded session can be debugged at either level.
Core planner rules live in packages/core/src/plan/: plan-text.ts, plan-delete.ts, plan-blocks.ts, plan-lists.ts, plan-media.ts, plan-paragraph-break.ts, plan-paste-structural.ts, and others. Each file owns the rules for one family of intents.
PlanOp union
From packages/core/src/plan/plan-types.ts:
type PlanOp =
| ReplaceRangeOp
| AddMarkOp
| RemoveMarkOp
| InsertFragmentOp
| SplitBlockOp
| JoinBlocksOp
| SetBlockTypeOp
| SetMediaWidthOp
| SetMediaAlignmentOp
| SetMediaIndentOp
| SetSelectionOp
| SetStoredMarksOp
| CustomOp;
interface Plan {
readonly id: PlanId;
readonly intent: Intent;
readonly ops: readonly PlanOp[];
readonly meta?: PlanMeta;
readonly traceTags?: readonly string[];
}
API
const planner = createPlanner(rules?: readonly PlannerRule[]);
planner.compile(intent: Intent, context: PlannerContext): Plan;
lowerPlanToTransaction(plan: Plan): Transaction;
PlannerContext exposes doc, selection, storedMarks?, and resolvePos(pos). Plugins contribute additional rules through the capability graph; see Plugins.