触发条件 (Occurrence Conditions)

  1. 宿主环境:iPad (Safari / Chrome) 或带触控笔与触摸屏的 Windows / Android 平板设备。
  2. 场景结构:在一个可滚动的文档容器(如 PDF 阅读器、长图、可缩放工作区)的上方,叠加了一层与视口/页面对齐的透明 HTML5 Canvas 作为数字墨迹批注层(Ink Layer)。
  3. 交互动作:用户开启批注模式,使用 Apple Pencil 或 Surface Pen 在页面内落笔书写文字,同时手腕或手指处于自然贴靠屏幕状态。

故障现象 (Symptoms)

  • 笔迹突发横向/纵向大拉丝:用触控笔书写连笔或特定笔画时,视口容器突然发生微小或剧烈的位移跳动,Canvas 捕获到的局部客户端坐标 $(e.clientX, e.clientY)$ 在滚动瞬移后发生突变,导致笔迹瞬间被拉扯出一条横贯半个屏幕的笔直长线或不规则尖刺折线。
  • 粗暴修复引发的衍生缺陷:如果直接给 Canvas 设置 touch-action: none,虽然触控笔书写不再触发页面滚动,但由于 Canvas 占据了整个视口且具有 pointer-events: auto,导致手指触控也彻底失效,用户无法在页面正中间用单指/双指滑动浏览 PDF 页面,必须挪到狭窄的页面边缝外侧才能滚动。

尝试方案与局限 (Attempted Solutions & Trade-offs)

方案具体操作结果与致命缺陷
方案 1:CSS touch-action: pan-y仅允许纵向平移,指望浏览器能原生分流手势与绘制失败。WebKit / Blink 将 Apple Pencil 同样视为指针输入,快速落笔滑移依然会被手势识别器吞噬判定为 pan,滚动容器发生位移,与落墨产生撕裂的大横线。
方案 2:直接 touch-action: none暴力禁止 Canvas 上的任何浏览器原生手势部分可用但严重破坏体验。触控笔写字稳固不飘,但手指也无法在页面中间滑动页面,阅读与翻页极其困难。
方案 3:动态切换 pointer-events批注模式下开启 pointer-events: auto,滚动模式下切换为 none失败。批注模式下用户依然需要用手指翻页、用笔写字(边看边批注),无法强行割裂两种模式。

核心根因分析 (Root Cause)

  1. W3C Pointer Events 的浏览器默认行为裁决管线:

    • 当 Canvas 具有 touch-action: pan-y 或 pan-x pan-y 时,浏览器在触控笔 (pointerType === 'pen') 接触屏幕并发生若干像素的微小位移时,底层的 Gesture Recognizer 会尝试抢占手势所有权。
    • 一旦触发浏览器默认平移行为,滚动容器(scrollContainer.scrollTop)就会发生位移。此时 pointermove 派发的坐标与父级滚动容器的位移并不同步,导致在世界坐标系到局部画布坐标系映射时产生瞬间跳变,拉出大横线。
  2. 原生事件分发的二元对立:

    • 浏览器的原生手势(平移、缩放)由合成器线程(Compositor Thread)直接处理,响应极快;但合成器无法原生区分“当前是手写笔要落墨,还是手指要滚动”。
    • 只要把手势权留给浏览器(保留 touch-action),触控笔就随时可能触发滚动;只要把手势权夺回(touch-action: none),浏览器就会把所有 touch 手势吞掉,原生滚动彻底停摆。

终极解决方案 (Final Resolution: 手笔分离 + 虚拟平移引擎)

彻底根治该问题的架构是:将手势裁决权 100% 收归 Web 应用程序,实行严格的手笔分离(Hand-Pen Separation),并为手指触控自研内置轻量虚拟平移引擎(Virtual Pan Engine)。

[用户物理输入 (Apple Pencil / 手指)]
                 │
                 ▼
      <canvas touch-action: none>  ───► 彻底扼杀浏览器原生 Gesture Recognizer 抢占,杜绝视口突发滚动
                 │
       [Pointer Events 路由分流]
        ├─── pointerType === 'pen' ────► 1. 严格阻断冒泡 (e.preventDefault)
        │                                2. 100% 专注落墨捕获与贝塞尔曲线拟合
        │                                3. 视口位置绝对锁定,零拉丝、零偏移
        │
        └─── pointerType === 'touch' ──► 1. 严禁落墨 (100% 掌误抑制 Palm Rejection)
                                         2. 注入轻量虚拟平移引擎 (Virtual Pan Engine)
                                         3. 同步累加更新 scrollContainer.scrollTop / scrollLeft
                                         4. 提手计算瞬时速度并触发惯性滑行 (Momentum Inertia)

1. 墨迹层样式强制锁定

.ink-canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: auto;
  touch-action: none; /* 必须显式为 none,杜绝笔写时视口位移拉出横线 */
  z-index: 5;
}

2. 交互引擎中的分流与阻断逻辑

在 canvas.ts 与 pdfViewer.ts 中:

