Files
cinny/src/app/hooks/usePinchZoom.ts
T
Lotus CIandClaude Opus 5.5 39589b40f3 feat(media): two-finger pinch zoom in the media viewer (#164)
Zoom on a phone was only the +/- buttons; the pinch people try first did
nothing. usePinchZoom tracks a two-finger gesture on the viewer's media area
and scales from the zoom it started at (clamped 1×–5×, snapping back to
exactly 1× when released near it, so one-finger swipe navigation re-arms).
The area gets touch-action: none so the browser doesn't zoom the page
instead. One-finger swipe and pan already ignore multi-touch.

Verified on an emulated Pixel 7 with CDP two-point touch: spread 80→200 px
gives 250 %, closing to 120 px gives 150 %, closing fully returns to 100 %,
and a one-finger swipe afterwards still moves 2/2 → 1/2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:33:13 -04:00

83 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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],
);
}