Skip to content

Add touch gestures to the image lightbox - #7097

Open
void-function865 wants to merge 1 commit into
stashapp:developfrom
void-function865:lightbox-touch-gestures
Open

Add touch gestures to the image lightbox#7097
void-function865 wants to merge 1 commit into
stashapp:developfrom
void-function865:lightbox-touch-gestures

Conversation

@void-function865

Copy link
Copy Markdown
Contributor

Description

This adds mobile/touch gesture support to the image lightbox. On a phone or tablet you can now swipe between images, swipe to delete or close, and double-tap or pinch to zoom — previously the lightbox was only usable on touch through the on-screen buttons.

Gesture Action
Swipe left / right Next / previous image
Swipe up Delete the current image (opens the existing confirmation dialog)
Swipe down Close the lightbox
Double-tap Toggle zoom (fit ↔ zoomed in, toward the tapped point)
Pinch Zoom in / out around the pinch point

While zoomed in, a drag pans the image (clamped to its edges) instead of navigating, deleting or closing.

The lightbox also now responds to device rotation and window resizing, re-fitting the image to the new viewport (and keeping a zoomed-in image centered on the same point) instead of staying laid out for the previous orientation.

Related Issue

Closes #2538

Testing

Desktop behaviour is unchanged: all the gesture handling is touch-gated, so mouse/keyboard navigation, scroll-zoom, the nav buttons and the delete/O/rating controls behave exactly as before.

Tested on real devices (iPhone and iPad) as well as across Playwright touch-emulation profiles — Pixel 5 (Chromium), iPhone and iPad (WebKit):

  • swipe left/right navigates; swipe up opens the delete dialog (confirmed and cancelled); swipe down closes
  • double-tap zooms toward the tapped point and back to fit; pinch zooms around the pinch point
  • while zoomed, a drag pans and stops at the image edges instead of navigating
  • a two-finger gesture doesn't trigger a single-finger swipe
  • rotating the device re-fits at fit zoom and preserves the scale/focal point while zoomed in

Screenshots

No new UI.

Checklist

  • I have read and understood the Contributing document.
  • I have read and understood the AI Usage Policy document.
  • I have made corresponding changes to the documentation (if applicable).

AI Usage Disclosure

  • I have used AI tools to assist with this pull request, and I have disclosed the tools and how I used them below.

I used Claude Code as a coding assistant for this PR. It helped explore the hand-rolled lightbox, draft and implement the approach (the axis-locked single-finger gesture state machine, the swipe/double-tap/pinch handling, the image- and device-aware zoom cap, the compositor-layer busy spinner, and the ResizeObserver-driven fit/centering/pan bounds), and author the Playwright end-to-end tests used to verify it. I directed the design decisions, reviewed all changes, and validated the behavior on real devices (iPhone and iPad) as well as through browser touch emulation and the automated tests before submitting.

Additional Context

