Skip to main content
  1. Posts/

What a tablet actually sends

Author
lxapgjryzc

Notes from debugging pen input in a browser UI. Five behaviours that broke assumptions I held, or that I have seen repeated as fact.

Everything below was measured twice, on two engines fifty-two versions and four years apart. That turned out to matter: one of the five does not reproduce on the current one, and I would have published it as a general truth if I had not checked.

The two setups
#

AB
EngineChromium 99, embedded (Adobe CEP 12)Chrome 151, standalone
TabletWacom Intuos Pro L1, Bluetooth, WTabletServicePro, Windows Ink onsame
OSWindows 11 Pro 26200same
PagedevicePixelRatio 1, single monitorsame

Same tablet, same driver, same machine, same session. Only the engine differs.

BehaviourA (Chromium 99)B (Chrome 151)
1Compatibility mouse events stop during a pen dragyes — 49 / 0yes — 1034 / 2
2A pen drag can be cancelled out from under you(prevented by CSS)yes — every stroke, under 0.5 s
3Hover mouse events echo the pen exactly, 1:1yes — 1294 / 1294yes — 82 / 82
4The cursor does follow the pen while hoveringyes — max Δ 0 pxyes — max Δ 0 px
5maxTouchPoints is 0 with a working penyesyes
Wheel coordinates land nowhere near the penyes — ~700 px offno — 1 px

That last row is the one to be careful with, and it comes last for that reason.


1. Compatibility mouse events stop during a pen drag
#

Split one session by whether a button was held:

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)

Also zero mousedown and zero mouseup for the press, on both engines. So a UI built on DOM mouse events sees a stroke that never starts, or starts and never moves — while the pointer stream is complete and correct throughout. The hovering rows are the control: while the pen hovers, the echo is perfect. It is specifically the press that turns it off.

This is the one most likely to bite you today, and it is engine-current — not a legacy quirk.

Fix, without rewriting an existing mouse-driven UI onto pointer events. Each pointer event issues a ticket; the compatibility event the browser normally sends immediately afterwards redeems it; a ticket still unredeemed one task later is an event the browser never sent, and only those get synthesised:

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);   // the browser never sent it
  }, 0);
}

function redeem(kind) {
  const q = pending[kind];
  if (q.length) { q.shift().redeemed = true; return true; }
  return false;    // no ticket outstanding: a genuine, independent mouse event
}

Mark synthesised events (e.mySynthetic = true) and skip them on the way back in, or they redeem their own tickets. When the browser behaves normally the mechanism is completely inert — every ticket is redeemed, nothing is synthesised, and there is no double handling to reason about.

2. A pen drag can be cancelled out from under you
#

The Chrome 151 stroke above did not end with the pen lifting. It ended like this:

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

A quarter of a second in, the browser took the drag away as a pan gesture and fired pointercancel. My test page set no touch-action.

That is the documented cause, so I ran it as a controlled A/B: six identical strips on one page, differing in one CSS declaration. Two long strokes on each condition, same pen, same session:

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

Not a marginal effect. Under the default the drag never survived half a second; with touch-action: none both strokes ran until the pen lifted, seventy times longer, and delivered eighty-five times the events.

Two consequences worth handling:

/* opt the draggable surfaces out of the gesture */
.canvas, .slider, .picker { touch-action: none; }
/* a cancelled pointer never produces a mouse-up, so a drag handler stays
   armed and the next stray move keeps painting */
el.addEventListener('pointercancel', e => endDragAsIfMouseUp(e));

/* and take the capture on press, so leaving the element does not end the drag */
el.addEventListener('pointerdown', e => el.setPointerCapture(e.pointerId));

This is the “the slider dies half-way through a stroke” bug. It reproduced spontaneously on a page that was not even trying to trigger it, which is worth sitting with: the failure needs no unusual input, no gesture, no edge case — only a pen, a drag, and a surface that forgot to opt out.

3. “Which input moved last” cannot be answered by event order
#

Suppose you want to know whether the pen or the mouse is the thing currently pointing. The obvious approach — remember both streams, compare timestamps or a sequence counter, most recent wins — cannot work.

Both engines mirror each pen pointermove with a compatibility mousemove at the same coordinates, one step later. Counting hover moves with no button held:

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

Exactly equal, because each is the other’s echo. So the mouse stream’s newest event is always newer than the pen’s, and a last-one-wins comparison concludes “the mouse moved last” every single time — including while the pen is the only thing being touched. The branch you wrote for the pen never runs.

This is not a bug. Compatibility mouse events are specified behaviour. The bug is reading them as evidence of an independent mouse.

