feat(gallery): mouse-wheel zoom at cursor in the lightbox (#885) (#927)

* feat(gallery): mouse-wheel zoom at cursor in the lightbox (#885)

* fix(gallery): chain rapid wheel events synchronously + handle page-mode deltas (#885)

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-31 08:56:29 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 39d397c086
commit 926a4a540d
@@ -457,6 +457,55 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}
};
// Mouse-wheel zoom centered on the cursor (#885). Multiplicative steps
// feel uniform across the 13 range and are finer than the 0.5-step
// toolbar buttons. Attached as a native non-passive listener because
// the event must be preventDefault()ed to keep the page behind the
// lightbox from scrolling, and React's onWheel can't guarantee that.
const wheelStateRef = useRef({ zoom, dragOffset });
wheelStateRef.current = { zoom, dragOffset };
useEffect(() => {
const el = trackContainerRef.current;
if (!el || currentPhoto?.media_type === 'video') return;
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const { zoom: prevZoom, dragOffset: prevOffset } = wheelStateRef.current;
// Normalise deltaY to pixels: deltaMode 1 = lines (Firefox),
// deltaMode 2 = pages (rare; deltaY is ±1 per notch there).
const deltaPx = e.deltaMode === 1 ? e.deltaY * 33
: e.deltaMode === 2 ? e.deltaY * 300
: e.deltaY;
const nextZoom = Math.min(3, Math.max(1, prevZoom * Math.exp(-deltaPx * 0.002)));
if (nextZoom === prevZoom) return;
if (nextZoom <= 1) {
// Sync the ref before the async setState so a burst of wheel
// events landing within one render frame chains each step off
// the previous one instead of all reading the same stale zoom.
wheelStateRef.current = { zoom: 1, dragOffset: { x: 0, y: 0 } };
setZoom(1);
setDragOffset({ x: 0, y: 0 });
return;
}
// Keep the image point under the cursor fixed while the scale
// changes: take the cursor's offset from the container centre (the
// image's natural centre) and rescale its distance to the current
// pan offset by the zoom ratio.
const rect = el.getBoundingClientRect();
const cx = e.clientX - (rect.left + rect.width / 2);
const cy = e.clientY - (rect.top + rect.height / 2);
const ratio = nextZoom / prevZoom;
const nextOffset = {
x: cx - (cx - prevOffset.x) * ratio,
y: cy - (cy - prevOffset.y) * ratio,
};
wheelStateRef.current = { zoom: nextZoom, dragOffset: nextOffset };
setZoom(nextZoom);
setDragOffset(nextOffset);
};
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [currentPhoto]);
// Touch event handlers: pinch-to-zoom (2 fingers) + single-finger
// carousel-style swipe nav. Swipe is suppressed while zoomed in so the
// user can pan instead. The carousel is also disabled mid-animation.