This commit is contained in:
Abhay Singh 2026-08-26 04:01:27 +05:30 committed by GitHub
commit 566b37cd28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 357 additions and 2 deletions

View file

@ -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<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [transform, setTransform] = useState<ImageTransform>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (dragRef.current?.pointerId === e.pointerId) {
dragRef.current = null
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
}, [])
if (imageError || !activeSrc) {
return (
<div className="flex items-center justify-center h-full text-[#737373]">
@ -63,7 +167,19 @@ export function ImagePreview({ url, title, documentId }: ImagePreviewProps) {
}
return (
<div className="relative size-full overflow-hidden flex items-center justify-center bg-[#0B1017]">
// biome-ignore lint/a11y/noStaticElementInteractions: pointer/dblclick gestures are optional enhancements; the zoom buttons provide the accessible controls
<div
ref={containerRef}
onDoubleClick={handleDoubleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={cn(
"group relative size-full overflow-hidden flex items-center justify-center bg-[#0B1017]",
zoomed ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in",
)}
>
{isLoading && (
<div className="absolute inset-0 bg-cover bg-center animate-pulse">
<div className="size-full bg-[#1B1F24]" />
@ -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 && (
<div
className={cn(
"absolute bottom-3 right-3 z-20 flex items-center gap-1 rounded-full border border-white/10 bg-black/55 p-1 backdrop-blur-md transition-opacity",
zoomed ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
>
<button
type="button"
aria-label="Zoom out"
disabled={!zoomed}
onClick={() => zoomByButton(1 / 1.4)}
className="flex size-7 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 disabled:opacity-35"
>
<Minus className="size-4" />
</button>
<span className="min-w-[3ch] text-center text-[11px] font-medium tabular-nums text-white/80">
{Math.round(transform.scale * 100)}%
</span>
<button
type="button"
aria-label="Zoom in"
disabled={transform.scale >= MAX_SCALE}
onClick={() => zoomByButton(1.4)}
className="flex size-7 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 disabled:opacity-35"
>
<Plus className="size-4" />
</button>
{zoomed && (
<button
type="button"
aria-label="Reset zoom"
onClick={resetZoom}
className="flex size-7 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15"
>
<X className="size-4" />
</button>
)}
</div>
)}
</div>
)
}

View file

@ -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)",
)
})
})

View file

@ -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})`
}