Files
Arkie-Library-Frontend/src/components/messageStream/overlays/ImageLightbox.tsx

310 lines
9.5 KiB
TypeScript
Raw Normal View History

import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type PropsWithChildren,
} from "react";
import { createPortal } from "react-dom";
feat(video): cross-platform inline player + polished overlay controls - MessageInlineVideo (new): custom-controlled inline video that disables the iOS Safari / Chromium native overlays entirely and reimplements the essentials: tap-to-play, centered play affordance while paused, bottom bar with play/pause + current time + drag-to-scrub progress bar + remaining time + fullscreen. Pointer events with pointer capture cover both mouse and touch scrubbing, including dragging past the bar's bounds. The element listens to 'seeked' as well as 'timeupdate' so external currentTime writes paint the bar even when the video is paused, and the goFullscreen callback synchronously syncs React state on close so the inline progress reflects the user's fullscreen playhead with no perceptible delay. - VideoBubble: replace the inline <video controls> with MessageInlineVideo and thread postId through openVideo so the fullscreen overlay can attach the download pill to the right post. - VideoPlayer overlay: replace its <video controls> with MessageInlineVideo size='lg', removing the iOS native arrows / PiP / mute / overflow controls. The overlay supplies its own large download pill and a beefier close button. - AttachmentDownloadPill: new 'size' prop ('sm' default 30 px, 'lg' 44 px with 22 px icon and text-[14px]) for overlay surfaces where the affordance can breathe and should feel touch-friendly. - ImageLightbox: drop the inline LightboxDownloadButton and use the shared AttachmentDownloadPill size='lg' instead, with a matching larger close button. Unused imports cleaned up.
2026-05-30 02:25:01 +08:00
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { Attachment } from "../../../types/post";
feat(video): cross-platform inline player + polished overlay controls - MessageInlineVideo (new): custom-controlled inline video that disables the iOS Safari / Chromium native overlays entirely and reimplements the essentials: tap-to-play, centered play affordance while paused, bottom bar with play/pause + current time + drag-to-scrub progress bar + remaining time + fullscreen. Pointer events with pointer capture cover both mouse and touch scrubbing, including dragging past the bar's bounds. The element listens to 'seeked' as well as 'timeupdate' so external currentTime writes paint the bar even when the video is paused, and the goFullscreen callback synchronously syncs React state on close so the inline progress reflects the user's fullscreen playhead with no perceptible delay. - VideoBubble: replace the inline <video controls> with MessageInlineVideo and thread postId through openVideo so the fullscreen overlay can attach the download pill to the right post. - VideoPlayer overlay: replace its <video controls> with MessageInlineVideo size='lg', removing the iOS native arrows / PiP / mute / overflow controls. The overlay supplies its own large download pill and a beefier close button. - AttachmentDownloadPill: new 'size' prop ('sm' default 30 px, 'lg' 44 px with 22 px icon and text-[14px]) for overlay surfaces where the affordance can breathe and should feel touch-friendly. - ImageLightbox: drop the inline LightboxDownloadButton and use the shared AttachmentDownloadPill size='lg' instead, with a matching larger close button. Unused imports cleaned up.
2026-05-30 02:25:01 +08:00
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { BubbleImage } from "../BubbleImage";
2026-05-26 12:07:13 +08:00
import { autolink } from "../utils/autolink";
type LightboxState = {
images: Attachment[];
index: number;
2026-05-26 12:07:13 +08:00
caption?: string;
postId?: string;
} | null;
type Ctx = {
2026-05-26 12:07:13 +08:00
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);
2026-05-26 12:07:13 +08:00
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}
2026-05-26 12:07:13 +08:00
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,
2026-05-26 12:07:13 +08:00
caption: captionText,
postId,
onClose,
}: {
images: Attachment[];
startIndex: number;
2026-05-26 12:07:13 +08:00
caption?: string;
postId?: string;
onClose: () => void;
}) {
const [index, setIndex] = useState(startIndex);
const [isCaptionVisible, setIsCaptionVisible] = 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];
2026-05-26 12:07:13 +08:00
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 ? (
feat(video): cross-platform inline player + polished overlay controls - MessageInlineVideo (new): custom-controlled inline video that disables the iOS Safari / Chromium native overlays entirely and reimplements the essentials: tap-to-play, centered play affordance while paused, bottom bar with play/pause + current time + drag-to-scrub progress bar + remaining time + fullscreen. Pointer events with pointer capture cover both mouse and touch scrubbing, including dragging past the bar's bounds. The element listens to 'seeked' as well as 'timeupdate' so external currentTime writes paint the bar even when the video is paused, and the goFullscreen callback synchronously syncs React state on close so the inline progress reflects the user's fullscreen playhead with no perceptible delay. - VideoBubble: replace the inline <video controls> with MessageInlineVideo and thread postId through openVideo so the fullscreen overlay can attach the download pill to the right post. - VideoPlayer overlay: replace its <video controls> with MessageInlineVideo size='lg', removing the iOS native arrows / PiP / mute / overflow controls. The overlay supplies its own large download pill and a beefier close button. - AttachmentDownloadPill: new 'size' prop ('sm' default 30 px, 'lg' 44 px with 22 px icon and text-[14px]) for overlay surfaces where the affordance can breathe and should feel touch-friendly. - ImageLightbox: drop the inline LightboxDownloadButton and use the shared AttachmentDownloadPill size='lg' instead, with a matching larger close button. Unused imports cleaned up.
2026-05-30 02:25:01 +08:00
<AttachmentDownloadPill
postId={postId}
attachment={current}
size="lg"
className=""
/>
) : null}
<button
type="button"
onClick={onClose}
feat(video): cross-platform inline player + polished overlay controls - MessageInlineVideo (new): custom-controlled inline video that disables the iOS Safari / Chromium native overlays entirely and reimplements the essentials: tap-to-play, centered play affordance while paused, bottom bar with play/pause + current time + drag-to-scrub progress bar + remaining time + fullscreen. Pointer events with pointer capture cover both mouse and touch scrubbing, including dragging past the bar's bounds. The element listens to 'seeked' as well as 'timeupdate' so external currentTime writes paint the bar even when the video is paused, and the goFullscreen callback synchronously syncs React state on close so the inline progress reflects the user's fullscreen playhead with no perceptible delay. - VideoBubble: replace the inline <video controls> with MessageInlineVideo and thread postId through openVideo so the fullscreen overlay can attach the download pill to the right post. - VideoPlayer overlay: replace its <video controls> with MessageInlineVideo size='lg', removing the iOS native arrows / PiP / mute / overflow controls. The overlay supplies its own large download pill and a beefier close button. - AttachmentDownloadPill: new 'size' prop ('sm' default 30 px, 'lg' 44 px with 22 px icon and text-[14px]) for overlay surfaces where the affordance can breathe and should feel touch-friendly. - ImageLightbox: drop the inline LightboxDownloadButton and use the shared AttachmentDownloadPill size='lg' instead, with a matching larger close button. Unused imports cleaned up.
2026-05-30 02:25:01 +08:00
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"
>
feat(video): cross-platform inline player + polished overlay controls - MessageInlineVideo (new): custom-controlled inline video that disables the iOS Safari / Chromium native overlays entirely and reimplements the essentials: tap-to-play, centered play affordance while paused, bottom bar with play/pause + current time + drag-to-scrub progress bar + remaining time + fullscreen. Pointer events with pointer capture cover both mouse and touch scrubbing, including dragging past the bar's bounds. The element listens to 'seeked' as well as 'timeupdate' so external currentTime writes paint the bar even when the video is paused, and the goFullscreen callback synchronously syncs React state on close so the inline progress reflects the user's fullscreen playhead with no perceptible delay. - VideoBubble: replace the inline <video controls> with MessageInlineVideo and thread postId through openVideo so the fullscreen overlay can attach the download pill to the right post. - VideoPlayer overlay: replace its <video controls> with MessageInlineVideo size='lg', removing the iOS native arrows / PiP / mute / overflow controls. The overlay supplies its own large download pill and a beefier close button. - AttachmentDownloadPill: new 'size' prop ('sm' default 30 px, 'lg' 44 px with 22 px icon and text-[14px]) for overlay surfaces where the affordance can breathe and should feel touch-friendly. - ImageLightbox: drop the inline LightboxDownloadButton and use the shared AttachmentDownloadPill size='lg' instead, with a matching larger close button. Unused imports cleaned up.
2026-05-30 02:25:01 +08:00
<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}
<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;
}}
>
<img
src={current.url}
alt={current.filename}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
/>
</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,
);
}