274 lines · 10.1 KB · TypeScript
RawDownload
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 (    <div role="alert" className={EXPANDED_MEDIA_STATE_CLASS_NAME}>      {children}    </div>  );} 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 (    <MediaVideoPlayer      src={src}      label={item.name}      sourceFailed={assetUrl._tag === "Failure"}      originalUrl={item.originalUrl}      preload="metadata"      autoPlay={item.autoPlay ?? true}      className="block max-h-[86vh] max-w-[92vw] text-center"      videoClassName="aspect-auto max-h-[86vh] w-auto max-w-[92vw] rounded-lg border border-border/70 shadow-2xl"      stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME}      onRetry={asset ? refreshAssetUrl : undefined}    />  );} export const ExpandedImageDialog = memo(function ExpandedImageDialog({  preview,  onClose,}: ExpandedImageDialogProps) {  const [imageOffset, setImageOffset] = useState(0);  const zoomableImageRef = useRef<ZoomableImageHandle>(null);  const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);  const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState<string | null>(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<Element | null>(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 ? (      <OpenMediaLink originalUrl={item.originalUrl} />    ) : 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(    <div      {...composerFloatingLayerProps}      className="fixed inset-0 z-[60] flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"      role="dialog"      aria-modal="true"      aria-label={`Expanded ${mediaLabel} preview`}    >      <button        type="button"        className="absolute inset-0 z-0 cursor-zoom-out"        aria-label={`Close ${mediaLabel} preview`}        onClick={onClose}      />      {preview.images.length > 1 && (        <Button          type="button"          size="icon-xl"          variant="overlay"          className="absolute left-2 top-1/2 z-20 -translate-y-1/2 sm:left-6"          aria-label="Previous image"          onClick={() => navigateImage(-1)}        >          <ChevronLeftIcon className="size-7" />        </Button>      )}      <MediaActions source={actionsSource}>        <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">          <Button            type="button"            size="icon-xs"            variant="ghost"            className="absolute right-2 top-2 z-20"            onClick={onClose}            aria-label={`Close ${mediaLabel} preview`}          >            <XIcon />          </Button>          {item.type === "video" ? (            <ExpandedVideo key={index} item={item} />          ) : showingAccessibilityDetails ? (            accessibilityDetails ? (              <SnapShotAccessibilityData                details={accessibilityDetails}                className="h-[min(86vh,40rem)] w-[min(92vw,42rem)] animate-[snap-shot-contents-enter_140ms_ease-out] rounded-lg border border-border/70 bg-background p-4 text-xs leading-5 shadow-2xl motion-reduce:animate-none"              />            ) : null          ) : item.src === null || failedImageSrc === item.src ? (            <ExpandedMediaFailure>              <p>                {openOriginalLink                  ? "This image could not be loaded."                  : "Image unavailable. The file may have been moved or deleted."}              </p>              {openOriginalLink}            </ExpandedMediaFailure>          ) : (            <ZoomableImage              ref={zoomableImageRef}              key={`${index}:${item.src}`}              src={item.src}              name={item.name}              onError={() => setFailedImageSrc(item.src)}            />          )}          <div className="mt-2 flex max-w-[92vw] items-center justify-center gap-1.5 text-xs text-white/80">            <span className="truncate">              {item.name}              {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}            </span>            {accessibilityDetails && item.source ? (              <Tooltip>                <TooltipTrigger                  render={                    <Button                      aria-label={contentsLabel}                      aria-pressed={showingAccessibilityDetails}                      className="[--control-icon-color:currentColor] hover:bg-white/10 hover:text-white"                      onClick={() =>                        setAccessibilityDetailsSrc(showingAccessibilityDetails ? null : item.src)                      }                      size="icon-micro"                      variant="ghost-muted"                    />                  }                >                  <ContentsIcon className="size-3" aria-hidden="true" />                </TooltipTrigger>                <TooltipPopup side="top">{contentsLabel}</TooltipPopup>              </Tooltip>            ) : item.source ? (              <SnapShotContentsButton                source={item.source}                side="top"                className="hover:bg-white/10 hover:text-white"              />            ) : null}          </div>        </div>      </MediaActions>      {preview.images.length > 1 && (        <Button          type="button"          size="icon-xl"          variant="overlay"          className="absolute right-2 top-1/2 z-20 -translate-y-1/2 sm:right-6"          aria-label="Next image"          onClick={() => navigateImage(1)}        >          <ChevronRightIcon className="size-7" />        </Button>      )}    </div>,    document.body,  );});