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. */
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<number | null>(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 [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<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.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<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;
setDragRect((prev) =>
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
);
},
[isDragging],
[isDragging, isPanning, clampPan],
);
const handleRegionPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
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() {
<div className="flex flex-1 overflow-hidden">
<div
ref={imageViewportRef}
className={`group relative flex flex-1 items-center justify-center overflow-auto p-10 ${
regionSelectMode ? "cursor-crosshair select-none" : ""
className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
regionSelectMode
? "cursor-crosshair select-none"
: isPanning
? "cursor-grabbing select-none"
: zoom > 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() {
<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
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>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<button
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>