mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user