跳过正文
  1. 文章/

数位板到底发送了什么

·3974 字·8 分钟
作者
lxapgjryzc

在浏览器 UI 里调试数位笔输入时记下的笔记。五个打破了我原有假设、或者我见过被当作事实反复引用的行为。

下面每一条都测了两遍,跑在相差 52 个版本、4 年的两个引擎上。这一点后来很重要:五条里有一条在较新的那个引擎上并不复现,如果我没去核对,我就会把它当成普遍规律发出来了。

两套环境
#

AB
引擎Chromium 99,嵌入式(Adobe CEP 12)Chrome 151,独立浏览器
数位板Wacom Intuos Pro L1,蓝牙,WTabletServicePro,Windows Ink 开启同上
系统Windows 11 Pro 26200同上
页面devicePixelRatio 1,单显示器同上

同一块数位板,同一个驱动,同一台机器,同一次会话。只有引擎不同。

行为A(Chromium 99)B(Chrome 151)
1笔按下拖动期间,兼容鼠标事件停止是 —— 49 / 0是 —— 1034 / 2
2一次笔的拖动会被从你手里取消掉(被 CSS 挡住了)是 —— 每一笔都如此,不到 0.5 秒
3悬停时鼠标事件 1:1 精确回显笔的事件是 —— 1294 / 1294是 —— 82 / 82
4悬停时光标确实跟着笔走是 —— 最大 Δ 0 px是 —— 最大 Δ 0 px
5笔明明能用,maxTouchPoints 却是 0
滚轮坐标落在离笔十万八千里的地方是 —— 差约 700 px —— 1 px

最后一行是最需要小心的一条,它排在最后正是因为这个。


1. 笔按下拖动期间,兼容鼠标事件会停止
#

把一次会话按「当时是否有按键按下」拆开统计:

Chromium 99 (CEP)          pointermove   mousemove from the browser
  hovering                    1294            1294
  pen down, dragging            49               0

Chrome 151                 pointermove   mousemove from the browser
  hovering                      82              82
  pen down, dragging          1034               2     (4 strokes)

另外,两个引擎上这次按下都没有产生任何 mousedown,也没有任何 mouseup。所以一个建立在 DOM 鼠标事件之上的 UI,看到的是一笔永远没有开始、或者开始了却从不移动的笔画——而与此同时,指针事件流自始至终都是完整且正确的。悬停那两行是对照组:笔在悬停时,回显是完美的。恰恰是「按下」这个动作把它关掉了。

这是今天最可能坑到你的一条,而且它就发生在当前版本的引擎上——不是什么遗留怪癖。

修法,不需要把现有基于鼠标事件的 UI 重写到指针事件上。每个指针事件发出一张票;浏览器通常紧随其后发出的那个兼容事件会把这张票兑走;一个任务之后仍未被兑走的票,说明这个事件浏览器根本没发,只有这些才需要合成:

const pending = { down: [], move: [], up: [] };

function backfill(kind, source) {
  const ticket = { redeemed: false };
  pending[kind].push(ticket);
  setTimeout(() => {
    const i = pending[kind].indexOf(ticket);
    if (i >= 0) pending[kind].splice(i, 1);
    if (!ticket.redeemed) synthesize(kind, source);   // 浏览器从来没发过这个事件
  }, 0);
}

function redeem(kind) {
  const q = pending[kind];
  if (q.length) { q.shift().redeemed = true; return true; }
  return false;    // 没有待兑的票:这是一个真实的、独立的鼠标事件
}

给合成出来的事件打上标记(e.mySynthetic = true),在它们回流进来时跳过,否则它们会把自己的票兑掉。浏览器行为正常时,这套机制完全是惰性的——每张票都被兑走,什么都不会被合成,也就没有重复处理需要你去操心。

2. 一次笔的拖动会被从你手里取消掉
#

上面 Chrome 151 那一笔,并不是以抬笔结束的。它是这样结束的:

stroke  [pen id=2]  248 ms  ended by pointercancel
  pointermove (buttons held) = 7
  mousemove                  = 0
  mousedown = 0    mouseup = 0

开始四分之一秒之后,浏览器把这次拖动当成平移手势收走了,并抛出 pointercancel。我的测试页面没有设置任何 touch-action

