import { TouchEventHandler, useCallback, useRef } from 'react'; /** Horizontal travel that counts as a swipe rather than a tap or a scroll. */ const THRESHOLD_PX = 60; /** Beyond this vertical travel it's a scroll/pan, not a horizontal swipe. */ const VERTICAL_TOLERANCE = 1.2; const MAX_DURATION_MS = 800; export type SwipeStart = { x: number; y: number; t: number }; /** * Classify a finished one-finger gesture. Exported for tests. */ export function classifySwipe( start: SwipeStart, end: { x: number; y: number }, now: number, ): 'next' | 'prev' | undefined { if (now - start.t > MAX_DURATION_MS) return undefined; const dx = end.x - start.x; const dy = end.y - start.y; if (Math.abs(dx) < THRESHOLD_PX) return undefined; if (Math.abs(dy) > Math.abs(dx) / VERTICAL_TOLERANCE) return undefined; return dx < 0 ? 'next' : 'prev'; } /** * [Gitea #164] One-finger horizontal swipe for the media viewer: left goes to * the next item, right to the previous. Inert while `active` is false (e.g. * the image is zoomed in, where the same gesture pans instead). */ export function useSwipeNav( active: boolean, onNext: () => void, onPrev: () => void, ): { onTouchStart: TouchEventHandler; onTouchEnd: TouchEventHandler } { const start = useRef(null); const onTouchStart = useCallback>( (evt) => { if (!active || evt.touches.length !== 1) { start.current = null; return; } const t = evt.touches[0]; start.current = { x: t.clientX, y: t.clientY, t: Date.now() }; }, [active], ); const onTouchEnd = useCallback>( (evt) => { const s = start.current; start.current = null; if (!active || !s) return; const t = evt.changedTouches[0]; if (!t) return; const verdict = classifySwipe(s, { x: t.clientX, y: t.clientY }, Date.now()); if (verdict === 'next') onNext(); else if (verdict === 'prev') onPrev(); }, [active, onNext, onPrev], ); return { onTouchStart, onTouchEnd }; }