Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 42 additions & 33 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
memo,
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
type KeyboardEvent,
} from "react";
import { ChevronLeftIcon, ChevronRightIcon, ImageIcon, TextIcon, XIcon } from "lucide-react";
import { Button } from "../ui/button";
import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
Expand All @@ -24,7 +32,7 @@ interface ExpandedImageDialogProps {
}

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";
"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";

function ExpandedMediaFailure({ children }: { children: ReactNode }) {
return (
Expand All @@ -51,8 +59,8 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {
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"
className="block max-h-[var(--media-height)] max-w-[var(--media-width)] text-center"
videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl"
stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME}
onRetry={asset ? refreshAssetUrl : undefined}
/>
Expand Down Expand Up @@ -111,29 +119,26 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
};
}, []);

useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented || isContextMenuOpen()) 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;
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement)
return;
if (zoomableImageRef.current?.pan(event.key)) {
event.preventDefault();
event.stopPropagation();
navigateImage(1);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [navigateImage, preview.images.length]);
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);
};

useEffect(() => {
const onEscape = (event: globalThis.KeyboardEvent) => {
Expand Down Expand Up @@ -176,31 +181,35 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
bottomStickOnMobile={false}
backdropClassName="z-[60]"
viewportClassName="z-[60] grid-rows-1 place-items-center px-4 py-6 [-webkit-app-region:no-drag]"
className="row-start-1 max-h-[92vh] w-auto max-w-[92vw] overflow-visible"
className="row-start-1 max-h-[92vh] w-[92vw] max-w-[92vw] items-center overflow-visible [--media-width:92vw] [--media-height:min(86vh,calc(100vh-160px))] sm:[--media-width:calc(92vw-96px)]"
onKeyDown={onKeyDown}
initialFocus={closeButtonRef}
finalFocus={() => returnFocusTarget}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<DialogTitle className="sr-only">Expanded {mediaLabel} preview</DialogTitle>
{preview.images.length > 1 && (
<Button
type="button"
size="icon"
variant="media-navigation"
className="left-2 sm:left-6"
className="left-0 top-auto -bottom-12 translate-y-0 rounded-full bg-white/10 sm:top-1/2 sm:bottom-auto sm:-translate-y-1/2"
aria-label="Previous media"
onClick={() => navigateImage(-1)}
>
<ChevronLeftIcon className="size-5" />
</Button>
)}
<MediaActions source={actionsSource}>
<div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
<div className="relative isolate z-10 max-h-[92vh] max-w-[var(--media-width)]">
<Button
type="button"
ref={closeButtonRef}
size="icon-xs"
variant="media-close"
className="absolute right-2 top-2 z-20"
className="absolute right-0 -top-10 z-20"
onClick={onClose}
aria-label={`Close ${mediaLabel} preview`}
>
Expand All @@ -212,7 +221,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
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"
className="h-[min(var(--media-height),40rem)] w-[min(var(--media-width),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 ? (
Expand All @@ -233,8 +242,8 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
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">
<div className="mt-2 flex max-w-[var(--media-width)] items-center justify-center gap-1.5 text-xs text-white/80">
<span className="truncate" aria-live="polite" aria-atomic="true">
{item.name}
{preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
</span>
Expand Down Expand Up @@ -273,7 +282,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
type="button"
size="icon"
variant="media-navigation"
className="right-2 sm:right-6"
className="right-0 top-auto -bottom-12 translate-y-0 rounded-full bg-white/10 sm:top-1/2 sm:bottom-auto sm:-translate-y-1/2"
aria-label="Next media"
onClick={() => navigateImage(1)}
>
Expand Down
16 changes: 12 additions & 4 deletions apps/web/src/components/chat/ZoomableImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ export function ZoomableImage({
} | null>(null);
const suppressClickRef = useRef(false);
const [dragging, setDragging] = useState(false);
const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80));
const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 160));
const fit = Math.min(
1,
(windowSize.width * 0.92) / (naturalSize.width || 1),
(windowSize.width * 0.92 - (windowSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1),
maxHeight / (naturalSize.height || 1),
);
const width = naturalSize.width * fit * zoom;
Expand All @@ -59,6 +59,12 @@ export function ZoomableImage({
pan(key) {
const viewport = viewportRef.current;
if (!viewport || zoomRef.current <= 1) return false;
// A vertical scrollbar alone must not swallow gallery navigation.
if (
(key === "ArrowLeft" || key === "ArrowRight") &&
viewport.scrollWidth <= viewport.offsetWidth
)
return false;
switch (key) {
case "ArrowLeft":
viewport.scrollLeft -= 40;
Expand Down Expand Up @@ -144,7 +150,7 @@ export function ZoomableImage({
aria-label={`${name}, zoomable image`}
aria-description="Click to zoom in or return to fit. Scroll to zoom, drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit."
tabIndex={0}
className="max-w-[92vw] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="max-w-[var(--media-width)] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{
width: width || undefined,
height: height || undefined,
Expand Down Expand Up @@ -221,7 +227,9 @@ export function ZoomableImage({
alt={name}
draggable={false}
className="block max-w-none select-none"
style={naturalSize.width ? { width, height } : { maxWidth: "92vw", maxHeight }}
style={
naturalSize.width ? { width, height } : { maxWidth: "var(--media-width)", maxHeight }
}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth,
Expand Down
Loading