feat(media): swipe between items in the full-screen viewer (#164)
CI / Build & Quality Checks (push) Successful in 1m29s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Successful in 1m29s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Touch had no way to move between images except the small arrow buttons. A one-finger horizontal swipe now goes next/previous, and is inert while the image is zoomed in (where the same gesture pans). The classifier is pure and unit-tested: ≥ 60 px horizontal, not mostly vertical, under 800 ms, single finger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -23,6 +23,7 @@ import classNames from 'classnames';
|
||||
import { useNearViewport } from '../../hooks/useNearViewport';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan, Pan } from '../../hooks/usePan';
|
||||
import { useSwipeNav } from '../../hooks/useSwipeNav';
|
||||
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
@@ -255,7 +256,15 @@ export function Lightbox({
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
// Pan is only active for a zoomed-in image; usePan resets its offset when this
|
||||
// flips false (i.e. back to 1x, on navigation, or on a video).
|
||||
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
|
||||
const zoomedIn = isImage && zoom !== 1;
|
||||
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoomedIn);
|
||||
// [Gitea #164] Swipe between items on touch — only while not zoomed in,
|
||||
// where the same gesture pans the image instead.
|
||||
const swipe = useSwipeNav(
|
||||
!zoomedIn,
|
||||
useCallback(() => setIndex((i) => Math.min(items.length - 1, i + 1)), [items.length]),
|
||||
useCallback(() => setIndex((i) => Math.max(0, i - 1)), []),
|
||||
);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
||||
|
||||
@@ -446,6 +455,8 @@ export function Lightbox({
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={swipe.onTouchStart}
|
||||
onTouchEnd={swipe.onTouchEnd}
|
||||
style={{ overflow: 'hidden', padding: config.space.S400 }}
|
||||
>
|
||||
{index > 0 && (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { classifySwipe } from './useSwipeNav';
|
||||
|
||||
const start = { x: 300, y: 200, t: 1_000 };
|
||||
const at = (x: number, y: number) => ({ x, y });
|
||||
|
||||
describe('classifySwipe', () => {
|
||||
it('left is next, right is prev', () => {
|
||||
assert.equal(classifySwipe(start, at(100, 210), 1_200), 'next');
|
||||
assert.equal(classifySwipe({ ...start, x: 100 }, at(300, 190), 1_200), 'prev');
|
||||
});
|
||||
|
||||
it('ignores short, too-vertical and too-slow gestures', () => {
|
||||
assert.equal(classifySwipe(start, at(260, 200), 1_200), undefined);
|
||||
assert.equal(classifySwipe(start, at(100, 400), 1_200), undefined);
|
||||
assert.equal(classifySwipe(start, at(100, 200), 2_500), undefined);
|
||||
});
|
||||
|
||||
it('allows a little vertical drift on a long horizontal swipe', () => {
|
||||
assert.equal(classifySwipe(start, at(60, 340), 1_300), 'next');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { TouchEventHandler, useCallback, useRef } from 'react';
|
||||
|
||||
/** Horizontal travel that counts as a swipe rather than a tap or a scroll. */
|
||||
const THRESHOLD_PX = 60;
|
||||
/** Beyond this vertical travel it's a scroll/pan, not a horizontal swipe. */
|
||||
const VERTICAL_TOLERANCE = 1.2;
|
||||
const MAX_DURATION_MS = 800;
|
||||
|
||||
export type SwipeStart = { x: number; y: number; t: number };
|
||||
|
||||
/**
|
||||
* Classify a finished one-finger gesture. Exported for tests.
|
||||
*/
|
||||
export function classifySwipe(
|
||||
start: SwipeStart,
|
||||
end: { x: number; y: number },
|
||||
now: number,
|
||||
): 'next' | 'prev' | undefined {
|
||||
if (now - start.t > MAX_DURATION_MS) return undefined;
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
if (Math.abs(dx) < THRESHOLD_PX) return undefined;
|
||||
if (Math.abs(dy) > Math.abs(dx) / VERTICAL_TOLERANCE) return undefined;
|
||||
return dx < 0 ? 'next' : 'prev';
|
||||
}
|
||||
|
||||
/**
|
||||
* [Gitea #164] One-finger horizontal swipe for the media viewer: left goes to
|
||||
* the next item, right to the previous. Inert while `active` is false (e.g.
|
||||
* the image is zoomed in, where the same gesture pans instead).
|
||||
*/
|
||||
export function useSwipeNav(
|
||||
active: boolean,
|
||||
onNext: () => void,
|
||||
onPrev: () => void,
|
||||
): { onTouchStart: TouchEventHandler<HTMLElement>; onTouchEnd: TouchEventHandler<HTMLElement> } {
|
||||
const start = useRef<SwipeStart | null>(null);
|
||||
|
||||
const onTouchStart = useCallback<TouchEventHandler<HTMLElement>>(
|
||||
(evt) => {
|
||||
if (!active || evt.touches.length !== 1) {
|
||||
start.current = null;
|
||||
return;
|
||||
}
|
||||
const t = evt.touches[0];
|
||||
start.current = { x: t.clientX, y: t.clientY, t: Date.now() };
|
||||
},
|
||||
[active],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback<TouchEventHandler<HTMLElement>>(
|
||||
(evt) => {
|
||||
const s = start.current;
|
||||
start.current = null;
|
||||
if (!active || !s) return;
|
||||
const t = evt.changedTouches[0];
|
||||
if (!t) return;
|
||||
const verdict = classifySwipe(s, { x: t.clientX, y: t.clientY }, Date.now());
|
||||
if (verdict === 'next') onNext();
|
||||
else if (verdict === 'prev') onPrev();
|
||||
},
|
||||
[active, onNext, onPrev],
|
||||
);
|
||||
|
||||
return { onTouchStart, onTouchEnd };
|
||||
}
|
||||
Reference in New Issue
Block a user