authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-09 20:38:19-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 20:38:19-03:00
log8d8189e67dc091ffb91df451c9a957bbd68a5364
treef03a11527aa69db0577003b3aad112349f71cad5
parent33242d0164d47c6935ba5c7725296539384de65d
signature Signed by PGP key B5690EEEBB952194

feat(web): zoom and pan expanded images (#10869)


3 files changed, 251 insertions(+), 4 deletions(-)

apps/web/src/components/ChatView.tsx+1
...@@ -579,6 +579,7 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [...@@ -579,6 +579,7 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [
579 '[role="tab"]',579 '[role="tab"]',
580].join(",");580].join(",");
581const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [581const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [
582 '[role="dialog"][aria-modal="true"]',
582 '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])',583 '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])',
583 '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])',584 '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])',
584 '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])',585 '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])',
apps/web/src/components/chat/ExpandedImageDialog.tsx+11-4
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16} from "./SnapShotAttachmentDetails";16} from "./SnapShotAttachmentDetails";
17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
18import { composerFloatingLayerProps } from "./composerEventScope";18import { composerFloatingLayerProps } from "./composerEventScope";
19import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage";
1920
20interface ExpandedImageDialogProps {21interface ExpandedImageDialogProps {
21 preview: ExpandedImagePreview;22 preview: ExpandedImagePreview;
...@@ -63,6 +64,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -63,6 +64,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
63 onClose,64 onClose,
64}: ExpandedImageDialogProps) {65}: ExpandedImageDialogProps) {
65 const [imageOffset, setImageOffset] = useState(0);66 const [imageOffset, setImageOffset] = useState(0);
67 const zoomableImageRef = useRef<ZoomableImageHandle>(null);
66 const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);68 const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
67 const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState<string | null>(null);69 const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState<string | null>(null);
68 const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;70 const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;
...@@ -117,6 +119,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -117,6 +119,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
117 onClose();119 onClose();
118 return;120 return;
119 }121 }
122 if (zoomableImageRef.current?.pan(event.key)) {
123 event.preventDefault();
124 event.stopPropagation();
125 return;
126 }
120 if (preview.images.length <= 1) return;127 if (preview.images.length <= 1) return;
121 if (event.key === "ArrowLeft") {128 if (event.key === "ArrowLeft") {
122 event.preventDefault();129 event.preventDefault();
...@@ -206,11 +213,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -206,11 +213,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
206 {openOriginalLink}213 {openOriginalLink}
207 </ExpandedMediaFailure>214 </ExpandedMediaFailure>
208 ) : (215 ) : (
209 <img216 <ZoomableImage
217 ref={zoomableImageRef}
218 key={`${index}:${item.src}`}
210 src={item.src}219 src={item.src}
211 alt={item.name}220 name={item.name}
212 className="max-h-[86vh] max-w-[92vw] animate-[snap-shot-contents-enter_140ms_ease-out] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl motion-reduce:animate-none"
213 draggable={false}
214 onError={() => setFailedImageSrc(item.src)}221 onError={() => setFailedImageSrc(item.src)}
215 />222 />
216 )}223 )}
apps/web/src/components/chat/ZoomableImage.tsx created+239
...@@ -0,0 +1,239 @@
1import {
2 useCallback,
3 useEffect,
4 useImperativeHandle,
5 useLayoutEffect,
6 useRef,
7 useState,
8 type Ref,
9} from "react";
10
11const MAX_ZOOM = 8;
12
13export interface ZoomableImageHandle {
14 pan: (key: string) => boolean;
15}
16
17/** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */
18export function ZoomableImage({
19 src,
20 name,
21 onError,
22 ref,
23}: {
24 src: string;
25 name: string;
26 onError: () => void;
27 ref?: Ref<ZoomableImageHandle>;
28}) {
29 const viewportRef = useRef<HTMLDivElement>(null);
30 const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
31 const [windowSize, setWindowSize] = useState(() => ({
32 width: window.innerWidth,
33 height: window.innerHeight,
34 }));
35 const [zoom, setZoom] = useState(1);
36 const zoomRef = useRef(1);
37 const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null);
38 const dragRef = useRef<{
39 pointerId: number;
40 x: number;
41 y: number;
42 left: number;
43 top: number;
44 } | null>(null);
45 const suppressClickRef = useRef(false);
46 const [dragging, setDragging] = useState(false);
47 const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80));
48 const fit = Math.min(
49 1,
50 (windowSize.width * 0.92) / (naturalSize.width || 1),
51 maxHeight / (naturalSize.height || 1),
52 );
53 const width = naturalSize.width * fit * zoom;
54 const height = naturalSize.height * fit * zoom;
55
56 useImperativeHandle(
57 ref,
58 () => ({
59 pan(key) {
60 const viewport = viewportRef.current;
61 if (!viewport || zoomRef.current <= 1) return false;
62 switch (key) {
63 case "ArrowLeft":
64 viewport.scrollLeft -= 40;
65 break;
66 case "ArrowRight":
67 viewport.scrollLeft += 40;
68 break;
69 case "ArrowUp":
70 viewport.scrollTop -= 40;
71 break;
72 case "ArrowDown":
73 viewport.scrollTop += 40;
74 break;
75 default:
76 return false;
77 }
78 return true;
79 },
80 }),
81 [],
82 );
83
84 const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => {
85 const viewport = viewportRef.current;
86 const previous = zoomRef.current;
87 const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
88 if (!viewport || previous === clamped) return;
89 const bounds = viewport.getBoundingClientRect();
90 const x = point ? point.x - bounds.left : viewport.clientWidth / 2;
91 const y = point ? point.y - bounds.top : viewport.clientHeight / 2;
92 anchorRef.current = {
93 x: (viewport.scrollLeft + x) / previous,
94 y: (viewport.scrollTop + y) / previous,
95 clientX: bounds.left + x,
96 clientY: bounds.top + y,
97 };
98 zoomRef.current = clamped;
99 setZoom(clamped);
100 }, []);
101
102 useLayoutEffect(() => {
103 const viewport = viewportRef.current;
104 const anchor = anchorRef.current;
105 if (!viewport || !anchor) return;
106 const bounds = viewport.getBoundingClientRect();
107 viewport.scrollLeft = anchor.x * zoom - (anchor.clientX - bounds.left);
108 viewport.scrollTop = anchor.y * zoom - (anchor.clientY - bounds.top);
109 anchorRef.current = null;
110 }, [zoom]);
111
112 useEffect(() => {
113 const resize = () => {
114 setWindowSize({ width: window.innerWidth, height: window.innerHeight });
115 changeZoom(1);
116 };
117 window.addEventListener("resize", resize);
118 return () => window.removeEventListener("resize", resize);
119 }, [changeZoom]);
120
121 useEffect(() => {
122 const viewport = viewportRef.current;
123 if (!viewport) return;
124 const wheel = (event: WheelEvent) => {
125 if (event.deltaY === 0) return;
126 event.preventDefault();
127 const delta =
128 event.deltaY *
129 (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1);
130 changeZoom(zoomRef.current * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), {
131 x: event.clientX,
132 y: event.clientY,
133 });
134 };
135 viewport.addEventListener("wheel", wheel, { passive: false });
136 return () => viewport.removeEventListener("wheel", wheel);
137 }, [changeZoom]);
138
139 return (
140 <div className="min-w-0">
141 <div
142 ref={viewportRef}
143 role="region"
144 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."
146 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"
148 style={{
149 width: width || undefined,
150 height: height || undefined,
151 maxHeight,
152 cursor: zoom > 1 ? (dragging ? "grabbing" : "grab") : "zoom-in",
153 }}
154 onClick={(event) => {
155 // Pointer capture also produces a click after dragging; leave the image zoomed.
156 if (suppressClickRef.current || event.detail > 1) return;
157 changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY });
158 }}
159 onKeyDown={(event) => {
160 if (event.ctrlKey || event.metaKey || event.altKey) return;
161 if (event.key === "Enter" || event.key === " ") {
162 event.preventDefault();
163 if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2);
164 } else if (event.key === "+" || event.key === "=") {
165 event.preventDefault();
166 changeZoom(zoomRef.current * 1.5);
167 } else if (event.key === "-") {
168 event.preventDefault();
169 changeZoom(zoomRef.current / 1.5);
170 } else if (event.key === "0") {
171 event.preventDefault();
172 changeZoom(1);
173 }
174 }}
175 onPointerDown={(event) => {
176 if (dragRef.current) return;
177 suppressClickRef.current = false;
178 if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return;
179 const viewport = event.currentTarget;
180 const bounds = viewport.getBoundingClientRect();
181 if (
182 event.clientX - bounds.left >= viewport.clientWidth ||
183 event.clientY - bounds.top >= viewport.clientHeight
184 )
185 return;
186 dragRef.current = {
187 pointerId: event.pointerId,
188 x: event.clientX,
189 y: event.clientY,
190 left: viewport.scrollLeft,
191 top: viewport.scrollTop,
192 };
193 viewport.setPointerCapture(event.pointerId);
194 setDragging(true);
195 }}
196 onPointerMove={(event) => {
197 const drag = dragRef.current;
198 if (!drag || drag.pointerId !== event.pointerId) return;
199 if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) {
200 suppressClickRef.current = true;
201 }
202 event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x);
203 event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y);
204 }}
205 onPointerUp={(event) => {
206 if (dragRef.current?.pointerId !== event.pointerId) return;
207 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
208 event.currentTarget.releasePointerCapture(event.pointerId);
209 }
210 dragRef.current = null;
211 setDragging(false);
212 }}
213 onLostPointerCapture={(event) => {
214 if (dragRef.current?.pointerId !== event.pointerId) return;
215 dragRef.current = null;
216 setDragging(false);
217 }}
218 >
219 <img
220 src={src}
221 alt={name}
222 draggable={false}
223 className="block max-w-none select-none"
224 style={naturalSize.width ? { width, height } : { maxWidth: "92vw", maxHeight }}
225 onLoad={(event) => {
226 setNaturalSize({
227 width: event.currentTarget.naturalWidth,
228 height: event.currentTarget.naturalHeight,
229 });
230 }}
231 onError={onError}
232 />
233 </div>
234 <span className="sr-only" aria-live="polite">
235 {Math.round(zoom * 100)}% zoom
236 </span>
237 </div>
238 );
239}