Skip to content

feat(pdf): hand the annotation gesture policy to the viewer - #852

Merged
andiwand merged 1 commit into
mainfrom
feat/pdf-annotation-gesture-policy
Sep 6, 2026
Merged

feat(pdf): hand the annotation gesture policy to the viewer#852
andiwand merged 1 commit into
mainfrom
feat/pdf-annotation-gesture-policy

Conversation

@andiwand

@andiwand andiwand commented Sep 6, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

odr.annotation decided things that are the viewer's to decide, and paid for a
stroke in redraws. Three changes, all inside the emitted script and style.

The host says when to mark

Arming a text tool used to mark whatever the reader selected next. The two
flows are both legitimate and the platforms disagree about which is right —
mobile selects first and offers a toolbar over the selection, desktop arms a
tool and drags — so picking one marks text the reader only meant to select. The
automatic path also has to take the selection with it (removeAllRanges, or
the next selectionchange marks the same range again), which on a phone tears
the range out from under the native selection handles mid-adjustment.

mark() is the explicit trigger now, and leaves the selection standing.
setOptions carries the rest:

option default decides
markOnSelection false mark as the selection changes, instead of on mark()
inkPointerTypes null (any) which pointer types draw — ['pen','mouse'] is palm rejection
touchAction "none" what a touch on an armed page does; "pinch-zoom" keeps two-finger zoom
overscrollBehavior "contain" scroll chaining out of the document mid-stroke

An unknown key throws rather than being ignored. Where the rendered view sits in
a native scroll container no css we emit can reach it, so the host freezes its
own scroller — it knows when to, having armed the tool. Nothing is added to the
api for that.

The default flip is not a released break (the feature is unreleased), but app
code that only calls setTool now needs mark() too.

A stroke extends its path instead of redrawing the page

pointermove appended a point and called redraw(), which re-serialized every
pending annotation on every page. It now appends to the live path's d and
schedules one dom write per frame, taking every sample getCoalescedEvents()
buffered; the full rebuild is kept for undo/remove/clear/resize. A second
pointer can no longer extend a stroke it did not start.

Measured in the rendered view, same page, before and after:

per pointermove before after
stroke alone 0.34 ms 0.051 ms
with 15 marks already pending 4.9 ms 0.054 ms

The second row is the point: the cost no longer grows with what else is on the
page. On a phone the old figure lands around 15–25 ms against an 8 ms budget at
120 Hz. pointerup flushes synchronously, so a window whose rAF is throttled
still ends up with the whole path.

Also added, scoped to an armed page: -webkit-touch-callout /
user-select: none, so a long press cannot interrupt a stroke.

A quad is the run's box, not the range's rect

The vertical extent came from Range.getClientRects(), but the selection layer
is transparent text in whatever font the browser substituted — that rect
measured 10.5 px against the run's 18.7 px box, so the highlight was visibly
shorter than the selection the reader was looking at. It is also not
reproducible: the same pdf on two machines with different fonts installed would
write different /QuadPoints. The quad now follows the .sr run — the line the
renderer laid out from the pdf's own metrics. A partly selected run still takes
its horizontal edges from the rects, clamped to the run.

Checks

test/browser/annotation/tests.html goes from 26 to 41 checks — the explicit
trigger, the options reaching the css, a rejected pointer type, the live path
growing in the node it started in, and the quad following the run box rather
than the substituted font's rect (the harness's .sr is now an inline-block
with a taller line box, so the two geometries differ there as they do in a real
view). All pass, as do 387 odr_test cases over html.*, Pdf* and the pdf
html-output suite.

Two things a reviewer should know:

  • The emitted pdf html changed, so the pdf reference-output snapshots need
    regenerating.
    Not done here.
  • The touch behaviour is verified as computed styles in desktop Chrome, not on
    a device. touch-action is honoured by WKWebView (iOS 13+) and Android
    WebView, but the real check is a phone.

Rationale is recorded in docs/design/pdf-annotation.md as decisions 7–9.

`odr.annotation` acted on input by itself: arming a text tool marked
whatever the reader selected next, and the ink overlay's `touch-action`
was ours to declare. Both are the viewer's decision — mobile selects
first and offers a toolbar, desktop arms a tool and drags — and a
library that picks one marks text the reader only meant to select.

`mark()` is now the trigger, and `setOptions` carries the rest:
`markOnSelection` opts back into the automatic path, `inkPointerTypes`
narrows which pointers draw, `touchAction` and `overscrollBehavior` say
what a touch on an armed page does. Nothing is marked unless asked.

A stroke now extends its own path and writes it once per frame, taking
what `getCoalescedEvents` buffered, instead of rebuilding every pending
annotation per `pointermove`: 4.9 ms per event with 15 marks pending
became 0.054 ms, and no longer grows with what else is on the page.

A quad's vertical extent comes from the selection layer's run rather
than `Range.getClientRects()`. That rect is sized by whatever font the
browser substituted for the layer — half the height of the run here,
visibly shorter than the selection — and differs between machines, so
the same pdf would not write the same `/QuadPoints` twice.

The script outgrew msvc's 16380-byte cap on a string literal, which a
crlf checkout reaches a line sooner, so an asset may carry a tail and a
`consteval` guard now fails the build everywhere rather than on windows
alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e3PEzyU2oAFSzsEoWsSmz
@andiwand
andiwand force-pushed the feat/pdf-annotation-gesture-policy branch from 17eeebf to a8e4847 Compare September 6, 2026 20:42
@andiwand
andiwand merged commit e402e1a into main Sep 6, 2026
31 of 32 checks passed
@andiwand
andiwand deleted the feat/pdf-annotation-gesture-policy branch September 6, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant