JEPA4Japan · 教程

第 7 章:鼠标、触控与触控笔说同一种语言

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

把指针、滚轮、键盘和手势输入转换成供工具层使用的规范化世界坐标事件。

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

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

本章唯一的真理:产品工具不应直接消费原始浏览器事件,而应消费规范化的输入事件。

拿来一个玩具鼠标、一支蜡笔和一根手指。在桌上放一个红色按钮,请一位“翻译员”坐在旁边。规则只有一条:无论孩子用什么触碰按钮,翻译员只报告“编号、位置、力度,以及动作是开始了还是结束了”。

先预测一下会发生什么。如果孩子按住蜡笔,把它移出桌面,然后松开,按钮还能知道动作“结束”了吗?如果第二根手指突然落下,我们如何区分它们?如果翻译员只能报告“鼠标移动了”,手指和笔还能参与吗?

  1. 不同的来客触碰、按压、滑动
  2. 一位翻译员编号、位置、压力
  3. 一种消息开始、移动、结束、取消
  4. 工具只读取消息它们不会猜测来客是谁
先回答这个问题:手离开桌面后,翻译员靠什么继续识别同一个动作?答案是“ID 加捕获”,而不是一张更大的表。

真实操作中的对应关系很具体:pointerId 就像一张带编号的票;Pointer Capture 就像翻译员暂时保管这张票;pointercancel 就像老师宣布游戏已被中断;而 lostpointercapture 表示这张票确实已被收走。工具收到的是格式一致的消息,因此选择工具不需要分别学习如何处理鼠标、手指和笔。

将玩具映射到 Canvas

玩具世界Canvas 实验室保证
鼠标、手指、蜡笔pointerType 的值 mouse、touch 和 pen来源不同,协议相同
带编号的票pointerId同时追踪多根手指
按住按钮buttons、pressure记录操作状态和压力
倾斜蜡笔tiltX/tiltY、altitudeAngle/azimuthAngle支持画笔姿态,并在不可用时提供后备方案
翻译员保管票据setPointerCapture(pointerId)指针离开元素后继续接收事件
老师叫停游戏pointercancel、焦点丢失绝不把未完成的操作误当作提交
一叠密集的微小消息合并事件自由绘制可以保留浏览器合并事件之前的采样点
猜测下一条消息预测事件仅用于瞬态预览,绝不能写入文档
手势规则卡touch-action告诉浏览器哪些直接操作属于页面,哪些属于应用

类比到这里就不再适用了:带编号的票不是用户身份,捕获也不会“锁住物理鼠标”。浏览器仍可能因为系统手势、窗口切换、设备断开或元素被移除而取消操作。预测事件也不是未来的事实;真实采样到达后,必须立即替换预测线条。键盘不是二维指针:它拥有独立的焦点、按键重复和修饰键生命周期。

先消灭错误直觉

  • “分别监听鼠标、触摸和笔的事件族最安全。” 三个事件族会产生兼容性鼠标事件、重复提交以及三套边界情况。Pointer Events 已经是与硬件无关的统一入口。
  • “只监听 pointerup 就足以完成一次操作。” 系统滚动、来电和设备切换都可能产生 pointercancel;捕获丢失时也必须回滚。
  • “clientX/clientY 是世界坐标。” 它们只是视口中的 CSS 像素。你必须减去元素边界,并应用上一章中相机矩阵的逆矩阵。
  • “wheel.deltaY 总是以像素为单位。” deltaMode 可能表示像素、行或页,而触控板会发送大量带惯性的微小增量。
  • “预测点看起来更平滑,所以直接保存它们。” 预测之后会被修正。持久化预测点会让重放依赖浏览器的猜测。
  • “拖动开始时读取一次 Shift 就够了。” 修饰键可能在拖动过程中发生变化。每条规范化消息都必须携带该时刻的状态快照。

生产环境工具包

前置契约

Canvas 实验室已经具备第 4 章中的 Camera.screenToWorld(),以及第 6 章中拥有对称 start()/dispose() 生命周期方法的引擎。输入层调用相机,而不是重复实现矩阵代码;它发出事件,但不修改文档。Canvas 元素可以接收焦点,而 DOM 覆盖层仍可接管文本输入。

形式化知识

Pointer Events 统一了 pointerdown/move/up/cancel。pointerId 只在当前页面会话和活动指针的生命周期内区分各个指针;绝不能将其持久化。使用 pointerType 选择能力,例如笔压,但不要因此创建产品层面的分支。buttons 是按钮位掩码。pressure、tiltX/Y、altitudeAngle 和 azimuthAngle 描述设备报告的笔数据;当设备不具备相应能力时,应使用中性值和能力标志,而不是凭空捏造精度。

