[{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/tags/browser/","section":"Tags","summary":"","title":"Browser","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/tags/chromium/","section":"Tags","summary":"","title":"Chromium","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/tags/debugging/","section":"Tags","summary":"","title":"Debugging","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/tags/input/","section":"Tags","summary":"","title":"Input","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/categories/notes/","section":"Categories","summary":"","title":"Notes","type":"categories"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/tags/pointer-events/","section":"Tags","summary":"","title":"Pointer-Events","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"欢迎。这里放一些笔记、折腾记录，以及随手写下的东西。\n","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/","section":"你好 👋","summary":"欢迎。这里放一些笔记、折腾记录，以及随手写下的东西。\n","title":"你好 👋","type":"page"},{"content":"在浏览器 UI 里调试数位笔输入时记下的笔记。五个打破了我原有假设、或者我见过被当作事实反复引用的行为。\n下面每一条都测了两遍，跑在相差 52 个版本、4 年的两个引擎上。这一点后来很重要：五条里有一条在较新的那个引擎上并不复现，如果我没去核对，我就会把它当成普遍规律发出来了。\n两套环境 # A B 引擎 Chromium 99，嵌入式（Adobe CEP 12） Chrome 151，独立浏览器 数位板 Wacom Intuos Pro L1，蓝牙，WTabletServicePro，Windows Ink 开启 同上 系统 Windows 11 Pro 26200 同上 页面 devicePixelRatio 1，单显示器 同上 同一块数位板，同一个驱动，同一台机器，同一次会话。只有引擎不同。\n行为 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 最后一行是最需要小心的一条，它排在最后正是因为这个。\n1. 笔按下拖动期间，兼容鼠标事件会停止 # 把一次会话按「当时是否有按键按下」拆开统计：\nChromium 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，看到的是一笔永远没有开始、或者开始了却从不移动的笔画——而与此同时，指针事件流自始至终都是完整且正确的。悬停那两行是对照组：笔在悬停时，回显是完美的。恰恰是「按下」这个动作把它关掉了。\n这是今天最可能坑到你的一条，而且它就发生在当前版本的引擎上——不是什么遗留怪癖。\n修法，不需要把现有基于鼠标事件的 UI 重写到指针事件上。每个指针事件发出一张票；浏览器通常紧随其后发出的那个兼容事件会把这张票兑走；一个任务之后仍未被兑走的票，说明这个事件浏览器根本没发，只有这些才需要合成：\nconst pending = { down: [], move: [], up: [] }; function backfill(kind, source) { const ticket = { redeemed: false }; pending[kind].push(ticket); setTimeout(() =\u0026gt; { const i = pending[kind].indexOf(ticket); if (i \u0026gt;= 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），在它们回流进来时跳过，否则它们会把自己的票兑掉。浏览器行为正常时，这套机制完全是惰性的——每张票都被兑走，什么都不会被合成，也就没有重复处理需要你去操心。\n2. 一次笔的拖动会被从你手里取消掉 # 上面 Chrome 151 那一笔，并不是以抬笔结束的。它是这样结束的：\nstroke [pen id=2] 248 ms ended by pointercancel pointermove (buttons held) = 7 mousemove = 0 mousedown = 0 mouseup = 0 开始四分之一秒之后，浏览器把这次拖动当成平移手势收走了，并抛出 pointercancel。我的测试页面没有设置任何 touch-action。\n这是文档里写明的成因，所以我把它做成了一次对照实验：同一个页面上六条一模一样的长条，只差一条 CSS 声明。每种条件下画两笔长笔画，同一支笔，同一次会话：\ntouch-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 之后，两笔都一直画到抬笔为止，时长是前者的七十倍，事件数是前者的八十五倍。\n有两个后果值得处理：\n/* 让可拖动的表面从手势里退出来 */ .canvas, .slider, .picker { touch-action: none; } /* 被取消的指针不会产生 mouse-up，于是拖动处理器一直处于待发状态， 下一个游离的移动事件会让它继续画 */ el.addEventListener(\u0026#39;pointercancel\u0026#39;, e =\u0026gt; endDragAsIfMouseUp(e)); /* 并且在按下时接管指针捕获，这样指针离开元素也不会结束拖动 */ el.addEventListener(\u0026#39;pointerdown\u0026#39;, e =\u0026gt; el.setPointerCapture(e.pointerId)); 这就是那个「滑块画到一半就死了」的 bug。它是在一个根本没打算触发它的页面上自发复现的，这一点值得多琢磨一会儿：这个失败不需要任何异常输入、不需要手势、不需要边缘情况——只需要一支笔、一次拖动，以及一个忘了退出手势的表面。\n3. 「哪个输入设备最后动过」这个问题，靠事件顺序回答不了 # 假设你想知道当前在指点的到底是笔还是鼠标。最直觉的做法——两条事件流都记下来，比较时间戳或者一个序号，谁新谁赢——是行不通的。\n两个引擎都会给笔的每个 pointermove 镜像出一个坐标相同、晚一步的兼容 mousemove。统计没有按键按下时的悬停移动：\nChromium 99 pointer = 1294 mouse = 1294 Chrome 151 pointer = 82 mouse = 82 完全相等，因为其中一条就是另一条的回声。于是鼠标那条流里最新的事件永远比笔的更新，「谁最后动谁赢」的比较每一次都会得出「鼠标刚刚动过」的结论——包括在只有笔被碰过的时候。你为笔写的那个分支永远不会执行。\n这不是 bug。兼容鼠标事件是规范里写明的行为。bug 在于把它们当成「存在一个独立鼠标」的证据。\n修法。 去问 pointerType，而不是问时钟：\nlet lastPointer = null; document.addEventListener(\u0026#39;pointermove\u0026#39;, e =\u0026gt; { lastPointer = { x: e.clientX, y: e.clientY, type: e.pointerType }; }, true); // pointerover 也值得一并处理：笔以悬停方式进入时， // 它会先报告 pointerover，之后才报告任何 move const penIsPointing = () =\u0026gt; lastPointer \u0026amp;\u0026amp; lastPointer.type === \u0026#39;pen\u0026#39;; 两条流之间不需要做任何记账，因为移动鼠标本身也会发出一个 pointerType: \u0026quot;mouse\u0026quot; 的 pointermove——鼠标一动，lastPointer 就不再是笔了。这个问题自己回答了自己。\n4. 悬停时，光标确实跟着笔走 # 流传的说法是：数位板驱动只有在笔落到板面上时才会移动 Windows 光标，所以笔在悬停期间，光标就停在鼠标上次被丢下的地方不动。这个说法我在好几个地方读到过，实测之前自己也照着它写过代码。\n在这套硬件上，这个说法就是错的，两个引擎都一样。把每个指针移动和 50 ms 之内回显它的那个鼠标移动配对起来：\nChromium 99 n = 1294 max |dx| = 0 max |dy| = 0 Chrome 151 n = 82 max |dx| = 0 max |dy| = 0 偏差是零，不是「很小」。光标精确地跟着笔走，悬停时也一样。\n这让我白做了一轮工作：我为一个根本不存在的偏差写了修复，而真正的缺陷在别处。如果你的症状是「它在笔下面的错误位置上生效」，先把这两个位置测出来，再去假设它们不一样——答案会改变你正在追的是哪个 bug。\n5. maxTouchPoints 不是笔能力的信号 # navigator.maxTouchPoints === 0 两个引擎上都是这个值，而这台机器上的笔会报告 pointerType: \u0026quot;pen\u0026quot;，压力 0 – 0.57，倾角 −26 – 27 度，并且在一次会话里产生了上千个悬停移动。一个正常工作的数位设备，在能力检测面前是隐形的。改成特性检测 window.PointerEvent，然后在事件发生时按 pointerType 分支。\n顺带一提：厂商针对这类问题的排查建议，惯例是「把 Windows Ink 关掉」。而在 Ink 开启的情况下，pointerType 正确地是 \u0026quot;pen\u0026quot;，压力和倾角都有值，上面这些也都正常工作。关掉 Ink 会让笔以鼠标的身份上报，而这恰恰就是让你再也分不出两者的那件事。\n还有一条没能复现 # 我差点把这条当成 Chromium 的普遍行为发出去。它不是。\n在嵌入在 CEP 里的 Chromium 99 上，笔悬停在位于 (103,391) 的一个控件上——系统光标也在同一个点上，见第 4 条——每一格滚轮到达时都带着这样的坐标：\nclientX/clientY = (1, 464) and in a second run, (0, 385) x 被钉在视口的左边缘，y 则四处游走。elementFromPoint 解析出来的是一个离那个控件十万八千里的容器，于是这个功能悄无声息地什么都没做——不是作用在错误的控件上，而是什么都没发生，这要难调试得多，因为它看起来就像你的处理函数根本没被调用过。\n在 Chrome 151 上，同一块数位板、同一台机器、同一次会话：五格笔悬停滚轮，每一格都落在离笔 1 px 以内，而且在滚轮位置上调用 elementFromPoint，每次返回的元素都和在笔位置上返回的相同。这条不复现。\n所以这条是那个老的嵌入式引擎特有的，不是该拿去给 Chromium 提的东西。比较靠谱的方向是：Win32 上的 WM_MOUSEWHEEL 与其他所有鼠标消息不同，携带的是屏幕坐标而不是客户区坐标，而那个引擎链路里的某个环节把它们当成了另一种坐标空间；其他框架也踩过同一类 bug（Godot、DirectXTK）。我没有去确认。\n不管怎样都值得防一手，如果你的东西要跑在一个你控制不了的嵌入式引擎里。防守手段就是第 3 条里那个 lastPointer，在没有这个 bug 的引擎上它不花任何代价，因为在那里两个位置本来就是一致的：\nfunction wheelPoint(e) { if (lastPointer \u0026amp;\u0026amp; lastPointer.type === \u0026#39;pen\u0026#39;) { return { x: lastPointer.x, y: lastPointer.y }; } return { x: e.clientX, y: e.clientY }; } 怎么自己测一遍 # 别直接跳到修复。每个事件记一行日志，然后去读这份日志：\n每一个指针事件和鼠标事件：类型、pointerType、pointerId、isPrimary、pressure、tiltX/tiltY、坐标、目标元素，以及一个标记「该事件是不是你自己合成的」的标志位。 每一个滚轮事件：deltaY、deltaMode、wheelDeltaY、事件自带的坐标——以及你的处理函数最终决定采用的坐标，和那个坐标解析出来的元素。 最后这部分能把「笔的位置压根没被用上」和「用上了但解析结果还是错的」区分开。光看事件本身分不出是哪一种，而这两种的修法不一样。\n在整份日志上汇总三个量，因为它们回答的正是你真正想问的问题：\n按设备 id 分组的悬停移动计数——驱动到底报不报悬停。 指针位置与鼠标位置在时间上配对之后的最大偏差——这两条流到底有没有分歧，还是你自己假设出来的。 按「当时是否有按键按下」拆分的计数——这才是暴露第 1 条的东西，而它在总数里是看不见的。 我在搭这套测试工具时掉进去的两个坑，两个都给出了自信满满的错误数字：\n把计数器的作用范围限制在一笔之内。 我的第一版用一个 penDown 标志给鼠标移动分桶，这个标志在 pointerdown 时置位、在 pointerup 时清除。结果有一笔是以 pointercancel 结束的，标志就一直卡在置位状态，之后每一个悬停移动都被算成了拖动——报出了一次拖动期间有 107 个兼容事件，而那次拖动实际上一个都没有。在 pointerdown 时开启计数器，在 pointerup 和 pointercancel 时关闭它，并且根据事件自身的 e.buttons 来分桶，而不是根据任何你自己维护的标志。\n环形缓冲区要按最吵的那个事件来定容量。 我那个只保留最后 600 行，几秒钟的滚动就把所有指针事件的行挤掉了，我还没来得及读。累计计数要单独存；原始行滚走之后，它们仍然是对的。\n","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/posts/what-a-tablet-actually-sends/","section":"文章","summary":"五个打破常见假设的数位笔输入行为，每一条都在相隔四年的两个引擎上测了两遍。其中有一条在较新的引擎上并不复现——这恰恰是重点。","title":"数位板到底发送了什么","type":"posts"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/posts/","section":"文章","summary":"","title":"文章","type":"posts"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/categories/log/","section":"Categories","summary":"","title":"Log","type":"categories"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/tags/meta/","section":"Tags","summary":"","title":"Meta","type":"tags"},{"content":"这个博客跑在 Hugo + GitHub Pages 上，每篇文章都有英文、中文、日文三个版本。整套流程是这样的：\n用 Markdown 写文章，放在 content/posts/ 目录下 git push 推到 GitHub GitHub Actions 自动跑 hugo --minify 构建产物发布到 GitHub Pages 从写完保存到线上可见，大概一分钟。\n写新文章 # 每篇文章是三个文件，共用同一个文件名，在扩展名前面加语言代码：\ncontent/posts/some-title.en.md content/posts/some-title.zh.md content/posts/some-title.ja.md Hugo 会自动把它们关联起来，所以顶部的语言切换器是在同一篇文章的不同版本之间跳转，而不是把你甩回首页。\n代码块支持语法高亮：\ndef greet(name: str) -\u0026gt; str: return f\u0026#34;Hello, {name}!\u0026#34; 表格、引用、图片这些常规 Markdown 语法都能用。\n图片放在 static/ 目录下，在文章里写 /图片名.png 引用。\n本地预览 # hugo server -D 起本地服务，带热更新，改文件就能实时看到效果。确认没问题再推。\n","date":"2026年8月22日","externalUrl":null,"permalink":"/zh/posts/hello-world/","section":"文章","summary":"这个博客跑在 Hugo + GitHub Pages 上，每篇文章都有英文、中文、日文三个版本。整套流程是这样的：\n用 Markdown 写文章，放在 content/posts/ 目录下 git push 推到 GitHub GitHub Actions 自动跑 hugo --minify 构建产物发布到 GitHub Pages 从写完保存到线上可见，大概一分钟。\n","title":"第一篇：博客上线了","type":"posts"}]