import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { ChevronLeftIcon, ChevronRightIcon, ImageIcon, TextIcon, XIcon } from "lucide-react"; import { Button } from "../ui/button"; import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; import { OpenMediaLink } from "../media/OpenMediaLink"; import { MediaActions, type MediaActionSource } from "../media/MediaActions"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { isContextMenuOpen } from "../../contextMenuFallback"; import { SnapShotAccessibilityData, SnapShotContentsButton, snapShotAccessibilityDetails, } from "./SnapShotAttachmentDetails"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { composerFloatingLayerProps } from "./composerEventScope"; import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; onClose: () => void; } const EXPANDED_MEDIA_STATE_CLASS_NAME = "flex aspect-auto h-48 min-h-0 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black p-6 text-center text-sm text-white shadow-2xl"; function ExpandedMediaFailure({ children }: { children: ReactNode }) { return (
{children}
); } function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { const asset = item.actionsSource?.asset; const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); const refreshAssetUrl = useAssetUrlRefresh(asset?.environmentId ?? null, asset?.resource ?? null); const src = asset ? assetUrl._tag === "Success" ? assetUrl.url + (item.srcFragment ?? "") : null : item.src; return ( ); } export const ExpandedImageDialog = memo(function ExpandedImageDialog({ preview, onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); const zoomableImageRef = useRef(null); const [failedImageSrc, setFailedImageSrc] = useState(null); const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; const item = preview.images[index]; const source: MediaActionSource = item?.actionsSource ?? { kind: item?.type === "video" ? "video" : "image", name: item?.name ?? "Media", src: item?.src ?? null, }; const openFile = source.onOpenFile; const actionsSource: MediaActionSource = openFile ? { ...source, onOpenFile: () => { openFile(); onClose(); }, } : source; const navigateImage = useCallback( (direction: -1 | 1) => { setImageOffset( (current) => (current + direction + preview.images.length) % preview.images.length, ); }, [preview.images.length], ); // The element that opened the preview gets focus back on close. Without // this a close button click leaves focus on the unmounted dialog, and the // composer that owned the opener reads that as a blur and rests. const openerRef = useRef(null); useEffect(() => { openerRef.current = document.activeElement; return () => { const opener = openerRef.current; if (opener instanceof HTMLElement && opener.isConnected) { opener.focus({ preventScroll: true }); } }; }, []); useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented || isContextMenuOpen()) { return; } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; } if (zoomableImageRef.current?.pan(event.key)) { event.preventDefault(); event.stopPropagation(); return; } if (preview.images.length <= 1) return; if (event.key === "ArrowLeft") { event.preventDefault(); event.stopPropagation(); navigateImage(-1); return; } if (event.key !== "ArrowRight") return; event.preventDefault(); event.stopPropagation(); navigateImage(1); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [navigateImage, onClose, preview.images.length]); if (!item) return null; const mediaLabel = item.type === "video" ? "video" : "image"; const openOriginalLink = item.originalUrl && resolveExternalWebLinkHost(item.originalUrl) !== null ? ( ) : null; const accessibilityDetails = item.source ? snapShotAccessibilityDetails(item.source) : undefined; const showingAccessibilityDetails = Boolean(accessibilityDetails) && accessibilityDetailsSrc === item.src; const contentsLabel = showingAccessibilityDetails ? "Show screenshot" : accessibilityDetails?.format === "json" ? "Show accessibility JSON" : "Show extracted text"; const ContentsIcon = showingAccessibilityDetails ? ImageIcon : TextIcon; return createPortal(
)}
{item.type === "video" ? ( ) : showingAccessibilityDetails ? ( accessibilityDetails ? ( ) : null ) : item.src === null || failedImageSrc === item.src ? (

{openOriginalLink ? "This image could not be loaded." : "Image unavailable. The file may have been moved or deleted."}

{openOriginalLink}
) : ( setFailedImageSrc(item.src)} /> )}
{item.name} {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""} {accessibilityDetails && item.source ? ( setAccessibilityDetailsSrc(showingAccessibilityDetails ? null : item.src) } size="icon-micro" variant="ghost-muted" /> } > {contentsLabel} ) : item.source ? ( ) : null}
{preview.images.length > 1 && ( )}
, document.body, ); });