在 pointerdown 之后捕获主指针,然后在 pointerup、pointercancel 和 lostpointercapture 上执行相同的清理。当第二个指针加入时,手势识别器决定是继续绘制、取消单指针工具,还是进入捏合手势。两个工具绝不能同时拥有同一输入。getCoalescedEvents() 可以向自由绘制提供细粒度采样;getPredictedEvents() 仅属于瞬态预览;只有当测量结果表明确实需要降低延迟且浏览器支持时,才启用 pointerrawupdate。普通选择工具应继续使用 pointermove,以避免不必要的事件洪流。

在应用滚动、平移或缩放策略之前,应根据 deltaMode 规范化 Wheel 值。浏览器没有提供一个始终能可靠证明输入来自“鼠标滚轮还是触控板”的字段,因此不要凭空捏造设备真相;应针对增量序列应用产品规则。捏合手势根据两个活动指针之间的距离和中心点创建 GestureChanged。CSS touch-action: none 会阻止元素内部的浏览器原生平移和缩放,因此只应将它应用于确实拥有这些手势的交互表面;不要扩散到页面导航区域。

键盘控制器追踪 keydown/keyup 和 alt/ctrl/meta/shift 的快照,同时忽略正在编辑文本的覆盖层。在 window.blur 或 visibilitychange 上,清除卡住的按键并取消正在进行的交互。以屏幕 CSS 像素衡量拖动阈值,这样缩放就不会改变操作手感。通过阈值、时间窗口和目标身份共同协调双击。使用可取消的计时器生成长按,并在移动超出阈值、第二个指针加入、焦点丢失或操作被取消时始终清除该计时器。

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

本章的工程增量

起点: 引擎将 DOM 事件直接交给工具,指针离开画布时偶尔会卡住,而且缩放后无法信任世界坐标点。终点: InputController 是唯一的浏览器输入入口,并发出可辨识的 NormalizedInput;工具只消费这些事件。

添加以下文件和职责:

  • src/engine/input/InputController.ts:监听、捕获、归一化并清理;
  • src/engine/input/types.ts:统一事件与能力;
  • src/engine/input/GestureRecognizer.ts:双指针捏合、长按和双击;
  • src/engine/input/__tests__/InputController.test.ts:取消、坐标、修饰键和清理;
  • tests/browser/input-capture.spec.ts:在真实浏览器中离开元素后释放。

下面的核心实现可直接按原样运行。唯一省略的是产品订阅容器;取消和坐标逻辑均已完整实现:

export type Modifiers = Readonly<{
  alt: boolean;
  ctrl: boolean;
  meta: boolean;
  shift: boolean;
}>;

export type PointerSample = Readonly<{
  pointerId: number;
  pointerType: string;
  screen: DOMPointReadOnly;
  world: DOMPointReadOnly;
  buttons: number;
  modifiers: Modifiers;
  timestamp: number;
  pressure: number;
  tilt: readonly [number, number];
  altitude: number | null;
  azimuth: number | null;
  captured: boolean;
  predicted: boolean;
}>;

export type NormalizedInput =
  | (PointerSample & { kind: 'PointerStarted' | 'PointerMoved' | 'PointerEnded' })
  | (PointerSample & { kind: 'PointerCanceled'; reason: 'browser' | 'capture-lost' | 'focus-lost' })
  | {
      kind: 'WheelZoomed';
      screen: DOMPointReadOnly;
      world: DOMPointReadOnly;
      deltaPixels: number;
      modifiers: Modifiers;
      timestamp: number;
    }
  | { kind: 'KeyChanged'; code: string; down: boolean; modifiers: Modifiers; timestamp: number }
  | {
      kind: 'GestureChanged';
      gesture: 'pinch' | 'long-press' | 'double-click';
      phase: 'start' | 'change' | 'end' | 'cancel';
      screen: DOMPointReadOnly;
      world: DOMPointReadOnly;
      scale: number;
      rotation: number;
      timestamp: number;
    };

export interface CameraPort {
  screenToWorld(point: DOMPointReadOnly): DOMPointReadOnly;
}

const mods = (event: MouseEvent | KeyboardEvent): Modifiers => ({
  alt: event.altKey,
  ctrl: event.ctrlKey,
  meta: event.metaKey,
  shift: event.shiftKey,
});

