import { ChevronLeftIcon, ChevronRightIcon, DownloadIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject, } from "react"; import { Dialog } from "radix-ui"; import { containedPortal } from "../ui/portal-container"; import { cn } from "../lib/cn"; import { usePortalTokenStyle } from "../lib/use-portal-token-style"; import { useOpenGeniText } from "../messages/runtime"; const DialogContainedPortal = containedPortal(Dialog.Portal); /* ---------------------------------------------------------------------------- Screenshot lightbox A single, app-level lightbox the computer_call / view_image renderers open by `src`. Built on Radix Dialog so it is focus-trapped, ESC-closable, and scroll-locked — fixing the v1 mockup's broken expand (an absolutely-positioned that overflowed its row). The image is centered, constrained to the viewport (`max-w/max-h` + `object-contain`), and sits on a dimmed backdrop. Consumers render `` once near the timeline; renderers call `useLightbox().open(src)`. -------------------------------------------------------------------------- */ type LightboxController = { open: ( src: string, caption?: string, source?: HTMLElement | null, label?: string, downloadFilename?: string, controlLabels?: LightboxControlLabels, releaseSource?: () => void, ) => void; }; export type LightboxControlLabels = { download?: string | undefined; close?: string | undefined; previous?: string | undefined; next?: string | undefined; }; const LightboxContext = createContext(null); /** One image the open lightbox can page to, resolved only when it is shown. */ export type LightboxGalleryImage = { src: string; caption?: string | undefined; label?: string | undefined; /** Releases a source owned by this load, such as an object URL. */ release?: (() => void) | undefined; }; export type LightboxGalleryEntry = { /** Whether this entry currently resolves to an image; defaults to yes. */ isImage?: (() => Promise) | undefined; load: () => Promise; }; type RegisteredGalleryEntry = { element: HTMLElement; entry: { current: LightboxGalleryEntry }; }; type LightboxGalleryRegistry = { register: (item: RegisteredGalleryEntry) => () => void; }; const LightboxGalleryContext = createContext(null); /** Images in one timeline page together; elsewhere the whole document does. */ const GALLERY_ROOT_SELECTOR = "[data-og-timeline-scroller]"; function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; return ( target.isContentEditable || target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null ); } /** * Adds the element's image to the lightbox gallery so an open lightbox can page * to it with the arrow controls, in document order. The entry is resolved only * when it is shown, so deferred images that never scrolled into view still page. * A lightbox opened from inside the element starts at this entry. */ export function useLightboxGalleryItem( ref: RefObject, entry: LightboxGalleryEntry | null, ): void { const registry = useContext(LightboxGalleryContext); const latest = useRef(entry); latest.current = entry; const enabled = entry !== null; useEffect(() => { const element = ref.current; if (!registry || !element || !enabled) return; return registry.register({ element, entry: latest as { current: LightboxGalleryEntry }, }); }, [registry, ref, enabled]); } /** Open the app-level screenshot lightbox. No-op outside a `LightboxProvider`. */ export function useLightbox(): LightboxController { return useContext(LightboxContext) ?? NOOP; } /** * The lightbox controller when one is mounted, or `null` outside a * `LightboxProvider`. Lets a media primitive degrade to a non-interactive image * (rather than a dead "Expand" button that announces an action it cannot do). */ export function useLightboxOptional(): LightboxController | null { return useContext(LightboxContext); } const NOOP: LightboxController = { open: () => {} }; /** @internal Owns one optional retained lightbox source and releases it exactly once. */ export function createLightboxSourceOwner() { let releaseCurrent: (() => void) | null = null; return { replace(release?: (() => void) | undefined) { const previous = releaseCurrent; releaseCurrent = release ?? null; previous?.(); }, release() { const release = releaseCurrent; releaseCurrent = null; release?.(); }, }; } /** * The app-level screenshot lightbox. Render once near the timeline; renderers * call `useLightbox().open(src)`. * * Idempotent by design: when an ancestor `LightboxProvider` already exists (e.g. * a `MessageTimeline` mounted inside an app that already wraps its shell), this * one becomes a pass-through and does NOT mount a second focus-trapping Dialog. * That keeps `MessageTimeline` self-sufficient (it owns its own provider) while * composing cleanly when nested. */ export function LightboxProvider({ children }: { children: ReactNode }) { const ancestor = useContext(LightboxContext); if (ancestor) { return <>{children}>; } return {children}; } function LightboxRoot({ children }: { children: ReactNode }) { const t = useOpenGeniText(); const [state, setState] = useState<{ src: string; caption?: string; label: string; downloadFilename?: string; controlLabels?: LightboxControlLabels; } | null>(null); const [tokenSource, setTokenSource] = useState(null); const sourceOwner = useMemo(() => createLightboxSourceOwner(), []); const portalStyle = usePortalTokenStyle(tokenSource); const entries = useRef(new Set()); const [gallery, setGallery] = useState<{ items: RegisteredGalleryEntry[]; index: number; } | null>(null); // Fences stale gallery discovery and navigation after a newer open or step. const generation = useRef(0); const registry = useMemo( () => ({ register(item) { entries.current.add(item); return () => { entries.current.delete(item); }; }, }), [], ); const releaseCurrentSource = useCallback(() => { sourceOwner.release(); }, [sourceOwner]); // Unmount also fences in-flight discovery and paging, so a load that settles // afterwards releases its own source instead of handing it to a dead owner. useEffect( () => () => { generation.current++; releaseCurrentSource(); }, [releaseCurrentSource], ); const discoverGallery = useCallback((source: HTMLElement, token: number) => { const root = source.closest(GALLERY_ROOT_SELECTOR) ?? source.ownerDocument.body; const candidates = [...entries.current] .filter((item) => item.element.isConnected && root.contains(item.element)) .sort((a, b) => a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1, ); // The innermost entry holding the clicked image comes last in document order. const current = candidates.filter((item) => item.element.contains(source)).pop(); if (!current) return; void Promise.all( candidates.map((item) => item === current ? Promise.resolve(true) : (item.entry.current.isImage?.() ?? Promise.resolve(true)).catch(() => false), ), ).then((images) => { if (generation.current !== token) return; const items = candidates.filter((_, index) => images[index]); if (items.length > 1) setGallery({ items, index: items.indexOf(current) }); }); }, []); const open = useCallback( ( src: string, caption?: string, source?: HTMLElement | null, label = t("timeline.screenshotLightbox.screenshot"), downloadFilename?: string, controlLabels?: LightboxControlLabels, releaseSource?: () => void, ) => { const token = ++generation.current; sourceOwner.replace(releaseSource); setTokenSource(source ?? null); setGallery(null); setState({ src, label, ...(caption ? { caption } : {}), ...(downloadFilename ? { downloadFilename } : {}), ...(controlLabels ? { controlLabels } : {}), }); if (source) discoverGallery(source, token); }, [sourceOwner, discoverGallery, t], ); const close = useCallback(() => { generation.current++; releaseCurrentSource(); setState(null); setTokenSource(null); setGallery(null); }, [releaseCurrentSource]); const step = useCallback( (delta: 1 | -1) => { if (!gallery) return; const token = ++generation.current; const count = gallery.items.length; void (async () => { // Skip entries that no longer resolve, but never loop past the start. for (let offset = 1; offset < count; offset++) { const index = (((gallery.index + delta * offset) % count) + count) % count; const image = await gallery.items[index]!.entry.current.load().catch(() => null); if (generation.current !== token) { image?.release?.(); return; } if (!image) continue; sourceOwner.replace(image.release); setGallery({ items: gallery.items, index }); setState((previous) => previous ? { src: image.src, label: image.label ?? previous.label, ...(image.caption ? { caption: image.caption } : {}), ...(previous.controlLabels ? { controlLabels: previous.controlLabels } : {}), } : previous, ); return; } })(); }, [gallery, sourceOwner], ); const galleryOpen = state !== null && gallery !== null; useEffect(() => { if (!galleryOpen) return; const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return; // Arrow keys typed into a field move its caret; they never page images. if (isEditableTarget(event.target)) return; if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); step(event.key === "ArrowLeft" ? -1 : 1); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [galleryOpen, step]); const controller = useMemo(() => ({ open }), [open]); return ( {children} !next && close()}> {state !== null ? ( event.preventDefault()} > { if (event.target === event.currentTarget) { close(); } }} className="og-root fixed inset-0 z-50 flex items-center justify-center p-6 sm:p-12" style={portalStyle} > {state.label} {state.caption ?? t("timeline.screenshotLightbox.expandedLowercase", { lowerCase: state.label.toLowerCase(), })} {gallery ? ( step(-1)} /> ) : null} {/* The figure is one self-contained object: image, caption, and its own close control. The close button anchors to a wrapper sized to the IMAGE (w-fit), so it hugs the real top-right corner regardless of aspect ratio — never floating into the empty space of a wide figure column. */} {/* A plain : this SDK is framework-agnostic, with no host Image component. */} {gallery ? ( {state.caption ? ( {state.caption} ) : null} {gallery.index + 1}{" "} {t("timeline.screenshotLightbox.ofLength", { length: gallery.items.length, })} ) : state.caption ? ( {state.caption} ) : ( {t("timeline.screenshotLightbox.escOrClickOutsideTo")} )} {gallery ? ( step(1)} /> ) : null} ) : null} ); } /** Pages the open lightbox to the neighboring gallery image, wrapping around. */ function LightboxStepButton({ direction, label, onClick, }: { direction: "previous" | "next"; label: string; onClick: () => void; }) { const Icon = direction === "previous" ? ChevronLeftIcon : ChevronRightIcon; return ( ); } /** @internal Exact controls rendered by an open screenshot lightbox. */ export function ScreenshotLightboxControls({ src, downloadFilename, controlLabels, }: { src: string; downloadFilename?: string | undefined; controlLabels?: LightboxControlLabels | undefined; }) { const t = useOpenGeniText(); return ( {downloadFilename ? ( ) : null} ); }