From 4c8eba0cb43635d92a53d90c58b19007136c1c12 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 28 Apr 2026 15:05:30 +0200 Subject: [PATCH] fix(gallery): single-finger swipe nav in mobile lightbox (#332) The lightbox showed a "Swipe to navigate" hint on mobile, but the touch handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe fell through and the user could only navigate with the on-screen arrows. Add a 1-finger swipe detector: track the initial touch position, and on touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious / goToNext when the horizontal swipe exceeds 50px, dominates over vertical motion (1.2x), and completes within 600ms. Suppressed while zoomed in so the user can pan the image instead. --- .../src/components/gallery/PhotoLightbox.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 7273ea4e..259107e4 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -47,6 +47,7 @@ export const PhotoLightbox: React.FC = ({ const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [touchDistance, setTouchDistance] = useState(null); + const [swipeStart, setSwipeStart] = useState<{ x: number; y: number; t: number } | null>(null); const [showFeedback, setShowFeedback] = useState(initialShowFeedback); const [isSmallScreen, setIsSmallScreen] = useState(typeof window !== 'undefined' ? window.innerWidth < 640 : false); const [feedbackSettings, setFeedbackSettings] = useState<{ @@ -362,7 +363,8 @@ export const PhotoLightbox: React.FC = ({ } }; - // Touch event handlers for pinch-to-zoom + // Touch event handlers: pinch-to-zoom (2 fingers) + single-finger swipe nav. + // Swipe is suppressed while zoomed in so the user can pan instead. const handleTouchStart = (e: React.TouchEvent) => { if (e.touches.length === 2) { const touch1 = e.touches[0]; @@ -372,6 +374,10 @@ export const PhotoLightbox: React.FC = ({ touch2.clientY - touch1.clientY ); setTouchDistance(distance); + setSwipeStart(null); + } else if (e.touches.length === 1 && zoom <= 1) { + const t = e.touches[0]; + setSwipeStart({ x: t.clientX, y: t.clientY, t: Date.now() }); } }; @@ -383,7 +389,7 @@ export const PhotoLightbox: React.FC = ({ touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY ); - + const scale = newDistance / touchDistance; const newZoom = Math.max(1, Math.min(3, zoom * scale)); setZoom(newZoom); @@ -391,8 +397,20 @@ export const PhotoLightbox: React.FC = ({ } }; - const handleTouchEnd = () => { + const handleTouchEnd = (e: React.TouchEvent) => { setTouchDistance(null); + if (swipeStart && e.changedTouches.length > 0) { + const t = e.changedTouches[0]; + const dx = t.clientX - swipeStart.x; + const dy = t.clientY - swipeStart.y; + const dt = Date.now() - swipeStart.t; + // Horizontal swipe: > 50px and dominant over vertical, completed in < 600ms. + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.2 && dt < 600) { + if (dx > 0) goToPrevious(); + else goToNext(); + } + } + setSwipeStart(null); }; // Apply protection class to the lightbox container