67 lines
2.1 KiB
TypeScript
67 lines
2.1 KiB
TypeScript
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<HTMLElement>; onTouchEnd: TouchEventHandler<HTMLElement> } {
|
||
|
|
const start = useRef<SwipeStart | null>(null);
|
||
|
|
|
||
|
|
const onTouchStart = useCallback<TouchEventHandler<HTMLElement>>(
|
||
|
|
(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<TouchEventHandler<HTMLElement>>(
|
||
|
|
(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 };
|
||
|
|
}
|