From 1a48c9458e838e77b43094ec0bc2e96da25af5b8 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 27 Dec 2025 14:11:13 +0100 Subject: [PATCH] =?UTF-8?q?Added=20pinch/zoom/drag=20for=20the=20photo=20v?= =?UTF-8?q?iewer=20using=20@use-gesture/react=20+=20@react-spring/web,=20w?= =?UTF-8?q?ith=20swipe=20navigation=20only=20=20=20when=20not=20zoomed=20a?= =?UTF-8?q?nd=20double=E2=80=91tap/double=E2=80=91click=20to=20toggle=20zo?= =?UTF-8?q?om.=20I=20also=20added=20a=20guest=20haptics=20toggle=20in=20se?= =?UTF-8?q?ttings=20(sheet=20=20=20+=20/settings)=20backed=20by=20localSto?= =?UTF-8?q?rage.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/useHapticsPreference.test.tsx | 37 +++ resources/js/guest/pages/PhotoLightbox.tsx | 222 ++++++++++++++---- .../__tests__/PhotoLightboxZoom.test.tsx | 56 +++++ 3 files changed, 268 insertions(+), 47 deletions(-) create mode 100644 resources/js/guest/hooks/__tests__/useHapticsPreference.test.tsx create mode 100644 resources/js/guest/pages/__tests__/PhotoLightboxZoom.test.tsx diff --git a/resources/js/guest/hooks/__tests__/useHapticsPreference.test.tsx b/resources/js/guest/hooks/__tests__/useHapticsPreference.test.tsx new file mode 100644 index 0000000..f3efdb2 --- /dev/null +++ b/resources/js/guest/hooks/__tests__/useHapticsPreference.test.tsx @@ -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 ( + + ); +} + +describe('useHapticsPreference', () => { + beforeEach(() => { + window.localStorage.removeItem(HAPTICS_STORAGE_KEY); + Object.defineProperty(navigator, 'vibrate', { + configurable: true, + value: vi.fn(), + }); + }); + + it('toggles and persists preference', () => { + render(); + 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'); + }); +}); diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index e5e043f..d94a402 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -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(null); - const startX = React.useRef(0); - const currentX = React.useRef(0); + const zoomContainerRef = React.useRef(null); + const zoomImageRef = React.useRef(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
{currentIndexVal > 0 && ( )} - {t('lightbox.photoAlt') { - console.error('Image load error:', e); - (e.target as HTMLImageElement).style.display = 'none'; + + `translate3d(${xValue}px, ${yValue}px, 0) scale(${scaleValue})` + ), }} - /> + > + {t('lightbox.photoAlt') { + console.error('Image load error:', e); + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {currentIndexVal < currentPhotos.length - 1 && (