这是文档里写明的成因,所以我把它做成了一次对照实验:同一个页面上六条一模一样的长条,只差一条 CSS 声明。每种条件下画两笔长笔画,同一支笔,同一次会话:

touch-action        strokes   cancelled   stroke length      pointermoves
auto (default)         2          2       480 ms, 131 ms          6,   6
none                   2          0      9231 ms, 8842 ms       513, 509

这不是什么边缘效应。默认设置下,拖动从来撑不过半秒;加上 touch-action: none 之后,两笔都一直画到抬笔为止,时长是前者的七十倍,事件数是前者的八十五倍。

有两个后果值得处理:

/* 让可拖动的表面从手势里退出来 */
.canvas, .slider, .picker { touch-action: none; }
/* 被取消的指针不会产生 mouse-up,于是拖动处理器一直处于待发状态,
   下一个游离的移动事件会让它继续画 */
el.addEventListener('pointercancel', e => endDragAsIfMouseUp(e));

/* 并且在按下时接管指针捕获,这样指针离开元素也不会结束拖动 */
el.addEventListener('pointerdown', e => el.setPointerCapture(e.pointerId));

这就是那个「滑块画到一半就死了」的 bug。它是在一个根本没打算触发它的页面上自发复现的,这一点值得多琢磨一会儿:这个失败不需要任何异常输入、不需要手势、不需要边缘情况——只需要一支笔、一次拖动,以及一个忘了退出手势的表面。

3. 「哪个输入设备最后动过」这个问题,靠事件顺序回答不了
#

假设你想知道当前在指点的到底是笔还是鼠标。最直觉的做法——两条事件流都记下来,比较时间戳或者一个序号,谁新谁赢——是行不通的。

两个引擎都会给笔的每个 pointermove 镜像出一个坐标相同、晚一步的兼容 mousemove。统计没有按键按下时的悬停移动:

Chromium 99   pointer = 1294   mouse = 1294
Chrome 151    pointer =   82   mouse =   82

完全相等,因为其中一条就是另一条的回声。于是鼠标那条流里最新的事件永远比笔的更新,「谁最后动谁赢」的比较每一次都会得出「鼠标刚刚动过」的结论——包括在只有笔被碰过的时候。你为笔写的那个分支永远不会执行。

这不是 bug。兼容鼠标事件是规范里写明的行为。bug 在于把它们当成「存在一个独立鼠标」的证据。

修法。 去问 pointerType,而不是问时钟:

let lastPointer = null;
document.addEventListener('pointermove', e => {
  lastPointer = { x: e.clientX, y: e.clientY, type: e.pointerType };
}, true);
// pointerover 也值得一并处理:笔以悬停方式进入时,
// 它会先报告 pointerover,之后才报告任何 move

const penIsPointing = () => lastPointer && lastPointer.type === 'pen';

两条流之间不需要做任何记账,因为移动鼠标本身也会发出一个 pointerType: "mouse"pointermove——鼠标一动,lastPointer 就不再是笔了。这个问题自己回答了自己。

4. 悬停时,光标确实跟着笔走
#

流传的说法是:数位板驱动只有在笔落到板面上时才会移动 Windows 光标,所以笔在悬停期间,光标就停在鼠标上次被丢下的地方不动。这个说法我在好几个地方读到过,实测之前自己也照着它写过代码。

在这套硬件上,这个说法就是错的,两个引擎都一样。把每个指针移动和 50 ms 之内回显它的那个鼠标移动配对起来:

Chromium 99    n = 1294    max |dx| = 0    max |dy| = 0
Chrome 151     n =   82    max |dx| = 0    max |dy| = 0

偏差是零,不是「很小」。光标精确地跟着笔走,悬停时也一样。

这让我白做了一轮工作:我为一个根本不存在的偏差写了修复,而真正的缺陷在别处。如果你的症状是「它在笔下面的错误位置上生效」,先把这两个位置测出来,再去假设它们不一样——答案会改变你正在追的是哪个 bug。

5. maxTouchPoints 不是笔能力的信号
#

navigator.maxTouchPoints === 0

两个引擎上都是这个值,而这台机器上的笔会报告 pointerType: "pen",压力 0 – 0.57,倾角 −26 – 27 度,并且在一次会话里产生了上千个悬停移动。一个正常工作的数位设备,在能力检测面前是隐形的。改成特性检测 window.PointerEvent,然后在事件发生时按 pointerType 分支。

