tipitaka / webapp /tipitaka-web /src /hooks /useSwipeNav.ts
dhammawatthumpra's picture
feat: implement vertical scroll navigation and direction-aware page transitions
634c146
Raw
History Blame Contribute Delete
4.75 kB
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;
}