JEPA4Japan · 教程

第 9 章:工具是交通灯,不是一袋布尔值

4,739字 14分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

用显式状态、守卫条件、取消、提交和命令实现选择、抓手、矩形、自由绘制与橡皮擦工具。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

本章唯一的真理:交互是一系列状态转换,而不是一堆零散的事件回调。

用纸板做一个交通信号灯,再找一辆玩具车。绿灯时车可以行驶,红灯时必须停车,黄灯时只能完成正在进行的穿越。每当信号灯变化时,孩子先收起上一张规则卡,再展示下一张。

先预测会发生什么。如果你同时贴上四张便签——“正在移动”“也许即将移动”“正在停止”和“正在转向”——当老师问“现在按 Escape 会怎样?”时,孩子能给出唯一的答案吗?如果绿灯在途中断电,应该把车的位置写入城市地图,还是将它恢复到起跑线?

  1. 恰好一个活动状态当前只适用一条规则
  2. 接收一张便签按下、移动、取消
  3. 走一条有名称的道路由守卫决定是否允许通行
  4. 只在结束时记录提交或取消
先做决定:当小车试运行时,地图是否应该已经包含几十个中间位置?不应该。那些只是桌面上的临时摆放状态。

交通信号灯不是装饰。它为每张输入便签指定了恰好一个合法的接收者。Idle → Pointing → Dragging → Idle 是一条有名称的道路。在移动距离超过阈值之前,状态机不会进入 Dragging。Escape、Pointer Cancel 和切换 Tool 都会沿取消路径返回。只有 Commit 才会生成可撤销的 Command。

将这些玩具概念映射到 Canvas

玩具交通游戏Canvas Lab明确含义
当前亮起的信号灯Interaction StateIdle、Pointing、Dragging、Resizing、Rotating、Editing
车辆类型ToolSelect、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一个原子且可撤销的事务
返回起跑线Cancel丢弃 Preview,且不生成 Command

这个类比有其局限。状态机不会替你决定产品规则,而红灯、黄灯、绿灯也无法表达文本编辑内部的子状态。因此,复杂 Tool 可以使用分层状态机(Hierarchical State Machine):select.transforming.resizing 可以共享 transforming 的取消规则,同时始终只激活一个叶状态。状态机也不意味着要把所有应用状态都塞进一个巨大的枚举中;Document、Session 和 Presence 仍然是彼此独立的数据。

先消除错误直觉

  • “几个布尔值更简单。” 五个布尔值有 32 种组合,其中大多数毫无意义。你无法证明 isDragging && isResizing 会把 Move 发送给 dragging 还是 resizing。
  • “直接在 Pointer 回调里修改 Shape,感觉响应最快。” 一次 Drag 会创建数百条 History 记录,Cancel 无法恢复原始状态,协作者也会看到未完成的局部操作。
  • “每个 Tool 都应该自行监听 DOM。” 输入所有权会变得模糊,而且切换 Tool 后,旧监听器仍可能执行提交。
  • “如果没有 pointerup,就在下一次点击时重置。” 下一次点击可能会意外提交旧事务。Cancel、Lost Capture 和 Blur 必须是一等事件。
  • “临时 Drag 期间,Undo 可以照常弹出 History。” 当 Preview 仍引用旧对象时,基线 Document 已经发生变化。应先执行 Cancel,或者定义明确的嵌套事务策略。
  • “在状态转换内部递归派发下一个事件。” 重入可能会在 Exit 完成之前再次触发转换。Engine 应该处理一个串行队列。

生产环境工具包

前置契约

第 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。命中缩放手柄会进入 Resizing,命中旋转手柄会进入 Rotating,双击 Text 会进入 Editing。Pointing 保存原点和 HitResult。只有在 Guard 检测到屏幕距离越过 Drag Threshold 后,才会进入 Dragging。Hand Tool 将移动写入 Camera Session,而不是 Document History。Pan 必须使用相邻 Screen Point 之间的增量;每次 Camera 改变后再根据 World Point 重新计算增量,会产生反馈漂移。Rectangle Tool 从锚点创建矩形 Preview。Freehand Tool 收集第 7 章中的合并点,并在后续章节中对其重新采样。Eraser Tool 收集它命中的稳定 ID,但只在 Commit 时创建一个 Batch Delete。

Transition 由事件、当前状态和只读上下文共同决定。Entry 创建 Preview 或 Transaction 令牌;Exit 保证完成清理。Escape、Pointer Cancel、Tool Switch,以及协作者删除对象,都需要显式的 Transition。Guard 检查的条件包括:当前 pointerId 是否拥有该交互、权限是否允许该操作、Shape 是否仍然存在、是否已经越过阈值,以及矩阵是否可逆。

Transient Preview 只绘制“如果现在释放会发生什么”。它不会被序列化、同步或添加到 Undo。Commit 将 Preview 与基线值之间的差异转换成一个 Command;Cancel 则丢弃该差异。Command Boundary 是唯一的领域写入入口。一次连续 Drag 会合并成一个 Transaction。针对多个 Shape 的操作会提交为一个 Batch,从而让 Undo 始终保持为单一步骤。

可重入意味着在一个转换完成之前分派另一个事件。引擎使用 FIFO 队列和一个 processing 锁存器串行处理事件。一个效果可以将另一个事件加入队列,但不能以递归方式修改当前状态。为每次转换记录 {tool,from,event,to,transactionId};生产日志不得记录敏感文本或完整的坐标轨迹。

