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.scrollBy(dx, dy)
}
#onTouchEnd() {
#onTouchEnd(e) {
this.#touchScrolled = false
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...?
// at this point I'm basically throwing `requestAnimationFrame` at
@@ -849,6 +849,7 @@ const {
deleteAnnotation,
redrawAnnotation,
setTextSelectedHandler,
setSelectionInteractionStartHandler,
setAnnotationClickHandler,
view: foliateView,
bookLanguage,
@@ -880,6 +881,7 @@ function handleAnnotationClick(cfi: string, popupPosition: { x: number; y: numbe
}
setTextSelectedHandler(handleTextSelected)
setSelectionInteractionStartHandler(selection.dismiss)
setAnnotationClickHandler(handleAnnotationClick)
onUnmounted(clearFoliateSource)
@@ -56,6 +56,7 @@ describe('Foliate navigation', () => {
let Paginator: new () => {
sections: { load: () => Promise<string | null> }[]
goTo: (target: { index: number }) => Promise<void>
snap: (vx: number, vy: number) => void
}
beforeAll(async () => {
@@ -220,8 +221,13 @@ describe('Foliate navigation', () => {
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 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
Object.defineProperty(doc, 'getSelection', {
value: () => ({ rangeCount: 1, isCollapsed: false }),
@@ -244,7 +250,58 @@ describe('Foliate navigation', () => {
})
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(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 () => {
@@ -20,8 +20,11 @@ vi.mock('../useFoliateAnnotations', () => ({
vi.mock('../useFoliateSelection', () => ({
useFoliateSelection: () => ({
setHandler: vi.fn<() => void>(),
setInteractionStartHandler: vi.fn<() => void>(),
handleSelectionEnd: vi.fn<() => void>(),
handleSelectionChange: vi.fn<() => void>(),
handleInteractionStart: vi.fn<() => void>(),
handleInteractionEnd: vi.fn<() => void>(),
}),
}))
@@ -157,6 +157,78 @@ describe('useFoliateInput', () => {
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([
{ endX: 40, expectedDirection: 'right' },
{ endX: 160, expectedDirection: 'left' },
@@ -147,4 +147,94 @@ describe('useFoliateSelection', () => {
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 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() {
if (customElements.get('foliate-view')) return
@@ -438,6 +446,7 @@ export function useFoliate(
deleteAnnotation: (cfi: string) => annotations.deleteAnnotation(viewRef.value, cfi),
redrawAnnotation: (cfi: string, color: string, style: string) => annotations.redrawAnnotation(viewRef.value, cfi, color, style),
setTextSelectedHandler: selection.setHandler,
setSelectionInteractionStartHandler: selection.setInteractionStartHandler,
setAnnotationClickHandler,
}
}
@@ -22,6 +22,8 @@ export function useFoliateInput(
handleSelectionEnd: (doc: Document) => void,
handleSelectionChange: (doc: Document) => void,
canNavigate: (() => boolean) | undefined = undefined,
handleSelectionInteractionStart: ((doc: Document) => void) | undefined = undefined,
handleSelectionInteractionEnd: ((doc: Document) => void) | undefined = undefined,
) {
const clickedDocs = new WeakSet<Document>()
@@ -80,8 +82,9 @@ export function useFoliateInput(
return canNavigate ? canNavigate() : true
}
function handleTouchStart(e: TouchEvent) {
function handleTouchStart(e: TouchEvent, doc: Document) {
if (e.touches.length !== 1) return
handleSelectionInteractionStart?.(doc)
const touch = e.touches[0]!
touchStartX = touch.clientX
touchStartY = touch.clientY
@@ -108,21 +111,22 @@ export function useFoliateInput(
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 touchDuration = touchEndTime - touchStartTime
lastTouchTime = touchEndTime
handleSelectionInteractionEnd?.(doc)
const selection = doc.defaultView?.getSelection()
const hasSelection = selection && !selection.isCollapsed && selection.rangeCount > 0
if (hasSelection) {
isTextSelectionInProgress = false
setTimeout(() => handleSelectionEnd(doc), 50)
if (!handleSelectionInteractionEnd) setTimeout(() => handleSelectionEnd(doc), 50)
return
}
if (!isTextSelectionInProgress && e.changedTouches.length === 1) {
if (!cancelled && !isTextSelectionInProgress && e.changedTouches.length === 1) {
const touch = e.changedTouches[0]!
const deltaX = touch.clientX - touchStartX
const deltaY = Math.abs(touch.clientY - touchStartY)
@@ -178,6 +182,7 @@ export function useFoliateInput(
doc.addEventListener(
'mousedown',
() => {
handleSelectionInteractionStart?.(doc)
if (longHoldTimeout) clearTimeout(longHoldTimeout)
longHoldTimeout = setTimeout(() => {
longHoldTimeout = null
@@ -187,7 +192,8 @@ export function useFoliateInput(
)
doc.addEventListener('mouseup', () => {
handleSelectionEnd(doc)
if (handleSelectionInteractionEnd) handleSelectionInteractionEnd(doc)
else handleSelectionEnd(doc)
})
doc.addEventListener(
@@ -214,9 +220,10 @@ export function useFoliateInput(
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('touchend', (e: TouchEvent) => handleTouchEnd(e, doc), { passive: true })
doc.addEventListener('touchcancel', (e: TouchEvent) => handleTouchEnd(e, doc, true), { passive: true })
doc.addEventListener('selectionchange', () => handleSelectionChange(doc))
}
@@ -7,63 +7,100 @@ export interface SelectionDetail {
export function useFoliateSelection(getView: () => unknown) {
let onTextSelected: ((detail: SelectionDetail) => void) | undefined
let onInteractionStart: (() => void) | undefined
let selectionChangeTimeout: ReturnType<typeof setTimeout> | null = null
const interactingDocs = new WeakSet<Document>()
const changedDocs = new WeakSet<Document>()
function setHandler(fn: (detail: SelectionDetail) => void) {
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) {
setTimeout(() => {
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
scheduleSelection(doc, 10)
}
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
function handleInteractionStart(doc: Document) {
clearSelectionTimer()
interactingDocs.add(doc)
const selection = doc.defaultView?.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
changedDocs.add(doc)
onInteractionStart?.()
}
if (iframe) {
const iframeRect = iframe.getBoundingClientRect()
popupX = iframeRect.left + rangeRect.left + rangeRect.width / 2
selectionTop = iframeRect.top + rangeRect.top
selectionBottom = iframeRect.top + rangeRect.bottom
}
function handleInteractionEnd(doc: Document) {
if (!interactingDocs.has(doc)) return
interactingDocs.delete(doc)
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 } })
}, 10)
const selection = doc.defaultView?.getSelection()
const hasSelection = Boolean(selection && !selection.isCollapsed && selection.rangeCount > 0)
const selectionChanged = changedDocs.has(doc)
changedDocs.delete(doc)
if (selectionChanged || hasSelection) scheduleSelection(doc, 50)
}
function handleSelectionChange(doc: Document) {
if (selectionChangeTimeout) clearTimeout(selectionChangeTimeout)
selectionChangeTimeout = setTimeout(() => {
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
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
handleSelectionEnd(doc)
}
}, 300)
clearSelectionTimer()
if (interactingDocs.has(doc)) {
changedDocs.add(doc)
return
}
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) scheduleSelection(doc, 300)
}
return { setHandler, handleSelectionEnd, handleSelectionChange }
return { setHandler, setInteractionStartHandler, handleSelectionEnd, handleSelectionChange, handleInteractionStart, handleInteractionEnd }
}