import { type TouchList as ReactTouchList, TouchEventHandler, useCallback, useEffect, useRef, } from 'react'; export const PINCH_MIN = 1; export const PINCH_MAX = 5; /** Within this of 1× the pinch snaps back to exactly 1× (so pan/swipe re-arm). */ const SNAP = 0.08; /** * Zoom for a pinch that started at `startDistance` with the image at * `startZoom` and is now `distance` apart. Clamped to [min, max]; snaps to 1× * when close. Exported for tests. */ export function pinchZoom( startDistance: number, startZoom: number, distance: number, min = PINCH_MIN, max = PINCH_MAX, ): number { if (startDistance <= 0) return startZoom; const z = Math.min(max, Math.max(min, startZoom * (distance / startDistance))); return Math.abs(z - 1) < SNAP ? 1 : Math.round(z * 100) / 100; } const distanceOf = (touches: TouchList | ReactTouchList): number => { const a = touches[0]; const b = touches[1]; return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); }; /** * [Gitea #164] Two-finger pinch zoom for the media viewer. Attach * `onTouchStart` to the media area (with `touch-action: none` so the browser * doesn't zoom the page instead). One-finger gestures are left to the swipe * and pan handlers, which ignore multi-touch. */ export function usePinchZoom( enabled: boolean, zoom: number, setZoom: (z: number) => void, ): TouchEventHandler { const zoomRef = useRef(zoom); zoomRef.current = zoom; const cleanupRef = useRef<(() => void) | null>(null); useEffect(() => () => cleanupRef.current?.(), []); return useCallback>( (evt) => { if (!enabled || evt.touches.length !== 2) return; cleanupRef.current?.(); const startDistance = distanceOf(evt.touches); const startZoom = zoomRef.current; const onMove = (e: TouchEvent) => { if (e.touches.length !== 2) return; e.preventDefault(); setZoom(pinchZoom(startDistance, startZoom, distanceOf(e.touches))); }; const onEnd = (e: TouchEvent) => { if (e.touches.length >= 2) return; cleanupRef.current?.(); }; document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onEnd); document.addEventListener('touchcancel', onEnd); cleanupRef.current = () => { document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); document.removeEventListener('touchcancel', onEnd); cleanupRef.current = null; }; }, [enabled, setZoom], ); }