fix: carousel image lightbox

This commit is contained in:
gosangam
2026-08-09 11:11:35 +05:30
parent df49e7afcc
commit baf36d3d67
4 changed files with 220 additions and 130 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ export function PostCard({
? `View photo ${i + 1} of ${post.images.length}` ? `View photo ${i + 1} of ${post.images.length}`
: "View photo" : "View photo"
} }
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 cursor-zoom-in transition hover:ring-foreground/30 outline-none focus-visible:ring-2 focus-visible:ring-ring" className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 cursor-pointer transition hover:ring-foreground/30 outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<Image <Image
src={img.url} src={img.url}
+101 -40
View File
@@ -1,26 +1,33 @@
"use client"; "use client";
/** /**
* PostImageLightbox — full-screen viewer for a post's attached photos. * PostImageLightbox — full-screen, swipeable viewer for a post's photos.
* *
* Opened by tapping any image on a PostCard. Controlled by the caller: `index` * Opened by tapping any image on a PostCard or PostScrollCard. Controlled by
* is the photo being shown (null = closed), so the card owns the state and this * the caller: `index` is the photo on screen (null = closed), so the card owns
* component stays a pure presentation layer. * the state and this stays presentation-only.
* *
* Dismiss: tap the backdrop ring around the photo, the ✕, or press Escape. * Navigation is a native CSS scroll-snap strip — the same primitive the feed
* Navigate a multi-photo post with ← / → or the on-screen chevrons. * carousel uses — so swiping works on touch for free, and the ← / → keys and
* chevrons simply scroll that strip. The scroller is the single source of
* truth: buttons/keys never set `index` directly, they scroll, and the scroll
* handler reports the settled index back up. That one-way flow is what keeps a
* programmatic scroll from fighting the user's own gesture.
*
* Dismiss: tap the photo or the surrounding ring, the ✕, or press Escape. Taps
* are distinguished from swipes by useTapGuard, so navigating never closes.
* *
* Rendered through a portal onto <body> so it escapes the feed's virtualised, * Rendered through a portal onto <body> so it escapes the feed's virtualised,
* transformed rows — an overlay nested inside one of those would be clipped by * transformed rows — an overlay nested inside one would be positioned against
* the row's stacking context and positioned against the transform, not the * that transform and clipped by its stacking context, not the viewport.
* viewport.
*/ */
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Image from "next/image"; import Image from "next/image";
import { ChevronLeft, ChevronRight, X } from "lucide-react"; import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { PostImage } from "@/lib/social"; import type { PostImage } from "@/lib/social";
import { useTapGuard } from "./use-tap-guard";
export function PostImageLightbox({ export function PostImageLightbox({
images, images,
@@ -41,17 +48,40 @@ export function PostImageLightbox({
}) { }) {
const open = index !== null; const open = index !== null;
const count = images.length; const count = images.length;
const scrollerRef = useRef<HTMLDivElement>(null);
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
/** Scroll the strip to a slide. Clamped, not wrapped: the buttons and keys
* should be able to reach exactly what a swipe can, and you can't swipe
* past the end. */
const scrollToIndex = useCallback(
(i: number, behavior: ScrollBehavior) => {
const el = scrollerRef.current;
if (!el) return;
const target = Math.max(0, Math.min(i, count - 1));
el.scrollTo({ left: target * el.offsetWidth, behavior });
},
[count],
);
// Wrap around at both ends so a long press on → never dead-ends.
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
if (index === null || count < 2) return; if (index === null) return;
onIndexChange((index - 1 + count) % count); scrollToIndex(index - 1, "smooth");
}, [index, count, onIndexChange]); }, [index, scrollToIndex]);
const goNext = useCallback(() => { const goNext = useCallback(() => {
if (index === null || count < 2) return; if (index === null) return;
onIndexChange((index + 1) % count); scrollToIndex(index + 1, "smooth");
}, [index, count, onIndexChange]); }, [index, scrollToIndex]);
// Jump to the tapped photo when the viewer opens. Deliberately keyed on
// `open` only — re-running on every `index` change would yank the strip back
// mid-swipe, since scrolling is what updates `index` in the first place.
useEffect(() => {
if (!open) return;
scrollToIndex(index ?? 0, "auto");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -74,41 +104,56 @@ export function PostImageLightbox({
}; };
}, [open]); }, [open]);
if (!open || typeof document === "undefined") return null; function onScroll(e: React.UIEvent<HTMLDivElement>) {
const img = images[index]; const el = e.currentTarget;
if (!img) return null; const w = el.offsetWidth;
if (w === 0) return;
const i = Math.round(el.scrollLeft / w);
if (i !== index && i >= 0 && i < count) onIndexChange(i);
}
return createPortal( if (!open || typeof document === "undefined") return null;
if (!images[index]) return null;
return (
createPortal(
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={ aria-label={count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"}
count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"
}
onClick={onClose} onClick={onClose}
className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0" className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0"
> >
{/* The photo sits inset from the edges, leaving a genuine backdrop ring {/* The strip sits inset from the edges, leaving a backdrop ring that
that closes on tap. `fill` resolves against this box (padding would also closes. `absolute inset-*` rather than padding because padding
NOT inset an absolutely-positioned child), hence inset-* rather than does NOT inset an absolutely-positioned `fill` child. */}
p-*. stopPropagation keeps a tap on the photo itself from closing. */}
<div <div
onClick={(e) => e.stopPropagation()} ref={scrollerRef}
className="absolute inset-4 sm:inset-12" onScroll={onScroll}
onPointerDown={onPointerDown}
onClick={(e) => {
// Swiping between photos must never dismiss the viewer; only a
// genuine tap falls through to the backdrop's onClose.
if (!wasTap(e)) e.stopPropagation();
}}
className="absolute inset-4 sm:inset-12 flex overflow-x-auto snap-x snap-mandatory overscroll-contain [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((img) => (
<div
key={img.id}
className="relative h-full w-full shrink-0 snap-center"
> >
<Image <Image
// Keyed so switching photos swaps the element instead of
// cross-fading a stale bitmap into the new slot.
key={img.id}
src={img.url} src={img.url}
alt="" alt=""
fill fill
sizes="100vw" sizes="100vw"
className="object-contain" className="object-contain"
unoptimized unoptimized
priority
/> />
</div> </div>
))}
</div>
<button <button
type="button" type="button"
@@ -123,40 +168,55 @@ export function PostImageLightbox({
<> <>
<button <button
type="button" type="button"
disabled={index === 0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
goPrev(); goPrev();
}} }}
aria-label="Previous photo" aria-label="Previous photo"
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors" className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
> >
<ChevronLeft className="size-5" /> <ChevronLeft className="size-5" />
</button> </button>
<button <button
type="button" type="button"
disabled={index === count - 1}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
goNext(); goNext();
}} }}
aria-label="Next photo" aria-label="Next photo"
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors" className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
> >
<ChevronRight className="size-5" /> <ChevronRight className="size-5" />
</button> </button>
{/* Counter sits top-left so it never collides with the caption {/* Counter sits top-left so it never collides with the caption
scrim along the bottom. */} scrim along the bottom. */}
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums"> <span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
{index + 1} / {count} {index + 1} / {count}
</span> </span>
{/* Dot indicators, mirroring the in-feed carousel. */}
<div className="pointer-events-none fixed inset-x-0 bottom-1.5 flex items-center justify-center gap-1.5">
{images.map((img, i) => (
<span
key={img.id}
aria-hidden
className={`size-1.5 rounded-full transition-all ${
i === index ? "bg-white" : "bg-white/40"
}`}
/>
))}
</div>
</> </>
) : null} ) : null}
{/* Caption scrim. `pointer-events-none` so it never intercepts a tap {/* Caption scrim. `pointer-events-none` so it never intercepts a tap
meant for the backdrop — the whole strip stays click-to-close — and meant for the backdrop, and the gradient fades upward so the
the gradient fades upward so the photo's lower edge stays readable photo's lower edge stays readable rather than cut by a hard band. */}
rather than being cut by a hard band. */}
{caption?.trim() ? ( {caption?.trim() ? (
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-6"> <div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-8">
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3"> <p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
&ldquo;{caption.trim()}&rdquo; &ldquo;{caption.trim()}&rdquo;
</p> </p>
@@ -164,5 +224,6 @@ export function PostImageLightbox({
) : null} ) : null}
</div>, </div>,
document.body, document.body,
)
); );
} }
+6 -28
View File
@@ -41,6 +41,7 @@ import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer"; import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu"; import { PostCardMenu } from "./post-card-menu";
import { PostImageLightbox } from "./post-image-lightbox"; import { PostImageLightbox } from "./post-image-lightbox";
import { useTapGuard } from "./use-tap-guard";
import { FeedFollowButton } from "./feed-follow-button"; import { FeedFollowButton } from "./feed-follow-button";
import { UpdateBackgroundModal } from "../update-background-modal"; import { UpdateBackgroundModal } from "../update-background-modal";
import { MentionsDrawer } from "../mentions-drawer"; import { MentionsDrawer } from "../mentions-drawer";
@@ -252,12 +253,9 @@ function ImageCarousel({
}) { }) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const scrollerRef = useRef<HTMLDivElement>(null); const scrollerRef = useRef<HTMLDivElement>(null);
// Where the pointer went down, plus the scroller's position at that moment. // Swiping the carousel and tapping a photo have to stay independent — the
// Swiping the carousel and tapping a photo have to stay independent: the // strip scrolls natively, and browsers still fire a click after some swipes.
// carousel scrolls natively (CSS scroll-snap), and browsers still fire a const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
// click after some swipe gestures. Comparing both the pointer travel AND
// whether the strip actually scrolled lets us drop those.
const tapStart = useRef({ x: 0, y: 0, scrollLeft: 0 });
function onScroll(e: React.UIEvent<HTMLDivElement>) { function onScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget; const el = e.currentTarget;
@@ -266,26 +264,6 @@ function ImageCarousel({
setActive(Math.round(el.scrollLeft / w)); setActive(Math.round(el.scrollLeft / w));
} }
function onPointerDown(e: React.PointerEvent) {
tapStart.current = {
x: e.clientX,
y: e.clientY,
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
};
}
/** True only for a genuine tap — barely any pointer travel and no scroll. */
function wasTap(e: React.MouseEvent) {
const moved =
Math.abs(e.clientX - tapStart.current.x) > 8 ||
Math.abs(e.clientY - tapStart.current.y) > 8;
const scrolled =
Math.abs(
(scrollerRef.current?.scrollLeft ?? 0) - tapStart.current.scrollLeft,
) > 4;
return !moved && !scrolled;
}
if (images.length === 1) { if (images.length === 1) {
// Single image: no scroller, so every click is unambiguously a tap. // Single image: no scroller, so every click is unambiguously a tap.
return ( return (
@@ -293,7 +271,7 @@ function ImageCarousel({
type="button" type="button"
onClick={() => onOpen(0)} onClick={() => onOpen(0)}
aria-label="View photo" aria-label="View photo"
className="relative block w-full aspect-square bg-muted cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" className="relative block w-full aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
> >
<Image <Image
src={images[0].url} src={images[0].url}
@@ -328,7 +306,7 @@ function ImageCarousel({
}} }}
aria-roledescription="slide" aria-roledescription="slide"
aria-label={`View photo ${i + 1} of ${images.length}`} aria-label={`View photo ${i + 1} of ${images.length}`}
className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
> >
<Image <Image
src={img.url} src={img.url}
+51
View File
@@ -0,0 +1,51 @@
"use client";
/**
* useTapGuard — tells a genuine tap apart from a swipe on a natively-scrolling
* strip (CSS scroll-snap carousels).
*
* Browsers still fire a `click` after some swipe gestures, so a plain onClick
* on a slide would trigger mid-swipe. Snapshot the pointer position AND the
* scroller's offset on pointerdown, then check both when the click arrives:
*
* • pointer travel — catches a drag that didn't scroll (e.g. hit the end)
* • scroll movement — catches an inertial swipe, where the pointer barely
* moves before `click` fires but the strip is already gliding
*
* Either signal alone misses cases, hence both. No staleness risk: a click is
* always immediately preceded by a pointerdown on the same element.
*/
import { useRef, type RefObject } from "react";
/** Pointer travel (px) still considered a tap rather than a drag. */
const MOVE_TOLERANCE = 8;
/** Scroll movement (px) still considered "didn't scroll". */
const SCROLL_TOLERANCE = 4;
type PointerLike = { clientX: number; clientY: number };
export function useTapGuard(scrollerRef: RefObject<HTMLElement | null>) {
const start = useRef({ x: 0, y: 0, scrollLeft: 0 });
function onPointerDown(e: PointerLike) {
start.current = {
x: e.clientX,
y: e.clientY,
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
};
}
function wasTap(e: PointerLike) {
const moved =
Math.abs(e.clientX - start.current.x) > MOVE_TOLERANCE ||
Math.abs(e.clientY - start.current.y) > MOVE_TOLERANCE;
const scrolled =
Math.abs(
(scrollerRef.current?.scrollLeft ?? 0) - start.current.scrollLeft,
) > SCROLL_TOLERANCE;
return !moved && !scrolled;
}
return { onPointerDown, wasTap };
}