Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 4 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 } from "./ZoomableImage";

interface ExpandedImageDialogProps {
preview: ExpandedImagePreview;
Expand Down Expand Up @@ -206,11 +207,10 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
{openOriginalLink}
</ExpandedMediaFailure>
) : (
<img
<ZoomableImage
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
200 changes: 200 additions & 0 deletions apps/web/src/components/chat/ZoomableImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";

const MAX_ZOOM = 8;

/** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */
export function ZoomableImage({
src,
name,
onError,
}: {
src: string;
name: string;
onError: () => void;
}) {
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;

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);
} else if (zoomRef.current > 1 && event.key.startsWith("Arrow")) {
// Leave native keyboard scrolling enabled without navigating the gallery.
event.stopPropagation();
}
}}
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