Fix. Ask pointerType, not the clock:

let lastPointer = null;
document.addEventListener('pointermove', e => {
  lastPointer = { x: e.clientX, y: e.clientY, type: e.pointerType };
}, true);
// worth handling pointerover too: a pen entering in hover reports it
// before it reports any move

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

No bookkeeping between streams is needed, because moving the mouse emits a pointermove of its own with pointerType: "mouse"lastPointer stops being a pen the moment the mouse moves. The question answers itself.

4. The cursor does follow the pen while hovering
#

The received wisdom — I have read it in several places, and built on it myself before measuring — is that tablet drivers only move the Windows cursor when the pen touches down, so while the pen hovers the cursor stays parked wherever the mouse was last left.

On this hardware it is simply false, on both engines. Pairing every pointer move with the mouse move that echoed it, inside 50 ms:

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

Zero divergence, not “small”. The cursor tracks the pen exactly, hover included.

This cost me a round of work: I had written a fix for a divergence that does not occur, and the real defect was elsewhere. If your symptom is “it acts on the wrong place under the pen”, measure the two positions before assuming they differ — the answer changes which bug you are chasing.

5. maxTouchPoints is not a pen-capability signal
#

navigator.maxTouchPoints === 0

on both engines, on a machine where the pen reports pointerType: "pen" with pressure 0 – 0.57 and tilt −26 – 27 degrees, and produced a thousand hover moves in one session. A working digitiser, invisible to the capability check. Feature-detect window.PointerEvent and branch on pointerType at event time instead.

Related: vendor troubleshooting advice for this class of problem is routinely “turn Windows Ink off”. With Ink on, pointerType was correctly "pen", pressure and tilt were populated, and everything above worked. Turning Ink off makes the pen report as a mouse, which is precisely what removes your ability to tell the two apart.


And one that did not reproduce
#

I nearly published this as a general Chromium behaviour. It is not one.

On Chromium 99 embedded in CEP, with the pen hovering a control at (103,391) — and the system cursor at that same point, see finding 4 — every wheel notch arrived carrying:

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

The x pinned to the viewport’s left edge, the y wandering. elementFromPoint resolved to a container nowhere near the control, so the feature silently did nothing — not the wrong control, nothing, which is much harder to debug because it looks like your handler never ran.

On Chrome 151, same tablet, same machine, same session: five pen-hover notches, every one of them landing within 1 px of the pen, and elementFromPoint at the wheel position returning the same element as at the pen position every time. It does not reproduce.

So this is specific to that old embedded engine, not something to file against Chromium. The plausible neighbourhood is that WM_MOUSEWHEEL on Win32 carries screen coordinates rather than client coordinates, unlike every other mouse message, and something in that engine’s chain treated them as the wrong space; other frameworks have hit the same class of bug (Godot, DirectXTK). I did not confirm it.

Worth defending against anyway, if you ship into an embedded engine you do not control. The defence is the same lastPointer from finding 3, and it costs nothing on engines without the bug, because there the two positions agree:

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

How to measure this yourself
#

Do not skip to a fix. Log one line per event and read the log:

  • Every pointer and mouse event: type, pointerType, pointerId, isPrimary, pressure, tiltX/tiltY, coordinates, target, and a flag for events you synthesised.
  • Every wheel event: deltaY, deltaMode, wheelDeltaY, the event’s own coordinates — and then what your handler decided to use, and what that resolved to.

That last part separates “the pen position was never used” from “it was used and still resolved wrong”. The event alone cannot tell you which, and they have different fixes.

Three things to summarise across the log, because they answer the questions you actually have:

  • Hover move counts per device id — does the driver report hover at all.
  • Maximum divergence between the pointer and mouse positions, paired in time — do the two streams actually disagree, or did you assume it.
  • Counts split by whether a button was held — this is what exposes finding 1, and it is invisible in a total.

Two traps I fell into while building the harness, both of which produced confident wrong numbers:

Scope your counters to one stroke. My first version bucketed mouse moves using a penDown flag set on pointerdown and cleared on pointerup. A stroke ended in pointercancel instead, the flag stuck on, and every subsequent hover move was counted as a drag — reporting 107 compatibility events during a drag that actually had none. Open the counter on pointerdown, close it on pointerup and pointercancel, and bucket from e.buttons on the event itself rather than from any flag you maintain.

Size a ring buffer for the noisiest event. Mine kept the last 600 rows, and a few seconds of scrolling evicted every pointer row before I could read them. Keep cumulative counters separately; they stay correct after the raw rows roll off.