From 2c4d5a6d329785656371cdbaa3b97e3375d52e6d Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Sat, 15 Aug 2026 14:32:45 +0530 Subject: [PATCH] feat(web): pan and zoom for the document image viewer Image documents (screenshots, diagrams, receipts) could only be viewed at fit size, so any fine detail was unreadable. This adds a proper viewer: - Scroll to zoom toward the cursor, drag to pan once zoomed, double-click to zoom in at that point (or reset), and a small +/-/reset control with a live zoom percentage. - Panning is clamped so the image can't be flung off-screen, and zooming fully out always snaps back to a clean centered fit. The transform math (zoom-to-cursor, translation clamping) lives in lib/image-zoom.ts as pure functions with unit tests; the component just wires pointer/wheel events to it. The keyboard-accessible zoom buttons back the pointer gestures. Existing load/retry/cached-blob fallback behavior is unchanged. Tests: lib/image-zoom.test.ts (10 passing). Type-checks and Biome clean. --- .../document-modal/content/image-preview.tsx | 167 +++++++++++++++++- apps/web/lib/image-zoom.test.ts | 94 ++++++++++ apps/web/lib/image-zoom.ts | 98 ++++++++++ 3 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/image-zoom.test.ts create mode 100644 apps/web/lib/image-zoom.ts diff --git a/apps/web/components/document-modal/content/image-preview.tsx b/apps/web/components/document-modal/content/image-preview.tsx index 7d9aac6b..bda72e50 100644 --- a/apps/web/components/document-modal/content/image-preview.tsx +++ b/apps/web/components/document-modal/content/image-preview.tsx @@ -1,8 +1,18 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" +import { Minus, Plus, X } from "lucide-react" import { cn } from "@lib/utils" import { getCachedFileBlob } from "@/lib/file-cache" +import { + IDENTITY_TRANSFORM, + type ImageTransform, + isZoomed, + MAX_SCALE, + panBy, + toCssTransform, + zoomAtPoint, +} from "@/lib/image-zoom" interface ImagePreviewProps { url: string @@ -10,6 +20,19 @@ interface ImagePreviewProps { documentId?: string | null } +// Pointer position relative to the container's center. +function centerRelativePoint( + el: HTMLElement, + clientX: number, + clientY: number, +): { x: number; y: number } { + const rect = el.getBoundingClientRect() + return { + x: clientX - rect.left - rect.width / 2, + y: clientY - rect.top - rect.height / 2, + } +} + export function ImagePreview({ url, title, documentId }: ImagePreviewProps) { const [imageError, setImageError] = useState(false) const [isLoading, setIsLoading] = useState(true) @@ -17,6 +40,16 @@ export function ImagePreview({ url, title, documentId }: ImagePreviewProps) { const [activeSrc, setActiveSrc] = useState(url) const objectUrlRef = useRef(null) + const containerRef = useRef(null) + const [transform, setTransform] = useState(IDENTITY_TRANSFORM) + const dragRef = useRef<{ + pointerId: number + startX: number + startY: number + startTransform: ImageTransform + } | null>(null) + const zoomed = isZoomed(transform) + useEffect(() => { return () => { if (objectUrlRef.current) { @@ -26,6 +59,21 @@ export function ImagePreview({ url, title, documentId }: ImagePreviewProps) { } }, []) + // Native, non-passive wheel listener so we can preventDefault the page scroll + // while zooming toward the cursor. + useEffect(() => { + const el = containerRef.current + if (!el) return + const onWheel = (e: WheelEvent) => { + e.preventDefault() + const point = centerRelativePoint(el, e.clientX, e.clientY) + const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15 + setTransform((t) => zoomAtPoint(t, factor, point.x, point.y)) + } + el.addEventListener("wheel", onWheel, { passive: false }) + return () => el.removeEventListener("wheel", onWheel) + }, []) + const handleImageError = useCallback(() => { if (retryKey === 0) { setTimeout(() => setRetryKey(1), 500) @@ -54,6 +102,62 @@ export function ImagePreview({ url, title, documentId }: ImagePreviewProps) { setIsLoading(false) }, [retryKey, documentId]) + const zoomByButton = useCallback((factor: number) => { + setTransform((t) => zoomAtPoint(t, factor, 0, 0)) + }, []) + + const resetZoom = useCallback(() => setTransform(IDENTITY_TRANSFORM), []) + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const el = containerRef.current + if (!el) return + if (zoomed) { + resetZoom() + return + } + const point = centerRelativePoint(el, e.clientX, e.clientY) + setTransform((t) => zoomAtPoint(t, 2.5, point.x, point.y)) + }, + [zoomed, resetZoom], + ) + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!zoomed) return + e.currentTarget.setPointerCapture(e.pointerId) + dragRef.current = { + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + startTransform: transform, + } + }, + [zoomed, transform], + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + const drag = dragRef.current + const el = containerRef.current + if (!drag || drag.pointerId !== e.pointerId || !el) return + const dx = e.clientX - drag.startX + const dy = e.clientY - drag.startY + const rect = el.getBoundingClientRect() + setTransform(panBy(drag.startTransform, dx, dy, rect.width, rect.height)) + }, + [], + ) + + const endDrag = useCallback((e: React.PointerEvent) => { + if (dragRef.current?.pointerId === e.pointerId) { + dragRef.current = null + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId) + } + } + }, []) + if (imageError || !activeSrc) { return (
@@ -63,7 +167,19 @@ export function ImagePreview({ url, title, documentId }: ImagePreviewProps) { } return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: pointer/dblclick gestures are optional enhancements; the zoom buttons provide the accessible controls +
{isLoading && (
@@ -83,14 +199,61 @@ export function ImagePreview({ url, title, documentId }: ImagePreviewProps) { key={retryKey} src={activeSrc} alt={title || "Image preview"} + draggable={false} className={cn( - "relative max-w-full max-h-full size-auto object-contain z-10", + "relative max-w-full max-h-full size-auto object-contain z-10 select-none", isLoading && "opacity-0", )} + style={{ + transform: toCssTransform(transform), + transition: dragRef.current ? "none" : "transform 0.12s ease-out", + willChange: "transform", + }} onError={handleImageError} onLoad={() => setIsLoading(false)} loading="lazy" /> + + {!isLoading && ( +
+ + + {Math.round(transform.scale * 100)}% + + + {zoomed && ( + + )} +
+ )}
) } diff --git a/apps/web/lib/image-zoom.test.ts b/apps/web/lib/image-zoom.test.ts new file mode 100644 index 00000000..be47ea91 --- /dev/null +++ b/apps/web/lib/image-zoom.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test" +import { + clampScale, + clampTranslation, + IDENTITY_TRANSFORM, + isZoomed, + MAX_SCALE, + MIN_SCALE, + panBy, + toCssTransform, + zoomAtPoint, +} from "./image-zoom" + +describe("clampScale", () => { + it("clamps to the [min, max] range", () => { + expect(clampScale(0.2)).toBe(MIN_SCALE) + expect(clampScale(3)).toBe(3) + expect(clampScale(999)).toBe(MAX_SCALE) + }) +}) + +describe("zoomAtPoint", () => { + it("zooms toward the center without shifting when pointer is centered", () => { + const next = zoomAtPoint(IDENTITY_TRANSFORM, 2, 0, 0) + expect(next.scale).toBe(2) + expect(next.x).toBe(0) + expect(next.y).toBe(0) + }) + + it("keeps the point under the cursor stationary", () => { + // Zooming 2x at pointer (100, 0) from identity. + // world point under cursor = (100 - 0)/1 = 100; after 2x it must stay at 100. + const next = zoomAtPoint(IDENTITY_TRANSFORM, 2, 100, 0) + const worldUnderCursor = (100 - next.x) / next.scale + expect(worldUnderCursor).toBeCloseTo(100) + expect(next.x).toBeCloseTo(-100) + }) + + it("recenters when zooming back out to minimum scale", () => { + const zoomed = zoomAtPoint(IDENTITY_TRANSFORM, 4, 120, 60) + const out = zoomAtPoint(zoomed, 0.01, 120, 60) + expect(out.scale).toBe(MIN_SCALE) + expect(out.x).toBe(0) + expect(out.y).toBe(0) + }) + + it("returns the same transform when already at max and zooming in", () => { + const atMax = { scale: MAX_SCALE, x: 5, y: 5 } + expect(zoomAtPoint(atMax, 2, 0, 0)).toBe(atMax) + }) +}) + +describe("clampTranslation", () => { + it("allows no travel at scale 1", () => { + const clamped = clampTranslation({ scale: 1, x: 50, y: 50 }, 800, 600) + expect(clamped.x).toBe(0) + expect(clamped.y).toBe(0) + }) + + it("limits travel to (scale-1)*half on each axis", () => { + // scale 2, container 800x600 => maxX 400, maxY 300 + expect(clampTranslation({ scale: 2, x: 999, y: -999 }, 800, 600)).toEqual({ + scale: 2, + x: 400, + y: -300, + }) + expect(clampTranslation({ scale: 2, x: 100, y: -50 }, 800, 600)).toEqual({ + scale: 2, + x: 100, + y: -50, + }) + }) +}) + +describe("panBy", () => { + it("applies a delta and re-clamps", () => { + const start = { scale: 2, x: 0, y: 0 } + const panned = panBy(start, 1000, 0, 800, 600) + expect(panned.x).toBe(400) // clamped to maxX + }) +}) + +describe("isZoomed / toCssTransform", () => { + it("reports zoom state", () => { + expect(isZoomed(IDENTITY_TRANSFORM)).toBe(false) + expect(isZoomed({ scale: 1.5, x: 0, y: 0 })).toBe(true) + }) + + it("serializes the CSS transform", () => { + expect(toCssTransform({ scale: 2, x: 10, y: -5 })).toBe( + "translate(10px, -5px) scale(2)", + ) + }) +}) diff --git a/apps/web/lib/image-zoom.ts b/apps/web/lib/image-zoom.ts new file mode 100644 index 00000000..0db5db13 --- /dev/null +++ b/apps/web/lib/image-zoom.ts @@ -0,0 +1,98 @@ +/** + * Pure transform math for the pan/zoom image viewer. + * + * The image is centered in its container and rendered with + * `transform: translate(x, y) scale(scale)` (transform-origin: center). All + * pointer coordinates here are relative to the container's center (so 0,0 is the + * middle), which keeps the zoom-to-cursor formula symmetric and easy to test. + */ + +export interface ImageTransform { + scale: number + x: number + y: number +} + +export const IDENTITY_TRANSFORM: ImageTransform = { scale: 1, x: 0, y: 0 } + +export const MIN_SCALE = 1 +export const MAX_SCALE = 8 + +export function clampScale( + scale: number, + min = MIN_SCALE, + max = MAX_SCALE, +): number { + return scale < min ? min : scale > max ? max : scale +} + +/** + * Zoom by `factor` while keeping the point under the cursor stationary. + * + * `pointerX`/`pointerY` are relative to the container center. When the result + * lands back at the minimum scale the image is recentered, so a full zoom-out + * always returns to a clean centered view. + */ +export function zoomAtPoint( + transform: ImageTransform, + factor: number, + pointerX: number, + pointerY: number, + min = MIN_SCALE, + max = MAX_SCALE, +): ImageTransform { + const newScale = clampScale(transform.scale * factor, min, max) + if (newScale === transform.scale) return transform + if (newScale <= min) return { scale: min, x: 0, y: 0 } + + const ratio = newScale / transform.scale + return { + scale: newScale, + x: pointerX - (pointerX - transform.x) * ratio, + y: pointerY - (pointerY - transform.y) * ratio, + } +} + +/** + * Keep the (scaled) image overlapping the container so it can't be dragged + * entirely off-screen. The image never extends past the container at + * `scale === 1`, so the allowed travel on each axis is `(scale - 1) * half`. + */ +export function clampTranslation( + transform: ImageTransform, + containerWidth: number, + containerHeight: number, +): ImageTransform { + const maxX = Math.max(0, ((transform.scale - 1) * containerWidth) / 2) + const maxY = Math.max(0, ((transform.scale - 1) * containerHeight) / 2) + const clamp = (v: number, m: number) => (v < -m ? -m : v > m ? m : v) + return { + scale: transform.scale, + x: clamp(transform.x, maxX), + y: clamp(transform.y, maxY), + } +} + +/** Apply a drag delta (in pixels) and re-clamp inside the container. */ +export function panBy( + transform: ImageTransform, + dx: number, + dy: number, + containerWidth: number, + containerHeight: number, +): ImageTransform { + return clampTranslation( + { scale: transform.scale, x: transform.x + dx, y: transform.y + dy }, + containerWidth, + containerHeight, + ) +} + +export function isZoomed(transform: ImageTransform): boolean { + return transform.scale > MIN_SCALE +} + +/** CSS transform string for the image element. */ +export function toCssTransform(transform: ImageTransform): string { + return `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})` +}