export class InputController {
  private readonly active = new Set<number>();
  private readonly lastPointers = new Map<number, PointerSample>();
  private readonly pressedKeys = new Set<string>();
  private readonly previousTouchAction: string;
  private readonly previousTabIndex: string | null;
  private disposed = false;

  constructor(
    private readonly element: HTMLElement,
    private readonly camera: CameraPort,
    private readonly emit: (event: NormalizedInput) => void,
  ) {
    this.previousTouchAction = element.style.touchAction;
    this.previousTabIndex = element.getAttribute('tabindex');
    element.style.touchAction = 'none';
    if (this.previousTabIndex === null) element.tabIndex = 0;
    element.addEventListener('pointerdown', this.onDown);
    element.addEventListener('pointermove', this.onMove);
    element.addEventListener('pointerup', this.onUp);
    element.addEventListener('pointercancel', this.onCancel);
    element.addEventListener('lostpointercapture', this.onLostCapture);
    element.addEventListener('wheel', this.onWheel, { passive: false });
    element.addEventListener('keydown', this.onKeyDown);
    element.addEventListener('keyup', this.onKeyUp);
    window.addEventListener('blur', this.onBlur);
    document.addEventListener('visibilitychange', this.onVisibilityChange);
  }

  private points(event: PointerEvent) {
    const rect = this.element.getBoundingClientRect();
    const screen = new DOMPointReadOnly(event.clientX - rect.left, event.clientY - rect.top);
    return { screen, world: this.camera.screenToWorld(screen) };
  }

  private pointerSample(event: PointerEvent, predicted = false): PointerSample {
    const { screen, world } = this.points(event);
    const altitude = 'altitudeAngle' in event ? event.altitudeAngle : null;
    const azimuth = 'azimuthAngle' in event ? event.azimuthAngle : null;
    return {
      pointerId: event.pointerId,
      pointerType: event.pointerType,
      screen,
      world,
      buttons: event.buttons,
      modifiers: mods(event),
      timestamp: event.timeStamp,
      pressure: event.pressure,
      tilt: [event.tiltX, event.tiltY],
      altitude,
      azimuth,
      captured: this.element.hasPointerCapture(event.pointerId),
      predicted,
    };
  }

  private emitPointer(
    event: PointerEvent,
    kind: 'PointerStarted' | 'PointerMoved' | 'PointerEnded',
    predicted = false,
  ) {
    const sample = this.pointerSample(event, predicted);
    if (!predicted) this.lastPointers.set(event.pointerId, sample);
    this.emit({ ...sample, kind });
  }

  private onDown = (event: PointerEvent) => {
    this.active.add(event.pointerId);
    try {
      this.element.setPointerCapture(event.pointerId);
    } catch {
      // Still emit Started if the element was removed or the UA rejected Capture;
      // captured:false lets the upper layer choose a fallback strategy.
    }
    this.emitPointer(event, 'PointerStarted');
  };

  private onMove = (event: PointerEvent) => {
    if (!this.active.has(event.pointerId)) return;
    const coalesced =
      typeof event.getCoalescedEvents === 'function' ? event.getCoalescedEvents() : [event];
    const samples = coalesced.length ? coalesced : [event];
    for (const sample of samples) this.emitPointer(sample, 'PointerMoved');
    if (typeof event.getPredictedEvents === 'function') {
      for (const sample of event.getPredictedEvents())
        this.emitPointer(sample, 'PointerMoved', true);
    }
  };

  private onUp = (event: PointerEvent) => {
    if (!this.active.delete(event.pointerId)) return;
    this.emitPointer(event, 'PointerEnded');
    if (this.element.hasPointerCapture(event.pointerId))
      this.element.releasePointerCapture(event.pointerId);
    this.lastPointers.delete(event.pointerId);
  };

