コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
まずは5歳児にもわかるゲームから
この章で覚える真実は一つだけです。インタラクションは状態遷移であって、ばらばらのイベントコールバックではありません。
段ボールで信号機を作り、おもちゃの車を用意します。青信号なら進め、赤信号なら止まり、黄信号ならすでに始めた横断だけを終えられます。信号を切り替えるたび、子どもは前のルールカードを片付けてから次のカードを出します。
先に予想してみてください。「走っている」「走るかもしれない」「止まりかけている」「曲がっている」という4枚の付箋を同時に貼ったら、先生に「いま Escape を押すとどうなるの」と聞かれて一つの答えを返せるでしょうか。青信号が途中で抜かれたら、車の位置を街の地図に記録するべきでしょうか、それとも出発線へ戻すべきでしょうか。
- 点灯する状態は一つだけいま適用する規則は一つ
- メモを一枚受け取る押す、動かす、キャンセル
- 名前のある道を進むGuard が通行可能か決める
- ゴールでだけ記録するCommit または Cancel
信号機は飾りではありません。入力メモの正当な受け手を常に一つにします。Idle → Pointing → Dragging → Idle は名前のある道です。移動量が閾値を超えるまで Dragging には入りません。Escape、Pointer Cancel、Tool の切り替えはすべてキャンセル経路を通って戻ります。Undo できる Command を生成するのは Commit だけです。
おもちゃを Canvas に置き換える
| おもちゃの交通ゲーム | Canvas Lab | 明確な意味 |
|---|---|---|
| 現在点灯している信号 | Interaction State | Idle、Pointing、Dragging、Resizing、Rotating、Editing |
| 車の種類 | Tool | Select、Hand、Rectangle、Freehand、Eraser |
| 届いたメモ | Normalized Input Event | 第7章の統一イベントで、ブラウザ分岐を含まない |
| 名前のある道 | Transition | (state,event) → next state + effects |
| 交差点の門番 | Guard | 距離、権限、オブジェクト存在などの条件 |
| 信号変更前にカードをしまう | Exit action | Capture 所有権を解放し Preview を消す |
| 信号変更後にカードを出す | Entry action | Preview を作り Cursor を設定 |
| テーブル上の試し置き | Transient Preview | Document にも History にも属さない |
| 街の地図へ記録 | Commit/Command | 一つの原子的で取り消し可能な transaction |
| 出発線へ戻る | Cancel | Preview を破棄し Command は生成しない |
この比喩には限界があります。状態機械がプロダクトルールを代わりに決めるわけではなく、赤・黄・青だけでテキスト編集内のサブ状態を表すこともできません。複雑なツールには Hierarchical State Machine を使えます。select.transforming.resizing は transforming のキャンセルルールを共有しつつ、アクティブな葉状態を一つに保ちます。状態機械はアプリケーション状態を巨大な enum 一つに詰め込むことでもありません。Document、Session、Presence は別々のデータのままです。
まず誤った直感を捨てる
- 「boolean をいくつか使うほうが簡単だ。」 5個の boolean には32通りの組み合わせがあり、その大半に意味がありません。
isDragging && isResizingの Move をどちらが処理するか証明できません。 - 「Pointer コールバック内で Shape を直接変えるのが一番なめらかだ。」 1回の Drag で何百件もの History が生まれ、Cancel で元に戻せず、共同編集者には未完成物が見えます。
- 「各 Tool が自分で DOM を監視すればよい。」 入力の所有権が曖昧になり、Tool 切り替え後も古い listener がコミットしかねません。
- 「
pointerupがなければ次のクリックでリセットする。」 次のクリックが古い transaction を誤ってコミットする可能性があります。Cancel、Lost Capture、Blur は第一級イベントです。 - 「一時 Drag 中でも Undo は普通に History を戻せる。」 基準 Document が変わる一方で Preview が古いオブジェクトを参照します。先に Cancel するか、明示的な入れ子 transaction 方針が必要です。
- 「状態遷移の中で次イベントを再帰的に dispatch する。」 Reentrancy により Exit が終わる前に再遷移します。Engine は直列キューで処理するべきです。
Production backpack
前提となる契約
第7章が発行するのは PointerStarted/Moved/Ended/Canceled、Wheel、Key、Gesture だけです。第8章は安定した HitResult を返します。Document は Command だけを受け取り、Preview Store は一時的な派生幾何だけを保持します。すべての Tool は React、DOM、Renderer なしの Node テストで動作します。
正式な知識
Finite-State Machine は有限個の状態、イベント、明示的な Transition を持ちます。Hierarchical State Machine を使えば、複数のサブ状態で Entry、Exit、Cancel を共有でき、コードを複製せずに済みます。Tool は長期的に選択される操作モードを表し、Interaction State はその Tool がいま何をしているかを表します。両者を tool='select-dragging-rotating' のような文字列へ混ぜてはいけません。
Select Tool の基本経路は Idle → Pointing → Dragging → Commit です。resize handle に命中すれば Resizing、rotation handle なら Rotating、Text のダブルクリックなら Editing に入ります。Pointing は開始点と HitResult を保存します。画面距離が Drag Threshold を超えたと Guard が判断して初めて Dragging へ移ります。Hand Tool は移動を Camera Session に書き、Document History には入れません。Pan は隣接する Screen Point の delta を使います。Camera を変更するたびに World Point から delta を求め直すとフィードバックドリフトが生じます。Rectangle Tool はアンカーから長方形 Preview を作ります。Freehand Tool は第7章の coalesced points を集め、後続章でリサンプリングします。Eraser Tool は命中した Stable ID を集めますが、Commit まで Batch Delete を作りません。
Transition はイベント、現在 state、読み取り専用 context で決まります。Entry は Preview または Transaction token を作り、Exit は確実に後始末します。Escape、Pointer Cancel、Tool Switch、共同編集中のオブジェクト削除には明示的な Transition が必要です。Guard は現在の pointerId が操作を所有するか、権限があるか、Shape がまだ存在するか、閾値を超えたか、行列を逆変換できるかを確認します。
Transient Preview が描くのは「いま離したらどうなるか」だけです。シリアライズも同期も Undo への追加も行いません。Commit は Preview と基準値の差を一つの Command にし、Cancel は破棄します。Command Boundary はドメイン書き込みの唯一の入口です。連続 Drag は一つの Transaction にまとめます。複数 Shape にまたがる操作は一つの Batch としてコミットし、Undo を1ステップにします。
Reentrancy は Transition の実行中に別イベントを dispatch することです。Engine は FIFO キューと processing ラッチでイベントを直列処理します。effect が新イベントをキューへ入れることはできますが、現在 state を再帰的に変えてはいけません。各遷移について {tool,from,event,to,transactionId} を記録します。本番ログには機密テキストや完全な座標軌跡を記録しません。
根拠と互換性(確認日:2026-08-29)
状態機械そのものはブラウザに依存しません。ブラウザが操作を終了させる原因は MDN pointercancel、lostpointercapture、Page Visibility API に従います。キーボードの repeat と KeyboardEvent.code には明示的な方針が必要です。Escape のリピートは無視でき、矢印キーによる連続移動は受け入れられます。DOM イベントを状態型の一部にすると Node Replay の可搬性を失います。
この章で積み上げる実装
開始地点: Pointer コールバック群と isDragging/isResizing/isDrawing/isMaybeDragging/isAboutToResize。完了地点: 各イベントについて、どの Tool、どの State、どの Transition が処理するか答えられ、書き込みは Commit Command でだけ発生します。
次のファイルを追加します。
src/engine/tools/ToolMachine.ts:直列 dispatch、Entry/Exit、trace。src/engine/tools/states.ts:暗黙の組み合わせを禁止する判別 union。src/engine/tools/{Select,Hand,Rectangle,Freehand,Eraser}Tool.ts:各ツールの遷移表。src/engine/preview/PreviewStore.ts:一時状態。src/engine/commands/CommandBus.ts:原子的コミット。src/engine/tools/__tests__/replay.test.ts:イベントシーケンスと遷移 assertion。
まず、同時には真になれない状態と effect のプロトコルを定義します。
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[] }>;
以下の reducer は、この章の5ツールについて基本経路、キャンセル、切り替え、Undo 境界を完全に扱います。hitAt は 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、Editing も同じプロトコルを使います。Entry で元の Selection Snapshot を固定し、Move は Preview だけを変え、Commit は一つの Batch を生成します。オブジェクトが削除された、または行列を逆変換できない場合は Cancel します。これらを Dragging の boolean フィールドへ戻してはいけません。
テストは実装詳細をモックするのではなく、イベントリプレイで経路を証明します。
import { describe, expect, it } from 'vitest';
import { ToolMachine } from '../ToolMachine';
describe('ToolMachine replay', () => {
it('閾値を超えるとドラッグし、Escape は Preview だけを消して 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('2本目の pointer の up/cancel は1本目の transaction を commit も cancel もしない', () => {
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);
});
});
pnpm vitest run src/engine/tools を実行し、すべての transition replay が通り、coverage レポートで各 state の Cancel path が実行されることを確認します。pnpm playwright test tests/browser/tool-cancel.spec.ts を実行し、Escape、フォーカス喪失、Pointer Cancel、Tool 切り替えのすべてで Idle に戻り、Document revision が増えないことを確認します。
信号機へ戻りましょう。状態は付箋の山ではなく、いま点灯している一つの信号です。Preview は練習場にある車の位置で、Command は街の地図に追加する新しい記録です。どのイベントを見ても、どの信号、どの道、記録を行うかを指し示せます。
わざと壊してみる
| 注入する障害 | 症状 | 証拠 | 修正 | 回帰テスト | 復旧 |
|---|---|---|---|---|---|
| Drag 中に Escape | Shape が途中で止まる、またはコミットされる | Transition trace と revision | Preview を Cancel し Command を発行しない | down/move/escape をリプレイ | 基準 snapshot から再描画 |
| Drag 中に Tool を切り替える | 二つの Tool が同時反応 | 古い state が Idle ではない | Switch は先に Exit/Cancel を通る | 全 Tool 間の切り替え | 入力所有権を消す |
| Pointer Cancel を送る | 永久に Dragging | trace にキャンセル辺がない | すべての active state に Cancel | Cancel table test | transaction token を破棄 |
| Resize 中にオブジェクト削除 | null 参照またはオブジェクト復活 | exists guard 失敗 | object-deleted → cancel | 共同削除シーケンス | Selection を更新 |
| Preview 中に Undo を要求 | 基準と Preview がずれる | History revision が変化 | 先に Cancel、次に Undo | 入れ子イベントシーケンス | 派生状態を再計算 |
| Double Click と Drag 閾値が衝突 | 編集と移動の両方が起きる | 二つの transition が発火 | Pointing Guard が一方を選ぶ | 境界時刻・距離のパラメータ | テキスト Overlay をキャンセル |
| Pointer Up を一度も送らない | state が固まる | Event Replay の終状態が Idle でない | Cancel/Lost Capture/Blur watchdog | Up 欠落テスト | Engine が明示的にキャンセル |
証拠をもって合格する
| 問い | 指し示すべき証拠 |
|---|---|
| 現在のイベントを誰が処理するか | tool + state + event + transition trace |
| Document をいつ書くか | 唯一の commit effect と CommandBus ログ |
| Cancel はきれいか | revision 不変、Preview 空、終状態 Idle |
| Continuous Drag は何ステップの Undo か | 一つの transactionId、一つの Command |
| Reentrancy は起き得るか | FIFO テスト内で effect が dispatch しても厳密に順序維持 |
| 状態は網羅されているか | TypeScript never チェックと遷移表 coverage |
| 検査面 | 自動化された証拠 | 手動の証拠 |
|---|---|---|
| Transition が一つ | Event Replay と state snapshot | DevTools trace が Tool/State/Transition を示す |
| Cancel がコミットしない | revision、Command 数、Preview の assertion | Escape、フォーカス喪失、システムキャンセルを操作 |
| 入力所有者が一つ | Tool 切り替えと Reentrancy シーケンステスト | 高速で Tool を交互に選び二重反応がない |
- Select、Hand、Rectangle、Freehand、Eraser に明示的な Idle/Active/Commit/Cancel 経路がある。
- Resizing、Rotating、Editing は boolean ではなく明示的な状態である。
- Guard、Entry、Exit、Transient Preview、Transaction Boundary に単体テストがある。
- Escape、Tool Switch、Pointer Cancel、オブジェクト削除、Undo、Up 喪失の結果が決定的である。
- Tool は DOM を監視せず、Document を直接変更しない。
- どのイベントにも「どの Tool、どの State、どの Transition」と答えられる。
5歳児に説明する
「状態機械」「トランザクション」「コマンド」「再入」という言葉を使わずに答えてください。
- なぜ信号機は一度に一つの規則だけをはっきり点灯させるのでしょうか。
- 練習場で先生に止められた車の途中位置を、なぜ街の地図へ書けないのでしょうか。
- 車を替える前に、なぜ前の車のルールカードを片付けるのでしょうか。
- 新しい問題です。車が止まりきる前に別のメモが届いたら、管理人はどうやって二枚のメモの割り込みを防ぎますか。