Added pinch/zoom/drag for the photo viewer using @use-gesture/react + @react-spring/web, with swipe navigation only

when not zoomed and double‑tap/double‑click to toggle zoom. I also added a guest haptics toggle in settings (sheet
  + /settings) backed by localStorage.
This commit is contained in:
Codex Agent
2025-12-27 14:11:13 +01:00
parent fa5a1fa367
commit 1a48c9458e
3 changed files with 268 additions and 47 deletions

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { useHapticsPreference } from '../useHapticsPreference';
import { HAPTICS_STORAGE_KEY } from '../../lib/haptics';
function TestHarness() {
const { enabled, setEnabled } = useHapticsPreference();
return (
<button
type="button"
data-testid="toggle"
onClick={() => setEnabled(!enabled)}
>
{enabled ? 'on' : 'off'}
</button>
);
}
describe('useHapticsPreference', () => {
beforeEach(() => {
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: vi.fn(),
});
});
it('toggles and persists preference', () => {
render(<TestHarness />);
const button = screen.getByTestId('toggle');
expect(button).toHaveTextContent('on');
fireEvent.click(button);
expect(button).toHaveTextContent('off');
expect(window.localStorage.getItem(HAPTICS_STORAGE_KEY)).toBe('0');
});
});

View File

@@ -12,6 +12,8 @@ import ShareSheet from '../components/ShareSheet';
import { useEventBranding } from '../context/EventBrandingContext';
import { getDeviceId } from '../lib/device';
import { triggerHaptic } from '../lib/haptics';
import { useGesture } from '@use-gesture/react';
import { animated, to, useSpring } from '@react-spring/web';
type Photo = {
id: number;
@@ -114,37 +116,148 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? null;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? null;
const touchRef = React.useRef<HTMLDivElement>(null);
const startX = React.useRef(0);
const currentX = React.useRef(0);
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
const baseSizeRef = React.useRef({ width: 0, height: 0 });
const scaleRef = React.useRef(1);
const lastTapRef = React.useRef(0);
const [isZoomed, setIsZoomed] = React.useState(false);
const handleTouchStart = (e: React.TouchEvent) => {
startX.current = e.touches[0].clientX;
};
const [{ x, y, scale }, api] = useSpring(() => ({
x: 0,
y: 0,
scale: 1,
config: { tension: 260, friction: 28 },
}));
const handleTouchMove = (e: React.TouchEvent) => {
if (!touchRef.current) return;
currentX.current = e.touches[0].clientX;
const deltaX = currentX.current - startX.current;
touchRef.current.style.transform = `translateX(${deltaX}px)`;
};
const handleTouchEnd = () => {
if (!touchRef.current) return;
const deltaX = currentX.current - startX.current;
const threshold = 50; // pixels
touchRef.current.style.transform = 'translateX(0)';
if (Math.abs(deltaX) > threshold) {
if (deltaX > 0 && currentIndexVal > 0) {
// Swipe right - previous
onIndexChange?.(currentIndexVal - 1);
} else if (deltaX < 0 && currentIndexVal < currentPhotos.length - 1) {
// Swipe left - next
onIndexChange?.(currentIndexVal + 1);
}
const updateBaseSize = React.useCallback(() => {
if (!zoomImageRef.current) {
return;
}
const rect = zoomImageRef.current.getBoundingClientRect();
baseSizeRef.current = { width: rect.width, height: rect.height };
}, []);
React.useEffect(() => {
updateBaseSize();
}, [photo?.id, updateBaseSize]);
React.useEffect(() => {
window.addEventListener('resize', updateBaseSize);
return () => window.removeEventListener('resize', updateBaseSize);
}, [updateBaseSize]);
const clamp = React.useCallback((value: number, min: number, max: number) => {
return Math.min(max, Math.max(min, value));
}, []);
const getBounds = React.useCallback(
(nextScale: number) => {
const container = zoomContainerRef.current?.getBoundingClientRect();
const { width, height } = baseSizeRef.current;
if (!container || !width || !height) {
return { maxX: 0, maxY: 0 };
}
const scaledWidth = width * nextScale;
const scaledHeight = height * nextScale;
const maxX = Math.max(0, (scaledWidth - container.width) / 2);
const maxY = Math.max(0, (scaledHeight - container.height) / 2);
return { maxX, maxY };
},
[]
);
const resetZoom = React.useCallback(() => {
scaleRef.current = 1;
setIsZoomed(false);
api.start({ x: 0, y: 0, scale: 1 });
}, [api]);
React.useEffect(() => {
resetZoom();
}, [photo?.id, resetZoom]);
const toggleZoom = React.useCallback(() => {
const nextScale = scaleRef.current > 1.01 ? 1 : 2;
scaleRef.current = nextScale;
setIsZoomed(nextScale > 1.01);
api.start({ x: 0, y: 0, scale: nextScale });
}, [api]);
const bind = useGesture(
{
onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => {
if (event.cancelable) {
event.preventDefault();
}
const zoomed = scaleRef.current > 1.01;
if (!zoomed) {
api.start({ x: down ? mx : 0, y: 0, immediate: down });
if (last) {
api.start({ x: 0, y: 0, immediate: false });
const threshold = 80;
if (Math.abs(mx) > threshold) {
if (mx > 0 && currentIndexVal > 0) {
onIndexChange?.(currentIndexVal - 1);
} else if (mx < 0 && currentIndexVal < currentPhotos.length - 1) {
onIndexChange?.(currentIndexVal + 1);
}
}
}
return;
}
const { maxX, maxY } = getBounds(scaleRef.current);
api.start({
x: clamp(ox, -maxX, maxX),
y: clamp(oy, -maxY, maxY),
immediate: down,
});
},
onPinch: ({ offset: [nextScale], last, event }) => {
if (event.cancelable) {
event.preventDefault();
}
const clampedScale = clamp(nextScale, 1, 3);
scaleRef.current = clampedScale;
setIsZoomed(clampedScale > 1.01);
const { maxX, maxY } = getBounds(clampedScale);
api.start({
scale: clampedScale,
x: clamp(x.get(), -maxX, maxX),
y: clamp(y.get(), -maxY, maxY),
immediate: true,
});
if (last && clampedScale <= 1.01) {
resetZoom();
}
},
},
{
drag: {
from: () => [x.get(), y.get()],
filterTaps: true,
threshold: 4,
},
pinch: {
scaleBounds: { min: 1, max: 3 },
rubberband: true,
},
eventOptions: { passive: false },
}
);
const handlePointerUp = (event: React.PointerEvent) => {
if (event.pointerType !== 'touch') {
return;
}
const now = Date.now();
if (now - lastTapRef.current < 280) {
lastTapRef.current = 0;
toggleZoom();
return;
}
lastTapRef.current = now;
};
@@ -352,11 +465,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
<div className="px-3 pb-5 pt-16">
<div
ref={touchRef}
ref={zoomContainerRef}
className="relative flex min-h-[60vh] items-center justify-center overflow-hidden rounded-[30px] border border-white/15 bg-black/30 p-4 shadow-xl backdrop-blur"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
data-zoomed={isZoomed ? 'true' : 'false'}
>
{currentIndexVal > 0 && (
<Button
@@ -368,22 +479,39 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
<ChevronLeft className="h-5 w-5" />
</Button>
)}
<img
src={photo?.file_path || photo?.thumbnail_path}
alt={t('lightbox.photoAlt')
.replace('{id}', `${photo?.id ?? ''}`)
.replace(
'{suffix}',
photo?.task_title
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
: ''
)}
className="max-h-[70vh] max-w-full object-contain transition-transform duration-200"
onError={(e) => {
console.error('Image load error:', e);
(e.target as HTMLImageElement).style.display = 'none';
<animated.div
{...bind()}
onDoubleClick={toggleZoom}
onPointerUp={handlePointerUp}
data-testid="lightbox-zoom"
className="touch-none"
style={{
transform: to(
[x, y, scale],
(xValue, yValue, scaleValue) =>
`translate3d(${xValue}px, ${yValue}px, 0) scale(${scaleValue})`
),
}}
/>
>
<img
ref={zoomImageRef}
src={photo?.file_path || photo?.thumbnail_path}
alt={t('lightbox.photoAlt')
.replace('{id}', `${photo?.id ?? ''}`)
.replace(
'{suffix}',
photo?.task_title
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
: ''
)}
className="max-h-[70vh] max-w-full select-none object-contain"
onLoad={updateBaseSize}
onError={(e) => {
console.error('Image load error:', e);
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</animated.div>
{currentIndexVal < currentPhotos.length - 1 && (
<Button
variant="ghost"

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeAll, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import PhotoLightbox from '../PhotoLightbox';
import { EventBrandingProvider } from '../../context/EventBrandingContext';
import { LocaleProvider } from '../../i18n/LocaleContext';
import { ToastProvider } from '../../components/ToastHost';
const photo = {
id: 1,
file_path: '/test.jpg',
likes_count: 0,
};
describe('PhotoLightbox zoom gestures', () => {
beforeAll(() => {
if (!window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
}
});
it('toggles zoom state on double click', () => {
render(
<MemoryRouter>
<LocaleProvider>
<EventBrandingProvider>
<ToastProvider>
<PhotoLightbox photos={[photo]} currentIndex={0} token="event-token" />
</ToastProvider>
</EventBrandingProvider>
</LocaleProvider>
</MemoryRouter>
);
const zoomSurface = screen.getByTestId('lightbox-zoom');
const container = zoomSurface.closest('[data-zoomed]');
expect(container).toHaveAttribute('data-zoomed', 'false');
fireEvent.doubleClick(zoomSurface);
expect(container).toHaveAttribute('data-zoomed', 'true');
fireEvent.doubleClick(zoomSurface);
expect(container).toHaveAttribute('data-zoomed', 'false');
});
});