From 178754f6c3b957e145c480f5a7c007e2dc1cf367 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 12 Jun 2026 20:45:50 +0100 Subject: [PATCH] feat: add Pan and Drag to lightbox Users can now click and drag to pan the image when zoomed in. Previously it would just zoom into the centre of the image without any way of panning --- src/components/Lightbox.tsx | 112 ++++++++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 18 deletions(-) diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 4921002..ff22e1a 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -115,6 +115,29 @@ function rectToNormalisedCrop( /** Minimum selection size as a fraction of the viewport container dimension. */ const MIN_SELECTION_FRACTION = 0.02; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 4; + +interface ViewTransform { + zoom: number; + panX: number; + panY: number; +} + +const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }; + +/** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the + * viewport centre — stays under the cursor across a zoom change. */ +function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform { + if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }; + const ratio = newZoom / view.zoom; + return { + zoom: newZoom, + panX: anchorX - (anchorX - view.panX) * ratio, + panY: anchorY - (anchorY - view.panY) * ratio, + }; +} + export function Lightbox() { const selectedImage = useGalleryStore((state) => state.selectedImage); const closeImage = useGalleryStore((state) => state.closeImage); @@ -135,7 +158,10 @@ export function Lightbox() { const currentImageIdRef = useRef(null); currentImageIdRef.current = selectedImage?.id ?? null; - const [zoom, setZoom] = useState(1); + const [view, setView] = useState(IDENTITY_VIEW); + const zoom = view.zoom; + const [isPanning, setIsPanning] = useState(false); + const lastPanPointRef = useRef({ x: 0, y: 0 }); const [imageTags, setImageTags] = useState([]); const [tagInput, setTagInput] = useState(""); const [tagAdding, setTagAdding] = useState(false); @@ -169,8 +195,24 @@ export function Lightbox() { setDragRect(null); }, []); + // Keep the pan within bounds: when the scaled image overflows the viewport + // the image edge may not be dragged past the viewport edge; when it fits, + // the pan snaps back to centre on that axis. + const clampPan = useCallback((v: ViewTransform): ViewTransform => { + const img = imgRef.current; + const vp = imageViewportRef.current; + if (!img || !vp) return v; + const maxX = Math.max(0, (img.offsetWidth * v.zoom - vp.clientWidth) / 2); + const maxY = Math.max(0, (img.offsetHeight * v.zoom - vp.clientHeight) / 2); + return { + ...v, + panX: Math.min(maxX, Math.max(-maxX, v.panX)), + panY: Math.min(maxY, Math.max(-maxY, v.panY)), + }; + }, []); + useEffect(() => { - setZoom(1); + setView(IDENTITY_VIEW); setImageTags([]); setTagInput(""); setTagsExpanded(false); @@ -203,15 +245,19 @@ export function Lightbox() { if (regionSelectMode) return; // don't zoom during selection if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; event.preventDefault(); - setZoom((value) => { + const bounds = viewport.getBoundingClientRect(); + const anchorX = event.clientX - (bounds.left + bounds.width / 2); + const anchorY = event.clientY - (bounds.top + bounds.height / 2); + setView((v) => { const delta = event.deltaY < 0 ? 0.15 : -0.15; - return Math.min(4, Math.max(0.5, value + delta)); + const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.zoom + delta)); + return clampPan(zoomViewAt(v, next, anchorX, anchorY)); }); }; viewport.addEventListener("wheel", handleWheel, { passive: false }); return () => viewport.removeEventListener("wheel", handleWheel); - }, [selectedImage, regionSelectMode]); + }, [selectedImage, regionSelectMode, clampPan]); useEffect(() => { const handler = (event: KeyboardEvent) => { @@ -227,18 +273,29 @@ export function Lightbox() { // Shift+arrows are reserved for video seeking (handled by VideoPlayer) if (event.key === "ArrowLeft" && !event.shiftKey) goPrev(); if (event.key === "ArrowRight" && !event.shiftKey) goNext(); - if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25)); - if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25)); + if (event.key === "+" || event.key === "=") + setView((v) => clampPan(zoomViewAt(v, Math.min(3, v.zoom + 0.25), 0, 0))); + if (event.key === "-") + setView((v) => clampPan(zoomViewAt(v, Math.max(0.75, v.zoom - 0.25), 0, 0))); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode]); + }, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode, clampPan]); - // ── Region selection pointer handlers ─────────────────────────────────────── + // ── Region selection / pan pointer handlers ───────────────────────────────── const handleRegionPointerDown = useCallback( (event: React.PointerEvent) => { - if (!regionSelectMode) return; + if (!regionSelectMode) { + // Drag-to-pan when the image is zoomed in + if (zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + lastPanPointRef.current = { x: event.clientX, y: event.clientY }; + setIsPanning(true); + } + return; + } event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); setIsDragging(true); @@ -249,21 +306,33 @@ export function Lightbox() { endY: event.clientY, }); }, - [regionSelectMode], + [regionSelectMode, zoom, selectedImage?.media_kind], ); const handleRegionPointerMove = useCallback( (event: React.PointerEvent) => { + if (isPanning) { + const dx = event.clientX - lastPanPointRef.current.x; + const dy = event.clientY - lastPanPointRef.current.y; + lastPanPointRef.current = { x: event.clientX, y: event.clientY }; + setView((v) => clampPan({ ...v, panX: v.panX + dx, panY: v.panY + dy })); + return; + } if (!isDragging) return; setDragRect((prev) => prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null, ); }, - [isDragging], + [isDragging, isPanning, clampPan], ); const handleRegionPointerUp = useCallback( (event: React.PointerEvent) => { + if (isPanning) { + event.currentTarget.releasePointerCapture(event.pointerId); + setIsPanning(false); + return; + } if (!isDragging || !dragRect || !selectedImage || !imgRef.current) { setIsDragging(false); return; @@ -296,7 +365,7 @@ export function Lightbox() { void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id) .finally(() => setRegionSearching(false)); }, - [isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode], + [isPanning, isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode], ); // Build the CSS rect for the selection overlay (viewport-relative) @@ -332,8 +401,14 @@ export function Lightbox() {
1 && selectedImage.media_kind === "image" + ? "cursor-grab" + : "" }`} onPointerDown={handleRegionPointerDown} onPointerMove={handleRegionPointerMove} @@ -386,9 +461,10 @@ export function Lightbox() { src={convertFileSrc(selectedImage.path)} alt={selectedImage.filename} className="max-w-full rounded-2xl shadow-2xl" + draggable={false} style={{ maxHeight: "calc(100vh - 10rem)", - transform: `scale(${zoom})`, + transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`, transformOrigin: "center center", // Slightly dim the image while in region select mode ...(regionSelectMode ? { opacity: 0.85 } : {}), @@ -399,14 +475,14 @@ export function Lightbox() {
{Math.round(zoom * 100)}%