Add swipe-to-dismiss gesture to lightbox overlays
Users can now swipe up or down to dismiss the full-screen image lightbox, matching the native mobile pattern of flicking an image away instead of reaching for the X button. The image follows the finger with opacity fade, and commits the dismiss after 15% of viewport height. When zoomed in the gesture is disabled so it doesn't conflict with panning. Applies to both the main Lightbox (feeds, galleries, media collage) and the ProfileImageLightbox (avatar/banner taps).
This commit is contained in:
@@ -372,6 +372,11 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
const EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
const DURATION = 280;
|
||||
|
||||
// ── Vertical swipe-to-dismiss state ───────────────────────────────────────
|
||||
const verticalOffsetRef = useRef(0);
|
||||
/** Whether a child LightboxImage is currently zoomed (scale > 1). */
|
||||
const childZoomedRef = useRef(false);
|
||||
|
||||
// Refs to each rendered slot keyed by image index
|
||||
const slotRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
|
||||
@@ -388,6 +393,21 @@ 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. */
|
||||
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<HTMLDivElement>('[data-lightbox-strip]');
|
||||
if (strip) {
|
||||
strip.style.transition = transition;
|
||||
strip.style.transform = `translateY(${offsetY}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// When currentIndex changes (keyboard/button nav), snap all slots into position instantly
|
||||
useEffect(() => {
|
||||
dragOffsetRef.current = 0;
|
||||
@@ -405,6 +425,9 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
axis.current = null;
|
||||
// Kill any in-flight transition
|
||||
slotRefs.current.forEach((_, idx) => setSlotTransform(idx, dragOffsetRef.current, 'none'));
|
||||
// Reset vertical dismiss offset
|
||||
applyVerticalDismiss(0, 'none');
|
||||
verticalOffsetRef.current = 0;
|
||||
};
|
||||
|
||||
// Registered via addEventListener with { passive: false } to allow preventDefault
|
||||
@@ -417,6 +440,14 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
|
||||
axis.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||||
}
|
||||
if (axis.current === 'v') {
|
||||
// Vertical swipe-to-dismiss — only when not zoomed
|
||||
if (childZoomedRef.current) return;
|
||||
e.preventDefault();
|
||||
verticalOffsetRef.current = dy;
|
||||
applyVerticalDismiss(dy, 'none');
|
||||
return;
|
||||
}
|
||||
if (axis.current !== 'h') return;
|
||||
e.preventDefault();
|
||||
const atEdge = (dx > 0 && !canGoPrev) || (dx < 0 && !canGoNext);
|
||||
@@ -433,8 +464,31 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
}, []);
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
// Handle vertical swipe-to-dismiss
|
||||
if (axis.current === 'v' && dragY.current !== null && !childZoomedRef.current) {
|
||||
const dy = e.changedTouches[0].clientY - dragY.current;
|
||||
dragX.current = null; dragY.current = null; axis.current = null;
|
||||
const committed = Math.abs(dy) > window.innerHeight * 0.15;
|
||||
if (committed) {
|
||||
// Animate out in the swipe direction and dismiss
|
||||
animating.current = true;
|
||||
const targetY = dy > 0 ? window.innerHeight : -window.innerHeight;
|
||||
applyVerticalDismiss(targetY, `transform ${DURATION}ms ${EASING}`);
|
||||
setTimeout(() => {
|
||||
animating.current = false;
|
||||
verticalOffsetRef.current = 0;
|
||||
onClose();
|
||||
}, DURATION);
|
||||
} else {
|
||||
// Spring back
|
||||
applyVerticalDismiss(0, `transform ${DURATION}ms ${EASING}`);
|
||||
verticalOffsetRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragX.current === null || axis.current !== 'h') {
|
||||
dragX.current = null; axis.current = null;
|
||||
dragX.current = null; dragY.current = null; axis.current = null;
|
||||
// Spring back
|
||||
slotRefs.current.forEach((_, idx) =>
|
||||
setSlotTransform(idx, 0, `transform ${DURATION}ms ${EASING}`)
|
||||
@@ -443,7 +497,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
return;
|
||||
}
|
||||
const dx = e.changedTouches[0].clientX - dragX.current;
|
||||
dragX.current = null; axis.current = null;
|
||||
dragX.current = null; dragY.current = null; axis.current = null;
|
||||
|
||||
const committed = Math.abs(dx) > window.innerWidth * 0.2;
|
||||
const goingNext = dx < 0 && canGoNext && committed;
|
||||
@@ -530,7 +584,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
)}
|
||||
|
||||
{/* Per-image slots — each absolutely positioned by index offset */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div data-lightbox-strip className="absolute inset-0 overflow-hidden">
|
||||
{visibleIndices.map((i) => {
|
||||
const url = images[i];
|
||||
const isCurrent = i === currentIndex;
|
||||
@@ -561,6 +615,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
isLoaded={isCurrent ? isLoaded : true}
|
||||
onLoad={markLoaded}
|
||||
onSwipeBlocked={() => { dragX.current = null; axis.current = null; }}
|
||||
onZoomChange={(zoomed) => { childZoomedRef.current = zoomed; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -593,12 +648,14 @@ const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 8;
|
||||
|
||||
/** Lightbox image with pinch/wheel zoom and pan support. */
|
||||
function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked, onZoomChange }: {
|
||||
url: string;
|
||||
isLoaded: boolean;
|
||||
onLoad: (url: string) => void;
|
||||
/** Called when a horizontal swipe is intercepted by pan (image is zoomed). */
|
||||
onSwipeBlocked?: () => void;
|
||||
/** Called when the image zoom state changes (zoomed in or back to 1x). */
|
||||
onZoomChange?: (zoomed: boolean) => void;
|
||||
}) {
|
||||
const { src, onError } = useBlossomFallback(url);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
@@ -625,13 +682,19 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) handleLoaded();
|
||||
}, [src, handleLoaded]);
|
||||
|
||||
/** Notify parent when zoom state changes. */
|
||||
const notifyZoom = useCallback(() => {
|
||||
onZoomChange?.(scale.current > 1);
|
||||
}, [onZoomChange]);
|
||||
|
||||
// Reset zoom when url changes
|
||||
useEffect(() => {
|
||||
scale.current = 1;
|
||||
panX.current = 0;
|
||||
panY.current = 0;
|
||||
applyTransform();
|
||||
}, [url]);
|
||||
notifyZoom();
|
||||
}, [url, notifyZoom]);
|
||||
|
||||
function applyTransform(animated = false) {
|
||||
const el = wrapRef.current;
|
||||
@@ -696,6 +759,7 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
}
|
||||
}
|
||||
applyTransform(true);
|
||||
notifyZoom();
|
||||
}
|
||||
lastTap.current = now;
|
||||
}
|
||||
@@ -713,6 +777,7 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
panY.current = p.panY + (midY - p.midY);
|
||||
clampPan(newScale);
|
||||
applyTransform();
|
||||
notifyZoom();
|
||||
} else if (e.touches.length === 1 && panStart.current && scale.current > 1) {
|
||||
e.preventDefault();
|
||||
const p = panStart.current;
|
||||
@@ -732,6 +797,7 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
if (scale.current < MIN_SCALE) {
|
||||
scale.current = MIN_SCALE; panX.current = 0; panY.current = 0;
|
||||
applyTransform(true);
|
||||
notifyZoom();
|
||||
} else {
|
||||
clampPan();
|
||||
applyTransform(true);
|
||||
@@ -748,6 +814,7 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
if (scale.current === MIN_SCALE) { panX.current = 0; panY.current = 0; }
|
||||
else clampPan();
|
||||
applyTransform();
|
||||
notifyZoom();
|
||||
} else if (scale.current > 1) {
|
||||
e.preventDefault();
|
||||
panX.current -= e.deltaX;
|
||||
@@ -827,6 +894,7 @@ function LightboxSlot({
|
||||
isLoaded,
|
||||
onLoad,
|
||||
onSwipeBlocked,
|
||||
onZoomChange,
|
||||
}: {
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'audio';
|
||||
@@ -835,6 +903,7 @@ function LightboxSlot({
|
||||
isLoaded: boolean;
|
||||
onLoad: (url: string) => void;
|
||||
onSwipeBlocked?: () => void;
|
||||
onZoomChange?: (zoomed: boolean) => void;
|
||||
}) {
|
||||
const author = useAuthor(type === 'audio' ? meta?.pubkey : undefined);
|
||||
const authorMeta = author.data?.metadata;
|
||||
@@ -870,5 +939,5 @@ function LightboxSlot({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <LightboxImage url={url} isLoaded={isLoaded} onLoad={onLoad} onSwipeBlocked={onSwipeBlocked} />;
|
||||
return <LightboxImage url={url} isLoaded={isLoaded} onLoad={onLoad} onSwipeBlocked={onSwipeBlocked} onZoomChange={onZoomChange} />;
|
||||
}
|
||||
|
||||
@@ -812,6 +812,15 @@ function PinnedLabel({ isOwn, onUnpin }: { isOwn: boolean; onUnpin: () => void }
|
||||
|
||||
function ProfileImageLightbox({ imageUrl, onClose }: { imageUrl: string; onClose: () => void }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Vertical swipe-to-dismiss state
|
||||
const dragY = useRef<number | null>(null);
|
||||
const verticalOffset = useRef(0);
|
||||
const animatingRef = useRef(false);
|
||||
const EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
const DURATION = 280;
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
@@ -831,6 +840,61 @@ function ProfileImageLightbox({ imageUrl, onClose }: { imageUrl: string; onClose
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyVerticalDismiss = useCallback((offsetY: number, transition: string) => {
|
||||
const el = containerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!el || !content) 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);
|
||||
content.style.transition = transition;
|
||||
content.style.transform = `translateY(${offsetY}px)`;
|
||||
}, []);
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
if (animatingRef.current || e.touches.length >= 2) return;
|
||||
dragY.current = e.touches[0].clientY;
|
||||
applyVerticalDismiss(0, 'none');
|
||||
verticalOffset.current = 0;
|
||||
};
|
||||
|
||||
// Use non-passive touchmove to allow preventDefault
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: TouchEvent) => {
|
||||
if (dragY.current === null || animatingRef.current || e.touches.length !== 1) return;
|
||||
const dy = e.touches[0].clientY - dragY.current;
|
||||
// Only start tracking after a small threshold
|
||||
if (Math.abs(dy) < 4) return;
|
||||
e.preventDefault();
|
||||
verticalOffset.current = dy;
|
||||
applyVerticalDismiss(dy, 'none');
|
||||
};
|
||||
el.addEventListener('touchmove', handler, { passive: false });
|
||||
return () => el.removeEventListener('touchmove', handler);
|
||||
}, [applyVerticalDismiss]);
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
if (dragY.current === null || animatingRef.current) { dragY.current = null; return; }
|
||||
const dy = e.changedTouches[0].clientY - dragY.current;
|
||||
dragY.current = null;
|
||||
const committed = Math.abs(dy) > window.innerHeight * 0.15;
|
||||
if (committed) {
|
||||
animatingRef.current = true;
|
||||
const targetY = dy > 0 ? window.innerHeight : -window.innerHeight;
|
||||
applyVerticalDismiss(targetY, `transform ${DURATION}ms ${EASING}`);
|
||||
setTimeout(() => {
|
||||
animatingRef.current = false;
|
||||
verticalOffset.current = 0;
|
||||
onClose();
|
||||
}, DURATION);
|
||||
} else {
|
||||
applyVerticalDismiss(0, `transform ${DURATION}ms ${EASING}`);
|
||||
verticalOffset.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'IMG' || target.closest('button') || target.closest('[data-gallery-topbar]')) return;
|
||||
@@ -847,8 +911,11 @@ function ProfileImageLightbox({ imageUrl, onClose }: { imageUrl: string; onClose
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center animate-in fade-in duration-200"
|
||||
onClick={handleBackdropClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/90 backdrop-blur-md" />
|
||||
|
||||
@@ -871,7 +938,7 @@ function ProfileImageLightbox({ imageUrl, onClose }: { imageUrl: string; onClose
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-[1] flex items-center justify-center w-full h-full px-4 py-16 sm:px-16">
|
||||
<div ref={contentRef} className="relative z-[1] flex items-center justify-center w-full h-full px-4 py-16 sm:px-16">
|
||||
{!isLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="size-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />
|
||||
|
||||
Reference in New Issue
Block a user