证据与兼容性(已于 2026-08-29 验证)

状态机本身与浏览器无关。浏览器终止事件的来源仍遵循 MDN pointercancel、lostpointercapture 和 页面可见性 API。键盘 repeat 和 KeyboardEvent.code 需要明确的策略:Escape 可以忽略重复事件,而使用方向键进行连续移动时可以接受这些事件。DOM 事件不得成为状态类型的一部分,否则 Node 回放将失去可移植性。

本章的工程增量

起点: 一组 Pointer 回调和 isDragging/isResizing/isDrawing/isMaybeDragging/isAboutToResize。终点: 每个事件都能标明处理它的工具、状态和转换,并且写入只能通过提交命令进行。

添加以下文件:

  • src/engine/tools/ToolMachine.ts:串行分派、进入/退出和跟踪;
  • src/engine/tools/states.ts:禁止隐式组合的可辨识联合;
  • 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:事件序列和转换断言。

首先定义不能同时为真的状态以及效果协议:

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 完整覆盖了本章全部五种工具的基本路径、取消、切换和撤销边界。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;
  }
}

调整大小、旋转和编辑使用相同的协议。它们的进入操作会冻结原始选择快照,移动只会更改预览,而提交会生成一个批次。如果对象被删除或矩阵变得不可逆,它们就会取消。不要将它们作为布尔字段塞回拖动状态中。

测试通过事件回放来验证路径,而不是模拟实现细节:

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);
  });
});

运行 pnpm vitest run src/engine/tools。转换回放应全部通过,覆盖率报告应显示每个状态的取消路径都已执行。运行 pnpm playwright test tests/browser/tool-cancel.spec.ts。Escape、焦点丢失、Pointer Cancel 和切换工具都应恢复 Idle,且不增加文档修订号。

回到交通信号灯。状态不是一堆便利贴,而是当前唯一亮着的那盏灯。预览是汽车在练习场上的位置。命令是城市地图上的新记录。对于任意事件,我们都能指出是哪盏灯、哪条道路,以及它是否会记录任何内容。

故意破坏它

注入操作症状证据修复回归测试恢复
拖动期间按 Escape形状停在半途或被提交转换跟踪和修订号取消预览;不发出命令回放 down/move/escape从基线快照重绘
拖动期间切换工具两个工具同时响应旧状态仍不是空闲切换前先执行退出/取消在每一对工具之间切换清除输入所有权
发送 Pointer Cancel永远卡在拖动状态跟踪中没有取消边每个活动状态都有取消路径取消表测试丢弃事务令牌
调整大小期间删除对象空引用或对象死而复生exists 守卫失败object-deleted → cancel协作删除序列刷新选择
预览期间请求撤销基线与预览产生分歧历史修订号发生变化先取消,再撤销嵌套事件序列重新计算派生状态
双击与拖动阈值冲突同时发生编辑和移动触发了两个转换指向守卫只选择一个分支边界时间/距离参数取消文本覆盖层
始终不发送 Pointer Up状态一直卡住事件回放结束时未处于空闲状态取消/捕获丢失/失焦看门狗缺失 Up 测试引擎显式取消

用证据通过验收

问题你必须找出的证据
谁处理当前事件?tool + state + event + transition 跟踪
文档何时被写入?唯一的 commit 效果和 CommandBus 日志
取消是否干净?修订号不变、预览为空、最终状态为空闲
连续拖动会产生多少个撤销步骤?一个 transactionId 和一个命令
是否可能发生可重入?在 FIFO 测试期间进行分派的效果仍保持严格顺序
状态是否穷尽?TypeScript never 检查和转换表覆盖率
测试范围自动化证据手动证据
一次转换事件回放和状态快照DevTools 跟踪能标明工具/状态/转换
取消不会提交修订号、命令数量和预览断言测试 Escape、焦点丢失和系统取消
一个输入所有者工具切换和可重入序列测试快速交替使用工具,观察是否没有重复响应
  • 选择、抓手、矩形、自由绘制和橡皮擦各自都有明确的空闲/活动/提交/取消路径。
  • 调整大小、旋转和编辑都是显式状态,而不是布尔值。
  • 守卫、进入、退出、瞬态预览和事务边界都有单元测试。
  • Escape、工具切换、Pointer Cancel、对象删除、撤销和缺失 Up 都有确定性结果。
  • 工具不会监听 DOM,也不会直接修改文档。
  • 对于任意事件,你都能回答“哪个工具、哪个状态、哪个转换”。

给五岁小孩讲明白

回答时不要使用“状态机”“事务”“命令”或“可重入”:

  1. 为什么交通信号灯一次只能清楚地显示一条规则?
  2. 当老师让练习场上的汽车停下时,为什么不能把半途的位置写进城市地图?
  3. 为什么换车之前必须先收起上一辆车的规则卡?
  4. 新问题:如果汽车停稳前又有一张纸条送来,管理员该如何防止两张纸条互相插队?
显示参考答案 每次只启用一条规则,每个动作就只有一个答案;同时贴着许多便利贴会产生相互矛盾的组合。练习场只是用来尝试的:抵达终点后才在地图上记一条记录,如果中途停下,就回到起点。换车前先收起旧卡片,这样旧车就不会继续遵循新的指令。管理员把新纸条排进队列,每处理完一张纸条并整理好桌面后才拿下一张,因此不会有任何纸条在信号灯切换到一半时插队。