From 914ade8a79579d0b592fd62bf2939366e752be86 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 15 Sep 2026 08:54:56 -0400 Subject: [PATCH] Custom touch selection: own the gesture, clamp to the page Chromium's native touch selection cannot be used in the columned layout: quick taps select words, the word iterator re-anchors at page boundaries (jumping to other pages), and the browser auto-scrolls the container when selections cross pages. preventDefault on touchstart disables it; long-press selects the word under the finger, drag extends word by word clamped to the visible page, and pan/snap stand down while a selection gesture is active. Native rendering still paints the selection. --- paginator.js | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 2 deletions(-) diff --git a/paginator.js b/paginator.js index 3127294..ede314b 100644 --- a/paginator.js +++ b/paginator.js @@ -151,11 +151,28 @@ const getVisibleRange = (doc, start, end, mapRect) => { } const selectionIsBackward = sel => { - const range = document.createRange() + // the selection may live in an iframe document while this module runs + // in the host document; create the range in the selection's own + // document or the boundary points cannot be compared + const doc = sel.anchorNode?.ownerDocument ?? document + const range = doc.createRange() range.setStart(sel.anchorNode, sel.anchorOffset) range.setEnd(sel.focusNode, sel.focusOffset) return range.collapsed } +const wordAt = (doc, node, offset) => { + if (!node) return null + if (node.nodeType !== 3) return { node, offset } + const text = node.nodeValue + if (!text) return null + const isWord = c => /[\p{L}\p{N}]/u.test(c) + let start = Math.min(offset, text.length) + let end = start + while (start > 0 && isWord(text[start - 1])) start-- + while (end < text.length && isWord(text[end])) end++ + if (start === end) return { node, offset } + return { node, offset: start, endOffset: end } +} const setSelectionTo = (target, collapse) => { let range @@ -464,6 +481,7 @@ export class Paginator extends HTMLElement { #scrollBounds #touchState #touchScrolled + #touchSelActive #lastVisibleRange constructor() { super() @@ -636,6 +654,93 @@ export class Paginator extends HTMLElement { doc.addEventListener('focusin', e => this.scrolled ? null : // NOTE: `requestAnimationFrame` is needed in WebKit requestAnimationFrame(() => this.#scrollToAnchor(e.target))) + // ----- custom touch selection ----- + // Chromium's native touch selection misbehaves in the + // columned layout: quick taps select words, the word + // iterator re-anchors at page boundaries, and the browser + // auto-scrolls the container when selections cross pages. + // preventDefault on touchstart disables it entirely; + // long-press selects the word under the finger and drag + // extends it word by word, clamped to the visible page, so + // the selection can never leave the page and the view can + // never move mid-gesture. + if (!this.scrolled) { + let gesture = null + const clearGesture = () => { + if (gesture?.timer) clearTimeout(gesture.timer) + gesture = null + this.#touchSelActive = false + } + doc.addEventListener('touchstart', e => { + const touch = e.changedTouches[0] + if (!touch || e.touches.length !== 1) return clearGesture() + e.preventDefault() + const g = gesture = { x: touch.clientX, y: touch.clientY, + id: touch.identifier, start: null, end: null, timer: null } + g.timer = setTimeout(() => { + if (gesture !== g) return + const caret = this.#caretAt(g.x, g.y, doc) + const word = caret && wordAt(doc, caret.node, caret.offset) + if (!word || word.endOffset == null) { + gesture = null + return + } + const sel = doc.getSelection() + sel.setBaseAndExtent(word.node, word.offset, + word.node, word.endOffset) + g.start = { node: word.node, offset: word.offset } + g.end = { node: word.node, offset: word.endOffset } + this.#touchSelActive = true + }, 450) + }, { passive: false }) + doc.addEventListener('touchmove', e => { + const g = gesture + if (!g) return + const touch = [...e.changedTouches] + .find(t => t.identifier === g.id) + if (!touch) return + if (!g.start) { + // moving before the long-press engages: a pan — + // stand down and let the paginator handle it + if (Math.hypot(touch.clientX - g.x, touch.clientY - g.y) > 12) + clearGesture() + return + } + e.preventDefault() + const caret = this.#caretAt(touch.clientX, touch.clientY, doc) + if (!caret) return + const word = wordAt(doc, caret.node, caret.offset) + const end = word?.endOffset != null + ? { node: word.node, offset: word.endOffset } + : caret + const startProbe = doc.createRange() + startProbe.setStart(g.start.node, g.start.offset) + startProbe.collapse(true) + const endProbe = doc.createRange() + endProbe.setStart(end.node, end.offset) + endProbe.collapse(true) + // whichever boundary of the long-pressed word keeps + // it fully covered serves as the anchor + const anchor = startProbe.compareBoundaryPoints( + Range.START_TO_START, endProbe) > 0 ? g.end : g.start + doc.getSelection().setBaseAndExtent(anchor.node, anchor.offset, + end.node, end.offset) + }, { passive: false }) + doc.addEventListener('touchend', e => { + const g = gesture + const wasSelecting = !!g?.start + clearGesture() + if (wasSelecting || e.changedTouches.length !== 1) return + const touch = e.changedTouches[0] + // touchstart's preventDefault suppresses click + // events; follow links directly + const link = doc.elementFromPoint(touch.clientX, touch.clientY) + ?.closest('a') + link?.click() + }) + doc.addEventListener('touchcancel', clearGesture) + doc.addEventListener('contextmenu', e => e.preventDefault()) + } }) this.#mediaQueryListener = () => { @@ -839,6 +944,21 @@ export class Paginator extends HTMLElement { }) }) } + #caretAt(x, y, doc) { + // Touch points and caret mapping share the iframe's own + // (content) space, where the visible page is [start - size, + // start] for LTR; clamp into it so selections cannot leave + // the visible page. + const size = this.#rtl ? -this.size : this.size + const left = this.start - size + const right = this.start + const cx = Math.min(Math.max(x, left + 2), right - 2) + const caret = doc.caretRangeFromPoint?.(cx, y) + ?? doc.caretPositionFromPoint?.(cx, y) + if (!caret) return null + return { node: caret.startContainer ?? caret.offsetNode, + offset: caret.startOffset ?? caret.offset } + } #onTouchStart(e) { const touch = e.changedTouches[0] this.#touchState = { @@ -852,6 +972,7 @@ export class Paginator extends HTMLElement { if (state.pinched) return state.pinched = globalThis.visualViewport.scale > 1 if (this.scrolled || state.pinched) return + if (this.#touchSelActive) return if (e.touches.length > 1) { if (this.#touchScrolled) e.preventDefault() return @@ -871,7 +992,7 @@ export class Paginator extends HTMLElement { } #onTouchEnd() { this.#touchScrolled = false - if (this.scrolled) return + if (this.scrolled || this.#touchSelActive) return // XXX: Firefox seems to report scale as 1... sometimes...? // at this point I'm basically throwing `requestAnimationFrame` at