From c23af72da75db5ae5df7ef4f993fb6e52ab24dba Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Mon, 20 Apr 2026 20:00:50 -0500 Subject: [PATCH] Fix lightbox swipe-to-dismiss flicker and locked controls race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dismiss animation only translated the image strip, leaving the top bar, nav buttons, dot indicators, and bottom bar stationary — visible as a jarring flicker of controls. The backdrop also flashed back to full opacity for one frame before the portal unmounted. Wrap all visible content (everything except the backdrop) in a single container that receives the translateY transform so the entire UI sweeps away as one unit. Reorder the setTimeout callback so onClose fires before clearing the animating lock, and add an unmount-cleanup effect as a safety net against stuck controls. Regression-of: cc655891 --- src/components/ImageGallery.tsx | 189 ++++++++++++++++---------------- src/pages/ProfilePage.tsx | 78 +++++++------ 2 files changed, 139 insertions(+), 128 deletions(-) diff --git a/src/components/ImageGallery.tsx b/src/components/ImageGallery.tsx index 3fb24c8a..6de20f09 100644 --- a/src/components/ImageGallery.tsx +++ b/src/components/ImageGallery.tsx @@ -393,18 +393,19 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT slotRefs.current.forEach((_, idx) => setSlotTransform(idx, offsetPx, 'none')); }, [setSlotTransform]); - /** Apply vertical drag offset + opacity to the lightbox container for swipe-to-dismiss. */ + /** Apply vertical drag offset + opacity to the lightbox for swipe-to-dismiss. + * Backdrop fades in-place; all visible content (top bar, nav, images, dots, + * bottom bar) translates together via [data-lightbox-content]. */ const applyVerticalDismiss = useCallback((offsetY: number, transition: string) => { const el = containerRef.current; if (!el) return; const progress = Math.min(Math.abs(offsetY) / (window.innerHeight * 0.4), 1); el.style.transition = transition ? `opacity ${transition.split(' ').slice(1).join(' ')}` : 'none'; el.style.opacity = String(1 - progress * 0.6); - // Apply translateY to the image strip container (the overflow div) - const strip = el.querySelector('[data-lightbox-strip]'); - if (strip) { - strip.style.transition = transition; - strip.style.transform = `translateY(${offsetY}px)`; + const content = el.querySelector('[data-lightbox-content]'); + if (content) { + content.style.transition = transition; + content.style.transform = `translateY(${offsetY}px)`; } }, []); @@ -414,7 +415,8 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT snapAll(0); }, [currentIndex, snapAll]); - + // Safety: clear animating lock on unmount so stale refs can't block controls + useEffect(() => () => { animating.current = false; }, []); const onTouchStart = (e: React.TouchEvent) => { if (animating.current) return; @@ -475,9 +477,9 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT const targetY = dy > 0 ? window.innerHeight : -window.innerHeight; applyVerticalDismiss(targetY, `transform ${DURATION}ms ${EASING}`); setTimeout(() => { - animating.current = false; verticalOffsetRef.current = 0; onClose(); + animating.current = false; }, DURATION); } else { // Spring back @@ -548,97 +550,98 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} > - {/* Backdrop */} + {/* Backdrop — fades in-place, never translates */}
- {/* Top bar */} -
- {topBarLeft !== undefined ? topBarLeft : ( - <> - {hasMultiple && {currentIndex + 1} / {images.length}} - {!hasMultiple && } - - )} -
- {showDownload && ( - + {/* All interactive content — translated together during swipe-to-dismiss */} +
+ {/* Top bar */} +
+ {topBarLeft !== undefined ? topBarLeft : ( + <> + {hasMultiple && {currentIndex + 1} / {images.length}} + {!hasMultiple && } + )} - + )} + +
+
+ + {/* Prev/next buttons (desktop) */} + {canGoPrev && ( + + )} + {canGoNext && ( + + )} + + {/* Per-image slots — each absolutely positioned by index offset */} +
+ {visibleIndices.map((i) => { + const url = images[i]; + const isCurrent = i === currentIndex; + const initialX = (i - currentIndex) * window.innerWidth; + return ( +
{ + if (el) slotRefs.current.set(i, el); + else slotRefs.current.delete(i); + }} + className={cn( + 'absolute inset-0 flex items-center justify-center will-change-transform', + bottomBar ? 'pb-24 pt-14 px-4 sm:px-12' : 'py-6 pt-14 px-4 sm:px-12', + )} + style={{ transform: `translateX(${initialX}px)` }} + > + {isCurrent && !isLoaded && (mediaTypes?.[i] ?? 'image') === 'image' && ( +
+
+
+ )} + { dragX.current = null; axis.current = null; }} + onZoomChange={(zoomed) => { childZoomedRef.current = zoomed; }} + /> +
+ ); + })}
+ + {/* Dot indicators */} + {hasMultiple && images.length <= maxDotIndicators && ( +
+ {images.map((_, i) => ( +
+ ))} +
+ )} + + {/* Bottom bar — author info, reactions, captions, etc. */} + {bottomBar && ( +
e.stopPropagation()}> + {bottomBar} +
+ )}
- - {/* Prev/next buttons (desktop) */} - {canGoPrev && ( - - )} - {canGoNext && ( - - )} - - {/* Per-image slots — each absolutely positioned by index offset */} -
- {visibleIndices.map((i) => { - const url = images[i]; - const isCurrent = i === currentIndex; - const initialX = (i - currentIndex) * window.innerWidth; - return ( -
{ - if (el) slotRefs.current.set(i, el); - else slotRefs.current.delete(i); - }} - className={cn( - 'absolute inset-0 flex items-center justify-center will-change-transform', - bottomBar ? 'pb-24 pt-14 px-4 sm:px-12' : 'py-6 pt-14 px-4 sm:px-12', - )} - style={{ transform: `translateX(${initialX}px)` }} - > - {isCurrent && !isLoaded && (mediaTypes?.[i] ?? 'image') === 'image' && ( -
-
-
- )} - { dragX.current = null; axis.current = null; }} - onZoomChange={(zoomed) => { childZoomedRef.current = zoomed; }} - /> -
- ); - })} - - -
- - {/* Dot indicators */} - {hasMultiple && images.length <= maxDotIndicators && ( -
- {images.map((_, i) => ( -
- ))} -
- )} - - {/* Bottom bar — author info, reactions, captions, etc. */} - {bottomBar && ( -
e.stopPropagation()}> - {bottomBar} -
- )}
, document.body, ); diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 5ade4211..5885b62e 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -840,6 +840,10 @@ function ProfileImageLightbox({ imageUrl, onClose }: { imageUrl: string; onClose }; }, []); + // Safety: clear animating lock on unmount so stale refs can't block controls + useEffect(() => () => { animatingRef.current = false; }, []); + + /** Backdrop fades in-place; all visible content translates together via contentRef. */ const applyVerticalDismiss = useCallback((offsetY: number, transition: string) => { const el = containerRef.current; const content = contentRef.current; @@ -885,9 +889,9 @@ function ProfileImageLightbox({ imageUrl, onClose }: { imageUrl: string; onClose const targetY = dy > 0 ? window.innerHeight : -window.innerHeight; applyVerticalDismiss(targetY, `transform ${DURATION}ms ${EASING}`); setTimeout(() => { - animatingRef.current = false; verticalOffset.current = 0; onClose(); + animatingRef.current = false; }, DURATION); } else { applyVerticalDismiss(0, `transform ${DURATION}ms ${EASING}`); @@ -917,44 +921,48 @@ function ProfileImageLightbox({ imageUrl, onClose }: { imageUrl: string; onClose onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} > + {/* Backdrop — fades in-place, never translates */}
-
-
- - -
-
- -
- {!isLoaded && ( -
-
+ {/* All interactive content — translated together during swipe-to-dismiss */} +
+
+
+ +
- )} - + +
+ {!isLoaded && ( +
+
+
)} - onLoad={() => setIsLoaded(true)} - draggable={false} - /> + setIsLoaded(true)} + draggable={false} + /> +
, document.body,