Skip to content
131 changes: 128 additions & 3 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ import {
} from "./SnapShotAttachmentDetails";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { composerFloatingLayerProps } from "./composerEventScope";
import {
clampImagePan,
DOUBLE_CLICK_IMAGE_ZOOM,
IMAGE_ZOOM_IDENTITY,
type ImageZoomFrame,
type ImageZoomState,
panImage,
wheelZoomFactor,
zoomImageAt,
} from "./imageZoom";

interface ExpandedImageDialogProps {
preview: ExpandedImagePreview;
Expand Down Expand Up @@ -58,6 +68,121 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {
);
}

function zoomFrame(image: HTMLImageElement, zoom: ImageZoomState): ImageZoomFrame {
// The rect is already transformed. Scaling around the center leaves the
// center put, so subtracting the translate gives the untransformed center.
const rect = image.getBoundingClientRect();
return {
width: image.offsetWidth,
height: image.offsetHeight,
viewportWidth: window.innerWidth,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
viewportHeight: window.innerHeight,
centerX: rect.left + rect.width / 2 - zoom.x - window.innerWidth / 2,
centerY: rect.top + rect.height / 2 - zoom.y - window.innerHeight / 2,
};
}

/** Pointer position relative to the untransformed center of the image box. */
function zoomAnchor(frame: ImageZoomFrame, event: { clientX: number; clientY: number }) {
return {
x: event.clientX - frame.viewportWidth / 2 - frame.centerX,
y: event.clientY - frame.viewportHeight / 2 - frame.centerY,
};
}

/**
* The screenshot with pinch, wheel and double-click zoom plus drag to pan.
* Only the transform changes while zooming, so Chromium keeps the work on the
* compositor and never repaints the bitmap.
*/
function ZoomableImage({
src,
alt,
onError,
}: {
readonly src: string;
readonly alt: string;
readonly onError: () => void;
}) {
const imageRef = useRef<HTMLImageElement | null>(null);
const [zoom, setZoom] = useState<ImageZoomState>(IMAGE_ZOOM_IDENTITY);
const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null);

// Wheel must be non-passive to stop the page from scrolling or, in the
// desktop app, from zooming the whole window. React registers wheel as
// passive, so attach it by hand.
useEffect(() => {
const image = imageRef.current;
if (!image) return;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
// macOS reports trackpad pinch as ctrl+wheel with small deltas. A plain
// wheel zooms too, since the dialog has nothing else to scroll.
const factor = wheelZoomFactor(event.ctrlKey ? event.deltaY * 3 : event.deltaY);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
setZoom((current) => {
const frame = zoomFrame(image, current);
return zoomImageAt(current, factor, zoomAnchor(frame, event), frame);
});
};
image.addEventListener("wheel", onWheel, { passive: false });
return () => image.removeEventListener("wheel", onWheel);
}, []);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

