From 39589b40f3494c01ae55a4f942102590cfb57c1f Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Thu, 24 Sep 2026 21:33:13 -0400 Subject: [PATCH] feat(media): two-finger pinch zoom in the media viewer (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/MediaGallery.tsx | 15 ++++- src/app/hooks/usePinchZoom.test.ts | 20 +++++++ src/app/hooks/usePinchZoom.ts | 82 ++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/app/hooks/usePinchZoom.test.ts create mode 100644 src/app/hooks/usePinchZoom.ts diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index 6689d7af1..c482b9b43 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -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(null); + const onPinchStart = usePinchZoom(isImage, zoom, setZoom); + const onMediaTouchStart = useCallback>( + (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 && ( { + 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); +}); diff --git a/src/app/hooks/usePinchZoom.ts b/src/app/hooks/usePinchZoom.ts new file mode 100644 index 000000000..b3f838a2a --- /dev/null +++ b/src/app/hooks/usePinchZoom.ts @@ -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 { + 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], + ); +}