83 lines
2.6 KiB
TypeScript
83 lines
2.6 KiB
TypeScript
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<HTMLElement> {
|
|||
|
|
const zoomRef = useRef(zoom);
|
|||
|
|
zoomRef.current = zoom;
|
|||
|
|
const cleanupRef = useRef<(() => void) | null>(null);
|
|||
|
|
|
|||
|
|
useEffect(() => () => cleanupRef.current?.(), []);
|
|||
|
|
|
|||
|
|
return useCallback<TouchEventHandler<HTMLElement>>(
|
|||
|
|
(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],
|
|||
|
|
);
|
|||
|
|
}
|