From 743086d3cb9100fb163bc9d04d968e5b611a1f99 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 1 May 2026 20:55:42 +0200 Subject: [PATCH] fix(lightbox): smooth carousel swipe + drop instructional hint (#348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for discussion #348. Carousel-style swipe The lightbox previously snapped to the next photo on swipe, then showed a loading spinner while the new image fetched — choppy compared with the reference video the reporter shared. The current photo is now rendered inside a 3-slide track (prev/current/next). As the finger drags, the track follows; on release the track animates to the neighbouring slot or springs back if the gesture didn't pass the threshold. Because the prev/next AuthenticatedImages render up front, the browser starts fetching them while the user is still on the current photo, so there's no loader flash on commit. - Phase machine ('idle' | 'dragging' | 'committing' | 'springing') drives the track's transform/transition. Commit + spring use a 280ms cubic-bezier ease. - Percentage-based transforms avoid measuring container width before the first paint. Commit threshold (read from the ref on demand) is max(60px, 20% of width) OR a fast flick (>0.5 px/ms with at least 40px of movement). - transitionend advances currentIndex with wrap-around and resets the track in one batch — slot contents rotate and the track snaps from the commit position back to centered with transition: none, so the visible image stays put. No flicker. - Vertical-cancel (>24px dy) abandons the drag and springs back so the user keeps the gesture they intended. - touch-action: none on the carousel container stops the browser fighting us with edge-swipe back navigation and native pinch-zoom. - Pinch starting mid-drag springs the track back smoothly so the image doesn't jerk under the second finger. - onTouchCancel covers system-interrupted gestures (incoming call etc). - dragX === 0 short-circuits to 'idle' instead of 'springing' so taps don't get stuck waiting for a transitionend that never fires. - Neighbour slides use a simplified AuthenticatedImage render (no canvas/fragment-grid pipeline) since they're only on screen during the swipe; the current slide keeps the full protection chain. - Neighbour videos render their thumbnail rather than spinning up a VideoPlayer. When the *current* photo is a video, the carousel is bypassed entirely — single VideoPlayer + no swipe handlers — because sliding a video element during a drag is awkward and adds nothing. - Removed the now-redundant imageLoaded state + spinner; AuthenticatedImage already shows a placeholder while loading. Keyboard arrows and the on-screen Prev/Next buttons still snap (no animation) — animating them would have required input queuing for fast double-presses, and the request was specifically about swipe. "Swipe to navigate" hint Removed the mobile-only overlay text. Swipe is universal in image viewers; the instruction read like training wheels and competed with the photo for attention. --- .../src/components/gallery/PhotoLightbox.tsx | 386 +++++++++++++----- 1 file changed, 286 insertions(+), 100 deletions(-) diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index a72a727f..3e654fb8 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; -import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react'; +import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; import type { Photo } from '../../types'; import { useDownloadPhoto } from '../../hooks/useGallery'; import { AuthenticatedImage } from '../common'; @@ -50,6 +50,21 @@ export const PhotoLightbox: React.FC = ({ // Ref (not state) so handleTouchEnd reads the value set by handleTouchStart // even when both fire in the same render batch. const swipeStartRef = useRef<{ x: number; y: number; t: number } | null>(null); + + // Carousel swipe state. A 3-slide track (prev/current/next) is shifted + // so the current slide is centered; the user's finger drags the track, + // and the track snaps to the neighbour or springs back when released. + // Percentage-based transforms avoid the need to measure the container + // before the first paint. + // - 'idle': showing the current slide, no transition + // - 'dragging': finger is down, track follows the finger (no transition) + // - 'committing': finger lifted past the threshold, animating to the + // neighbouring slot. On transitionend we advance currentIndex and reset. + // - 'springing': finger lifted below threshold, animating back to center. + const trackContainerRef = useRef(null); + const [dragX, setDragX] = useState(0); + const [phase, setPhase] = useState<'idle' | 'dragging' | 'committing' | 'springing'>('idle'); + const [commitDirection, setCommitDirection] = useState<-1 | 1>(1); const [showFeedback, setShowFeedback] = useState(initialShowFeedback); const [isSmallScreen, setIsSmallScreen] = useState(typeof window !== 'undefined' ? window.innerWidth < 640 : false); const [feedbackSettings, setFeedbackSettings] = useState<{ @@ -66,7 +81,6 @@ export const PhotoLightbox: React.FC = ({ const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); - const [imageLoaded, setImageLoaded] = useState(false); const guestIdentity = useGuestIdentityOptional(); const isGuestMode = guestIdentity?.identityMode === 'guest'; @@ -76,11 +90,7 @@ export const PhotoLightbox: React.FC = ({ return () => window.removeEventListener('resize', onResize); }, []); - // Reset image loaded state when changing photos - useEffect(() => { - setImageLoaded(false); - }, [currentIndex]); - + const downloadPhotoMutation = useDownloadPhoto(); const currentPhoto = photos[currentIndex]; @@ -365,8 +375,9 @@ export const PhotoLightbox: React.FC = ({ } }; - // Touch event handlers: pinch-to-zoom (2 fingers) + single-finger swipe nav. - // Swipe is suppressed while zoomed in so the user can pan instead. + // Touch event handlers: pinch-to-zoom (2 fingers) + single-finger + // carousel-style swipe nav. Swipe is suppressed while zoomed in so the + // user can pan instead. The carousel is also disabled mid-animation. const handleTouchStart = (e: React.TouchEvent) => { if (e.touches.length === 2) { const touch1 = e.touches[0]; @@ -377,9 +388,21 @@ export const PhotoLightbox: React.FC = ({ ); setTouchDistance(distance); swipeStartRef.current = null; - } else if (e.touches.length === 1 && zoom <= 1) { + // Cancel any in-progress carousel motion when a pinch starts — + // spring the track back so the image doesn't jerk under the user. + if (phase === 'dragging') { + if (dragX === 0) { + setPhase('idle'); + } else { + setPhase('springing'); + setDragX(0); + } + } + } else if (e.touches.length === 1 && zoom <= 1 && (phase === 'idle' || phase === 'dragging')) { const t = e.touches[0]; swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }; + setPhase('dragging'); + setDragX(0); } }; @@ -396,26 +419,124 @@ export const PhotoLightbox: React.FC = ({ const newZoom = Math.max(1, Math.min(3, zoom * scale)); setZoom(newZoom); setTouchDistance(newDistance); + return; + } + + if (phase === 'dragging' && e.touches.length === 1 && swipeStartRef.current) { + const t = e.touches[0]; + const dx = t.clientX - swipeStartRef.current.x; + const dy = t.clientY - swipeStartRef.current.y; + // Cancel the carousel drag if the gesture turns out to be vertical + // (e.g. an accidental scroll attempt while not zoomed). If we + // haven't moved horizontally yet, snap straight to idle — there's + // no transition to wait on — otherwise let the spring carry it back. + if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 24) { + swipeStartRef.current = null; + if (dragX === 0) { + setPhase('idle'); + } else { + setPhase('springing'); + setDragX(0); + } + return; + } + setDragX(dx); } }; const handleTouchEnd = (e: React.TouchEvent) => { setTouchDistance(null); const start = swipeStartRef.current; - if (start && e.changedTouches.length > 0) { + if (phase === 'dragging' && start && e.changedTouches.length > 0) { const t = e.changedTouches[0]; const dx = t.clientX - start.x; const dy = t.clientY - start.y; - const dt = Date.now() - start.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(); + const dt = Math.max(1, Date.now() - start.t); + const velocity = Math.abs(dx) / dt; // px / ms + const containerWidth = trackContainerRef.current?.offsetWidth ?? 0; + const threshold = Math.max(60, containerWidth * 0.2); + const isHorizontal = Math.abs(dx) > Math.abs(dy) * 1.2; + const shouldCommit = isHorizontal && (Math.abs(dx) > threshold || (velocity > 0.5 && Math.abs(dx) > 40)); + + if (shouldCommit) { + setCommitDirection(dx < 0 ? 1 : -1); + setDragX(dx); + setPhase('committing'); + } else if (dragX === 0) { + // Tap with no movement — no transition would fire, so skip the + // springing phase to avoid getting stuck waiting for transitionend. + setPhase('idle'); + } else { + setPhase('springing'); + setDragX(0); } + } else if (phase === 'dragging') { + // Touch ended without changedTouches data (rare) — reset cleanly. + setPhase('idle'); + setDragX(0); } swipeStartRef.current = null; }; + const handleTouchCancel = () => { + // System took over the gesture (incoming call, edge swipe, etc.). + // Spring back if the carousel was being dragged. + if (phase === 'dragging') { + if (dragX === 0) { + setPhase('idle'); + } else { + setPhase('springing'); + setDragX(0); + } + } + swipeStartRef.current = null; + setTouchDistance(null); + }; + + // Track transform. Percentages on translateX are self-referential (a + // 300%-wide track translated -33.333% moves left by exactly one container + // width), so we never need to know the container width to position the + // slides. The drag delta is added in pixels. + // - idle / springing target: -33.333% (current centered) + // - dragging: -33.333% + dragX px (finger follows) + // - committing next: -66.666% (next centered) + // - committing prev: 0% (previous centered) + const trackTransform = (() => { + if (phase === 'dragging') return `translate3d(calc(-33.3333% + ${dragX}px), 0, 0)`; + if (phase === 'committing') { + return commitDirection === 1 + ? 'translate3d(-66.6666%, 0, 0)' + : 'translate3d(0%, 0, 0)'; + } + return 'translate3d(-33.3333%, 0, 0)'; // idle | springing + })(); + + const trackTransition = phase === 'committing' || phase === 'springing' + ? 'transform 280ms cubic-bezier(0.22, 0.61, 0.36, 1)' + : 'none'; + + const handleTrackTransitionEnd = (e: React.TransitionEvent) => { + if (e.propertyName !== 'transform') return; + if (phase === 'committing') { + if (commitDirection === 1) { + setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0)); + } else { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); + } + setDragX(0); + setPhase('idle'); + } else if (phase === 'springing') { + setPhase('idle'); + } + }; + + const prevPhoto = photos.length > 1 + ? photos[(currentIndex - 1 + photos.length) % photos.length] + : null; + const nextPhoto = photos.length > 1 + ? photos[(currentIndex + 1) % photos.length] + : null; + // Apply protection class to the lightbox container const lightboxClass = useEnhancedProtection ? `fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` : @@ -566,93 +687,158 @@ export const PhotoLightbox: React.FC = ({ - {/* Image/Video container */} -
1 ? (isDragging ? 'grabbing' : 'grab') : 'default'), - right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0, - }} - > - {/* Loading spinner */} - {!imageLoaded && currentPhoto.media_type !== 'video' && ( -
- -
- )} + {/* Image/Video container. + For photos this hosts a 3-slide carousel (prev/current/next) so + swipe gestures animate the track and the neighbour images preload + while the user views the current one. Videos still render as a + single player — sliding video elements during a drag is awkward + and the carousel adds nothing for that case. */} + {(() => { + const isVideoCurrent = currentPhoto.media_type === 'video'; - {currentPhoto.media_type === 'video' ? ( - - ) : ( - { + // Reserve the slot even when there's no neighbour (single-photo + // gallery) so the flex layout keeps slides aligned. + if (!photo) { + return + ); + })()} {/* Feedback Panel */} {showFeedback && (