  private cancel(
    pointerId: number,
    reason: 'browser' | 'capture-lost' | 'focus-lost',
    event?: PointerEvent,
  ) {
    if (!this.active.delete(pointerId)) return;
    const previous = event ? this.pointerSample(event) : this.lastPointers.get(pointerId);
    if (previous)
      this.emit({
        ...previous,
        captured: this.element.hasPointerCapture(pointerId),
        predicted: false,
        kind: 'PointerCanceled',
        reason,
      });
    this.lastPointers.delete(pointerId);
  }
  private onCancel = (event: PointerEvent) => this.cancel(event.pointerId, 'browser', event);
  private onLostCapture = (event: PointerEvent) =>
    this.cancel(event.pointerId, 'capture-lost', event);
  private onKeyDown = (event: KeyboardEvent) => {
    this.pressedKeys.add(event.code);
    this.emit({
      kind: 'KeyChanged',
      code: event.code,
      down: true,
      modifiers: mods(event),
      timestamp: event.timeStamp,
    });
  };
  private onKeyUp = (event: KeyboardEvent) => {
    this.pressedKeys.delete(event.code);
    this.emit({
      kind: 'KeyChanged',
      code: event.code,
      down: false,
      modifiers: mods(event),
      timestamp: event.timeStamp,
    });
  };
  private onBlur = () => {
    for (const id of [...this.active]) this.cancel(id, 'focus-lost');
    for (const code of this.pressedKeys)
      this.emit({
        kind: 'KeyChanged',
        code,
        down: false,
        modifiers: { alt: false, ctrl: false, meta: false, shift: false },
        timestamp: performance.now(),
      });
    this.pressedKeys.clear();
  };
  private onVisibilityChange = () => {
    if (document.visibilityState === 'hidden') this.onBlur();
  };
  private onWheel = (event: WheelEvent) => {
    event.preventDefault();
    const unit =
      event.deltaMode === WheelEvent.DOM_DELTA_LINE
        ? 16
        : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
          ? Math.max(1, this.element.clientHeight)
          : 1;
    const rect = this.element.getBoundingClientRect();
    const screen = new DOMPointReadOnly(event.clientX - rect.left, event.clientY - rect.top);
    this.emit({
      kind: 'WheelZoomed',
      screen,
      world: this.camera.screenToWorld(screen),
      deltaPixels: event.deltaY * unit,
      modifiers: mods(event),
      timestamp: event.timeStamp,
    });
  };

  dispose() {
    if (this.disposed) return;
    this.disposed = true;
    this.onBlur();
    this.element.removeEventListener('pointerdown', this.onDown);
    this.element.removeEventListener('pointermove', this.onMove);
    this.element.removeEventListener('pointerup', this.onUp);
    this.element.removeEventListener('pointercancel', this.onCancel);
    this.element.removeEventListener('lostpointercapture', this.onLostCapture);
    this.element.removeEventListener('wheel', this.onWheel);
    this.element.removeEventListener('keydown', this.onKeyDown);
    this.element.removeEventListener('keyup', this.onKeyUp);
    window.removeEventListener('blur', this.onBlur);
    document.removeEventListener('visibilitychange', this.onVisibilityChange);
    this.element.style.touchAction = this.previousTouchAction;
    if (this.previousTabIndex === null) this.element.removeAttribute('tabindex');
    else this.element.setAttribute('tabindex', this.previousTabIndex);
  }
}

GestureRecognizer 订阅这些真实样本,而不再重复监听 DOM。它按照 pointerId 存储两个活动指针的初始中心、距离和角度。当两个指针都存在时,它会发出 phase:'start';后续样本将缩放比例报告为“当前距离 ÷ 初始距离”,并将旋转报告为角度差;任一指针结束或取消时,它都会恰好发出一次 end/cancel 并清除会话。当移动距离越过拖动阈值、第二个指针加入、焦点丢失或操作被取消时,应清除长按计时器。双击必须同时满足时间窗口、屏幕距离和目标 ID 条件。真实的合并点可以进入自由绘制,而预测点只能用于可替换的预览。pointerrawupdate 可以作为经过特性检测的可选样本源,但不得重复对应 pointermove 中的点。此识别器的序列测试和上述浏览器适配器测试缺一不可。

单元测试将浏览器外壳精简到最低限度,并验证世界坐标转换、使用最后一个完整样本执行取消,以及幂等清理:

import { describe, expect, it, vi } from 'vitest';
import { InputController } from '../InputController';

describe('InputController', () => {
  it('converts screen points to world points and cancels only once when capture is lost', () => {
    const el = document.createElement('canvas');
    Object.defineProperty(el, 'getBoundingClientRect', {
      value: () => ({ left: 10, top: 20, width: 300, height: 200 }),
    });
    el.setPointerCapture = vi.fn();
    el.hasPointerCapture = vi.fn(() => true);
    const events: unknown[] = [];
    const input = new InputController(
      el,
      {
        screenToWorld: (p) => new DOMPointReadOnly(p.x / 2, p.y / 2),
      },
      (e) => events.push(e),
    );

    el.dispatchEvent(
      new PointerEvent('pointerdown', {
        pointerId: 7,
        clientX: 50,
        clientY: 60,
        buttons: 1,
        pointerType: 'pen',
      }),
    );
    el.dispatchEvent(new PointerEvent('lostpointercapture', { pointerId: 7 }));
    el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: 7 }));

    expect(events).toMatchObject([
      { kind: 'PointerStarted', screen: { x: 40, y: 40 }, world: { x: 20, y: 20 } },
      {
        kind: 'PointerCanceled',
        pointerId: 7,
        reason: 'capture-lost',
        screen: { x: -10, y: -20 },
        world: { x: -5, y: -10 },
        predicted: false,
      },
    ]);
    input.dispose();
    input.dispose();
    expect(events).toHaveLength(2);
  });
});

