import { createContext, useCallback, useContext, useEffect, useRef, useState, type PropsWithChildren, } from "react"; import { createPortal } from "react-dom"; import { ChevronLeft, ChevronRight, X } from "lucide-react"; import type { Attachment } from "../../../types/post"; import { useI18n } from "../../../i18n"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { BubbleImage } from "../BubbleImage"; import { autolink } from "../utils/autolink"; type LightboxState = { images: Attachment[]; index: number; caption?: string; postId?: string; } | null; type Ctx = { openLightbox: ( images: Attachment[], startIndex?: number, caption?: string, postId?: string, ) => void; closeLightbox: () => void; }; const LightboxContext = createContext(null); export function useLightbox(): Ctx { const ctx = useContext(LightboxContext); if (!ctx) throw new Error("useLightbox must be used inside ImageLightboxProvider"); return ctx; } export function ImageLightboxProvider({ children }: PropsWithChildren) { const [state, setState] = useState(null); const openLightbox = useCallback( ( images: Attachment[], startIndex = 0, caption?: string, postId?: string, ) => { if (!images.length) return; const i = Math.min(Math.max(0, startIndex), images.length - 1); setState({ images, index: i, caption, postId }); }, [], ); const closeLightbox = useCallback(() => setState(null), []); return ( {children} {state ? ( ) : null} ); } function Filmstrip({ images, index, onSelect, }: { images: Attachment[]; index: number; onSelect: (i: number) => void; }) { const activeRef = useRef(null); useEffect(() => { activeRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center", }); }, [index]); return (
e.stopPropagation()} >
{images.map((image, i) => { const active = i === index; return ( ); })}
); } function LightboxView({ images, startIndex, caption: captionText, postId, onClose, }: { images: Attachment[]; startIndex: number; caption?: string; postId?: string; onClose: () => void; }) { const { t } = useI18n(); const [index, setIndex] = useState(startIndex); const [isCaptionVisible, setIsCaptionVisible] = useState(true); const [showSaveHint, setShowSaveHint] = useState(true); const [hintTop, setHintTop] = useState(null); const touchStartX = useRef(null); const stageRef = useRef(null); const imgRef = useRef(null); const measureHintPosition = useCallback(() => { const stage = stageRef.current; const img = imgRef.current; if (!stage || !img) return; const stageRect = stage.getBoundingClientRect(); const imgRect = img.getBoundingClientRect(); const gap = 12; const safeBottomReserve = 80; const desired = imgRect.bottom - stageRect.top + gap; const maxTop = stageRect.height - safeBottomReserve; setHintTop(Math.max(0, Math.min(desired, maxTop))); }, []); useEffect(() => { if (!showSaveHint) return; measureHintPosition(); const img = imgRef.current; const stage = stageRef.current; const ro = new ResizeObserver(() => measureHintPosition()); if (img) ro.observe(img); if (stage) ro.observe(stage); window.addEventListener("resize", measureHintPosition); return () => { ro.disconnect(); window.removeEventListener("resize", measureHintPosition); }; }, [measureHintPosition, showSaveHint, index]); // Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow // keys should all behave like a linear gallery, not a carousel. const goPrev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []); const goNext = useCallback( () => setIndex((i) => Math.min(images.length - 1, i + 1)), [images.length], ); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "ArrowLeft") goPrev(); if (e.key === "ArrowRight") goNext(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [goPrev, goNext, onClose]); // Lock background scroll while the lightbox is open, then restore the exact // scroll position on close — otherwise dismissing the overlay leaves the page // scrolled back to the top instead of where the user was reading. useEffect(() => { const scrollY = window.scrollY; const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prevOverflow; window.scrollTo(0, scrollY); }; }, []); useEffect(() => { setIsCaptionVisible(true); }, [captionText, index]); const current = images[index]; useEffect(() => { const timer = window.setTimeout(() => setShowSaveHint(false), 2500); return () => window.clearTimeout(timer); }, []); const caption = captionText?.trim(); const showCaption = !!caption && isCaptionVisible; const hasMany = images.length > 1; if (!current) return null; return createPortal(
{/* Top bar: counter + actions */}
e.stopPropagation()} >
{hasMany ? ( {index + 1} / {images.length} ) : null}
{postId ? ( ) : null}
{/* Image stage */}
{hasMany && index > 0 ? ( ) : null} {hasMany && index < images.length - 1 ? ( ) : null} {showSaveHint ? (
e.stopPropagation()} > ☝️ {t("longPressImageSave")}
) : null}
{ e.stopPropagation(); if (caption) setIsCaptionVisible((visible) => !visible); }} onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX; }} onTouchEnd={(e) => { if (touchStartX.current == null) return; const dx = e.changedTouches[0].clientX - touchStartX.current; if (Math.abs(dx) > 40) { if (dx > 0) goPrev(); else goNext(); } touchStartX.current = null; }} > {/* No select-none / touch-callout:none here so iOS Safari's native long-press menu ("Save in Photos") works on the full-size image. */} {current.filename}
{showCaption ? (
e.stopPropagation()} >
{autolink(caption)}
) : null}
{/* Bottom thumbnail filmstrip */} {hasMany ? ( ) : null}
, document.body, ); }