| 1 | import { |
| 2 | memo, |
| 3 | useCallback, |
| 4 | useEffect, |
| 5 | useRef, |
| 6 | useState, |
| 7 | type ReactNode, |
| 8 | type KeyboardEvent, |
| 9 | } from "react"; |
| 10 | import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; |
| 11 | import { Image as ImageGlyph, Text as TextGlyph } from "lucide"; |
| 12 | import { Button } from "../ui/button"; |
| 13 | import { MorphIcon } from "~/components/MorphIcon"; |
| 14 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; |
| 15 | import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 16 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; |
| 17 | import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; |
| 18 | import { OpenMediaLink } from "../media/OpenMediaLink"; |
| 19 | import { MediaActions, type MediaActionSource } from "../media/MediaActions"; |
| 20 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; |
| 21 | import { isContextMenuOpen } from "../../contextMenuFallback"; |
| 22 | import { |
| 23 | SnapShotAccessibilityData, |
| 24 | SnapShotContentsButton, |
| 25 | snapShotAccessibilityDetails, |
| 26 | } from "./SnapShotAttachmentDetails"; |
| 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 28 | import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage"; |
| 29 | import { composerFloatingLayerProps } from "./composerEventScope"; |
| 30 | |
| 31 | interface ExpandedImageDialogProps { |
| 32 | preview: ExpandedImagePreview; |
| 33 | onClose: () => void; |
| 34 | } |
| 35 | |
| 36 | const EXPANDED_MEDIA_STATE_CLASS_NAME = |
| 37 | "flex aspect-auto h-48 min-h-0 w-[min(var(--media-width),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"; |
| 38 | |
| 39 | function ExpandedMediaFailure({ children }: { children: ReactNode }) { |
| 40 | return ( |
| 41 | <div role="alert" className={EXPANDED_MEDIA_STATE_CLASS_NAME}> |
| 42 | {children} |
| 43 | </div> |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { |
| 48 | const asset = item.actionsSource?.asset; |
| 49 | const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); |
| 50 | const refreshAssetUrl = useAssetUrlRefresh(asset?.environmentId ?? null, asset?.resource ?? null); |
| 51 | const src = asset |
| 52 | ? assetUrl._tag === "Success" |
| 53 | ? assetUrl.url + (item.srcFragment ?? "") |
| 54 | : null |
| 55 | : item.src; |
| 56 | return ( |
| 57 | <MediaVideoPlayer |
| 58 | src={src} |
| 59 | label={item.name} |
| 60 | sourceFailed={assetUrl._tag === "Failure"} |
| 61 | originalUrl={item.originalUrl} |
| 62 | preload="metadata" |
| 63 | autoPlay={item.autoPlay ?? true} |
| 64 | className="block max-h-[var(--media-height)] max-w-[var(--media-width)] text-center" |
| 65 | videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl" |
| 66 | stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME} |
| 67 | onRetry={asset ? refreshAssetUrl : undefined} |
| 68 | actionsSource={item.actionsSource} |
| 69 | /> |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 74 | preview, |
| 75 | onClose, |
| 76 | }: ExpandedImageDialogProps) { |
| 77 | const [imageOffset, setImageOffset] = useState(0); |
| 78 | const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null); |
| 79 | const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState<string | null>(null); |
| 80 | const zoomableImageRef = useRef<ZoomableImageHandle>(null); |
| 81 | const [returnFocusTarget] = useState(() => |
| 82 | document.activeElement instanceof HTMLElement ? document.activeElement : null, |
| 83 | ); |
| 84 | const closeButtonRef = useRef<HTMLButtonElement>(null); |
| 85 | // The offset accumulates without bound, so wrap it into range in both directions: |
| 86 | // JavaScript `%` keeps the sign of the dividend, and a negative index blanks the dialog. |
| 87 | const imageCount = preview.images.length; |
| 88 | const index = |
| 89 | imageCount > 0 ? (((preview.index + imageOffset) % imageCount) + imageCount) % imageCount : 0; |
| 90 | const item = preview.images[index]; |
| 91 | const source: MediaActionSource = item?.actionsSource ?? { |
| 92 | kind: item?.type === "video" ? "video" : "image", |
| 93 | name: item?.name ?? "Media", |
| 94 | src: item?.src ?? null, |
| 95 | }; |
| 96 | const openFile = source.onOpenFile; |
| 97 | const actionsSource: MediaActionSource = openFile |
| 98 | ? { |
| 99 | ...source, |
| 100 | onOpenFile: () => { |
| 101 | openFile(); |
| 102 | onClose(); |
| 103 | }, |
| 104 | } |
| 105 | : source; |
| 106 | |
| 107 | const navigateImage = useCallback((direction: -1 | 1) => { |
| 108 | setImageOffset((current) => current + direction); |
| 109 | }, []); |
| 110 | |
| 111 | // The element that opened the preview gets focus back on close. Without |
| 112 | // this a close button click leaves focus on the unmounted dialog, and the |
| 113 | // composer that owned the opener reads that as a blur and rests. |
| 114 | const openerRef = useRef<Element | null>(null); |
| 115 | useEffect(() => { |
| 116 | openerRef.current = document.activeElement; |
| 117 | return () => { |
| 118 | const opener = openerRef.current; |
| 119 | if (opener instanceof HTMLElement && opener.isConnected) { |
| 120 | opener.focus({ preventScroll: true }); |
| 121 | } |
| 122 | }; |
| 123 | }, []); |
| 124 | |
| 125 | const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { |
| 126 | if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement) |
| 127 | return; |
| 128 | if (zoomableImageRef.current?.pan(event.key)) { |
| 129 | event.preventDefault(); |
| 130 | event.stopPropagation(); |
| 131 | return; |
| 132 | } |
| 133 | if (preview.images.length <= 1) return; |
| 134 | if (event.key === "ArrowLeft") { |
| 135 | event.preventDefault(); |
| 136 | event.stopPropagation(); |
| 137 | navigateImage(-1); |
| 138 | return; |
| 139 | } |
| 140 | if (event.key !== "ArrowRight") return; |
| 141 | event.preventDefault(); |
| 142 | event.stopPropagation(); |
| 143 | navigateImage(1); |
| 144 | }; |
| 145 | |
| 146 | useEffect(() => { |
| 147 | const onEscape = (event: globalThis.KeyboardEvent) => { |
| 148 | if (event.key !== "Escape" || isContextMenuOpen()) return; |
| 149 | event.preventDefault(); |
| 150 | event.stopPropagation(); |
| 151 | onClose(); |
| 152 | }; |
| 153 | window.addEventListener("keydown", onEscape, { capture: true }); |
| 154 | return () => window.removeEventListener("keydown", onEscape, { capture: true }); |
| 155 | }, [onClose]); |
| 156 | |
| 157 | if (!item) return null; |
| 158 | const mediaLabel = item.type === "video" ? "video" : "image"; |
| 159 | const openOriginalLink = |
| 160 | item.originalUrl && resolveExternalWebLinkHost(item.originalUrl) !== null ? ( |
| 161 | <OpenMediaLink originalUrl={item.originalUrl} /> |
| 162 | ) : null; |
| 163 | const accessibilityDetails = item.source ? snapShotAccessibilityDetails(item.source) : undefined; |
| 164 | const showingAccessibilityDetails = |
| 165 | Boolean(accessibilityDetails) && accessibilityDetailsSrc === item.src; |
| 166 | const contentsLabel = showingAccessibilityDetails |
| 167 | ? "Show screenshot" |
| 168 | : accessibilityDetails?.format === "json" |
| 169 | ? "Show accessibility JSON" |
| 170 | : "Show extracted text"; |
| 171 | |
| 172 | return ( |
| 173 | <Dialog |
| 174 | open |
| 175 | onOpenChange={(open) => { |
| 176 | if (!open) onClose(); |
| 177 | }} |
| 178 | > |
| 179 | <DialogPopup |
| 180 | {...composerFloatingLayerProps} |
| 181 | variant="media" |
| 182 | showCloseButton={false} |
| 183 | bottomStickOnMobile={false} |
| 184 | className="row-start-1 h-full max-h-[92vh] w-[92vw] max-w-[92vw] items-center justify-center overflow-visible [--media-width:92vw] [--media-height:min(86vh,calc(100vh-160px))] sm:[--media-width:calc(92vw-96px)]" |
| 185 | onKeyDown={onKeyDown} |
| 186 | initialFocus={closeButtonRef} |
| 187 | finalFocus={() => returnFocusTarget} |
| 188 | onClick={(event) => { |
| 189 | if (event.target === event.currentTarget) onClose(); |
| 190 | }} |
| 191 | > |
| 192 | <DialogTitle className="sr-only">Expanded {mediaLabel} preview</DialogTitle> |
| 193 | {/* The stage keeps a fixed size, so the close button stays put whatever the media or caption. */} |
| 194 | <Button |
| 195 | type="button" |
| 196 | ref={closeButtonRef} |
| 197 | size="icon-sm" |
| 198 | variant="media-close" |
| 199 | className="absolute right-0 top-0 z-20" |
| 200 | onClick={onClose} |
| 201 | aria-label={`Close ${mediaLabel} preview`} |
| 202 | > |
| 203 | <XIcon /> |
| 204 | </Button> |
| 205 | {preview.images.length > 1 && ( |
| 206 | <Button |
| 207 | type="button" |
| 208 | size="icon" |
| 209 | variant="media-navigation" |
| 210 | className="left-0 top-auto bottom-0 translate-y-0 sm:top-1/2 sm:bottom-auto sm:-translate-y-1/2" |
| 211 | aria-label="Previous media" |
| 212 | onClick={() => navigateImage(-1)} |
| 213 | > |
| 214 | <ChevronLeftIcon className="size-5" /> |
| 215 | </Button> |
| 216 | )} |
| 217 | <MediaActions source={actionsSource}> |
| 218 | <div className="relative isolate z-10 flex max-h-full max-w-[var(--media-width)] flex-col items-center"> |
| 219 | {item.type === "video" ? ( |
| 220 | <ExpandedVideo key={index} item={item} /> |
| 221 | ) : showingAccessibilityDetails ? ( |
| 222 | accessibilityDetails ? ( |
| 223 | <SnapShotAccessibilityData |
| 224 | details={accessibilityDetails} |
| 225 | className="h-[min(var(--media-height),40rem)] w-[min(var(--media-width),42rem)] transition-opacity duration-140 ease-out starting:opacity-0 rounded-lg border border-border/70 bg-background p-4 text-xs leading-5 shadow-2xl motion-reduce:transition-none" |
| 226 | /> |
| 227 | ) : null |
| 228 | ) : item.src === null || failedImageSrc === item.src ? ( |
| 229 | <ExpandedMediaFailure> |
| 230 | <p> |
| 231 | {openOriginalLink |
| 232 | ? "This image could not be loaded." |
| 233 | : "Image unavailable. The file may have been moved or deleted."} |
| 234 | </p> |
| 235 | {openOriginalLink} |
| 236 | </ExpandedMediaFailure> |
| 237 | ) : ( |
| 238 | <ZoomableImage |
| 239 | ref={zoomableImageRef} |
| 240 | key={`${index}:${item.src}`} |
| 241 | src={item.src} |
| 242 | name={item.name} |
| 243 | onError={() => setFailedImageSrc(item.src)} |
| 244 | /> |
| 245 | )} |
| 246 | <div className="mt-2 flex max-w-[var(--media-width)] items-center justify-center gap-1.5 text-xs text-white/80 max-sm:px-10"> |
| 247 | <span className="truncate" aria-live="polite" aria-atomic="true"> |
| 248 | {item.name} |
| 249 | {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""} |
| 250 | </span> |
| 251 | {accessibilityDetails && item.source ? ( |
| 252 | <Tooltip> |
| 253 | <TooltipTrigger |
| 254 | render={ |
| 255 | <Button |
| 256 | aria-label={contentsLabel} |
| 257 | aria-pressed={showingAccessibilityDetails} |
| 258 | onClick={() => |
| 259 | setAccessibilityDetailsSrc(showingAccessibilityDetails ? null : item.src) |
| 260 | } |
| 261 | size="icon-micro" |
| 262 | variant="overlay" |
| 263 | /> |
| 264 | } |
| 265 | > |
| 266 | <MorphIcon |
| 267 | className="size-3" |
| 268 | aria-hidden="true" |
| 269 | icon={showingAccessibilityDetails ? ImageGlyph : TextGlyph} |
| 270 | /> |
| 271 | </TooltipTrigger> |
| 272 | <TooltipPopup side="top">{contentsLabel}</TooltipPopup> |
| 273 | </Tooltip> |
| 274 | ) : item.source ? ( |
| 275 | <SnapShotContentsButton source={item.source} side="top" /> |
| 276 | ) : null} |
| 277 | </div> |
| 278 | </div> |
| 279 | </MediaActions> |
| 280 | {preview.images.length > 1 && ( |
| 281 | <Button |
| 282 | type="button" |
| 283 | size="icon" |
| 284 | variant="media-navigation" |
| 285 | className="right-0 top-auto bottom-0 translate-y-0 sm:top-1/2 sm:bottom-auto sm:-translate-y-1/2" |
| 286 | aria-label="Next media" |
| 287 | onClick={() => navigateImage(1)} |
| 288 | > |
| 289 | <ChevronRightIcon className="size-5" /> |
| 290 | </Button> |
| 291 | )} |
| 292 | </DialogPopup> |
| 293 | </Dialog> |
| 294 | ); |
| 295 | }); |