Files
Arkie-Library-Frontend/src/components/messageStream/overlays/ImageLightbox.tsx
2026-05-30 18:44:15 +08:00

344 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Ctx | null>(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<LightboxState>(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 (
<LightboxContext.Provider value={{ openLightbox, closeLightbox }}>
{children}
{state ? (
<LightboxView
images={state.images}
startIndex={state.index}
caption={state.caption}
postId={state.postId}
onClose={closeLightbox}
/>
) : null}
</LightboxContext.Provider>
);
}
function Filmstrip({
images,
index,
onSelect,
}: {
images: Attachment[];
index: number;
onSelect: (i: number) => void;
}) {
const activeRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
activeRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}, [index]);
return (
<div
className="shrink-0 bg-gradient-to-t from-black/90 to-transparent pb-[max(env(safe-area-inset-bottom),0.75rem)] pt-3"
onClick={(e) => e.stopPropagation()}
>
<div className="overflow-x-auto px-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="mx-auto flex w-fit max-w-[920px] gap-2">
{images.map((image, i) => {
const active = i === index;
return (
<button
key={image.id}
ref={active ? activeRef : null}
type="button"
onClick={(e) => {
e.stopPropagation();
onSelect(i);
}}
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-black transition duration-200 md:h-16 md:w-16 ${
active
? "opacity-100 ring-2 ring-white"
: "opacity-45 hover:opacity-80"
}`}
aria-label={`Image ${i + 1}`}
aria-current={active}
>
<BubbleImage
src={image.thumbnailUrl ?? image.thumbUrl ?? image.url}
fallbackSrc={[image.thumbUrl, image.url]}
className="h-full w-full object-cover"
/>
</button>
);
})}
</div>
</div>
</div>
);
}
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 touchStartX = useRef<number | null>(null);
// 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), 2000);
return () => window.clearTimeout(timer);
}, []);
const caption = captionText?.trim();
const showCaption = !!caption && isCaptionVisible;
const hasMany = images.length > 1;
if (!current) return null;
return createPortal(
<div
className="fixed inset-0 z-[100] flex flex-col bg-black/95 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
>
{/* Top bar: counter + actions */}
<div
className="relative z-20 flex shrink-0 items-center justify-between px-4 pb-2 pt-[max(env(safe-area-inset-top),1rem)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex h-10 items-center">
{hasMany ? (
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white">
{index + 1} / {images.length}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
{postId ? (
<AttachmentDownloadPill
postId={postId}
attachment={current}
size="lg"
className=""
/>
) : null}
<button
type="button"
onClick={onClose}
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-white/20"
aria-label="Close"
>
<X className="h-6 w-6" />
</button>
</div>
</div>
{/* Image stage */}
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
{hasMany && index > 0 ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/25 transition hover:bg-black/80 md:left-6"
aria-label="Previous"
>
<ChevronLeft className="h-6 w-6" />
</button>
) : null}
{hasMany && index < images.length - 1 ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/25 transition hover:bg-black/80 md:right-6"
aria-label="Next"
>
<ChevronRight className="h-6 w-6" />
</button>
) : null}
{showSaveHint ? (
<div className="pointer-events-none fixed inset-x-0 top-[58%] z-30 flex -translate-y-1/2 justify-center px-4">
<div
className="pointer-events-auto relative flex w-44 max-w-[calc(100vw-2rem)] flex-col items-center gap-1.5 rounded-xl bg-black/70 px-4 py-3 text-center text-white shadow-2xl ring-1 ring-white/20 backdrop-blur-md animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setShowSaveHint(false)}
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
aria-label="Close hint"
>
<X className="h-3.5 w-3.5" />
</button>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/15 text-2xl animate-bounce">
</div>
<div className="max-w-full break-words text-xs font-medium leading-4">
{t("longPressImageSave")}
</div>
</div>
</div>
) : null}
<div
className="flex h-full w-full items-center justify-center px-4 py-2"
onClick={(e) => {
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. */}
<img
src={current.url}
alt={current.filename}
className="max-h-full max-w-full object-contain [-webkit-touch-callout:default]"
/>
</div>
{showCaption ? (
<div
className="absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black via-black/90 to-black/70 px-4 py-4 text-sm leading-snug text-white sm:px-6"
onClick={(e) => e.stopPropagation()}
>
<div className="message-stream-copyable-text mx-auto max-h-[32vh] max-w-[920px] overflow-y-auto whitespace-pre-wrap break-words [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{autolink(caption)}
</div>
</div>
) : null}
</div>
{/* Bottom thumbnail filmstrip */}
{hasMany ? (
<Filmstrip images={images} index={index} onSelect={setIndex} />
) : null}
</div>,
document.body,
);
}