ブラウザ UI でペン入力をデバッグしたときのメモ。自分が持っていた思い込みを壊した、あるいは事実として繰り返し語られているのを見かけた、5 つの挙動。
以下はすべて 2 回測定している。52 バージョン、4 年離れた 2 つのエンジンの上で。これが結果的に効いてきた。5 つのうち 1 つは新しいほうでは再現せず、確かめていなければ一般的な事実として公開してしまうところだった。
2 つの環境#
| A | B | |
|---|---|---|
| エンジン | Chromium 99、埋め込み(Adobe CEP 12) | Chrome 151、単体 |
| タブレット | Wacom Intuos Pro L1、Bluetooth、WTabletServicePro、Windows Ink オン | 同じ |
| OS | 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. ペンのドラッグ中は互換マウスイベントが止まる#
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 が見るのは、決して始まらないストローク、あるいは始まったのに一度も動かないストロークになる——その間ずっと、ポインターのストリームのほうは完全かつ正確に流れているのに。ホバーの行が対照群だ。ペンがホバーしている間、反響は完璧に一致する。それを止めているのは、ほかでもない「押し下げ」である。
これは今日いちばん刺さりやすい 1 つで、しかも現行のエンジンで起きている——過去の遺物ではない。
対処法。既存のマウス駆動の UI をポインターイベントへ書き換えずに済ませる。ポインターイベントごとに整理券を 1 枚発行する。ブラウザが通常その直後に送る互換イベントが、その券を引き換える。1 タスク後になっても引き換えられていない券は、ブラウザが結局送らなかったイベントであり、合成するのはそれだけでいい:
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開始から 4 分の 1 秒後、ブラウザがそのドラッグをパンのジェスチャーとして取り上げ、pointercancel を発火させた。テストページには touch-action を一切指定していなかった。
これはドキュメントに書かれている原因なので、対照実験として走らせた。同じページ上に同一のストリップを 6 本並べ、違いは CSS 宣言 1 つだけ。各条件で長いストロークを 2 回、同じペン、同じセッションで:
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 を付けると、どちらのストロークもペンを持ち上げるまで生き延び、時間にして 70 倍、イベント数にして 85 倍になった。
対処しておく価値のある帰結が 2 つある:
/* ドラッグ可能な面をジェスチャーの対象から外す */
.canvas, .slider, .picker { touch-action: none; }/* キャンセルされたポインターは mouse-up を生まないので、ドラッグ処理は構えたまま残り、
次に紛れ込んだ移動イベントで描き続けてしまう */
el.addEventListener('pointercancel', e => endDragAsIfMouseUp(e));
/* さらに押し下げ時にキャプチャを取り、要素の外へ出てもドラッグが終わらないようにする */
el.addEventListener('pointerdown', e => el.setPointerCapture(e.pointerId));これが「スライダーがストロークの途中で死ぬ」バグだ。しかもこれは、それを起こそうとすらしていないページの上で自然に再現した。ここは少し立ち止まる価値がある。この失敗には、変わった入力もジェスチャーもエッジケースも要らない——必要なのはペンと、ドラッグと、ジェスチャーから抜けるのを忘れた面だけだ。
3. 「最後に動いた入力はどちらか」は、イベントの順序では答えられない#
いま指しているのがペンなのかマウスなのかを知りたいとする。真っ先に思いつくやり方——両方のストリームを覚えておき、タイムスタンプか連番を比べて、新しいほうを勝ちにする——は、うまくいかない。
どちらのエンジンも、ペンの pointermove 1 つ 1 つを、同じ座標で 1 ステップ遅れの互換 mousemove として鏡写しにする。ボタンを押していない状態のホバー移動を数えると:
Chromium 99 pointer = 1294 mouse = 1294
Chrome 151 pointer = 82 mouse = 82ぴったり同数だ。片方はもう片方の反響そのものだからである。したがってマウス側のストリームの最新イベントは常にペン側より新しく、「最後に来たほうが勝ち」の比較は毎回「マウスが最後に動いた」と結論する——触れているのがペンだけのときでさえそうなる。ペン用に書いた分岐は一度も走らない。
これはバグではない。互換マウスイベントは仕様どおりの挙動だ。バグは、それを「独立したマウスが存在する証拠」として読むほうにある。
対処法。 時計ではなく pointerType に尋ねる:
let lastPointer = null;
document.addEventListener('pointermove', e => {
lastPointer = { x: e.clientX, y: e.clientY, type: e.pointerType };
}, true);
// pointerover も拾っておくとよい:ペンがホバーで入ってくるときは、
// move より先に pointerover で報告される
const penIsPointing = () => lastPointer && lastPointer.type === 'pen';2 つのストリーム間の帳簿付けは要らない。マウスを動かせばそれ自体が 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ずれは「小さい」ではなくゼロ。カーソルはホバーも含めて、ペンを正確に追いかけている。
これで丸ひと仕事を無駄にした。起きてもいないずれに対する修正を書いてしまい、本当の欠陥は別の場所にあった。症状が「ペンの下の、間違った場所に作用する」であれば、ずれていると仮定する前に 2 つの位置を測ること——その答えによって、追うべきバグが変わる。
5. maxTouchPoints はペンの能力を示す信号ではない#
navigator.maxTouchPoints === 0両方のエンジンでこの値だった。しかもこのマシンのペンは pointerType: "pen" を報告し、筆圧 0 – 0.57、傾き −26 – 27 度を返し、1 セッションで千を超えるホバー移動を発生させている。正常に動作しているデジタイザーが、能力チェックからは見えない。代わりに 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 では、同じタブレット、同じマシン、同じセッションで、ペンホバー中のノッチ 5 回、そのすべてがペンから 1 px 以内に着地し、ホイール位置での elementFromPoint は毎回ペン位置と同じ要素を返した。再現しない。
つまりこれは、あの古い埋め込みエンジンに固有のものであって、Chromium に対して起票すべきものではない。ありそうな見当としては、Win32 の WM_MOUSEWHEEL が他のすべてのマウスメッセージと違ってクライアント座標ではなくスクリーン座標を運ぶこと、そしてあのエンジンの経路のどこかがそれを別の座標系として扱ったこと。他のフレームワークも同種のバグを踏んでいる(Godot、DirectXTK)。確認は取っていない。
それでも防いでおく価値はある。自分で制御できない埋め込みエンジンに載せて出荷するのなら。防ぎ方は所見 3 と同じ lastPointer で、このバグのないエンジンでは何のコストにもならない。そこでは 2 つの位置が一致するからだ:
function wheelPoint(e) {
if (lastPointer && lastPointer.type === 'pen') {
return { x: lastPointer.x, y: lastPointer.y };
}
return { x: e.clientX, y: e.clientY };
}自分で測るには#
修正へ飛ばないこと。1 イベントにつき 1 行を記録し、そのログを読む:
- すべてのポインターイベントとマウスイベント:種類、
pointerType、pointerId、isPrimary、pressure、tiltX/tiltY、座標、ターゲット、そして自分が合成したイベントかどうかのフラグ。 - すべてのホイールイベント:
deltaY、deltaMode、wheelDeltaY、イベント自身の座標——さらに、ハンドラーが実際に採用した座標と、それが解決した先。
この最後の部分が、「ペンの位置がそもそも使われなかった」のか「使ったうえで解決先が間違った」のかを切り分ける。イベント単体ではどちらか分からず、この 2 つは修正が異なる。
ログ全体で集計すべきものが 3 つある。それが、実際に抱えている問いに答えるものだからだ:
- デバイス id ごとのホバー移動数——ドライバーがそもそもホバーを報告しているのか。
- 時間で対にしたポインター位置とマウス位置の最大のずれ——2 つのストリームは本当に食い違っているのか、それとも自分がそう思い込んだだけか。
- ボタンが押されていたかどうかで分けた件数——所見 1 を暴くのはこれであり、合計値の中では見えない。
計測用の仕掛けを作る過程で自分が落ちた罠が 2 つある。どちらも、自信に満ちた誤った数字を吐き出した:
カウンターの範囲を 1 ストロークに閉じること。 最初の版では、pointerdown で立てて pointerup で降ろす penDown フラグでマウス移動を仕分けていた。あるストロークが代わりに pointercancel で終わり、フラグは立ったまま固まり、以降のホバー移動がすべてドラッグとして数えられた——実際には互換イベントが 1 つもなかったドラッグについて、107 件と報告した。カウンターは pointerdown で開き、pointerup と pointercancel の両方で閉じること。そして仕分けは自分で管理するフラグではなく、イベント自身の e.buttons から行うこと。
リングバッファはいちばん騒がしいイベントに合わせて大きさを決めること。 私のものは直近 600 行しか保持しておらず、数秒のスクロールでポインターの行がすべて追い出され、読む前に消えていた。累積カウンターは別に持っておくこと。生の行が流れ去っても、そちらは正しいままだ。