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
This commit is contained in:
Lotus CI
2026-09-24 21:33:13 -04:00
co-authored by Claude Opus 5.5
parent c1661e48fa
commit 39589b40f3
3 changed files with 115 additions and 2 deletions
+13 -2
View File
@@ -24,6 +24,7 @@ import { useNearViewport } from '../../hooks/useNearViewport';
import { useZoom } from '../../hooks/useZoom';
import { usePan, Pan } from '../../hooks/usePan';
import { useSwipeNav } from '../../hooks/useSwipeNav';
import { usePinchZoom } from '../../hooks/usePinchZoom';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -266,6 +267,14 @@ export function Lightbox({
useCallback(() => setIndex((i) => Math.max(0, i - 1)), []),
);
const dialogRef = useRef<HTMLDivElement>(null);
const onPinchStart = usePinchZoom(isImage, zoom, setZoom);
const onMediaTouchStart = useCallback<React.TouchEventHandler<HTMLElement>>(
(e) => {
swipe.onTouchStart(e);
onPinchStart(e);
},
[swipe, onPinchStart],
);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
// Reset zoom when navigating to another item (and thus pan, via usePan).
@@ -455,9 +464,11 @@ export function Lightbox({
alignItems="Center"
justifyContent="Center"
onWheel={handleWheel}
onTouchStart={swipe.onTouchStart}
onTouchStart={onMediaTouchStart}
onTouchEnd={swipe.onTouchEnd}
style={{ overflow: 'hidden', padding: config.space.S400 }}
// Our own swipe/pinch/pan handle touch here; stop the browser from
// zooming or scrolling the page underneath instead.
style={{ overflow: 'hidden', padding: config.space.S400, touchAction: 'none' }}
>
{index > 0 && (
<IconButton
+20
View File
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pinchZoom } from './usePinchZoom';
test('pinchZoom scales with finger distance', () => {
assert.equal(pinchZoom(100, 1, 200), 2);
assert.equal(pinchZoom(100, 2, 150), 3);
assert.equal(pinchZoom(200, 3, 100), 1.5);
});
test('pinchZoom clamps to 1×–5×', () => {
assert.equal(pinchZoom(100, 1, 1000), 5);
assert.equal(pinchZoom(100, 1, 20), 1);
});
test('pinchZoom snaps near 1× and ignores a zero start', () => {
assert.equal(pinchZoom(100, 1, 105), 1);
assert.equal(pinchZoom(100, 2, 52), 1);
assert.equal(pinchZoom(0, 1.7, 120), 1.7);
});
+82
View File
@@ -0,0 +1,82 @@
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],
);
}