在浏览器 UI 里调试数位笔输入时记下的笔记。五个打破了我原有假设、或者我见过被当作事实反复引用的行为。
下面每一条都测了两遍,跑在相差 52 个版本、4 年的两个引擎上。这一点后来很重要:五条里有一条在较新的那个引擎上并不复现,如果我没去核对,我就会把它当成普遍规律发出来了。
两套环境#
| A | B | |
|---|---|---|
| 引擎 | 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(Godot、DirectXTK)。我没有去确认。
不管怎样都值得防一手,如果你的东西要跑在一个你控制不了的嵌入式引擎里。防守手段就是第 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 };
}怎么自己测一遍#
别直接跳到修复。每个事件记一行日志,然后去读这份日志:
- 每一个指针事件和鼠标事件:类型、
pointerType、pointerId、isPrimary、pressure、tiltX/tiltY、坐标、目标元素,以及一个标记「该事件是不是你自己合成的」的标志位。 - 每一个滚轮事件:
deltaY、deltaMode、wheelDeltaY、事件自带的坐标——以及你的处理函数最终决定采用的坐标,和那个坐标解析出来的元素。
最后这部分能把「笔的位置压根没被用上」和「用上了但解析结果还是错的」区分开。光看事件本身分不出是哪一种,而这两种的修法不一样。
在整份日志上汇总三个量,因为它们回答的正是你真正想问的问题:
- 按设备 id 分组的悬停移动计数——驱动到底报不报悬停。
- 指针位置与鼠标位置在时间上配对之后的最大偏差——这两条流到底有没有分歧,还是你自己假设出来的。
- 按「当时是否有按键按下」拆分的计数——这才是暴露第 1 条的东西,而它在总数里是看不见的。
我在搭这套测试工具时掉进去的两个坑,两个都给出了自信满满的错误数字:
把计数器的作用范围限制在一笔之内。 我的第一版用一个 penDown 标志给鼠标移动分桶,这个标志在 pointerdown 时置位、在 pointerup 时清除。结果有一笔是以 pointercancel 结束的,标志就一直卡在置位状态,之后每一个悬停移动都被算成了拖动——报出了一次拖动期间有 107 个兼容事件,而那次拖动实际上一个都没有。在 pointerdown 时开启计数器,在 pointerup 和 pointercancel 时关闭它,并且根据事件自身的 e.buttons 来分桶,而不是根据任何你自己维护的标志。
环形缓冲区要按最吵的那个事件来定容量。 我那个只保留最后 600 行,几秒钟的滚动就把所有指针事件的行挤掉了,我还没来得及读。累计计数要单独存;原始行滚走之后,它们仍然是对的。