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
This commit is contained in:
2026-06-12 20:45:50 +01:00
parent 3fb4a9685f
commit 178754f6c3
+94 -18
View File
@@ -115,6 +115,29 @@ function rectToNormalisedCrop(
/** Minimum selection size as a fraction of the viewport container dimension. */ /** Minimum selection size as a fraction of the viewport container dimension. */
const MIN_SELECTION_FRACTION = 0.02; 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() { export function Lightbox() {
const selectedImage = useGalleryStore((state) => state.selectedImage); const selectedImage = useGalleryStore((state) => state.selectedImage);
const closeImage = useGalleryStore((state) => state.closeImage); const closeImage = useGalleryStore((state) => state.closeImage);
@@ -135,7 +158,10 @@ export function Lightbox() {
const currentImageIdRef = useRef<number | null>(null); const currentImageIdRef = useRef<number | null>(null);
currentImageIdRef.current = selectedImage?.id ?? null; currentImageIdRef.current = selectedImage?.id ?? null;
const [zoom, setZoom] = useState(1); const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW);
const zoom = view.zoom;
const [isPanning, setIsPanning] = useState(false);
const lastPanPointRef = useRef({ x: 0, y: 0 });
const [imageTags, setImageTags] = useState<ImageTag[]>([]); const [imageTags, setImageTags] = useState<ImageTag[]>([]);
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [tagAdding, setTagAdding] = useState(false); const [tagAdding, setTagAdding] = useState(false);
@@ -169,8 +195,24 @@ export function Lightbox() {
setDragRect(null); 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(() => { useEffect(() => {
setZoom(1); setView(IDENTITY_VIEW);
setImageTags([]); setImageTags([]);
setTagInput(""); setTagInput("");
setTagsExpanded(false); setTagsExpanded(false);
@@ -203,15 +245,19 @@ export function Lightbox() {
if (regionSelectMode) return; // don't zoom during selection if (regionSelectMode) return; // don't zoom during selection
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
event.preventDefault(); 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; 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 }); viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel); return () => viewport.removeEventListener("wheel", handleWheel);
}, [selectedImage, regionSelectMode]); }, [selectedImage, regionSelectMode, clampPan]);
useEffect(() => { useEffect(() => {
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
@@ -227,18 +273,29 @@ export function Lightbox() {
// Shift+arrows are reserved for video seeking (handled by VideoPlayer) // Shift+arrows are reserved for video seeking (handled by VideoPlayer)
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev(); if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
if (event.key === "ArrowRight" && !event.shiftKey) goNext(); if (event.key === "ArrowRight" && !event.shiftKey) goNext();
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25)); if (event.key === "+" || event.key === "=")
if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25)); 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); window.addEventListener("keydown", handler);
return () => window.removeEventListener("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( const handleRegionPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
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.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true); setIsDragging(true);
@@ -249,21 +306,33 @@ export function Lightbox() {
endY: event.clientY, endY: event.clientY,
}); });
}, },
[regionSelectMode], [regionSelectMode, zoom, selectedImage?.media_kind],
); );
const handleRegionPointerMove = useCallback( const handleRegionPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
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; if (!isDragging) return;
setDragRect((prev) => setDragRect((prev) =>
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null, prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
); );
}, },
[isDragging], [isDragging, isPanning, clampPan],
); );
const handleRegionPointerUp = useCallback( const handleRegionPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
event.currentTarget.releasePointerCapture(event.pointerId);
setIsPanning(false);
return;
}
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) { if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
setIsDragging(false); setIsDragging(false);
return; return;
@@ -296,7 +365,7 @@ export function Lightbox() {
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id) void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
.finally(() => setRegionSearching(false)); .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) // Build the CSS rect for the selection overlay (viewport-relative)
@@ -332,8 +401,14 @@ export function Lightbox() {
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div <div
ref={imageViewportRef} ref={imageViewportRef}
className={`group relative flex flex-1 items-center justify-center overflow-auto p-10 ${ className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
regionSelectMode ? "cursor-crosshair select-none" : "" regionSelectMode
? "cursor-crosshair select-none"
: isPanning
? "cursor-grabbing select-none"
: zoom > 1 && selectedImage.media_kind === "image"
? "cursor-grab"
: ""
}`} }`}
onPointerDown={handleRegionPointerDown} onPointerDown={handleRegionPointerDown}
onPointerMove={handleRegionPointerMove} onPointerMove={handleRegionPointerMove}
@@ -386,9 +461,10 @@ export function Lightbox() {
src={convertFileSrc(selectedImage.path)} src={convertFileSrc(selectedImage.path)}
alt={selectedImage.filename} alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl" className="max-w-full rounded-2xl shadow-2xl"
draggable={false}
style={{ style={{
maxHeight: "calc(100vh - 10rem)", maxHeight: "calc(100vh - 10rem)",
transform: `scale(${zoom})`, transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`,
transformOrigin: "center center", transformOrigin: "center center",
// Slightly dim the image while in region select mode // Slightly dim the image while in region select mode
...(regionSelectMode ? { opacity: 0.85 } : {}), ...(regionSelectMode ? { opacity: 0.85 } : {}),
@@ -399,14 +475,14 @@ export function Lightbox() {
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur"> <div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
<button <button
className="px-2 text-sm text-gray-300 hover:text-white" className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))} onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.max(MIN_ZOOM, v.zoom - 0.25), 0, 0)))}
> >
- -
</button> </button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span> <span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<button <button
className="px-2 text-sm text-gray-300 hover:text-white" className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.min(4, value + 0.25))} onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.min(MAX_ZOOM, v.zoom + 0.25), 0, 0)))}
> >
+ +
</button> </button>