Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans Current lesson
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
Start with a game a five-year-old can understand
The one truth in this chapter: interaction is a sequence of state transitions, not a scatter of event callbacks.
Make a traffic light from cardboard and get a toy car. The car may move on green, must stop on red, and may only finish crossing on yellow. Whenever the light changes, the child first puts away the previous rule card and then displays the next one.
Predict what happens first. If you attach four sticky notes at the same time—“moving,” “maybe about to move,” “stopping,” and “turning”—can the child give one answer when the teacher asks, “What does Escape do now?” If the green light is unplugged midway, should the car’s position be written into the city map or restored to the starting line?
- Exactly one active stateone rule applies now
- Receive one notepress, move, cancel
- Take a named roada Guard decides whether it is allowed
- Record it only at the finishCommit or Cancel
The traffic light is not decoration. It gives every input note exactly one legal recipient. Idle → Pointing → Dragging → Idle is a named road. The machine does not enter Dragging until movement exceeds the threshold. Escape, Pointer Cancel, and switching Tools all follow a cancellation road back. Only Commit produces a Command that can be undone.
Translate the toys into Canvas
| Toy traffic game | Canvas Lab | Explicit meaning |
|---|---|---|
| Currently lit signal | Interaction State | Idle, Pointing, Dragging, Resizing, Rotating, Editing |
| Kind of vehicle | Tool | Select, Hand, Rectangle, Freehand, Eraser |
| Delivered note | Normalized Input Event | The unified Chapter 7 event, with no browser branches |
| Named road | Transition | (state,event) → next state + effects |
| Gatekeeper at an intersection | Guard | Distance, permission, object existence, and other conditions |
| Put away the card before changing lights | Exit action | Release Capture ownership and clear Preview |
| Display the card after changing lights | Entry action | Create Preview and set Cursor |
| Trial arrangement on the table | Transient Preview | Belongs to neither Document nor History |
| Write into the city map | Commit/Command | One atomic, undoable transaction |
| Return to the starting line | Cancel | Discard Preview and produce no Command |
The analogy has limits. A state machine does not decide product rules for you, and red-yellow-green cannot express substates within text editing. Complex tools can therefore use a Hierarchical State Machine: select.transforming.resizing shares transforming’s cancellation rules while keeping exactly one leaf state active. A state machine also does not mean putting all application state into one giant enum; Document, Session, and Presence remain separate data.
Kill the wrong instincts first
- “A few booleans are simpler.” Five booleans have 32 combinations, most of them meaningless. You cannot prove whether
isDragging && isResizingsends Move to dragging or resizing. - “Changing the Shape directly inside Pointer callbacks feels most responsive.” One Drag creates hundreds of History entries, Cancel cannot restore the original, and collaborators see partial work.
- “Every Tool should listen to the DOM itself.” Input ownership becomes ambiguous, and an old listener can still commit after switching Tools.
- “If there is no
pointerup, reset on the next click.” That next click can accidentally commit the old transaction. Cancel, Lost Capture, and Blur must be first-class events. - “Undo can pop History normally during a temporary Drag.” The baseline Document changes while Preview still references the old object. Cancel first or define an explicit nested-transaction policy.
- “Dispatch the next event recursively inside a state transition.” Reentrancy can transition again before Exit finishes. The Engine should process a serial queue.
Production backpack
Prerequisite contract
Chapter 7 emits only PointerStarted/Moved/Ended/Canceled, Wheel, Key, and Gesture events. Chapter 8 returns a stable HitResult. The Document accepts only Commands, while Preview Store holds only temporary derived geometry. Every Tool runs in Node tests without React, DOM, or Renderer.
Formal knowledge
A Finite-State Machine has a finite set of states, events, and explicit Transitions. A Hierarchical State Machine lets substates share Entry, Exit, and Cancel behavior instead of duplicating code. Tool is the interaction mode selected for the longer term; Interaction State describes what that Tool is doing right now. Do not combine them into a string such as tool='select-dragging-rotating'.
The basic Select Tool path is Idle → Pointing → Dragging → Commit. A resize-handle hit enters Resizing, a rotation-handle hit enters Rotating, and double-clicking Text enters Editing. Pointing stores the origin and HitResult. Only a Guard that sees screen distance cross the Drag Threshold enters Dragging. Hand Tool writes movement to Camera Session rather than Document History. Pan must use the delta between adjacent Screen Points; calculating delta from World Points again after every Camera change creates feedback drift. Rectangle Tool creates a rectangle Preview from an anchor. Freehand Tool collects the coalesced points from Chapter 7 and resamples them in a later chapter. Eraser Tool collects stable IDs that it hits, but creates one Batch Delete only at Commit.
A Transition is determined by the event, current state, and read-only context. Entry creates a Preview or Transaction token; Exit guarantees cleanup. Escape, Pointer Cancel, Tool Switch, and deletion of the object by a collaborator all require explicit Transitions. Guards include whether the current pointerId owns the interaction, whether permission allows the action, whether the Shape still exists, whether the threshold has been crossed, and whether the matrix is invertible.
Transient Preview draws only “what would happen if you released now.” It is not serialized, synchronized, or added to Undo. Commit turns the difference between Preview and baseline values into one Command; Cancel discards it. The Command Boundary is the only domain write entry point. A continuous Drag coalesces into one Transaction. An operation over multiple Shapes commits one Batch so Undo remains a single step.
Reentrancy means dispatching another event before a Transition finishes. The Engine processes events serially with a FIFO queue and a processing latch. An effect may enqueue another event but may not recursively mutate the current state. Log {tool,from,event,to,transactionId} for every transition; production logs must not record sensitive text or complete coordinate trails.
Evidence and compatibility (verified 2026-08-29)
The state machine itself is browser-independent. Sources of browser termination still follow MDN pointercancel, lostpointercapture, and the Page Visibility API. Keyboard repeat and KeyboardEvent.code require explicit policies: Escape can ignore repeats, while continuous movement with arrow keys can accept them. A DOM event must not become part of the state type, or Node Replay loses portability.
The engineering increment for this chapter
Starting point: A collection of Pointer callbacks and isDragging/isResizing/isDrawing/isMaybeDragging/isAboutToResize. Finish line: Every event identifies the Tool, State, and Transition that handles it, and writes occur only through a Commit Command.
Add these files:
src/engine/tools/ToolMachine.ts: serial dispatch, Entry/Exit, and trace;src/engine/tools/states.ts: discriminated unions that prohibit implicit combinations;src/engine/tools/{Select,Hand,Rectangle,Freehand,Eraser}Tool.ts: tool transition tables;src/engine/preview/PreviewStore.ts: transient state;src/engine/commands/CommandBus.ts: atomic commit;src/engine/tools/__tests__/replay.test.ts: event sequences and transition assertions.
First define states that cannot be true simultaneously and the effects protocol:
type Point = Readonly<{ x: number; y: number }>;
type ToolId = 'select' | 'hand' | 'rectangle' | 'freehand' | 'eraser';
export type ToolState =
| Readonly<{ kind: 'idle'; tool: ToolId }>
| Readonly<{
kind: 'pointing';
tool: 'select';
pointerId: number;
originScreen: Point;
originWorld: Point;
hitId: string | null;
transactionId: string;
}>
| Readonly<{
kind: 'dragging';
tool: 'select';
pointerId: number;
originScreen: Point;
originWorld: Point;
current: Point;
hitId: string;
transactionId: string;
}>
| Readonly<{
kind: 'resizing';
tool: 'select';
pointerId: number;
shapeIds: readonly string[];
handle: string;
origin: Point;
current: Point;
transactionId: string;
}>
| Readonly<{
kind: 'rotating';
tool: 'select';
pointerId: number;
shapeIds: readonly string[];
pivot: Point;
current: Point;
transactionId: string;
}>
| Readonly<{ kind: 'editing'; tool: 'select'; shapeId: string; transactionId: string }>
| Readonly<{
kind: 'panning';
tool: 'hand';
pointerId: number;
originScreen: Point;
currentScreen: Point;
}>
| Readonly<{
kind: 'drawing-rectangle';
tool: 'rectangle';
pointerId: number;
anchor: Point;
current: Point;
transactionId: string;
}>
| Readonly<{
kind: 'drawing-freehand';
tool: 'freehand';
pointerId: number;
points: readonly Point[];
transactionId: string;
}>
| Readonly<{
kind: 'erasing';
tool: 'eraser';
pointerId: number;
ids: ReadonlySet<string>;
transactionId: string;
}>;
export type ToolEvent =
| Readonly<{ kind: 'down'; pointerId: number; world: Point; screen: Point; hitId: string | null }>
| Readonly<{ kind: 'move'; pointerId: number; world: Point; screen: Point }>
| Readonly<{ kind: 'up'; pointerId: number; world: Point }>
| Readonly<{ kind: 'cancel'; pointerId?: number; reason: string }>
| Readonly<{ kind: 'switch'; tool: ToolId }>
| Readonly<{ kind: 'escape' }>
| Readonly<{ kind: 'object-deleted'; id: string }>
| Readonly<{ kind: 'undo-requested' }>;
export type Effect =
| Readonly<{ kind: 'preview-move'; id: string; from: Point; to: Point }>
| Readonly<{ kind: 'preview-rectangle'; anchor: Point; current: Point }>
| Readonly<{ kind: 'preview-freehand'; points: readonly Point[] }>
| Readonly<{ kind: 'preview-erase'; ids: ReadonlySet<string> }>
| Readonly<{ kind: 'pan-camera'; from: Point; to: Point }>
| Readonly<{ kind: 'commit'; transactionId: string; command: Command }>
| Readonly<{ kind: 'cancel-preview'; transactionId?: string }>;
export type Command =
| Readonly<{ type: 'MoveShape'; id: string; from: Point; to: Point }>
| Readonly<{ type: 'CreateRectangle'; anchor: Point; opposite: Point }>
| Readonly<{ type: 'CreateFreehand'; points: readonly Point[] }>
| Readonly<{ type: 'DeleteShapes'; ids: readonly string[] }>;
The reducer below completely covers the basic paths, cancellation, switching, and Undo boundary for all five tools in this chapter. hitAt comes from GeometryKernel:
type Result = Readonly<{ state: ToolState; effects: readonly Effect[] }>;
type Context = Readonly<{
dragThresholdPx: number;
exists(id: string): boolean;
hitAt(point: Point): string | null;
transactionId(): string;
}>;
const idle = (tool: ToolId): ToolState => ({ kind: 'idle', tool });
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
const cancel = (state: ToolState, tool = state.tool): Result => ({
state: idle(tool),
effects:
state.kind === 'idle' || state.kind === 'panning'
? []
: [
{
kind: 'cancel-preview',
transactionId: 'transactionId' in state ? state.transactionId : undefined,
},
],
});
export function transition(state: ToolState, event: ToolEvent, ctx: Context): Result {
if (event.kind === 'switch') return cancel(state, event.tool);
if (event.kind === 'escape') return cancel(state);
if (event.kind === 'cancel') {
if (
event.pointerId !== undefined &&
'pointerId' in state &&
event.pointerId !== state.pointerId
)
return { state, effects: [] };
return cancel(state);
}
if (event.kind === 'undo-requested' && state.kind !== 'idle') return cancel(state);
if (event.kind === 'object-deleted' && 'shapeId' in state && state.shapeId === event.id)
return cancel(state);
if (event.kind === 'object-deleted' && 'hitId' in state && state.hitId === event.id)
return cancel(state);
if (event.kind === 'object-deleted' && 'shapeIds' in state && state.shapeIds.includes(event.id))
return cancel(state);
if (state.kind === 'idle' && event.kind === 'down') {
if (state.tool === 'select')
return {
state: {
kind: 'pointing',
tool: 'select',
pointerId: event.pointerId,
originScreen: event.screen,
originWorld: event.world,
hitId: event.hitId,
transactionId: ctx.transactionId(),
},
effects: [],
};
if (state.tool === 'hand')
return {
state: {
kind: 'panning',
tool: 'hand',
pointerId: event.pointerId,
originScreen: event.screen,
currentScreen: event.screen,
},
effects: [],
};
if (state.tool === 'rectangle')
return {
state: {
kind: 'drawing-rectangle',
tool: 'rectangle',
pointerId: event.pointerId,
anchor: event.world,
current: event.world,
transactionId: ctx.transactionId(),
},
effects: [{ kind: 'preview-rectangle', anchor: event.world, current: event.world }],
};
if (state.tool === 'freehand')
return {
state: {
kind: 'drawing-freehand',
tool: 'freehand',
pointerId: event.pointerId,
points: [event.world],
transactionId: ctx.transactionId(),
},
effects: [{ kind: 'preview-freehand', points: [event.world] }],
};
return {
state: {
kind: 'erasing',
tool: 'eraser',
pointerId: event.pointerId,
ids: new Set(event.hitId ? [event.hitId] : []),
transactionId: ctx.transactionId(),
},
effects: [],
};
}
if (state.kind === 'pointing' && event.kind === 'move' && event.pointerId === state.pointerId) {
if (
!state.hitId ||
!ctx.exists(state.hitId) ||
distance(state.originScreen, event.screen) < ctx.dragThresholdPx
)
return { state, effects: [] };
const next: ToolState = {
kind: 'dragging',
tool: 'select',
pointerId: state.pointerId,
originScreen: state.originScreen,
originWorld: state.originWorld,
current: event.world,
hitId: state.hitId,
transactionId: state.transactionId,
};
return {
state: next,
effects: [
{ kind: 'preview-move', id: state.hitId, from: state.originWorld, to: event.world },
],
};
}
if (state.kind === 'dragging' && event.kind === 'move' && event.pointerId === state.pointerId) {
return {
state: { ...state, current: event.world },
effects: [
{ kind: 'preview-move', id: state.hitId, from: state.originWorld, to: event.world },
],
};
}
if (state.kind === 'dragging' && event.kind === 'up' && event.pointerId === state.pointerId) {
return {
state: idle('select'),
effects: [
{
kind: 'commit',
transactionId: state.transactionId,
command: { type: 'MoveShape', id: state.hitId, from: state.originWorld, to: event.world },
},
],
};
}
if (state.kind === 'pointing' && event.kind === 'up' && event.pointerId === state.pointerId)
return { state: idle('select'), effects: [] };
if (state.kind === 'panning' && event.kind === 'move' && event.pointerId === state.pointerId)
return {
state: { ...state, currentScreen: event.screen },
effects: [{ kind: 'pan-camera', from: state.currentScreen, to: event.screen }],
};
if (state.kind === 'panning' && event.kind === 'up' && event.pointerId === state.pointerId)
return { state: idle('hand'), effects: [] };
if (
state.kind === 'drawing-rectangle' &&
event.kind === 'move' &&
event.pointerId === state.pointerId
)
return {
state: { ...state, current: event.world },
effects: [{ kind: 'preview-rectangle', anchor: state.anchor, current: event.world }],
};
if (
state.kind === 'drawing-rectangle' &&
event.kind === 'up' &&
event.pointerId === state.pointerId
)
return {
state: idle('rectangle'),
effects: [
{
kind: 'commit',
transactionId: state.transactionId,
command: { type: 'CreateRectangle', anchor: state.anchor, opposite: event.world },
},
],
};
if (
state.kind === 'drawing-freehand' &&
event.kind === 'move' &&
event.pointerId === state.pointerId
) {
const points = [...state.points, event.world];
return { state: { ...state, points }, effects: [{ kind: 'preview-freehand', points }] };
}
if (
state.kind === 'drawing-freehand' &&
event.kind === 'up' &&
event.pointerId === state.pointerId
) {
const last = state.points[state.points.length - 1];
const points =
last && last.x === event.world.x && last.y === event.world.y
? state.points
: [...state.points, event.world];
return {
state: idle('freehand'),
effects: [
{
kind: 'commit',
transactionId: state.transactionId,
command: { type: 'CreateFreehand', points },
},
],
};
}
if (state.kind === 'erasing' && event.kind === 'move' && event.pointerId === state.pointerId) {
const id = ctx.hitAt(event.world),
ids = new Set(state.ids);
if (id) ids.add(id);
return { state: { ...state, ids }, effects: [{ kind: 'preview-erase', ids }] };
}
if (state.kind === 'erasing' && event.kind === 'up' && event.pointerId === state.pointerId) {
const ids = new Set(state.ids),
id = ctx.hitAt(event.world);
if (id) ids.add(id);
return {
state: idle('eraser'),
effects: [
{
kind: 'commit',
transactionId: state.transactionId,
command: { type: 'DeleteShapes', ids: [...ids] },
},
],
};
}
return { state, effects: [] };
}
export type TransitionTrace = Readonly<{
tool: ToolId;
from: ToolState['kind'];
event: ToolEvent['kind'];
to: ToolState['kind'];
transactionId?: string;
}>;
export class ToolMachine {
private state: ToolState = idle('select');
private queue: ToolEvent[] = [];
private processing = false;
constructor(
private readonly context: Context,
private readonly run: (effect: Effect) => void,
private readonly trace: (entry: TransitionTrace) => void = () => undefined,
) {}
dispatch(event: ToolEvent) {
this.queue.push(event);
if (this.processing) return;
this.processing = true;
try {
while (this.queue.length) {
const event = this.queue.shift()!;
const from = this.state;
const result = transition(from, event, this.context);
this.state = result.state;
const transactionId =
'transactionId' in from
? from.transactionId
: 'transactionId' in result.state
? result.state.transactionId
: undefined;
this.trace({
tool: from.tool,
from: from.kind,
event: event.kind,
to: result.state.kind,
transactionId,
});
for (const effect of result.effects) this.run(effect);
}
} finally {
this.processing = false;
}
}
snapshot() {
return this.state;
}
}
Resize, Rotate, and Editing use the same protocol. Their Entry freezes the original Selection Snapshot, Move changes only Preview, and Commit produces one Batch. If an object is deleted or a matrix becomes non-invertible, they Cancel. Do not push them back into Dragging as boolean fields.
Tests prove paths through event replay rather than mocking implementation details:
import { describe, expect, it } from 'vitest';
import { ToolMachine } from '../ToolMachine';
describe('ToolMachine replay', () => {
it('drags after crossing the threshold, and Escape clears only preview without committing a Command', () => {
const effects: Array<{ kind: string }> = [];
let sequence = 0;
const machine = new ToolMachine(
{
dragThresholdPx: 4,
exists: () => true,
hitAt: () => 'shape-1',
transactionId: () => `tx-${++sequence}`,
},
(e) => effects.push(e),
);
machine.dispatch({
kind: 'down',
pointerId: 3,
screen: { x: 0, y: 0 },
world: { x: 0, y: 0 },
hitId: 'shape-1',
});
machine.dispatch({
kind: 'move',
pointerId: 3,
screen: { x: 10, y: 0 },
world: { x: 10, y: 0 },
});
machine.dispatch({ kind: 'escape' });
expect(machine.snapshot()).toEqual({ kind: 'idle', tool: 'select' });
expect(effects.map((e) => e.kind)).toEqual(['preview-move', 'cancel-preview']);
expect(effects.some((e) => e.kind === 'commit')).toBe(false);
});
it('up or cancel from a second pointer cannot commit or cancel the first pointer transaction', () => {
const effects: Array<{ kind: string }> = [];
const machine = new ToolMachine(
{
dragThresholdPx: 4,
exists: () => true,
hitAt: () => 'shape-1',
transactionId: () => 'tx-owner',
},
(e) => effects.push(e),
);
machine.dispatch({
kind: 'down',
pointerId: 1,
screen: { x: 0, y: 0 },
world: { x: 0, y: 0 },
hitId: 'shape-1',
});
machine.dispatch({
kind: 'move',
pointerId: 1,
screen: { x: 10, y: 0 },
world: { x: 10, y: 0 },
});
machine.dispatch({ kind: 'up', pointerId: 2, world: { x: 99, y: 99 } });
machine.dispatch({ kind: 'cancel', pointerId: 2, reason: 'other pointer canceled' });
expect(machine.snapshot().kind).toBe('dragging');
expect(effects.some((e) => e.kind === 'commit' || e.kind === 'cancel-preview')).toBe(false);
});
});
Run pnpm vitest run src/engine/tools. Transition replay should be entirely green, and the coverage report should show the Cancel path for every state executing. Run pnpm playwright test tests/browser/tool-cancel.spec.ts. Escape, focus loss, Pointer Cancel, and switching Tools should all restore Idle without increasing the Document revision.
Return to the traffic light. State is not a pile of sticky notes; it is the one light that is currently on. Preview is the car’s position on the practice field. Command is the new record on the city map. Given any event, we can point to the light, the road, and whether it records anything.
Break it on purpose
| Injection | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Press Escape during Drag | Shape stops halfway or is committed | Transition trace and revision | Cancel Preview; emit no Command | Replay down/move/escape | Redraw from baseline snapshot |
| Switch Tool during Drag | Two Tools respond at once | Old state is still not Idle | Switch follows Exit/Cancel first | Switch between every Tool pair | Clear input ownership |
| Send Pointer Cancel | Stuck in Dragging forever | Trace has no cancellation edge | Every active state has Cancel | Cancel table test | Discard transaction token |
| Delete object during Resize | Null reference or resurrected object | exists guard fails | object-deleted → cancel | Collaborative-delete sequence | Refresh Selection |
| Request Undo during Preview | Baseline and Preview diverge | History revision changes | Cancel first, then Undo | Nested event sequence | Recompute derived state |
| Double Click conflicts with Drag threshold | Both edits and moves | Two transitions fire | Pointing Guard selects one branch | Boundary time/distance parameters | Cancel text Overlay |
| Never send Pointer Up | State remains stuck | Event Replay ends outside Idle | Cancel/Lost Capture/Blur watchdog | Missing-Up test | Engine cancels explicitly |
Pass with evidence
| Question | Evidence you must identify |
|---|---|
| Who handles the current event? | tool + state + event + transition trace |
| When is the Document written? | The sole commit effect and CommandBus log |
| Is Cancel clean? | Unchanged revision, empty Preview, final state Idle |
| How many Undo steps does Continuous Drag produce? | One transactionId and one Command |
| Is reentrancy possible? | Effects that dispatch during a FIFO test remain strictly ordered |
| Are states exhaustive? | TypeScript never checks and transition-table coverage |
| Test surface | Automated evidence | Manual evidence |
|---|---|---|
| One Transition | Event Replay and state snapshot | DevTools trace identifies Tool/State/Transition |
| Cancel does not commit | Revision, Command-count, and Preview assertions | Exercise Escape, focus loss, and system cancellation |
| One input owner | Tool-switch and reentrancy sequence tests | Alternate tools quickly and observe no double response |
- Select, Hand, Rectangle, Freehand, and Eraser each have explicit Idle/Active/Commit/Cancel paths.
- Resizing, Rotating, and Editing are explicit states, not booleans.
- Guard, Entry, Exit, Transient Preview, and Transaction Boundary have unit tests.
- Escape, Tool Switch, Pointer Cancel, object deletion, Undo, and missing Up all have deterministic results.
- A Tool does not listen to the DOM or modify the Document directly.
- For any event, you can answer “which Tool, which State, which Transition.”
Explain it to a five-year-old
Answer without using “state machine,” “transaction,” “command,” or “reentrancy”:
- Why can a traffic light clearly display only one rule at a time?
- When the teacher stops the car on the practice field, why can the halfway point not be written into the city map?
- Why must you put away the previous car’s rule card before switching cars?
- New question: if another note arrives before the car settles, how does the manager stop the two notes from cutting in front of each other?