fix(reader): disable page swipe gestures and use screen coordinates in scrolled flow

Disable horizontal page swipe gestures in scrolled flow and use screen coordinates for touch movement checks to prevent touch scrolling from triggering accidental page jumps.

Closes #910
This commit is contained in:
neon
2026-08-06 21:45:51 +00:00
parent 17cdd14546
commit 7dc854034d
2 changed files with 188 additions and 12 deletions
@@ -7,6 +7,9 @@ interface ViewLike {
goLeft?: () => void
goRight?: () => void
getBoundingClientRect: () => DOMRect
renderer?: {
getAttribute: (name: string) => string | null
}
}
type DocTarget = EventTarget & Document
@@ -28,11 +31,26 @@ function makeDocTarget(selection: Selection | null = null): DocTarget {
return target
}
function makeTouchEvent(type: string, touches: { clientX: number; clientY: number }[], changedTouches = touches): TouchEvent {
interface TouchLike {
clientX: number
clientY: number
screenX?: number
screenY?: number
}
function withScreenCoords(touches: TouchLike[]) {
return touches.map((touch) => ({
...touch,
screenX: touch.screenX ?? touch.clientX,
screenY: touch.screenY ?? touch.clientY,
}))
}
function makeTouchEvent(type: string, touches: TouchLike[], changedTouches = touches): TouchEvent {
const event = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent
Object.defineProperties(event, {
touches: { value: touches },
changedTouches: { value: changedTouches },
touches: { value: withScreenCoords(touches) },
changedTouches: { value: withScreenCoords(changedTouches) },
})
return event
}
@@ -42,13 +60,14 @@ describe('useFoliateInput', () => {
vi.useRealTimers()
})
it('navigates with document keyboard shortcuts', () => {
it('keeps document keyboard shortcuts active in scrolled flow', () => {
const prev = vi.fn<() => void>()
const next = vi.fn<() => void>()
const view: ViewLike = {
prev,
next,
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
renderer: { getAttribute: (name) => (name === 'flow' ? 'scrolled' : null) },
}
const input = useFoliateInput(() => view, undefined, vi.fn<() => void>(), vi.fn<() => void>())
@@ -138,6 +157,140 @@ describe('useFoliateInput', () => {
input.cleanup()
})
it.each([
{ endX: 40, expectedDirection: 'right' },
{ endX: 160, expectedDirection: 'left' },
])('navigates $expectedDirection for horizontal swipes in paginated flow', ({ endX, expectedDirection }) => {
const goLeft = vi.fn<() => void>()
const goRight = vi.fn<() => 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>())
const doc = makeDocTarget()
input.attachIframeClicks(doc)
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
doc.dispatchEvent(makeTouchEvent('touchmove', [{ clientX: endX, clientY: 110 }]))
doc.dispatchEvent(makeTouchEvent('touchend', [], [{ clientX: endX, clientY: 110 }]))
expect(goLeft).toHaveBeenCalledTimes(expectedDirection === 'left' ? 1 : 0)
expect(goRight).toHaveBeenCalledTimes(expectedDirection === 'right' ? 1 : 0)
input.cleanup()
})
it.each([
{ endX: 40, direction: 'right' },
{ endX: 160, direction: 'left' },
])('preserves native scrolling for $direction horizontal movement in scrolled flow', ({ endX }) => {
const prev = vi.fn<() => void>()
const next = vi.fn<() => void>()
const goLeft = vi.fn<() => void>()
const goRight = vi.fn<() => void>()
const view: ViewLike = {
prev,
next,
goLeft,
goRight,
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
renderer: { getAttribute: (name) => (name === 'flow' ? 'scrolled' : null) },
}
const input = useFoliateInput(() => view, undefined, vi.fn<() => void>(), vi.fn<() => void>())
const doc = makeDocTarget()
input.attachIframeClicks(doc)
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
doc.dispatchEvent(makeTouchEvent('touchmove', [{ clientX: endX, clientY: 140 }]))
doc.dispatchEvent(makeTouchEvent('touchend', [], [{ clientX: endX, clientY: 140 }]))
expect(prev).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
expect(goLeft).not.toHaveBeenCalled()
expect(goRight).not.toHaveBeenCalled()
input.cleanup()
})
it.each([
{ endX: 149, endY: 100, gesture: 'below the swipe threshold' },
{ endX: 160, endY: 170, gesture: 'predominantly vertical' },
])('does not navigate for a gesture $gesture in paginated flow', ({ endX, endY }) => {
const prev = vi.fn<() => void>()
const next = vi.fn<() => void>()
const goLeft = vi.fn<() => void>()
const goRight = vi.fn<() => void>()
const view: ViewLike = {
prev,
next,
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>())
const doc = makeDocTarget()
input.attachIframeClicks(doc)
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
doc.dispatchEvent(makeTouchEvent('touchmove', [{ clientX: endX, clientY: endY }]))
doc.dispatchEvent(makeTouchEvent('touchend', [], [{ clientX: endX, clientY: endY }]))
expect(prev).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
expect(goLeft).not.toHaveBeenCalled()
expect(goRight).not.toHaveBeenCalled()
input.cleanup()
})
it('ignores a scrolling flick whose touch point stays put in the scrolled document', () => {
const postMessage = vi.spyOn(window, 'postMessage').mockImplementation(() => {})
const view: ViewLike = {
prev: vi.fn<() => void>(),
next: vi.fn<() => void>(),
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
renderer: { getAttribute: (name) => (name === 'flow' ? 'scrolled' : null) },
}
const input = useFoliateInput(() => view, vi.fn<() => void>(), vi.fn<() => void>(), vi.fn<() => void>())
const doc = makeDocTarget()
input.attachIframeClicks(doc)
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 50, clientY: 50, screenX: 50, screenY: 150 }]))
doc.dispatchEvent(makeTouchEvent('touchend', [], [{ clientX: 50, clientY: 50, screenX: 50, screenY: 50 }]))
expect(postMessage).not.toHaveBeenCalled()
postMessage.mockRestore()
input.cleanup()
})
it('reports a stationary touch as a tap', () => {
const postMessage = vi.spyOn(window, 'postMessage').mockImplementation(() => {})
const view: ViewLike = {
prev: vi.fn<() => void>(),
next: vi.fn<() => void>(),
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
renderer: { getAttribute: (name) => (name === 'flow' ? 'scrolled' : null) },
}
const input = useFoliateInput(() => view, vi.fn<() => void>(), vi.fn<() => void>(), vi.fn<() => void>())
const doc = makeDocTarget()
input.attachIframeClicks(doc)
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 50, clientY: 50 }]))
doc.dispatchEvent(makeTouchEvent('touchend', [], [{ clientX: 50, clientY: 50 }]))
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'foliate-click' }), window.location.origin)
postMessage.mockRestore()
input.cleanup()
})
it('uses physical left and right helpers for arrow keys when available', () => {
const prev = vi.fn<() => void>()
const next = vi.fn<() => void>()
@@ -3,6 +3,18 @@ const RIGHT_ZONE = 0.7
const DOUBLE_CLICK_MS = 300
const ANNOTATION_CLICK_SUPPRESSION_MS = DOUBLE_CLICK_MS + 100
const SWIPE_THRESHOLD = 50
const TAP_MOVEMENT_THRESHOLD = 10
interface FoliateInputView {
prev?: () => void
next?: () => void
goLeft?: () => void
goRight?: () => void
getBoundingClientRect?: () => DOMRect
renderer?: {
getAttribute?: (name: string) => string | null
}
}
export function useFoliateInput(
getView: () => unknown,
@@ -18,19 +30,19 @@ export function useFoliateInput(
let suppressClickNavigationUntil = 0
let touchStartX = 0
let touchStartY = 0
let touchStartScreenX = 0
let touchStartScreenY = 0
let touchStartTime = 0
let lastTouchTime = 0
let isTextSelectionInProgress = false
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null
function getViewEl() {
return getView() as {
prev?: () => void
next?: () => void
goLeft?: () => void
goRight?: () => void
getBoundingClientRect?: () => DOMRect
} | null
return getView() as FoliateInputView | null
}
function isScrolledFlow() {
return getViewEl()?.renderer?.getAttribute?.('flow') === 'scrolled'
}
function navigateLeft() {
@@ -68,6 +80,8 @@ export function useFoliateInput(
const touch = e.touches[0]!
touchStartX = touch.clientX
touchStartY = touch.clientY
touchStartScreenX = touch.screenX
touchStartScreenY = touch.screenY
touchStartTime = Date.now()
isTextSelectionInProgress = false
longHoldTimeout = setTimeout(() => {
@@ -108,6 +122,10 @@ export function useFoliateInput(
const deltaY = Math.abs(touch.clientY - touchStartY)
if (Math.abs(deltaX) >= SWIPE_THRESHOLD && Math.abs(deltaX) > deltaY) {
// Scrolled flow scrolls the iframe along with its container, so the content stays
// under the finger and deltaY collapses to roughly zero. Any sideways drift while
// scrolling would then read as a swipe and turn the page.
if (isScrolledFlow()) return
if (isNavigating) return
isNavigating = true
if (deltaX < 0) navigateRight()
@@ -116,7 +134,12 @@ export function useFoliateInput(
return
}
if (touchDuration < 500 && Math.abs(deltaX) < 10 && deltaY < 10) {
// Client coordinates move with the scrolling container, so only screen coordinates
// separate a stationary tap from a flick that scrolled the page under the finger.
const screenDeltaX = Math.abs(touch.screenX - touchStartScreenX)
const screenDeltaY = Math.abs(touch.screenY - touchStartScreenY)
if (touchDuration < 500 && screenDeltaX < TAP_MOVEMENT_THRESHOLD && screenDeltaY < TAP_MOVEMENT_THRESHOLD) {
const iframe = doc.defaultView?.frameElement as HTMLIFrameElement | null
if (!iframe) return
const iframeRect = iframe.getBoundingClientRect()