diff --git a/components/social/post/post-card.tsx b/components/social/post/post-card.tsx index dc826e5b..aab66ad9 100644 --- a/components/social/post/post-card.tsx +++ b/components/social/post/post-card.tsx @@ -145,7 +145,7 @@ export function PostCard({ ? `View photo ${i + 1} of ${post.images.length}` : "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" > so it escapes the feed's virtualised, - * transformed rows — an overlay nested inside one of those would be clipped by - * the row's stacking context and positioned against the transform, not the - * viewport. + * transformed rows — an overlay nested inside one would be positioned against + * that transform and clipped by its stacking context, not the viewport. */ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import Image from "next/image"; import { ChevronLeft, ChevronRight, X } from "lucide-react"; import type { PostImage } from "@/lib/social"; +import { useTapGuard } from "./use-tap-guard"; export function PostImageLightbox({ images, @@ -41,17 +48,40 @@ export function PostImageLightbox({ }) { const open = index !== null; const count = images.length; + const scrollerRef = useRef(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(() => { - if (index === null || count < 2) return; - onIndexChange((index - 1 + count) % count); - }, [index, count, onIndexChange]); + if (index === null) return; + scrollToIndex(index - 1, "smooth"); + }, [index, scrollToIndex]); const goNext = useCallback(() => { - if (index === null || count < 2) return; - onIndexChange((index + 1) % count); - }, [index, count, onIndexChange]); + if (index === null) return; + scrollToIndex(index + 1, "smooth"); + }, [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(() => { if (!open) return; @@ -74,95 +104,126 @@ export function PostImageLightbox({ }; }, [open]); + function onScroll(e: React.UIEvent) { + const el = e.currentTarget; + const w = el.offsetWidth; + if (w === 0) return; + const i = Math.round(el.scrollLeft / w); + if (i !== index && i >= 0 && i < count) onIndexChange(i); + } + if (!open || typeof document === "undefined") return null; - const img = images[index]; - if (!img) return null; + if (!images[index]) return null; - return createPortal( -
1 ? `Photo ${index + 1} of ${count}` : "Photo" - } - onClick={onClose} - 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 - that closes on tap. `fill` resolves against this box (padding would - NOT inset an absolutely-positioned child), hence inset-* rather than - p-*. stopPropagation keeps a tap on the photo itself from closing. */} + return ( + createPortal(
e.stopPropagation()} - className="absolute inset-4 sm:inset-12" - > - -
- - - - {count > 1 ? ( - <> - - - {/* Counter sits top-left so it never collides with the caption - scrim along the bottom. */} - - {index + 1} / {count} - - - ) : null} - - {/* Caption scrim. `pointer-events-none` so it never intercepts a tap - meant for the backdrop — the whole strip stays click-to-close — and - the gradient fades upward so the photo's lower edge stays readable - rather than being cut by a hard band. */} - {caption?.trim() ? ( -
-

- “{caption.trim()}” -

+ {/* The strip sits inset from the edges, leaving a backdrop ring that + also closes. `absolute inset-*` rather than padding because padding + does NOT inset an absolutely-positioned `fill` child. */} +
{ + // 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) => ( +
+ +
+ ))}
- ) : null} -
, - document.body, + + + + {count > 1 ? ( + <> + + + + {/* Counter sits top-left so it never collides with the caption + scrim along the bottom. */} + + {index + 1} / {count} + + + {/* Dot indicators, mirroring the in-feed carousel. */} +
+ {images.map((img, i) => ( + + ))} +
+ + ) : null} + + {/* Caption scrim. `pointer-events-none` so it never intercepts a tap + meant for the backdrop, and the gradient fades upward so the + photo's lower edge stays readable rather than cut by a hard band. */} + {caption?.trim() ? ( +
+

+ “{caption.trim()}” +

+
+ ) : null} +
, + document.body, + ) ); } diff --git a/components/social/post/post-scroll-card.tsx b/components/social/post/post-scroll-card.tsx index 8814b071..644725c1 100644 --- a/components/social/post/post-scroll-card.tsx +++ b/components/social/post/post-scroll-card.tsx @@ -41,6 +41,7 @@ import { postSourceHref, quoteCardBgStyle } from "./post-card-shared"; import { PostActionsFooter } from "./post-actions-footer"; import { PostCardMenu } from "./post-card-menu"; import { PostImageLightbox } from "./post-image-lightbox"; +import { useTapGuard } from "./use-tap-guard"; import { FeedFollowButton } from "./feed-follow-button"; import { UpdateBackgroundModal } from "../update-background-modal"; import { MentionsDrawer } from "../mentions-drawer"; @@ -252,12 +253,9 @@ function ImageCarousel({ }) { const [active, setActive] = useState(0); const scrollerRef = useRef(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 - // carousel scrolls natively (CSS scroll-snap), and browsers still fire a - // 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 }); + // Swiping the carousel and tapping a photo have to stay independent — the + // strip scrolls natively, and browsers still fire a click after some swipes. + const { onPointerDown, wasTap } = useTapGuard(scrollerRef); function onScroll(e: React.UIEvent) { const el = e.currentTarget; @@ -266,26 +264,6 @@ function ImageCarousel({ 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) { // Single image: no scroller, so every click is unambiguously a tap. return ( @@ -293,7 +271,7 @@ function ImageCarousel({ type="button" onClick={() => onOpen(0)} 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" > ) { + 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 }; +}