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(
{preview.images.length > 1 && (
)}
{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,
);
});