Spaces:
Running
Running
| import { useEffect, useRef, type RefObject } from 'react'; | |
| import { useReaderStore } from '../stores/appStore'; | |
| interface UseSwipeNavOptions { | |
| /** Minimum px distance to register as a horizontal swipe (default: 40) */ | |
| threshold?: number; | |
| /** Minimum px distance to register as a vertical boundary drag (default: 60) */ | |
| verticalThreshold?: number; | |
| /** Cooldown time in ms between page transitions (default: 800) */ | |
| cooldownMs?: number; | |
| /** Disables the navigation triggers (e.g. when loading) */ | |
| disabled?: boolean; | |
| } | |
| /** | |
| * Swipe and scroll boundary navigation for the reader page. | |
| * | |
| * Horizontal swipes: | |
| * β Swipe left β next page | |
| * β Swipe right β prev page | |
| * | |
| * Vertical boundary scroll/swipe: | |
| * β Drag/scroll down at top β prev page | |
| * β Drag/scroll up at bottom β next page | |
| */ | |
| export function useSwipeNav<T extends HTMLElement>({ | |
| threshold = 40, | |
| verticalThreshold = 60, | |
| cooldownMs = 800, | |
| disabled = false, | |
| }: UseSwipeNavOptions = {}): RefObject<T | null> { | |
| const ref = useRef<T | null>(null); | |
| const touchStart = useRef<{ x: number; y: number } | null>(null); | |
| const startScrollTop = useRef<number>(0); | |
| const lastNavTime = useRef<number>(0); | |
| const { prevPage, nextPage } = useReaderStore(); | |
| useEffect(() => { | |
| // Attempt to bind to the scroll container, fallback to ref element | |
| const container = document.getElementById('reader-scroll-container') || ref.current; | |
| if (!container) return; | |
| const getScrollTop = () => { | |
| const scrollEl = document.getElementById('reader-scroll-container'); | |
| return scrollEl ? scrollEl.scrollTop : 0; | |
| }; | |
| const getScrollBounds = () => { | |
| const scrollEl = document.getElementById('reader-scroll-container'); | |
| if (!scrollEl) return { isAtTop: true, isAtBottom: true }; | |
| const isAtTop = scrollEl.scrollTop <= 2; | |
| const isAtBottom = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 5; | |
| return { isAtTop, isAtBottom }; | |
| }; | |
| const triggerPrev = () => { | |
| const now = Date.now(); | |
| if (now - lastNavTime.current < cooldownMs) return; | |
| lastNavTime.current = now; | |
| prevPage(); | |
| }; | |
| const triggerNext = () => { | |
| const now = Date.now(); | |
| if (now - lastNavTime.current < cooldownMs) return; | |
| lastNavTime.current = now; | |
| nextPage(); | |
| }; | |
| const onTouchStart = (e: TouchEvent) => { | |
| if (disabled) return; | |
| touchStart.current = { | |
| x: e.touches[0].clientX, | |
| y: e.touches[0].clientY, | |
| }; | |
| startScrollTop.current = getScrollTop(); | |
| }; | |
| const onTouchEnd = (e: TouchEvent) => { | |
| if (disabled) return; | |
| const start = touchStart.current; | |
| if (!start) return; | |
| const dx = e.changedTouches[0].clientX - start.x; | |
| const dy = e.changedTouches[0].clientY - start.y; | |
| const { isAtTop, isAtBottom } = getScrollBounds(); | |
| // 1. Horizontal swipe: anywhere on screen, must be distinct horizontal movement | |
| if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) >= threshold) { | |
| if (dx > 0) { | |
| triggerPrev(); | |
| } else { | |
| triggerNext(); | |
| } | |
| } | |
| // 2. Vertical swipe: must be at boundaries when touch started | |
| else if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) >= verticalThreshold) { | |
| const isStartAtTop = startScrollTop.current <= 2; | |
| const scrollEl = document.getElementById('reader-scroll-container'); | |
| const isStartAtBottom = scrollEl | |
| ? startScrollTop.current + scrollEl.clientHeight >= scrollEl.scrollHeight - 5 | |
| : true; | |
| if (dy > 0 && isAtTop && isStartAtTop) { | |
| triggerPrev(); // Pull down at top β previous page | |
| } else if (dy < 0 && isAtBottom && isStartAtBottom) { | |
| triggerNext(); // Pull up at bottom β next page | |
| } | |
| } | |
| touchStart.current = null; | |
| }; | |
| const onWheel = (e: WheelEvent) => { | |
| if (disabled) return; | |
| const { isAtTop, isAtBottom } = getScrollBounds(); | |
| // Check deltaY to ensure intent | |
| if (e.deltaY > 30 && isAtBottom) { | |
| triggerNext(); | |
| } else if (e.deltaY < -30 && isAtTop) { | |
| triggerPrev(); | |
| } | |
| }; | |
| container.addEventListener('touchstart', onTouchStart, { passive: true }); | |
| container.addEventListener('touchend', onTouchEnd, { passive: true }); | |
| container.addEventListener('wheel', onWheel, { passive: true }); | |
| return () => { | |
| container.removeEventListener('touchstart', onTouchStart); | |
| container.removeEventListener('touchend', onTouchEnd); | |
| container.removeEventListener('wheel', onWheel); | |
| }; | |
| }, [threshold, verticalThreshold, cooldownMs, disabled, prevPage, nextPage]); | |
| return ref; | |
| } | |