// A resize changes the pan bounds. Reclamp so a grown window does not leave
// the image offset with backdrop showing at one edge.
useEffect(() => {
const image = imageRef.current;
if (!image) return;
const onResize = () => setZoom((current) => clampImagePan(current, zoomFrame(image, current)));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);

const zoomed = zoom.scale > 1;
return (
<img
ref={imageRef}
src={src}
alt={alt}
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 ${
zoomed ? "cursor-grab touch-none active:cursor-grabbing" : "cursor-zoom-in"
}`}
style={{ transform: `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.scale})` }}
draggable={false}
onError={onError}
onPointerDown={(event) => {
if (!zoomed || event.button !== 0) return;
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
drag.x = event.clientX;
drag.y = event.clientY;
const image = event.currentTarget;
setZoom((current) => panImage(current, dx, dy, zoomFrame(image, current)));
}}
onPointerUp={(event) => {
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
}}
onPointerCancel={() => {
dragRef.current = null;
}}
onDoubleClick={(event) => {
const image = event.currentTarget;
setZoom((current) => {
if (current.scale > 1) return IMAGE_ZOOM_IDENTITY;
const frame = zoomFrame(image, current);
return zoomImageAt(current, DOUBLE_CLICK_IMAGE_ZOOM, zoomAnchor(frame, event), frame);
});
}}
/>
);
}

export const ExpandedImageDialog = memo(function ExpandedImageDialog({
preview,
onClose,
Expand Down Expand Up @@ -206,11 +331,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
{openOriginalLink}
</ExpandedMediaFailure>
) : (
<img
<ZoomableImage
// A new image starts unzoomed with its own wheel listener.
key={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}
onError={() => setFailedImageSrc(item.src)}
/>
)}
Expand Down
97 changes: 97 additions & 0 deletions apps/web/src/components/chat/imageZoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vite-plus/test";

import {
clampImagePan,
IMAGE_ZOOM_IDENTITY,
MAX_IMAGE_ZOOM,
panImage,
wheelZoomFactor,
zoomImageAt,
} from "./imageZoom";

const frame = {
width: 400,
height: 300,
viewportWidth: 1000,
viewportHeight: 800,
centerX: 0,
centerY: 0,
};

describe("zoomImageAt", () => {
it("keeps the anchored point fixed on screen", () => {
const anchor = { x: 100, y: -50 };
// 4x overflows the viewport on both axes, so the clamp leaves room to
// keep the anchor fixed. The image point under the anchor before the zoom
// is `anchor` itself, and after the zoom it renders at x + scale * anchor.
const zoomed = zoomImageAt(IMAGE_ZOOM_IDENTITY, 4, anchor, frame);
expect(zoomed.scale).toBe(4);
expect(zoomed.x + zoomed.scale * anchor.x).toBeCloseTo(anchor.x);
expect(zoomed.y + zoomed.scale * anchor.y).toBeCloseTo(anchor.y);
});

it("recenters when the zoomed image still fits the viewport", () => {
expect(zoomImageAt(IMAGE_ZOOM_IDENTITY, 2, { x: 100, y: 100 }, frame)).toEqual({
scale: 2,
x: 0,
y: 0,
});
});

it("clamps the scale and returns the same state when nothing changes", () => {
const max = zoomImageAt(IMAGE_ZOOM_IDENTITY, 100, { x: 0, y: 0 }, frame);
expect(max.scale).toBe(MAX_IMAGE_ZOOM);
expect(zoomImageAt(max, 2, { x: 0, y: 0 }, frame)).toBe(max);
expect(zoomImageAt(IMAGE_ZOOM_IDENTITY, 0.5, { x: 0, y: 0 }, frame)).toBe(IMAGE_ZOOM_IDENTITY);
});

it("recenters when zooming back out to 1x", () => {
const zoomed = zoomImageAt(IMAGE_ZOOM_IDENTITY, 4, { x: 150, y: 100 }, frame);
expect(zoomImageAt(zoomed, 0.25, { x: 0, y: 0 }, frame)).toEqual(IMAGE_ZOOM_IDENTITY);
});
});

describe("clampImagePan", () => {
it("centers an axis where the image still fits the viewport", () => {
expect(clampImagePan({ scale: 2, x: 300, y: 200 }, frame)).toEqual({ scale: 2, x: 0, y: 0 });
});

it("stops the far edge at the viewport edge once the image overflows", () => {
// 400 * 4 = 1600 wide in a 1000 viewport leaves 300 of slack per side.
expect(clampImagePan({ scale: 4, x: 900, y: -900 }, frame)).toEqual({
scale: 4,
x: 300,
y: -200,
});
});

it("shifts the bounds by the image center offset so the viewport stays covered", () => {
// The caption sits the image 12px above the viewport center. Dragging down
// must stop 12px earlier and dragging up may go 12px further.
const offset = { ...frame, centerY: -12 };
expect(clampImagePan({ scale: 4, x: 0, y: 900 }, offset).y).toBe(212);
expect(clampImagePan({ scale: 4, x: 0, y: -900 }, offset).y).toBe(-188);
});
});

describe("panImage", () => {
it("ignores pans at 1x", () => {
expect(panImage(IMAGE_ZOOM_IDENTITY, 40, 40, frame)).toBe(IMAGE_ZOOM_IDENTITY);
});

it("moves a zoomed image within the clamp", () => {
expect(panImage({ scale: 4, x: 0, y: 0 }, -100, 50, frame)).toEqual({
scale: 4,
x: -100,
y: 50,
});
});
});

describe("wheelZoomFactor", () => {
it("zooms in on negative deltas and caps a mouse notch", () => {
expect(wheelZoomFactor(-5)).toBeGreaterThan(1);
expect(wheelZoomFactor(5)).toBeLessThan(1);
expect(wheelZoomFactor(-100)).toBe(wheelZoomFactor(-50));
});
});
101 changes: 101 additions & 0 deletions apps/web/src/components/chat/imageZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Pure zoom and pan math for the expanded image dialog. The image keeps its
* layout box and is scaled around its center with a CSS transform, so `x` and
* `y` are the translate applied after scaling, in CSS pixels.
*/
export interface ImageZoomState {
readonly scale: number;
readonly x: number;
readonly y: number;
}

/**
* The untransformed image size, the viewport it must stay visible in, and the
* offset of the untransformed image center from the viewport center. The
* caption below the image pushes the image above the viewport center, so the
* pan bounds are not symmetric.
*/
export interface ImageZoomFrame {
readonly width: number;
readonly height: number;
readonly viewportWidth: number;
readonly viewportHeight: number;
readonly centerX: number;
readonly centerY: number;
}

export interface Point {
readonly x: number;
readonly y: number;
}

export const IMAGE_ZOOM_IDENTITY: ImageZoomState = { scale: 1, x: 0, y: 0 };
const MIN_IMAGE_ZOOM = 1;
export const MAX_IMAGE_ZOOM = 8;
/** Zoom applied by a double click on an unzoomed image. */
export const DOUBLE_CLICK_IMAGE_ZOOM = 2.5;

function clampAxis(
translate: number,
scaledSize: number,
viewportSize: number,
center: number,
): number {
// An image that fits on this axis keeps its layout position. One that
// overflows may be panned until its far edge meets the viewport edge, never
// past it, so it always covers the viewport on this axis.
if (scaledSize <= viewportSize) return 0;
const overflow = (scaledSize - viewportSize) / 2;
// `|| 0` turns a -0 from clamping into 0 so identity states compare equal.
return Math.min(overflow - center, Math.max(-overflow - center, translate)) || 0;
}

export function clampImagePan(state: ImageZoomState, frame: ImageZoomFrame): ImageZoomState {
return {
scale: state.scale,
x: clampAxis(state.x, frame.width * state.scale, frame.viewportWidth, frame.centerX),
y: clampAxis(state.y, frame.height * state.scale, frame.viewportHeight, frame.centerY),
};
}

/**
* Multiplies the scale by `factor` while keeping `anchor` fixed on screen.
* `anchor` is the pointer position relative to the untransformed image center.
*/
export function zoomImageAt(
state: ImageZoomState,
factor: number,
anchor: Point,
frame: ImageZoomFrame,
): ImageZoomState {
const scale = Math.min(MAX_IMAGE_ZOOM, Math.max(MIN_IMAGE_ZOOM, state.scale * factor));
if (scale === state.scale) return state;
const ratio = scale / state.scale;
return clampImagePan(
{
scale,
x: anchor.x - ratio * (anchor.x - state.x),
y: anchor.y - ratio * (anchor.y - state.y),
},
frame,
);
}

export function panImage(
state: ImageZoomState,
dx: number,
dy: number,
frame: ImageZoomFrame,
): ImageZoomState {
if (state.scale <= MIN_IMAGE_ZOOM) return state;
return clampImagePan({ scale: state.scale, x: state.x + dx, y: state.y + dy }, frame);
}

/**
* Converts a wheel delta into a zoom factor. Trackpad pinch on macOS arrives
* as ctrl+wheel with small deltas, mouse wheels send about 100 per notch, so
* the delta is capped to keep a notch from jumping more than about 1.6x.
*/
export function wheelZoomFactor(deltaY: number): number {
return Math.exp(-Math.max(-50, Math.min(50, deltaY)) * 0.01);
}
Loading