运行 pnpm vitest run src/engine/input;最后一行应包含 1 passed。运行 pnpm playwright test tests/browser/input-capture.spec.ts;拖动到画布外并释放后,状态应返回 Idle,且事件日志应恰好包含一次结束或取消。最后,分别使用鼠标、触摸屏和压感笔记录一段 JSON 序列。它们的字段结构必须完全相同。

编写完代码后,回到桌面游戏的比喻。InputController 是翻译员;相机是地图,它把“桌子的哪一厘米”转换为“玩具城市的哪条街道”;工具只读取翻译后的纸条。更换一支笔不再意味着重写交通规则。

故意破坏它

故障注入症状要收集的证据修复回归测试恢复
按下、离开画布,然后释放永远卡在拖动状态事件序列缺少结束按下时进行捕获使用 Playwright 拖出后释放分派取消并清除临时状态
在开发者工具中模拟 pointercancel部分工作被提交文档中多出一条命令取消时仅回滚预览断言命令数为 0删除受污染的记录并重放快照
操作中途添加第二个指针形状跳动并发生缩放两个工具同时拥有该指针手势守卫取消单指针事务重放双指针序列清除活动集合
拖动期间松开 Shift宽高比仍保持锁定事件中的修饰键快照未更新每次移动时读取修饰键中途改变 Shift 状态的测试在下一个事件中重新计算约束
触控板发送 200 个微小增量缩放失控deltaMode 和时间戳序列归一化并按帧聚合固定输入流快照重置累加器
构造 LINE/PAGE 滚轮事件不同浏览器中的幅度不同比较原始增量与像素增量根据 deltaMode 进行转换对全部三种模式进行参数化测试恢复默认缩放
显式释放捕获工具卡住lostpointercapture 日志统一取消路径捕获丢失测试回滚临时事务

用证据证明通过

要证明的属性自动化证据手动证据
三种指针类型都进入同一协议类型测试和事件快照真实的鼠标、触摸和笔设备
离开元素不会丢失完成通知浏览器捕获测试拖动越过窗口边缘
相机转换只发生一次且保持可逆固定矩阵的点断言在不同缩放级别点击同一个角
取消不会写入文档命令数断言中断系统手势
预测点不会被持久化序列化快照中没有 predicted慢速重放时没有幽灵线条
生命周期对称调用两次 Dispose 并检查监听器数量严格模式下事件不会重复
  • 工具源代码中没有 if mouse ... else if touch ... else if pen ...。
  • 每个统一指针事件都包含屏幕点、世界点、类型、按键、修饰键、时间戳、压力和捕获状态。
  • 指针捕获、取消、捕获丢失和焦点丢失都进入同一条清理路径。
  • 合并点、预测点和原始更新路径都具有特性检测和明确的回退方案。
  • 拖动、双击和长按阈值使用屏幕像素,并且在每个缩放级别都具有一致的操作体验。
  • 滚轮单位差异和高频微小增量均有可重放的测试。

给五岁小朋友讲明白

回答时不要使用“事件”“捕获”或“坐标”这些词:

  1. 为什么三种不同的手都需要先去找翻译员?
  2. 翻译员拿到编号票后,为什么手离开桌子时游戏仍然可以正常结束?
  3. 为什么“我们猜测的下一个笔画”只能暂时显示,而不能写进画作?
  4. 新问题:如果一只手正拿着积木时突然停电,你会把积木的新位置写进账本吗?
显示参考答案 三种手使用不同的语言,因此,如果一个工具分别学习每一种语言,就会遗漏规则;翻译员把它们全都转换成相同的“开始、移动、结束”纸条。编号票让同一个动作在桌子之外仍能被识别,但老师随时都可以叫停游戏,所以我们需要一种方法来“撤销这次尚未完成的摆放”。猜测的点可能不正确,因此它只是一道淡淡的铅笔影子;当我们实际触碰的位置到来时,就会将它擦除。如果停电,不要把未完成的摆放写进共享账本;应恢复到摆放开始前的位置。