Key design choices:

  • Single-finger, axis-locked state machine. Once a drag passes a small threshold the axis (horizontal-navigate / vertical / pan) is locked for the rest of the gesture, so it can't change its mind halfway. Whether a drag pans or swipes is decided from the image-vs-viewport geometry: it only pans on an axis where the (zoomed) image actually overflows the viewport, otherwise the drag is a swipe candidate that commits on release by distance or flick velocity. A gesture bails as soon as a second finger goes down.
  • Delete never bypasses confirmation. Swipe-up reuses the existing DeleteImagesDialog; it only ever opens the dialog, exactly like the delete button, and is disabled when the current image has no id.
  • Swipe-down to close is the near-universal photo-viewer dismiss gesture (iOS Photos, Google Photos, PhotoSwipe's closeOnVerticalDrag). Vertical gestures only fire at fit zoom; once zoomed in, a vertical drag pans instead.
  • Pinch is computed absolutely from a snapshot taken at gesture start, and anchored on the pinch focal point, rather than accumulated per frame. Per-frame accumulation (and per-frame getBoundingClientRect) drifted and could wedge the view; the absolute form (pan' = (focal − centre)(1 − r) + r·startPan) stays stable for the whole pinch.
  • Ghost-click guard. iOS fires a synthetic click shortly after a tap; a short timestamp guard suppresses it so a tap doesn't also trigger the desktop half-screen click-to-navigate. Real mouse clicks on desktop are unaffected — taps zoom, clicks still advance.
  • Pan is clamped to the image edges for both touch drags and mouse drags, so a pan stops at the edge instead of flinging the image off-screen. (The horizontal clamp mirrors the pre-existing vertical one.)
  • Image- and device-aware zoom cap — the least obvious part, worth a look. A composited image layer at high zoom can exhaust GPU memory and crash the tab on a phone, but the risk is bound by the image's source resolution, not the zoom multiplier, so a small image is cheap to raster at any scale. Only large images (above ~30 MP) get a conservative, device-tied cap (derived from navigator.deviceMemory where available, else a phone-vs-tablet screen-size proxy on Safari); every other image keeps the full zoom ceiling on every device. The cap feeds the pinch and double-tap clamps as well as the existing scroll/keyboard zoom.
  • Busy spinner for the first large-image zoom. The first zoom-in of a large image forces an expensive full-resolution raster that briefly blocks the main thread (a double-tap jumps straight to the target zoom, so unlike a pinch there's no live feedback to cover it). A small spinner on its own compositor layer keeps animating through that block; it's unmounted during an active pinch so its animation can't jank the live tracking.
  • Live container size via ResizeObserver. Fit-zoom, centering, the axis decision and the pan bounds all depend on the viewport box, so they now follow window resizes and device rotation (previously measured once at mount, which left a zoomed image mis-centered after a rotate). On rotate at fit it re-fits; while zoomed it preserves the current scale and focal point and re-clamps. This also removed a createRef()-per-render.

Related PR: #6197 also targets #2538, but is narrower. The issue itself asks for swipe left/right navigation and double-tap-to-zoom; #6197 implements the swipe navigation and makes panning stop at the image edge (reworking the pan-position model so 0 means centered, with a non-zero position being an offset), while this PR implements both of the issue's asks and adds further gestures beyond it — pinch zoom, and swipe up/down to delete/close — plus the supporting zoom cap and rotation handling. The two touch the same Lightbox.tsx/LightboxImage.tsx and overlap on swipe navigation and the pan-edge behaviour (and take different approaches to the pan-position model).

Known device limitation: rotating a very large image (tested with a ~140 MP photo) on a memory-constrained iPhone can briefly stutter or freeze the tab. This is an iOS Safari decoded-image/canvas memory ceiling, not this code — it reproduces with the resize handling disabled, because the rotate itself forces the browser to re-raster the full-resolution bitmap. The real fix is serving a downscaled image to the lightbox, a separate Stash-wide change that's out of scope here. Normal-sized images are unaffected.

Add mobile touch gestures to the lightbox (stashapp#2538), driven by a
single-finger axis-locked gesture state machine in LightboxImage:

- swipe left/right: previous/next image (only when not horizontally
  pannable; a zoomed-in horizontal drag still pans)
- swipe up: delete the current image via the existing confirmation
  dialog (never bypassed; only when not vertically pannable)
- swipe down: close the lightbox
- double-tap: zoom toward the tapped point, toggling between fit and
  1:1 native (capped)
- pinch-to-zoom is reworked to anchor on the pinch focal point and is
  computed absolutely from a snapshot taken at gesture start (stable,
  no per-frame drift)

The axis (horizontal/vertical/pan) is locked once movement passes a
threshold, decided from the image-vs-box geometry so panning only
happens when the image actually overflows; panning (touch and mouse)
clamps to the image edges. A single tap is a no-op on touch; desktop
mouse/keyboard behaviour is unchanged (a ghost-click guard stops the
synthetic click iOS fires after a tap from navigating).

Supporting changes:
- cap the maximum zoom per image and per device, since a very large
  image at high zoom can exhaust GPU memory and crash the tab on phones
  (small images reach the full ceiling everywhere)
- show a busy spinner, on its own compositor layer, for the first
  (expensive) zoom-in of a large image
- track the container size with a ResizeObserver so fit/centering follow
  window resizes and device rotation, preserving the focal point and
  zoom when zoomed in

Document the gestures under the Lightbox section of the keyboard
shortcuts manual.
void-function865 added a commit to void-function865/stash that referenced this pull request Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support mobile controls inside lightbox

1 participant