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
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [
'[role="tab"]',
].join(",");
const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [
'[role="dialog"][aria-modal="true"]',
'[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])',
'[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])',
'[data-slot="dialog-popup"]:is([data-open],[data-ending-style])',
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "./SnapShotAttachmentDetails";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { composerFloatingLayerProps } from "./composerEventScope";
import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage";

interface ExpandedImageDialogProps {
preview: ExpandedImagePreview;
Expand Down Expand Up @@ -63,6 +64,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
onClose,
}: ExpandedImageDialogProps) {
const [imageOffset, setImageOffset] = useState(0);
const zoomableImageRef = useRef<ZoomableImageHandle>(null);
const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState<string | null>(null);
const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;
Expand Down Expand Up @@ -117,6 +119,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
onClose();
return;
}
if (zoomableImageRef.current?.pan(event.key)) {
event.preventDefault();
event.stopPropagation();
return;
}
if (preview.images.length <= 1) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
Expand Down Expand Up @@ -206,11 +213,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
{openOriginalLink}
</ExpandedMediaFailure>
) : (
<img
<ZoomableImage
ref={zoomableImageRef}
key={`${index}:${item.src}`}
src={item.src}
alt={item.name}
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"
draggable={false}
name={item.name}
onError={() => setFailedImageSrc(item.src)}
/>
)}
Expand Down
239 changes: 239 additions & 0 deletions apps/web/src/components/chat/ZoomableImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type Ref,
} from "react";

const MAX_ZOOM = 8;

export interface ZoomableImageHandle {
pan: (key: string) => boolean;
}

/** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */
export function ZoomableImage({
src,
name,
onError,
ref,
}: {
src: string;
name: string;
onError: () => void;
ref?: Ref<ZoomableImageHandle>;
}) {
const viewportRef = useRef<HTMLDivElement>(null);
const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
const [windowSize, setWindowSize] = useState(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
const [zoom, setZoom] = useState(1);
const zoomRef = useRef(1);
const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null);
const dragRef = useRef<{
pointerId: number;
x: number;
y: number;
left: number;
top: number;
} | 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 fit = Math.min(
1,
(windowSize.width * 0.92) / (naturalSize.width || 1),
maxHeight / (naturalSize.height || 1),
);
const width = naturalSize.width * fit * zoom;
const height = naturalSize.height * fit * zoom;

useImperativeHandle(
ref,
() => ({
pan(key) {
const viewport = viewportRef.current;
if (!viewport || zoomRef.current <= 1) return false;
switch (key) {
case "ArrowLeft":
viewport.scrollLeft -= 40;
break;
case "ArrowRight":
viewport.scrollLeft += 40;
break;
case "ArrowUp":
viewport.scrollTop -= 40;
break;
case "ArrowDown":
viewport.scrollTop += 40;
break;
default:
return false;
}
return true;
},
}),
[],
);

const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => {
const viewport = viewportRef.current;
const previous = zoomRef.current;
const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
if (!viewport || previous === clamped) return;
const bounds = viewport.getBoundingClientRect();
const x = point ? point.x - bounds.left : viewport.clientWidth / 2;
const y = point ? point.y - bounds.top : viewport.clientHeight / 2;
anchorRef.current = {
x: (viewport.scrollLeft + x) / previous,
y: (viewport.scrollTop + y) / previous,
clientX: bounds.left + x,
clientY: bounds.top + y,
};
zoomRef.current = clamped;
setZoom(clamped);
}, []);

useLayoutEffect(() => {
const viewport = viewportRef.current;
const anchor = anchorRef.current;
if (!viewport || !anchor) return;
const bounds = viewport.getBoundingClientRect();
viewport.scrollLeft = anchor.x * zoom - (anchor.clientX - bounds.left);
viewport.scrollTop = anchor.y * zoom - (anchor.clientY - bounds.top);
anchorRef.current = null;
}, [zoom]);

useEffect(() => {
const resize = () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
changeZoom(1);
};
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [changeZoom]);

useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const wheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
Comment thread
maria-rcks marked this conversation as resolved.
event.preventDefault();
const delta =
event.deltaY *
(event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1);
changeZoom(zoomRef.current * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
x: event.clientX,
y: event.clientY,
});
};
viewport.addEventListener("wheel", wheel, { passive: false });
return () => viewport.removeEventListener("wheel", wheel);
}, [changeZoom]);

return (
<div className="min-w-0">
<div
ref={viewportRef}
role="region"
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"
style={{
width: width || undefined,
height: height || undefined,
maxHeight,
cursor: zoom > 1 ? (dragging ? "grabbing" : "grab") : "zoom-in",
}}
onClick={(event) => {
// Pointer capture also produces a click after dragging; leave the image zoomed.
if (suppressClickRef.current || event.detail > 1) return;
changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY });
}}
onKeyDown={(event) => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2);
} else if (event.key === "+" || event.key === "=") {
event.preventDefault();
changeZoom(zoomRef.current * 1.5);
} else if (event.key === "-") {
event.preventDefault();
changeZoom(zoomRef.current / 1.5);
} else if (event.key === "0") {
event.preventDefault();
changeZoom(1);
}
}}
onPointerDown={(event) => {
if (dragRef.current) return;
suppressClickRef.current = false;
if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return;
const viewport = event.currentTarget;
const bounds = viewport.getBoundingClientRect();
if (
event.clientX - bounds.left >= viewport.clientWidth ||
event.clientY - bounds.top >= viewport.clientHeight
)
return;
dragRef.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
left: viewport.scrollLeft,
top: viewport.scrollTop,
};
viewport.setPointerCapture(event.pointerId);
setDragging(true);
}}
onPointerMove={(event) => {
const drag = dragRef.current;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (!drag || drag.pointerId !== event.pointerId) return;
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) {
suppressClickRef.current = true;
}
event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x);
event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y);
}}
onPointerUp={(event) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragRef.current = null;
setDragging(false);
}}
onLostPointerCapture={(event) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
dragRef.current = null;
setDragging(false);
}}
>
<img
src={src}
alt={name}
draggable={false}
className="block max-w-none select-none"
style={naturalSize.width ? { width, height } : { maxWidth: "92vw", maxHeight }}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
});
}}
onError={onError}
/>
</div>
<span className="sr-only" aria-live="polite">
{Math.round(zoom * 100)}% zoom
</span>
</div>
);
}
Loading