// 批注模式下挂载触控笔分流
inkCanvas.addEventListener('pointerdown', (e: PointerEvent) => {
  // 关键 1: 触控笔书写
  if (e.pointerType =<mark> 'pen' || e.pointerType </mark>= 'mouse') {
    e.preventDefault(); // 阻止浏览器任何剩余默认行为
    inkCanvas.setPointerCapture(e.pointerId);
    inkEngine.startStroke(e);
    return;
  }

  // 关键 2: 手指触摸交互 (touch)
  if (e.pointerType === 'touch') {
    // 严禁落墨,执行 100% 掌误抑制
    isPanningWithFinger = true;
    lastTouchY = e.clientY;
    lastTouchX = e.clientX;
    lastTouchTime = performance.now();
    touchVelocityY = 0;
  }
});

3. 轻量平移与惯性滑行算法 (Pan Engine with Momentum)

inkCanvas.addEventListener('pointermove', (e: PointerEvent) => {
  if (e.pointerType =<mark> 'pen' || e.pointerType </mark>= 'mouse') {
    inkEngine.appendStroke(e);
    return;
  }

  if (e.pointerType === 'touch' && isPanningWithFinger) {
    const deltaY = lastTouchY - e.clientY;
    const deltaX = lastTouchX - e.clientX;
    const now = performance.now();
    const dt = Math.max(1, now - lastTouchTime);

    // 瞬时速度滑动平均(用于抬手后的惯性滑行)
    const currentVy = deltaY / dt;
    touchVelocityY = touchVelocityY * 0.4 + currentVy * 0.6;

    lastTouchY = e.clientY;
    lastTouchX = e.clientX;
    lastTouchTime = now;

    // 直接驱动视口滚动容器
    scrollContainer.scrollTop += deltaY;
    scrollContainer.scrollLeft += deltaX;
  }
});

inkCanvas.addEventListener('pointerup', (e: PointerEvent) => {
  if (e.pointerType === 'touch' && isPanningWithFinger) {
    isPanningWithFinger = false;
    // 触发惯性减速滑行 (Deceleration Loop)
    triggerInertiaMomentum(touchVelocityY, scrollContainer);
  }
});

历史劣化踩坑与二次攻坚 (Regression Post-Mortem: 2026-09)

在后续工程演进中,曾出现一次典型的“破坏性回退”:

  1. 回退表现:开发者试图让 PDF 阅读器的手指滚动更具原生阻尼感,误在 CSS 中加入了 .pdf-ink-layer .ink-canvas > .ink-canvas-layer { touch-action: pan-x pan-y !important; },并在 canvas.ts 中写了 if (scroller) { this.isSingleTouchPanning = false; return; }。
  2. 致命代价:Chromium / Edge 在 Windows Ink 与触摸设备上,将 touch-action: pan-y 同样作用于 pointerType === 'pen'。当用户手写笔向下滑动落笔时,合成器直接判定为纵向平移手势,向 Canvas 发送 pointercancel 并强行滚动视口,导致手写笔向下滑动被彻底当成手指滑动滚动页面!
  3. 同时并发问题:PDF 初始化将 annotationMode 设为 'browse',导致页面初次渲染时不挂载墨迹层,手写笔落笔直接击中底层视口触发拖动。
  4. 终极纠偏落地:

    • CSS 绝对铁律:墨迹层与所有 Canvas 必须坚守 touch-action: none !important;,任何借用浏览器原生 pan-y 滚动墨迹层上方手指的尝试都会直接破坏触控笔!
    • JS 动能惯性调度:手指平移由 InkCanvas 内置 isSingleTouchPanning 配合双缓冲滑动速度历史队列驱动,在 pointerup 触发 startMomentum() 指数衰减滑行,既实现了平滑的物理滑行,又保证了触控笔完全隔离。
    • 笔优先防掌误 (Palm Rejection):落笔期间 (this.isDrawing && this.activePointerType === 'pen') 到达的任何 touch 事件均判定为手掌贴屏,静默 preventDefault(),严禁中断笔画或滚动视口。
    • 默认模式修正:PDF 查看器默认进入 'annotate' 批注就绪态,所有已渲染槽位无条件挂载墨迹层。

判定准则与防劣化复盘 (How to Recognize Next Time)

  1. 代码审查快速自检清单:

    • 凡是在具有滚动的容器内嵌入可绘制 Canvas 时,若只设置了 touch-action: none 而没有配备手指事件代平移逻辑,必定会在移动端造成“页面正中手指划不动”的严重可访问性缺陷。
    • 凡是允许 touch-action: pan-y 且 Canvas 监听了 pointerdown,若没有阻断 pen 类型的平移手势,在真实硬件笔上必定会出现拉出长直线的偶然性 Bug。
  2. 测试用例防劣化:

    • 编写 Headless/Puppeteer 冒烟测试时,必须同时包含:

      1. 模拟 pointerType: 'pen':验证笔迹有效落墨且 scrollContainer.scrollTop 保持为 0。
      2. 模拟 pointerType: 'touch':验证画布不产生墨迹(inkPixels === 0),且 scrollContainer.scrollTop 发生正确的位移变化。


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Frontend