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 @@...@@ -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";
2import { ChevronLeftIcon, ChevronRightIcon, ImageIcon, TextIcon, XIcon } from "lucide-react";10import { ChevronLeftIcon, ChevronRightIcon, ImageIcon, TextIcon, XIcon } from "lucide-react";
3import { Button } from "../ui/button";11import { Button } from "../ui/button";
4import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";12import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
...@@ -24,7 +32,7 @@ interface ExpandedImageDialogProps {...@@ -24,7 +32,7 @@ interface ExpandedImageDialogProps {
24}32}
2533
26const EXPANDED_MEDIA_STATE_CLASS_NAME =34const 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
29function ExpandedMediaFailure({ children }: { children: ReactNode }) {37function ExpandedMediaFailure({ children }: { children: ReactNode }) {
30 return (38 return (
...@@ -51,8 +59,8 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {...@@ -51,8 +59,8 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {
51 originalUrl={item.originalUrl}59 originalUrl={item.originalUrl}
52 preload="metadata"60 preload="metadata"
53 autoPlay={item.autoPlay ?? true}61 autoPlay={item.autoPlay ?? true}
54 className="block max-h-[86vh] max-w-[92vw] text-center"62 className="block max-h-[var(--media-height)] max-w-[var(--media-width)] text-center"
55 videoClassName="aspect-auto max-h-[86vh] w-auto max-w-[92vw] rounded-lg border border-border/70 shadow-2xl"63 videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl"
56 stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME}64 stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME}
57 onRetry={asset ? refreshAssetUrl : undefined}65 onRetry={asset ? refreshAssetUrl : undefined}
58 />66 />
...@@ -111,29 +119,26 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -111,29 +119,26 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
111 };119 };
112 }, []);120 }, []);
113121
114 useEffect(() => {122 const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
115 const onKeyDown = (event: globalThis.KeyboardEvent) => {123 if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement)
116 if (event.defaultPrevented || isContextMenuOpen()) return;124 return;
117 if (zoomableImageRef.current?.pan(event.key)) {125 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;
130 event.preventDefault();126 event.preventDefault();
131 event.stopPropagation();127 event.stopPropagation();
132 navigateImage(1);128 return;
133 };129 }
134 window.addEventListener("keydown", onKeyDown);130 if (preview.images.length <= 1) return;
135 return () => window.removeEventListener("keydown", onKeyDown);131 if (event.key === "ArrowLeft") {
136 }, [navigateImage, preview.images.length]);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
138 useEffect(() => {143 useEffect(() => {
139 const onEscape = (event: globalThis.KeyboardEvent) => {144 const onEscape = (event: globalThis.KeyboardEvent) => {
...@@ -176,9 +181,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -176,9 +181,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
176 bottomStickOnMobile={false}181 bottomStickOnMobile={false}
177 backdropClassName="z-[60]"182 backdropClassName="z-[60]"
178 viewportClassName="z-[60] grid-rows-1 place-items-center px-4 py-6 [-webkit-app-region:no-drag]"183 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}
180 initialFocus={closeButtonRef}186 initialFocus={closeButtonRef}
181 finalFocus={() => returnFocusTarget}187 finalFocus={() => returnFocusTarget}
188 onClick={(event) => {
189 if (event.target === event.currentTarget) onClose();
190 }}
182 >191 >
183 <DialogTitle className="sr-only">Expanded {mediaLabel} preview</DialogTitle>192 <DialogTitle className="sr-only">Expanded {mediaLabel} preview</DialogTitle>
184 {preview.images.length > 1 && (193 {preview.images.length > 1 && (
...@@ -186,7 +195,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -186,7 +195,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
186 type="button"195 type="button"
187 size="icon"196 size="icon"
188 variant="media-navigation"197 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"
190 aria-label="Previous media"199 aria-label="Previous media"
191 onClick={() => navigateImage(-1)}200 onClick={() => navigateImage(-1)}
192 >201 >
...@@ -194,13 +203,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -194,13 +203,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
194 </Button>203 </Button>
195 )}204 )}
196 <MediaActions source={actionsSource}>205 <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)]">
198 <Button207 <Button
199 type="button"208 type="button"
200 ref={closeButtonRef}209 ref={closeButtonRef}
201 size="icon-xs"210 size="icon-xs"
202 variant="media-close"211 variant="media-close"
203 className="absolute right-2 top-2 z-20"212 className="absolute right-0 -top-10 z-20"
204 onClick={onClose}213 onClick={onClose}
205 aria-label={`Close ${mediaLabel} preview`}214 aria-label={`Close ${mediaLabel} preview`}
206 >215 >
...@@ -212,7 +221,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -212,7 +221,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
212 accessibilityDetails ? (221 accessibilityDetails ? (
213 <SnapShotAccessibilityData222 <SnapShotAccessibilityData
214 details={accessibilityDetails}223 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"
216 />225 />
217 ) : null226 ) : null
218 ) : item.src === null || failedImageSrc === item.src ? (227 ) : item.src === null || failedImageSrc === item.src ? (
...@@ -233,8 +242,8 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -233,8 +242,8 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
233 onError={() => setFailedImageSrc(item.src)}242 onError={() => setFailedImageSrc(item.src)}
234 />243 />
235 )}244 )}
236 <div className="mt-2 flex max-w-[92vw] items-center justify-center gap-1.5 text-xs text-white/80">245 <div className="mt-2 flex max-w-[var(--media-width)] items-center justify-center gap-1.5 text-xs text-white/80">
237 <span className="truncate">246 <span className="truncate" aria-live="polite" aria-atomic="true">
238 {item.name}247 {item.name}
239 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}248 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
240 </span>249 </span>
...@@ -273,7 +282,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -273,7 +282,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
273 type="button"282 type="button"
274 size="icon"283 size="icon"
275 variant="media-navigation"284 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"
277 aria-label="Next media"286 aria-label="Next media"
278 onClick={() => navigateImage(1)}287 onClick={() => navigateImage(1)}
279 >288 >
apps/web/src/components/chat/ZoomableImage.tsx+12-4
...@@ -44,10 +44,10 @@ export function ZoomableImage({...@@ -44,10 +44,10 @@ export function ZoomableImage({
44 } | null>(null);44 } | null>(null);
45 const suppressClickRef = useRef(false);45 const suppressClickRef = useRef(false);
46 const [dragging, setDragging] = useState(false);46 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));
48 const fit = Math.min(48 const fit = Math.min(
49 1,49 1,
50 (windowSize.width * 0.92) / (naturalSize.width || 1),50 (windowSize.width * 0.92 - (windowSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1),
51 maxHeight / (naturalSize.height || 1),51 maxHeight / (naturalSize.height || 1),
52 );52 );
53 const width = naturalSize.width * fit * zoom;53 const width = naturalSize.width * fit * zoom;
...@@ -59,6 +59,12 @@ export function ZoomableImage({...@@ -59,6 +59,12 @@ export function ZoomableImage({
59 pan(key) {59 pan(key) {
60 const viewport = viewportRef.current;60 const viewport = viewportRef.current;
61 if (!viewport || zoomRef.current <= 1) return false;61 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;
62 switch (key) {68 switch (key) {
63 case "ArrowLeft":69 case "ArrowLeft":
64 viewport.scrollLeft -= 40;70 viewport.scrollLeft -= 40;
...@@ -144,7 +150,7 @@ export function ZoomableImage({...@@ -144,7 +150,7 @@ export function ZoomableImage({
144 aria-label={`${name}, zoomable image`}150 aria-label={`${name}, zoomable image`}
145 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."151 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."
146 tabIndex={0}152 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"
148 style={{154 style={{
149 width: width || undefined,155 width: width || undefined,
150 height: height || undefined,156 height: height || undefined,
...@@ -221,7 +227,9 @@ export function ZoomableImage({...@@ -221,7 +227,9 @@ export function ZoomableImage({
221 alt={name}227 alt={name}
222 draggable={false}228 draggable={false}
223 className="block max-w-none select-none"229 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 }
225 onLoad={(event) => {233 onLoad={(event) => {
226 setNaturalSize({234 setNaturalSize({
227 width: event.currentTarget.naturalWidth,235 width: event.currentTarget.naturalWidth,