* 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:
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 1–3 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.
|
||||
|
||||
Reference in New Issue
Block a user