authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 19:25:45-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 19:25:45-03:00
logc1ff6ab3d6531e733fb1bca5d4b94f0ff97f7af4
treeff3f4d193f184ae2fb0b22a980cd01f2fb31f7da
parent75d8b132cd0718d8cd703c97d02b143891d80022
signature Signed by PGP key B5690EEEBB952194

fix(web): move viewer controls outside media and restore arrow navigation (#11470)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 54 insertions(+), 37 deletions(-)

apps/web/src/components/chat/ExpandedImageDialog.tsx+42-33
......@@ -1,4 +1,12 @@
1import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
1import {
2 memo,
3 useCallback,
4 useEffect,
5 useRef,
6 useState,
7 type ReactNode,
8 type KeyboardEvent,
9} from "react";
210import { ChevronLeftIcon, ChevronRightIcon, ImageIcon, TextIcon, XIcon } from "lucide-react";
311import { Button } from "../ui/button";
412import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
......@@ -24,7 +32,7 @@ interface ExpandedImageDialogProps {
2432}
2533
2634const EXPANDED_MEDIA_STATE_CLASS_NAME =
27 "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";
35 "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";
2836
2937function ExpandedMediaFailure({ children }: { children: ReactNode }) {
3038 return (
......@@ -51,8 +59,8 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {
5159 originalUrl={item.originalUrl}
5260 preload="metadata"
5361 autoPlay={item.autoPlay ?? true}
54 className="block max-h-[86vh] max-w-[92vw] text-center"
55 videoClassName="aspect-auto max-h-[86vh] w-auto max-w-[92vw] rounded-lg border border-border/70 shadow-2xl"
62 className="block max-h-[var(--media-height)] max-w-[var(--media-width)] text-center"
63 videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl"
5664 stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME}
5765 onRetry={asset ? refreshAssetUrl : undefined}
5866 />
......@@ -111,29 +119,26 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
111119 };
112120 }, []);
113121
114 useEffect(() => {
115 const onKeyDown = (event: globalThis.KeyboardEvent) => {
116 if (event.defaultPrevented || isContextMenuOpen()) return;
117 if (zoomableImageRef.current?.pan(event.key)) {
118 event.preventDefault();
119 event.stopPropagation();
120 return;
121 }
122 if (preview.images.length <= 1) return;
123 if (event.key === "ArrowLeft") {
124 event.preventDefault();
125 event.stopPropagation();
126 navigateImage(-1);
127 return;
128 }
129 if (event.key !== "ArrowRight") return;
122 const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
123 if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement)
124 return;
125 if (zoomableImageRef.current?.pan(event.key)) {
130126 event.preventDefault();
131127 event.stopPropagation();
132 navigateImage(1);
133 };
134 window.addEventListener("keydown", onKeyDown);
135 return () => window.removeEventListener("keydown", onKeyDown);
136 }, [navigateImage, preview.images.length]);
128 return;
129 }
130 if (preview.images.length <= 1) return;
131 if (event.key === "ArrowLeft") {
132 event.preventDefault();
133 event.stopPropagation();
134 navigateImage(-1);
135 return;
136 }
137 if (event.key !== "ArrowRight") return;
138 event.preventDefault();
139 event.stopPropagation();
140 navigateImage(1);
141 };
137142
138143 useEffect(() => {
139144 const onEscape = (event: globalThis.KeyboardEvent) => {
......@@ -176,9 +181,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
176181 bottomStickOnMobile={false}
177182 backdropClassName="z-[60]"
178183 viewportClassName="z-[60] grid-rows-1 place-items-center px-4 py-6 [-webkit-app-region:no-drag]"
179 className="row-start-1 max-h-[92vh] w-auto max-w-[92vw] overflow-visible"
184 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)]"
185 onKeyDown={onKeyDown}
180186 initialFocus={closeButtonRef}
181187 finalFocus={() => returnFocusTarget}
188 onClick={(event) => {
189 if (event.target === event.currentTarget) onClose();
190 }}
182191 >
183192 <DialogTitle className="sr-only">Expanded {mediaLabel} preview</DialogTitle>
184193 {preview.images.length > 1 && (
......@@ -186,7 +195,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
186195 type="button"
187196 size="icon"
188197 variant="media-navigation"
189 className="left-2 sm:left-6"
198 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"
190199 aria-label="Previous media"
191200 onClick={() => navigateImage(-1)}
192201 >
......@@ -194,13 +203,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
194203 </Button>
195204 )}
196205 <MediaActions source={actionsSource}>
197 <div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
206 <div className="relative isolate z-10 max-h-[92vh] max-w-[var(--media-width)]">
198207 <Button
199208 type="button"
200209 ref={closeButtonRef}
201210 size="icon-xs"
202211 variant="media-close"
203 className="absolute right-2 top-2 z-20"
212 className="absolute right-0 -top-10 z-20"
204213 onClick={onClose}
205214 aria-label={`Close ${mediaLabel} preview`}
206215 >
......@@ -212,7 +221,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
212221 accessibilityDetails ? (
213222 <SnapShotAccessibilityData
214223 details={accessibilityDetails}
215 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"
224 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"
216225 />
217226 ) : null
218227 ) : item.src === null || failedImageSrc === item.src ? (
......@@ -233,8 +242,8 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
233242 onError={() => setFailedImageSrc(item.src)}
234243 />
235244 )}
236 <div className="mt-2 flex max-w-[92vw] items-center justify-center gap-1.5 text-xs text-white/80">
237 <span className="truncate">
245 <div className="mt-2 flex max-w-[var(--media-width)] items-center justify-center gap-1.5 text-xs text-white/80">
246 <span className="truncate" aria-live="polite" aria-atomic="true">
238247 {item.name}
239248 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
240249 </span>
......@@ -273,7 +282,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
273282 type="button"
274283 size="icon"
275284 variant="media-navigation"
276 className="right-2 sm:right-6"
285 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"
277286 aria-label="Next media"
278287 onClick={() => navigateImage(1)}
279288 >
apps/web/src/components/chat/ZoomableImage.tsx+12-4
......@@ -44,10 +44,10 @@ export function ZoomableImage({
4444 } | null>(null);
4545 const suppressClickRef = useRef(false);
4646 const [dragging, setDragging] = useState(false);
47 const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80));
47 const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 160));
4848 const fit = Math.min(
4949 1,
50 (windowSize.width * 0.92) / (naturalSize.width || 1),
50 (windowSize.width * 0.92 - (windowSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1),
5151 maxHeight / (naturalSize.height || 1),
5252 );
5353 const width = naturalSize.width * fit * zoom;
......@@ -59,6 +59,12 @@ export function ZoomableImage({
5959 pan(key) {
6060 const viewport = viewportRef.current;
6161 if (!viewport || zoomRef.current <= 1) return false;
62 // A vertical scrollbar alone must not swallow gallery navigation.
63 if (
64 (key === "ArrowLeft" || key === "ArrowRight") &&
65 viewport.scrollWidth <= viewport.offsetWidth
66 )
67 return false;
6268 switch (key) {
6369 case "ArrowLeft":
6470 viewport.scrollLeft -= 40;
......@@ -144,7 +150,7 @@ export function ZoomableImage({
144150 aria-label={`${name}, zoomable image`}
145151 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."
146152 tabIndex={0}
147 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"
153 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"
148154 style={{
149155 width: width || undefined,
150156 height: height || undefined,
......@@ -221,7 +227,9 @@ export function ZoomableImage({
221227 alt={name}
222228 draggable={false}
223229 className="block max-w-none select-none"
224 style={naturalSize.width ? { width, height } : { maxWidth: "92vw", maxHeight }}
230 style={
231 naturalSize.width ? { width, height } : { maxWidth: "var(--media-width)", maxHeight }
232 }
225233 onLoad={(event) => {
226234 setNaturalSize({
227235 width: event.currentTarget.naturalWidth,