From ad0efdff60bde56fb88ea481f18aaf915bfd6521 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Mon, 2 Mar 2026 17:40:42 -0600 Subject: [PATCH] fix(lightbox): smoother swipe and consistent top/bottom padding Swipe: replace React state-driven translation (caused re-renders + jarring snap-back) with direct DOM mutation via ref. Axis is locked on first significant movement; at edges resistance is 20% rubber-band. On release, if past the 30% threshold the next/prev fires and the position resets without animation; otherwise it springs back with a single cubic-bezier transition. Bottom bar: matches top bar rhythm (px-4 py-3 + safe-area-bottom). Gradient scrim is now a fixed h-32 pseudo-element that extends upward from the bottom of the bar, independent of the content height, so it fades cleanly over the image without needing extra pt-10 padding on the strip. --- src/components/ImageGallery.tsx | 74 +++++++++++++++++++++++-------- src/components/PhotoBottomBar.tsx | 10 ++--- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/components/ImageGallery.tsx b/src/components/ImageGallery.tsx index 94c79e31..1e560295 100644 --- a/src/components/ImageGallery.tsx +++ b/src/components/ImageGallery.tsx @@ -315,9 +315,6 @@ export interface LightboxProps { export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, topBarLeft, showDownload = true, maxDotIndicators = 10, bottomBar, onNextEvent, onPrevEvent }: LightboxProps) { const [isLoaded, setIsLoaded] = useState(false); - const [touchStart, setTouchStart] = useState(null); - const [touchDelta, setTouchDelta] = useState(0); - const [isDragging, setIsDragging] = useState(false); const currentUrl = images[currentIndex]; const hasMultiple = images.length > 1; @@ -370,25 +367,67 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, topBar }; }, []); - // Touch handlers for swipe + // ── Swipe gesture — direct DOM mutation for zero-jank 1:1 tracking ────────── + const imageWrapRef = useRef(null); + const touchStartX = useRef(null); + const touchStartY = useRef(null); + const lockedAxis = useRef<'h' | 'v' | null>(null); + const SNAP_THRESHOLD = 0.3; // fraction of viewport width + + const setTranslate = (px: number, animated: boolean) => { + const el = imageWrapRef.current; + if (!el) return; + el.style.transition = animated ? 'transform 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94)' : 'none'; + el.style.transform = px === 0 ? '' : `translateX(${px}px)`; + }; + const handleTouchStart = (e: React.TouchEvent) => { - setTouchStart(e.touches[0].clientX); - setIsDragging(true); + touchStartX.current = e.touches[0].clientX; + touchStartY.current = e.touches[0].clientY; + lockedAxis.current = null; + setTranslate(0, false); }; const handleTouchMove = (e: React.TouchEvent) => { - if (touchStart === null) return; - setTouchDelta(e.touches[0].clientX - touchStart); + if (touchStartX.current === null || touchStartY.current === null) return; + const dx = e.touches[0].clientX - touchStartX.current; + const dy = e.touches[0].clientY - touchStartY.current; + + // Determine dominant axis on first significant movement + if (!lockedAxis.current) { + if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return; + lockedAxis.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v'; + } + if (lockedAxis.current !== 'h') return; + + e.preventDefault(); + + // Rubber-band resistance at edges + const atEdge = (dx > 0 && !canGoPrev) || (dx < 0 && !canGoNext); + setTranslate(atEdge ? dx * 0.2 : dx, false); }; - const handleTouchEnd = () => { - if (Math.abs(touchDelta) > 60) { - if (touchDelta > 0 && canGoPrev) handlePrev(); - else if (touchDelta < 0 && canGoNext) handleNext(); + const handleTouchEnd = (e: React.TouchEvent) => { + if (touchStartX.current === null || lockedAxis.current !== 'h') { + touchStartX.current = null; + lockedAxis.current = null; + setTranslate(0, true); + return; + } + const dx = e.changedTouches[0].clientX - touchStartX.current; + touchStartX.current = null; + lockedAxis.current = null; + + const threshold = window.innerWidth * SNAP_THRESHOLD; + if (dx < -threshold && canGoNext) { + handleNext(); + setTranslate(0, false); // parent will swap image; just reset without animation + } else if (dx > threshold && canGoPrev) { + handlePrev(); + setTranslate(0, false); + } else { + setTranslate(0, true); // spring back } - setTouchStart(null); - setTouchDelta(0); - setIsDragging(false); }; // Click anywhere that isn't a button or the image itself to close @@ -481,11 +520,8 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, topBar {/* Image — fills the entire viewport; top/bottom space is consumed by the top bar overlay */}
{/* Spinner while loading */} {!isLoaded && ( diff --git a/src/components/PhotoBottomBar.tsx b/src/components/PhotoBottomBar.tsx index b4bbe172..c8a5e9be 100644 --- a/src/components/PhotoBottomBar.tsx +++ b/src/components/PhotoBottomBar.tsx @@ -52,12 +52,12 @@ export function PhotoBottomBar({ event, onCommentClick, commentsEvent, onComment return ( <> - {/* Gradient scrim + action strip */} -
- {/* Gradient scrim */} -
+ {/* Action strip — mirrors top bar: px-4 py-3 + safe-area */} +
+ {/* Gradient scrim: tall enough to fade nicely over the image above the bar */} +
-
+
{/* Avatar + name */}