fix(reader): preserve iOS text selection adjustment

Keep pagination and the selection popup idle until native selection gestures finish, including cancelled touches.

Cover paginated and scrolled flows while retaining ordinary swipe snapping.

Closes #1487
This commit is contained in:
neon
2026-09-22 10:19:38 +00:00
parent a4809512d8
commit 304ccd5640
9 changed files with 332 additions and 53 deletions
+3 -1
View File
@@ -876,9 +876,11 @@ export class Paginator extends HTMLElement {
this.#touchScrolled = true this.#touchScrolled = true
this.scrollBy(dx, dy) this.scrollBy(dx, dy)
} }
#onTouchEnd() { #onTouchEnd(e) {
this.#touchScrolled = false this.#touchScrolled = false
if (this.scrolled) return if (this.scrolled) return
const doc = e.currentTarget?.getSelection ? e.currentTarget : this.#view?.document
if (hasActiveTextSelection(doc)) 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
@@ -849,6 +849,7 @@ const {
deleteAnnotation, deleteAnnotation,
redrawAnnotation, redrawAnnotation,
setTextSelectedHandler, setTextSelectedHandler,
setSelectionInteractionStartHandler,
setAnnotationClickHandler, setAnnotationClickHandler,
view: foliateView, view: foliateView,
bookLanguage, bookLanguage,
@@ -880,6 +881,7 @@ function handleAnnotationClick(cfi: string, popupPosition: { x: number; y: numbe
} }
setTextSelectedHandler(handleTextSelected) setTextSelectedHandler(handleTextSelected)
setSelectionInteractionStartHandler(selection.dismiss)
setAnnotationClickHandler(handleAnnotationClick) setAnnotationClickHandler(handleAnnotationClick)
onUnmounted(clearFoliateSource) onUnmounted(clearFoliateSource)
@@ -56,6 +56,7 @@ describe('Foliate navigation', () => {
let Paginator: new () => { let Paginator: new () => {
sections: { load: () => Promise<string | null> }[] sections: { load: () => Promise<string | null> }[]
goTo: (target: { index: number }) => Promise<void> goTo: (target: { index: number }) => Promise<void>
snap: (vx: number, vy: number) => void
} }
beforeAll(async () => { beforeAll(async () => {
@@ -220,8 +221,13 @@ describe('Foliate navigation', () => {
expect(getPageScrollOffset(1, 800, true, false)).toBe(800) expect(getPageScrollOffset(1, 800, true, false)).toBe(800)
}) })
it('does not intercept paginated touch movement while native text selection is active', () => { it('does not intercept or snap paginated touch gestures while native text selection is active', () => {
const paginator = new Paginator() as InstanceType<typeof Paginator> & EventTarget const paginator = new Paginator() as InstanceType<typeof Paginator> & EventTarget
const snap = vi.spyOn(paginator, 'snap').mockImplementation(() => {})
const animationFrame = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0)
return 0
})
const doc = new EventTarget() as EventTarget & Document const doc = new EventTarget() as EventTarget & Document
Object.defineProperty(doc, 'getSelection', { Object.defineProperty(doc, 'getSelection', {
value: () => ({ rangeCount: 1, isCollapsed: false }), value: () => ({ rangeCount: 1, isCollapsed: false }),
@@ -244,7 +250,58 @@ describe('Foliate navigation', () => {
}) })
doc.dispatchEvent(move) doc.dispatchEvent(move)
const end = new Event('touchend', { bubbles: true, cancelable: true }) as TouchEvent
Object.defineProperties(end, {
touches: { value: [] },
changedTouches: { value: [touch] },
})
doc.dispatchEvent(end)
expect(move.defaultPrevented).toBe(false) expect(move.defaultPrevented).toBe(false)
expect(end.defaultPrevented).toBe(false)
expect(snap).not.toHaveBeenCalled()
animationFrame.mockRestore()
})
it('still snaps ordinary paginated touch gestures', () => {
const paginator = new Paginator() as InstanceType<typeof Paginator> & EventTarget
const snap = vi.spyOn(paginator, 'snap').mockImplementation(() => {})
const originalVisualViewport = Object.getOwnPropertyDescriptor(globalThis, 'visualViewport')
Object.defineProperty(globalThis, 'visualViewport', {
configurable: true,
value: { scale: 1 },
})
const animationFrame = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0)
return 0
})
const doc = new EventTarget() as EventTarget & Document
Object.defineProperty(doc, 'getSelection', {
value: () => null,
})
paginator.dispatchEvent(new CustomEvent('load', { detail: { doc } }))
const touch = { clientX: 30, clientY: 40, screenX: 30, screenY: 40 }
const start = new Event('touchstart', { bubbles: true, cancelable: true }) as TouchEvent
Object.defineProperties(start, {
touches: { value: [touch] },
changedTouches: { value: [touch] },
})
doc.dispatchEvent(start)
const end = new Event('touchend', { bubbles: true, cancelable: true }) as TouchEvent
Object.defineProperties(end, {
touches: { value: [] },
changedTouches: { value: [touch] },
})
doc.dispatchEvent(end)
expect(snap).toHaveBeenCalledWith(0, undefined)
animationFrame.mockRestore()
if (originalVisualViewport) Object.defineProperty(globalThis, 'visualViewport', originalVisualViewport)
else Reflect.deleteProperty(globalThis, 'visualViewport')
}) })
it('maps physical left and right navigation using OPF RTL page progression', async () => { it('maps physical left and right navigation using OPF RTL page progression', async () => {
@@ -20,8 +20,11 @@ vi.mock('../useFoliateAnnotations', () => ({
vi.mock('../useFoliateSelection', () => ({ vi.mock('../useFoliateSelection', () => ({
useFoliateSelection: () => ({ useFoliateSelection: () => ({
setHandler: vi.fn<() => void>(), setHandler: vi.fn<() => void>(),
setInteractionStartHandler: vi.fn<() => void>(),
handleSelectionEnd: vi.fn<() => void>(), handleSelectionEnd: vi.fn<() => void>(),
handleSelectionChange: vi.fn<() => void>(), handleSelectionChange: vi.fn<() => void>(),
handleInteractionStart: vi.fn<() => void>(),
handleInteractionEnd: vi.fn<() => void>(),
}), }),
})) }))
@@ -157,6 +157,78 @@ describe('useFoliateInput', () => {
input.cleanup() input.cleanup()
}) })
it.each(['paginated', 'scrolled'])('brackets a selection gesture in %s flow until touchend', (flow) => {
const handleInteractionStart = vi.fn<(doc: Document) => void>()
const handleInteractionEnd = vi.fn<(doc: Document) => void>()
const selection = {
isCollapsed: false,
rangeCount: 1,
} as Selection
const view: ViewLike = {
prev: vi.fn<() => void>(),
next: vi.fn<() => void>(),
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
renderer: { getAttribute: (name) => (name === 'flow' ? flow : null) },
}
const input = useFoliateInput(
() => view,
undefined,
vi.fn<() => void>(),
vi.fn<() => void>(),
undefined,
handleInteractionStart,
handleInteractionEnd,
)
const doc = makeDocTarget(selection)
input.attachIframeClicks(doc)
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 20, clientY: 30 }]))
doc.dispatchEvent(new Event('selectionchange'))
expect(handleInteractionStart).toHaveBeenCalledWith(doc)
expect(handleInteractionEnd).not.toHaveBeenCalled()
doc.dispatchEvent(makeTouchEvent('touchend', [], [{ clientX: 30, clientY: 40 }]))
expect(handleInteractionEnd).toHaveBeenCalledWith(doc)
input.cleanup()
})
it('finishes selection interaction without navigating when touch is cancelled', () => {
const goLeft = vi.fn<() => void>()
const goRight = vi.fn<() => void>()
const handleInteractionEnd = vi.fn<(doc: Document) => void>()
const view: ViewLike = {
prev: vi.fn<() => void>(),
next: vi.fn<() => void>(),
goLeft,
goRight,
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
renderer: { getAttribute: (name) => (name === 'flow' ? 'paginated' : null) },
}
const input = useFoliateInput(
() => view,
undefined,
vi.fn<() => void>(),
vi.fn<() => void>(),
undefined,
vi.fn<(doc: Document) => void>(),
handleInteractionEnd,
)
const doc = makeDocTarget()
input.attachIframeClicks(doc)
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
doc.dispatchEvent(makeTouchEvent('touchcancel', [], [{ clientX: 20, clientY: 100 }]))
expect(handleInteractionEnd).toHaveBeenCalledWith(doc)
expect(goLeft).not.toHaveBeenCalled()
expect(goRight).not.toHaveBeenCalled()
input.cleanup()
})
it.each([ it.each([
{ endX: 40, expectedDirection: 'right' }, { endX: 40, expectedDirection: 'right' },
{ endX: 160, expectedDirection: 'left' }, { endX: 160, expectedDirection: 'left' },
@@ -147,4 +147,94 @@ describe('useFoliateSelection', () => {
vi.useRealTimers() vi.useRealTimers()
}) })
it('waits for an active touch interaction to finish before publishing the selection', () => {
vi.useFakeTimers()
const range = makeRange('Adjusted selection', {
left: 120,
top: 220,
bottom: 260,
width: 80,
height: 40,
})
const selection = {
isCollapsed: false,
rangeCount: 1,
getRangeAt: () => range,
} as unknown as Selection
const foliateSelection = useFoliateSelection(() => ({
renderer: { getContents: () => [{ index: 2 }] },
getCFI: () => 'epubcfi(/6/10)',
}))
const onSelected = vi.fn<(detail: unknown) => void>()
const onInteractionStart = vi.fn<() => void>()
foliateSelection.setHandler(onSelected)
foliateSelection.setInteractionStartHandler(onInteractionStart)
const doc = makeDoc({ selection })
foliateSelection.handleInteractionStart(doc)
foliateSelection.handleSelectionChange(doc)
vi.advanceTimersByTime(1_000)
expect(onInteractionStart).toHaveBeenCalledTimes(1)
expect(onSelected).not.toHaveBeenCalled()
foliateSelection.handleInteractionEnd(doc)
vi.advanceTimersByTime(49)
expect(onSelected).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onSelected).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('publishes a selection created during a touch interaction only after touch end', () => {
vi.useFakeTimers()
let currentSelection = {
isCollapsed: true,
rangeCount: 0,
} as Selection
const range = makeRange('New selection', {
left: 120,
top: 220,
bottom: 260,
width: 80,
height: 40,
})
const doc = {
defaultView: {
getSelection: () => currentSelection,
frameElement: null,
},
} as unknown as Document
const foliateSelection = useFoliateSelection(() => ({
renderer: { getContents: () => [{ index: 2 }] },
getCFI: () => 'epubcfi(/6/10)',
}))
const onSelected = vi.fn<(detail: unknown) => void>()
const onInteractionStart = vi.fn<() => void>()
foliateSelection.setHandler(onSelected)
foliateSelection.setInteractionStartHandler(onInteractionStart)
foliateSelection.handleInteractionStart(doc)
currentSelection = {
isCollapsed: false,
rangeCount: 1,
getRangeAt: () => range,
} as unknown as Selection
foliateSelection.handleSelectionChange(doc)
vi.advanceTimersByTime(1_000)
expect(onInteractionStart).not.toHaveBeenCalled()
expect(onSelected).not.toHaveBeenCalled()
foliateSelection.handleInteractionEnd(doc)
vi.advanceTimersByTime(50)
expect(onSelected).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
}) })
@@ -115,7 +115,15 @@ export function useFoliate(
const annotations = useFoliateAnnotations() const annotations = useFoliateAnnotations()
const selection = useFoliateSelection(() => viewRef.value) const selection = useFoliateSelection(() => viewRef.value)
const input = useFoliateInput(() => viewRef.value, onMiddleTap, selection.handleSelectionEnd, selection.handleSelectionChange, canNavigate) const input = useFoliateInput(
() => viewRef.value,
onMiddleTap,
selection.handleSelectionEnd,
selection.handleSelectionChange,
canNavigate,
selection.handleInteractionStart,
selection.handleInteractionEnd,
)
async function loadScript() { async function loadScript() {
if (customElements.get('foliate-view')) return if (customElements.get('foliate-view')) return
@@ -438,6 +446,7 @@ export function useFoliate(
deleteAnnotation: (cfi: string) => annotations.deleteAnnotation(viewRef.value, cfi), deleteAnnotation: (cfi: string) => annotations.deleteAnnotation(viewRef.value, cfi),
redrawAnnotation: (cfi: string, color: string, style: string) => annotations.redrawAnnotation(viewRef.value, cfi, color, style), redrawAnnotation: (cfi: string, color: string, style: string) => annotations.redrawAnnotation(viewRef.value, cfi, color, style),
setTextSelectedHandler: selection.setHandler, setTextSelectedHandler: selection.setHandler,
setSelectionInteractionStartHandler: selection.setInteractionStartHandler,
setAnnotationClickHandler, setAnnotationClickHandler,
} }
} }
@@ -22,6 +22,8 @@ export function useFoliateInput(
handleSelectionEnd: (doc: Document) => void, handleSelectionEnd: (doc: Document) => void,
handleSelectionChange: (doc: Document) => void, handleSelectionChange: (doc: Document) => void,
canNavigate: (() => boolean) | undefined = undefined, canNavigate: (() => boolean) | undefined = undefined,
handleSelectionInteractionStart: ((doc: Document) => void) | undefined = undefined,
handleSelectionInteractionEnd: ((doc: Document) => void) | undefined = undefined,
) { ) {
const clickedDocs = new WeakSet<Document>() const clickedDocs = new WeakSet<Document>()
@@ -80,8 +82,9 @@ export function useFoliateInput(
return canNavigate ? canNavigate() : true return canNavigate ? canNavigate() : true
} }
function handleTouchStart(e: TouchEvent) { function handleTouchStart(e: TouchEvent, doc: Document) {
if (e.touches.length !== 1) return if (e.touches.length !== 1) return
handleSelectionInteractionStart?.(doc)
const touch = e.touches[0]! const touch = e.touches[0]!
touchStartX = touch.clientX touchStartX = touch.clientX
touchStartY = touch.clientY touchStartY = touch.clientY
@@ -108,21 +111,22 @@ export function useFoliateInput(
if (deltaX > 10 && deltaX > deltaY && !isTextSelectionInProgress) return if (deltaX > 10 && deltaX > deltaY && !isTextSelectionInProgress) return
} }
function handleTouchEnd(e: TouchEvent, doc: Document) { function handleTouchEnd(e: TouchEvent, doc: Document, cancelled = false) {
const touchEndTime = Date.now() const touchEndTime = Date.now()
const touchDuration = touchEndTime - touchStartTime const touchDuration = touchEndTime - touchStartTime
lastTouchTime = touchEndTime lastTouchTime = touchEndTime
handleSelectionInteractionEnd?.(doc)
const selection = doc.defaultView?.getSelection() const selection = doc.defaultView?.getSelection()
const hasSelection = selection && !selection.isCollapsed && selection.rangeCount > 0 const hasSelection = selection && !selection.isCollapsed && selection.rangeCount > 0
if (hasSelection) { if (hasSelection) {
isTextSelectionInProgress = false isTextSelectionInProgress = false
setTimeout(() => handleSelectionEnd(doc), 50) if (!handleSelectionInteractionEnd) setTimeout(() => handleSelectionEnd(doc), 50)
return return
} }
if (!isTextSelectionInProgress && e.changedTouches.length === 1) { if (!cancelled && !isTextSelectionInProgress && e.changedTouches.length === 1) {
const touch = e.changedTouches[0]! const touch = e.changedTouches[0]!
const deltaX = touch.clientX - touchStartX const deltaX = touch.clientX - touchStartX
const deltaY = Math.abs(touch.clientY - touchStartY) const deltaY = Math.abs(touch.clientY - touchStartY)
@@ -178,6 +182,7 @@ export function useFoliateInput(
doc.addEventListener( doc.addEventListener(
'mousedown', 'mousedown',
() => { () => {
handleSelectionInteractionStart?.(doc)
if (longHoldTimeout) clearTimeout(longHoldTimeout) if (longHoldTimeout) clearTimeout(longHoldTimeout)
longHoldTimeout = setTimeout(() => { longHoldTimeout = setTimeout(() => {
longHoldTimeout = null longHoldTimeout = null
@@ -187,7 +192,8 @@ export function useFoliateInput(
) )
doc.addEventListener('mouseup', () => { doc.addEventListener('mouseup', () => {
handleSelectionEnd(doc) if (handleSelectionInteractionEnd) handleSelectionInteractionEnd(doc)
else handleSelectionEnd(doc)
}) })
doc.addEventListener( doc.addEventListener(
@@ -214,9 +220,10 @@ export function useFoliateInput(
true, true,
) )
doc.addEventListener('touchstart', (e: TouchEvent) => handleTouchStart(e), { passive: true }) doc.addEventListener('touchstart', (e: TouchEvent) => handleTouchStart(e, doc), { passive: true })
doc.addEventListener('touchmove', (e: TouchEvent) => handleTouchMove(e, doc), { passive: true }) doc.addEventListener('touchmove', (e: TouchEvent) => handleTouchMove(e, doc), { passive: true })
doc.addEventListener('touchend', (e: TouchEvent) => handleTouchEnd(e, doc), { passive: true }) doc.addEventListener('touchend', (e: TouchEvent) => handleTouchEnd(e, doc), { passive: true })
doc.addEventListener('touchcancel', (e: TouchEvent) => handleTouchEnd(e, doc, true), { passive: true })
doc.addEventListener('selectionchange', () => handleSelectionChange(doc)) doc.addEventListener('selectionchange', () => handleSelectionChange(doc))
} }
@@ -7,63 +7,100 @@ export interface SelectionDetail {
export function useFoliateSelection(getView: () => unknown) { export function useFoliateSelection(getView: () => unknown) {
let onTextSelected: ((detail: SelectionDetail) => void) | undefined let onTextSelected: ((detail: SelectionDetail) => void) | undefined
let onInteractionStart: (() => void) | undefined
let selectionChangeTimeout: ReturnType<typeof setTimeout> | null = null let selectionChangeTimeout: ReturnType<typeof setTimeout> | null = null
const interactingDocs = new WeakSet<Document>()
const changedDocs = new WeakSet<Document>()
function setHandler(fn: (detail: SelectionDetail) => void) { function setHandler(fn: (detail: SelectionDetail) => void) {
onTextSelected = fn onTextSelected = fn
} }
function setInteractionStartHandler(fn: () => void) {
onInteractionStart = fn
}
function clearSelectionTimer() {
if (selectionChangeTimeout) clearTimeout(selectionChangeTimeout)
selectionChangeTimeout = null
}
function emitSelection(doc: Document) {
const selection = doc.defaultView?.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
const text = range.toString().trim()
if (!text) return
const iframe = doc.defaultView?.frameElement as HTMLIFrameElement | null
const rangeRect = range.getBoundingClientRect()
let popupX = rangeRect.left + rangeRect.width / 2
let selectionTop = rangeRect.top
let selectionBottom = rangeRect.bottom
if (iframe) {
const iframeRect = iframe.getBoundingClientRect()
popupX = iframeRect.left + rangeRect.left + rangeRect.width / 2
selectionTop = iframeRect.top + rangeRect.top
selectionBottom = iframeRect.top + rangeRect.bottom
}
const minSpaceAbove = 120
const showBelow = selectionTop < minSpaceAbove
const popupY = showBelow ? selectionBottom + 10 : selectionTop - 50
const clampedX = Math.max(100, Math.min(popupX, window.innerWidth - 150))
const view = getView() as {
renderer?: { getContents?: () => { index: number }[] }
getCFI?: (index: number, range: Range) => string | null
} | null
const contents = view?.renderer?.getContents?.()
const content = contents?.[0]
const selectionCfi = content ? (view?.getCFI?.(content.index, range) ?? null) : null
onTextSelected?.({ text, cfi: selectionCfi, range: range.cloneRange(), popupPosition: { x: clampedX, y: popupY, showBelow } })
}
function scheduleSelection(doc: Document, delay: number) {
clearSelectionTimer()
selectionChangeTimeout = setTimeout(() => {
selectionChangeTimeout = null
emitSelection(doc)
}, delay)
}
function handleSelectionEnd(doc: Document) { function handleSelectionEnd(doc: Document) {
setTimeout(() => { scheduleSelection(doc, 10)
const selection = doc.defaultView?.getSelection() }
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
const text = range.toString().trim()
if (!text) return
const iframe = doc.defaultView?.frameElement as HTMLIFrameElement | null function handleInteractionStart(doc: Document) {
const rangeRect = range.getBoundingClientRect() clearSelectionTimer()
let popupX = rangeRect.left + rangeRect.width / 2 interactingDocs.add(doc)
let selectionTop = rangeRect.top const selection = doc.defaultView?.getSelection()
let selectionBottom = rangeRect.bottom if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
changedDocs.add(doc)
onInteractionStart?.()
}
if (iframe) { function handleInteractionEnd(doc: Document) {
const iframeRect = iframe.getBoundingClientRect() if (!interactingDocs.has(doc)) return
popupX = iframeRect.left + rangeRect.left + rangeRect.width / 2 interactingDocs.delete(doc)
selectionTop = iframeRect.top + rangeRect.top
selectionBottom = iframeRect.top + rangeRect.bottom
}
const minSpaceAbove = 120 const selection = doc.defaultView?.getSelection()
const showBelow = selectionTop < minSpaceAbove const hasSelection = Boolean(selection && !selection.isCollapsed && selection.rangeCount > 0)
const popupY = showBelow ? selectionBottom + 10 : selectionTop - 50 const selectionChanged = changedDocs.has(doc)
const clampedX = Math.max(100, Math.min(popupX, window.innerWidth - 150)) changedDocs.delete(doc)
if (selectionChanged || hasSelection) scheduleSelection(doc, 50)
const view = getView() as {
renderer?: { getContents?: () => { index: number }[] }
getCFI?: (index: number, range: Range) => string | null
} | null
const contents = view?.renderer?.getContents?.()
const content = contents?.[0]
const selectionCfi = content ? (view?.getCFI?.(content.index, range) ?? null) : null
onTextSelected?.({ text, cfi: selectionCfi, range: range.cloneRange(), popupPosition: { x: clampedX, y: popupY, showBelow } })
}, 10)
} }
function handleSelectionChange(doc: Document) { function handleSelectionChange(doc: Document) {
if (selectionChangeTimeout) clearTimeout(selectionChangeTimeout) clearSelectionTimer()
selectionChangeTimeout = setTimeout(() => { if (interactingDocs.has(doc)) {
const selection = doc.defaultView?.getSelection() changedDocs.add(doc)
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return return
const range = selection.getRangeAt(0) }
const text = range.toString().trim() if ('ontouchstart' in window || navigator.maxTouchPoints > 0) scheduleSelection(doc, 300)
if (!text) return
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
handleSelectionEnd(doc)
}
}, 300)
} }
return { setHandler, handleSelectionEnd, handleSelectionChange } return { setHandler, setInteractionStartHandler, handleSelectionEnd, handleSelectionChange, handleInteractionStart, handleInteractionEnd }
} }