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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,8 @@ import ShareSheet from '../components/ShareSheet';
|
|||||||
import { useEventBranding } from '../context/EventBrandingContext';
|
import { useEventBranding } from '../context/EventBrandingContext';
|
||||||
import { getDeviceId } from '../lib/device';
|
import { getDeviceId } from '../lib/device';
|
||||||
import { triggerHaptic } from '../lib/haptics';
|
import { triggerHaptic } from '../lib/haptics';
|
||||||
|
import { useGesture } from '@use-gesture/react';
|
||||||
|
import { animated, to, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
type Photo = {
|
type Photo = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -114,37 +116,148 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? null;
|
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? null;
|
||||||
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? null;
|
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? null;
|
||||||
|
|
||||||
const touchRef = React.useRef<HTMLDivElement>(null);
|
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const startX = React.useRef(0);
|
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
|
||||||
const currentX = React.useRef(0);
|
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) => {
|
const [{ x, y, scale }, api] = useSpring(() => ({
|
||||||
startX.current = e.touches[0].clientX;
|
x: 0,
|
||||||
};
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
config: { tension: 260, friction: 28 },
|
||||||
|
}));
|
||||||
|
|
||||||
const handleTouchMove = (e: React.TouchEvent) => {
|
const updateBaseSize = React.useCallback(() => {
|
||||||
if (!touchRef.current) return;
|
if (!zoomImageRef.current) {
|
||||||
currentX.current = e.touches[0].clientX;
|
return;
|
||||||
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 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 className="px-3 pb-5 pt-16">
|
||||||
<div
|
<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"
|
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}
|
data-zoomed={isZoomed ? 'true' : 'false'}
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
>
|
>
|
||||||
{currentIndexVal > 0 && (
|
{currentIndexVal > 0 && (
|
||||||
<Button
|
<Button
|
||||||
@@ -368,22 +479,39 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
|||||||
<ChevronLeft className="h-5 w-5" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<img
|
<animated.div
|
||||||
src={photo?.file_path || photo?.thumbnail_path}
|
{...bind()}
|
||||||
alt={t('lightbox.photoAlt')
|
onDoubleClick={toggleZoom}
|
||||||
.replace('{id}', `${photo?.id ?? ''}`)
|
onPointerUp={handlePointerUp}
|
||||||
.replace(
|
data-testid="lightbox-zoom"
|
||||||
'{suffix}',
|
className="touch-none"
|
||||||
photo?.task_title
|
style={{
|
||||||
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
|
transform: to(
|
||||||
: ''
|
[x, y, scale],
|
||||||
)}
|
(xValue, yValue, scaleValue) =>
|
||||||
className="max-h-[70vh] max-w-full object-contain transition-transform duration-200"
|
`translate3d(${xValue}px, ${yValue}px, 0) scale(${scaleValue})`
|
||||||
onError={(e) => {
|
),
|
||||||
console.error('Image load error:', e);
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<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 && (
|
{currentIndexVal < currentPhotos.length - 1 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user