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.
This commit is contained in:
@@ -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<number | null>(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<HTMLDivElement>(null);
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchStartY = useRef<number | null>(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 */}
|
||||
<div
|
||||
ref={imageWrapRef}
|
||||
className="relative z-[1] flex items-center justify-center w-full h-full"
|
||||
style={{
|
||||
transform: isDragging ? `translateX(${touchDelta}px)` : undefined,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Spinner while loading */}
|
||||
{!isLoaded && (
|
||||
|
||||
@@ -52,12 +52,12 @@ export function PhotoBottomBar({ event, onCommentClick, commentsEvent, onComment
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Gradient scrim + action strip */}
|
||||
<div className="relative pb-[env(safe-area-inset-bottom,0px)]">
|
||||
{/* Gradient scrim */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
|
||||
{/* Action strip — mirrors top bar: px-4 py-3 + safe-area */}
|
||||
<div className="relative safe-area-bottom">
|
||||
{/* Gradient scrim: tall enough to fade nicely over the image above the bar */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="relative flex items-center gap-1 px-3 pt-10 pb-3 max-w-xl mx-auto">
|
||||
<div className="relative flex items-center gap-1 px-4 py-3 max-w-xl mx-auto">
|
||||
{/* Avatar + name */}
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user