mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-30 23:49:10 -04:00
Revert experimental touch selection work back to the minimal auto-paging gate
The custom gesture system and the selection clamp/repair chain
replaced browser behavior with worse approximations. Keep only the
non-touch gate on selection auto-paging (de70103), which fixes
drag-selecting paging away mid-gesture; everything else returns to
upstream behavior.
This commit is contained in:
+2
-212
@@ -151,28 +151,11 @@ const getVisibleRange = (doc, start, end, mapRect) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectionIsBackward = sel => {
|
const selectionIsBackward = sel => {
|
||||||
// the selection may live in an iframe document while this module runs
|
const range = document.createRange()
|
||||||
// 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.setStart(sel.anchorNode, sel.anchorOffset)
|
||||||
range.setEnd(sel.focusNode, sel.focusOffset)
|
range.setEnd(sel.focusNode, sel.focusOffset)
|
||||||
return range.collapsed
|
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) => {
|
const setSelectionTo = (target, collapse) => {
|
||||||
let range
|
let range
|
||||||
@@ -465,7 +448,6 @@ export class Paginator extends HTMLElement {
|
|||||||
#scrollBounds
|
#scrollBounds
|
||||||
#touchState
|
#touchState
|
||||||
#touchScrolled
|
#touchScrolled
|
||||||
#touchSelActive
|
|
||||||
#lastVisibleRange
|
#lastVisibleRange
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -617,47 +599,6 @@ export class Paginator extends HTMLElement {
|
|||||||
})
|
})
|
||||||
doc.addEventListener('pointerup', () => isPointerSelecting = false)
|
doc.addEventListener('pointerup', () => isPointerSelecting = false)
|
||||||
doc.addEventListener('pointercancel', () => isPointerSelecting = false)
|
doc.addEventListener('pointercancel', () => isPointerSelecting = false)
|
||||||
let touchSelecting = false
|
|
||||||
doc.addEventListener('pointerdown', e => {
|
|
||||||
touchSelecting = e.pointerType === 'touch'
|
|
||||||
})
|
|
||||||
doc.addEventListener('keydown', () => touchSelecting = false)
|
|
||||||
// The word selected by the initial long-press. Chrome can
|
|
||||||
// re-anchor a touch drag at a rendered line break when the
|
|
||||||
// finger crosses just past a line-start word, silently dropping
|
|
||||||
// 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
|
|
||||||
doc.addEventListener('pointerdown', e => {
|
|
||||||
if (e.pointerType === 'touch') anchorWordStart = null
|
|
||||||
})
|
|
||||||
const recordAnchorWord = sel => {
|
|
||||||
if (anchorWordStart || !sel.rangeCount) return
|
|
||||||
const r = sel.getRangeAt(0)
|
|
||||||
anchorWordStart =
|
|
||||||
{ node: r.startContainer, offset: r.startOffset }
|
|
||||||
}
|
|
||||||
const repairAnchorWord = sel => {
|
|
||||||
if (!anchorWordStart || !sel.rangeCount
|
|
||||||
|| sel.type !== 'Range') return false
|
|
||||||
const r = sel.getRangeAt(0)
|
|
||||||
const probe = doc.createRange()
|
|
||||||
probe.setStart(anchorWordStart.node, anchorWordStart.offset)
|
|
||||||
probe.collapse(true)
|
|
||||||
if (r.compareBoundaryPoints(Range.START_TO_START, probe) <= 0)
|
|
||||||
return false
|
|
||||||
if (selectionIsBackward(sel))
|
|
||||||
sel.extend(probe.startContainer, probe.startOffset)
|
|
||||||
else
|
|
||||||
sel.setBaseAndExtent(probe.startContainer,
|
|
||||||
probe.startOffset, sel.focusNode, sel.focusOffset)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
let isKeyboardSelecting = false
|
let isKeyboardSelecting = false
|
||||||
doc.addEventListener('keydown', () => isKeyboardSelecting = true)
|
doc.addEventListener('keydown', () => isKeyboardSelecting = true)
|
||||||
doc.addEventListener('keyup', () => isKeyboardSelecting = false)
|
doc.addEventListener('keyup', () => isKeyboardSelecting = false)
|
||||||
@@ -667,34 +608,6 @@ export class Paginator extends HTMLElement {
|
|||||||
if (!range) return
|
if (!range) return
|
||||||
const sel = doc.getSelection()
|
const sel = doc.getSelection()
|
||||||
if (!sel.rangeCount) return
|
if (!sel.rangeCount) return
|
||||||
if (touchSelecting && sel.type === 'Range') {
|
|
||||||
recordAnchorWord(sel)
|
|
||||||
this.#clampTouchSelection(sel, doc)
|
|
||||||
// Chrome ignores (or re-maps) selection writes made from
|
|
||||||
// JS while the touch selection gesture is active, and no
|
|
||||||
// pointer or touch event reaches the document when the
|
|
||||||
// finger lifts after the takeover — the only signal is
|
|
||||||
// the last selectionchange, mid-gesture. Retry after the
|
|
||||||
// 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 s = doc.getSelection()
|
|
||||||
if (!s || !s.rangeCount || s.type !== 'Range'
|
|
||||||
|| !same(s)) return
|
|
||||||
if (repairAnchorWord(s)) return
|
|
||||||
this.#clampTouchSelection(s, doc)
|
|
||||||
}
|
|
||||||
setTimeout(clampLater, 150)
|
|
||||||
setTimeout(clampLater, 500)
|
|
||||||
}
|
|
||||||
if (isPointerSelecting && sel.type === 'Range')
|
if (isPointerSelecting && sel.type === 'Range')
|
||||||
checkPointerSelection(range, sel)
|
checkPointerSelection(range, sel)
|
||||||
else if (isKeyboardSelecting) {
|
else if (isKeyboardSelecting) {
|
||||||
@@ -707,97 +620,6 @@ export class Paginator extends HTMLElement {
|
|||||||
doc.addEventListener('focusin', e => this.scrolled ? null :
|
doc.addEventListener('focusin', e => this.scrolled ? null :
|
||||||
// NOTE: `requestAnimationFrame` is needed in WebKit
|
// NOTE: `requestAnimationFrame` is needed in WebKit
|
||||||
requestAnimationFrame(() => this.#scrollToAnchor(e.target)))
|
requestAnimationFrame(() => this.#scrollToAnchor(e.target)))
|
||||||
// ----- custom touch selection -----
|
|
||||||
// Chrome's native touch selection is disabled for the paginated
|
|
||||||
// layout (preventDefault on touchstart): quick taps would select
|
|
||||||
// words, and dragging from a line-start word re-anchors at a
|
|
||||||
// rendered line break, dropping the word and freezing the
|
|
||||||
// gesture. Selection is built here instead: long-press selects
|
|
||||||
// the word under the finger, dragging extends it word by word,
|
|
||||||
// clamped to the visible page — identical for every word.
|
|
||||||
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: this is 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
|
|
||||||
const sel = doc.getSelection()
|
|
||||||
sel.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]
|
|
||||||
const sel = doc.getSelection()
|
|
||||||
if (sel && sel.rangeCount) {
|
|
||||||
// a plain tap clears the selection instead of paging
|
|
||||||
sel.removeAllRanges()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.#mediaQueryListener = () => {
|
this.#mediaQueryListener = () => {
|
||||||
@@ -1014,7 +836,6 @@ export class Paginator extends HTMLElement {
|
|||||||
if (state.pinched) return
|
if (state.pinched) return
|
||||||
state.pinched = globalThis.visualViewport.scale > 1
|
state.pinched = globalThis.visualViewport.scale > 1
|
||||||
if (this.scrolled || state.pinched) return
|
if (this.scrolled || state.pinched) return
|
||||||
if (this.#touchSelActive) return
|
|
||||||
if (e.touches.length > 1) {
|
if (e.touches.length > 1) {
|
||||||
if (this.#touchScrolled) e.preventDefault()
|
if (this.#touchScrolled) e.preventDefault()
|
||||||
return
|
return
|
||||||
@@ -1034,7 +855,7 @@ export class Paginator extends HTMLElement {
|
|||||||
}
|
}
|
||||||
#onTouchEnd() {
|
#onTouchEnd() {
|
||||||
this.#touchScrolled = false
|
this.#touchScrolled = false
|
||||||
if (this.scrolled || this.#touchSelActive) return
|
if (this.scrolled) return
|
||||||
|
|
||||||
// XXX: Firefox seems to report scale as 1... sometimes...?
|
// XXX: Firefox seems to report scale as 1... sometimes...?
|
||||||
// at this point I'm basically throwing `requestAnimationFrame` at
|
// at this point I'm basically throwing `requestAnimationFrame` at
|
||||||
@@ -1131,37 +952,6 @@ export class Paginator extends HTMLElement {
|
|||||||
return getVisibleRange(this.#view.document,
|
return getVisibleRange(this.#view.document,
|
||||||
this.start - size, this.end - size, this.#getRectMapper())
|
this.start - size, this.end - size, this.#getRectMapper())
|
||||||
}
|
}
|
||||||
#caretAt(x, y, doc) {
|
|
||||||
const size = this.#rtl ? -this.size : this.size
|
|
||||||
const left = this.start - size
|
|
||||||
const right = this.end - size
|
|
||||||
const cx = Math.min(Math.max(x, left + 1), right - 1)
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
#clampTouchSelection(sel, doc) {
|
|
||||||
if (!sel.rangeCount) return
|
|
||||||
const backward = selectionIsBackward(sel)
|
|
||||||
const probe = sel.getRangeAt(0).cloneRange()
|
|
||||||
probe.collapse(backward)
|
|
||||||
const rect = probe.getBoundingClientRect()
|
|
||||||
if (!rect || (rect.width === 0 && rect.height === 0 && !rect.x && !rect.y))
|
|
||||||
return
|
|
||||||
const size = this.#rtl ? -this.size : this.size
|
|
||||||
const left = this.start - size
|
|
||||||
const right = this.end - size
|
|
||||||
if (backward ? rect.left >= left : rect.left <= right) return
|
|
||||||
const x = backward ? left + 1 : right - 1
|
|
||||||
const caret = doc.caretRangeFromPoint?.(x, rect.top)
|
|
||||||
?? doc.caretPositionFromPoint?.(x, rect.top)
|
|
||||||
if (!caret) return
|
|
||||||
const node = caret.startContainer ?? caret.offsetNode
|
|
||||||
const offset = caret.startOffset ?? caret.offset
|
|
||||||
if (node) sel.extend(node, offset)
|
|
||||||
}
|
|
||||||
#afterScroll(reason) {
|
#afterScroll(reason) {
|
||||||
const range = this.#getVisibleRange()
|
const range = this.#getVisibleRange()
|
||||||
this.#lastVisibleRange = range
|
this.#lastVisibleRange = range
|
||||||
|
|||||||
Reference in New Issue
Block a user