顺带一提:厂商针对这类问题的排查建议,惯例是「把 Windows Ink 关掉」。而在 Ink 开启的情况下,pointerType 正确地是 "pen",压力和倾角都有值,上面这些也都正常工作。关掉 Ink 会让笔以鼠标的身份上报,而这恰恰就是让你再也分不出两者的那件事。


还有一条没能复现
#

我差点把这条当成 Chromium 的普遍行为发出去。它不是。

嵌入在 CEP 里的 Chromium 99 上,笔悬停在位于 (103,391) 的一个控件上——系统光标也在同一个点上,见第 4 条——每一格滚轮到达时都带着这样的坐标:

clientX/clientY = (1, 464)      and in a second run, (0, 385)

x 被钉在视口的左边缘,y 则四处游走。elementFromPoint 解析出来的是一个离那个控件十万八千里的容器,于是这个功能悄无声息地什么都没做——不是作用在错误的控件上,而是什么都没发生,这要难调试得多,因为它看起来就像你的处理函数根本没被调用过。

Chrome 151 上,同一块数位板、同一台机器、同一次会话:五格笔悬停滚轮,每一格都落在离笔 1 px 以内,而且在滚轮位置上调用 elementFromPoint,每次返回的元素都和在笔位置上返回的相同。这条不复现。

所以这条是那个老的嵌入式引擎特有的,不是该拿去给 Chromium 提的东西。比较靠谱的方向是:Win32 上的 WM_MOUSEWHEEL 与其他所有鼠标消息不同,携带的是屏幕坐标而不是客户区坐标,而那个引擎链路里的某个环节把它们当成了另一种坐标空间;其他框架也踩过同一类 bug(GodotDirectXTK)。我没有去确认。

不管怎样都值得防一手,如果你的东西要跑在一个你控制不了的嵌入式引擎里。防守手段就是第 3 条里那个 lastPointer,在没有这个 bug 的引擎上它不花任何代价,因为在那里两个位置本来就是一致的:

function wheelPoint(e) {
  if (lastPointer && lastPointer.type === 'pen') {
    return { x: lastPointer.x, y: lastPointer.y };
  }
  return { x: e.clientX, y: e.clientY };
}

怎么自己测一遍
#

别直接跳到修复。每个事件记一行日志,然后去读这份日志:

  • 每一个指针事件和鼠标事件:类型、pointerTypepointerIdisPrimarypressuretiltX/tiltY、坐标、目标元素,以及一个标记「该事件是不是你自己合成的」的标志位。
  • 每一个滚轮事件deltaYdeltaModewheelDeltaY、事件自带的坐标——以及你的处理函数最终决定采用的坐标,和那个坐标解析出来的元素。

最后这部分能把「笔的位置压根没被用上」和「用上了但解析结果还是错的」区分开。光看事件本身分不出是哪一种,而这两种的修法不一样。

在整份日志上汇总三个量,因为它们回答的正是你真正想问的问题:

  • 按设备 id 分组的悬停移动计数——驱动到底报不报悬停。
  • 指针位置与鼠标位置在时间上配对之后的最大偏差——这两条流到底有没有分歧,还是你自己假设出来的。
  • 按「当时是否有按键按下」拆分的计数——这才是暴露第 1 条的东西,而它在总数里是看不见的。

我在搭这套测试工具时掉进去的两个坑,两个都给出了自信满满的错误数字:

把计数器的作用范围限制在一笔之内。 我的第一版用一个 penDown 标志给鼠标移动分桶,这个标志在 pointerdown 时置位、在 pointerup 时清除。结果有一笔是以 pointercancel 结束的,标志就一直卡在置位状态,之后每一个悬停移动都被算成了拖动——报出了一次拖动期间有 107 个兼容事件,而那次拖动实际上一个都没有。在 pointerdown 时开启计数器,在 pointerup pointercancel 时关闭它,并且根据事件自身的 e.buttons 来分桶,而不是根据任何你自己维护的标志。

环形缓冲区要按最吵的那个事件来定容量。 我那个只保留最后 600 行,几秒钟的滚动就把所有指针事件的行挤掉了,我还没来得及读。累计计数要单独存;原始行滚走之后,它们仍然是对的。