mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 2c4d5a6d32 into 3f7b9667c6
This commit is contained in:
commit
566b37cd28
3 changed files with 357 additions and 2 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
94
apps/web/lib/image-zoom.test.ts
Normal file
94
apps/web/lib/image-zoom.test.ts
Normal 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)",
|
||||
)
|
||||
})
|
||||
})
|
||||
98
apps/web/lib/image-zoom.ts
Normal file
98
apps/web/lib/image-zoom.ts
Normal 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})`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue