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({ threshold = 40, verticalThreshold = 60, cooldownMs = 800, disabled = false, }: UseSwipeNavOptions = {}): RefObject { const ref = useRef(null); const touchStart = useRef<{ x: number; y: number } | null>(null); const startScrollTop = useRef(0); const lastNavTime = useRef(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; }