Add pinch-zoom and pan gestures to image lightbox (#2261)
* Add touch gestures to image lightbox
Pinch-to-zoom, double-tap toggle, and pan when zoomed are essential
on phones; without them iOS users couldn't zoom into chat images at all
because the overlay disabled native pinch.
- Pointer Events for unified touch/mouse/pen handling
- Pinch (2 pointers) zooms about the midpoint with rubber-band overshoot
- One-finger pan when zoomed, with rubber-band at the edges and
hard-clamp on release
- Double-tap on image toggles between 1x and 2.5x at the tap point
- Trackpad pinch (Ctrl+wheel) and wheel pan on desktop
- touch-action: none disables iOS native pinch / double-tap zoom
- Tap-to-close preserved on overlay at scale 1; X button and Escape
still close from any state
* Harden image lightbox gestures against PhotoSwipe-style edge cases
Audit against PhotoSwipe v5 and pinch-zoom-element surfaced three real
bugs and several robustness gaps. Pinch math itself was already correct.
Bugs:
- Wheel deltaMode wasn't normalized; Firefox LINE-mode mouse wheels
produced ~1% scale changes per tick instead of ~15%.
- iOS Safari's proprietary gesturestart/gesturechange/gestureend can
trigger native page-zoom on a fullscreen overlay even with
touch-action:none; now blocked.
- Body-only overflow lock leaves iOS rubber-band bounce on the page
underneath; lock html overflow too.
Robustness:
- Cap active pointers at 2 to avoid stale pinch baselines on 3-finger
touches (matches PhotoSwipe).
- preventDefault on touch pointerdown for the image/overlay (not the
close button) to suppress emulated mouse events.
- Move pointermove/pointerup/pointercancel to window so mouse pans that
exit the viewport keep tracking (matches PhotoSwipe / pinch-zoom).
- captureBaseSize now temporarily clears the transform before measuring,
so orientation changes while zoomed don't drift the bounds.
- Tighten double-tap distance from 30px to 25px (PhotoSwipe parity).
* Extract gesture logic to PinchZoomImage component
ImageLightbox had grown to ~340 lines mixing portal/overlay/keyboard
orchestration with pinch-zoom-pan gesture handling. Extract the gesture
surface into a self-contained component.
PinchZoomImage owns: pointer/wheel/iOS-gesture listeners, scale/tx/ty
state, pinch math, double-tap, rubber-band, transform styling. It
exposes a single onTapEmpty callback for "user tapped outside the image
while not zoomed".
ImageLightbox is now 56 lines: just the portal, overlay backdrop, close
button, Escape key, and html/body scroll lock.
Component (over action) was the right fit here: the gesture state needs
to drive inline styles and class bindings on a specific surface+image
DOM structure that we own. Actions are best for behavior layered onto
foreign elements; here we own the markup.
---------
Co-authored-by: Claude <noreply@anthropic.com>