Repair the anchor word only from the stability-gated deferred path

A selection write at the moment Chrome re-anchors the drag detaches
its touch selection controller; the same writes on a settled
selection are fine. Record the word synchronously, repair only when
the selection has been unchanged for 150ms.
This commit is contained in:
John O'Keefe
2026-09-13 20:25:40 -04:00
parent 1bd7e52417
commit 4cae16013e
+31 -13
View File
@@ -611,29 +611,38 @@ export class Paginator extends HTMLElement {
// The word selected by the initial long-press. Chrome can // The word selected by the initial long-press. Chrome can
// re-anchor a touch drag at a rendered line break when the // re-anchor a touch drag at a rendered line break when the
// finger crosses just past a line-start word, silently dropping // finger crosses just past a line-start word, silently dropping
// that word from the selection; keep it covered. // that word from the selection; keep it covered. The repair
// must never run while the gesture is still changing the
// selection: a write at the re-anchor moment detaches Chrome's
// touch selection controller and the selection stops following
// the finger. Focus-only writes on a settled selection are
// tolerated, so record here and repair only from the deferred,
// stability-gated callbacks below.
let anchorWordStart = null let anchorWordStart = null
doc.addEventListener('pointerdown', e => { doc.addEventListener('pointerdown', e => {
if (e.pointerType === 'touch') anchorWordStart = null if (e.pointerType === 'touch') anchorWordStart = null
}) })
const preserveAnchorWord = sel => { const recordAnchorWord = sel => {
if (!sel.rangeCount || sel.type !== 'Range') return if (anchorWordStart || !sel.rangeCount) return
const r = sel.getRangeAt(0) const r = sel.getRangeAt(0)
if (!anchorWordStart) {
anchorWordStart = anchorWordStart =
{ node: r.startContainer, offset: r.startOffset } { node: r.startContainer, offset: r.startOffset }
return
} }
const repairAnchorWord = sel => {
if (!anchorWordStart || !sel.rangeCount
|| sel.type !== 'Range') return false
const r = sel.getRangeAt(0)
const probe = doc.createRange() const probe = doc.createRange()
probe.setStart(anchorWordStart.node, anchorWordStart.offset) probe.setStart(anchorWordStart.node, anchorWordStart.offset)
probe.collapse(true) probe.collapse(true)
if (r.compareBoundaryPoints(Range.START_TO_START, probe) <= 0) if (r.compareBoundaryPoints(Range.START_TO_START, probe) <= 0)
return return false
if (selectionIsBackward(sel)) if (selectionIsBackward(sel))
sel.extend(probe.startContainer, probe.startOffset) sel.extend(probe.startContainer, probe.startOffset)
else else
sel.setBaseAndExtent(probe.startContainer, probe.startOffset, sel.setBaseAndExtent(probe.startContainer,
sel.focusNode, sel.focusOffset) probe.startOffset, sel.focusNode, sel.focusOffset)
return true
} }
let isKeyboardSelecting = false let isKeyboardSelecting = false
doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keydown', () => isKeyboardSelecting = true)
@@ -645,21 +654,30 @@ export class Paginator extends HTMLElement {
const sel = doc.getSelection() const sel = doc.getSelection()
if (!sel.rangeCount) return if (!sel.rangeCount) return
if (touchSelecting && sel.type === 'Range') { if (touchSelecting && sel.type === 'Range') {
preserveAnchorWord(sel) recordAnchorWord(sel)
this.#clampTouchSelection(sel, doc) this.#clampTouchSelection(sel, doc)
// Chrome ignores (or re-maps) selection writes made from // Chrome ignores (or re-maps) selection writes made from
// JS while the touch selection gesture is active, and no // JS while the touch selection gesture is active, and no
// pointer or touch event reaches the document when the // pointer or touch event reaches the document when the
// finger lifts after the takeover — the only signal is // finger lifts after the takeover — the only signal is
// the last selectionchange, mid-gesture. Retry after the // the last selectionchange, mid-gesture. Retry after the
// gesture has most likely ended. // gesture has most likely ended, and only while the
// selection is unchanged since the event that scheduled
// the retry (an actively changing selection supersedes it
// and owns the next retry).
const snap = [sel.anchorNode, sel.anchorOffset,
sel.focusNode, sel.focusOffset]
const same = s => s.anchorNode === snap[0]
&& s.anchorOffset === snap[1]
&& s.focusNode === snap[2]
&& s.focusOffset === snap[3]
const clampLater = () => { const clampLater = () => {
const s = doc.getSelection() const s = doc.getSelection()
if (s && s.rangeCount && s.type === 'Range') { if (!s || !s.rangeCount || s.type !== 'Range'
preserveAnchorWord(s) || !same(s)) return
if (repairAnchorWord(s)) return
this.#clampTouchSelection(s, doc) this.#clampTouchSelection(s, doc)
} }
}
setTimeout(clampLater, 150) setTimeout(clampLater, 150)
setTimeout(clampLater, 500) setTimeout(clampLater, 500)
} }