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 { 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"
|
||||
|
||||
@@ -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