From 53139b8cb87e0669fe38089f38848a59ce3cbb28 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 20 May 2026 08:03:32 +0200 Subject: [PATCH] fix(lightbox): pan zoomed image with single-finger touch on mobile (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Rekoo-PS reported zoom on mobile only shows the centre crop — the image zooms but you can't pan around to see other parts. Single-finger touch was being routed through the carousel-swipe branch which is gated on zoom <= 1 (so swipe doesn't fight with pan), so when zoomed the touch hit no handler at all. Desktop has the equivalent path via handleMouseDown / handleMouseMove (line 364), which is why this only manifests on mobile. Add a single-finger pan branch to the touch handlers that mirrors the mouse path: - handleTouchStart: when zoom > 1 and one finger, record dragStart relative to the existing dragOffset (so subsequent moves continue from where the last pan left off, not from origin). - handleTouchMove: when isDragging + zoom > 1 + one finger, update dragOffset from touch position. - handleTouchEnd: clear the isDragging flag (offset persists so the image stays where the user left it). Also fix a latent bug surfaced while reading the pinch-zoom path: when pinch-out drops zoom back to 1.0, dragOffset wasn't reset, so the photo sat off-centre at natural zoom. Re-centre in handleTouchMove when newZoom drops to <=1 with a non-zero offset. Carousel swipe stays disabled when zoomed (existing behaviour). Mouse path untouched. Pinch-to-zoom path untouched. Refs: #532 --- .../src/components/gallery/PhotoLightbox.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 51d8777e..504735dd 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -411,6 +411,16 @@ export const PhotoLightbox: React.FC = ({ setDragX(0); } } + } else if (e.touches.length === 1 && zoom > 1) { + // Single-finger pan when zoomed in (#532). Mirrors the desktop + // handleMouseDown path so mobile users can drag a zoomed image + // around instead of being stuck looking at the centre crop. + // Carousel swipe is disabled in this branch — when zoom > 1 the + // gesture has to mean "pan", not "next photo", or zoomed nav + // becomes unusable. + const t = e.touches[0]; + setIsDragging(true); + setDragStart({ x: t.clientX - dragOffset.x, y: t.clientY - dragOffset.y }); } else if (e.touches.length === 1 && zoom <= 1 && (phase === 'idle' || phase === 'dragging')) { const t = e.touches[0]; swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }; @@ -432,6 +442,24 @@ export const PhotoLightbox: React.FC = ({ const newZoom = Math.max(1, Math.min(3, zoom * scale)); setZoom(newZoom); setTouchDistance(newDistance); + // Pinch-out back down to 1.0 has to re-centre the image — without + // this the previous pan offset persists and the photo sits off- + // centre at the natural zoom level (#532 follow-on). + if (newZoom <= 1 && (dragOffset.x !== 0 || dragOffset.y !== 0)) { + setDragOffset({ x: 0, y: 0 }); + } + return; + } + + if (isDragging && zoom > 1 && e.touches.length === 1) { + // Single-finger pan when zoomed (#532). Touch counterpart to + // handleMouseMove. Same dragOffset state so the transform on the + // stays consistent across input modalities. + const t = e.touches[0]; + setDragOffset({ + x: t.clientX - dragStart.x, + y: t.clientY - dragStart.y, + }); return; } @@ -459,6 +487,10 @@ export const PhotoLightbox: React.FC = ({ const handleTouchEnd = (e: React.TouchEvent) => { setTouchDistance(null); + // Release single-finger pan state (#532). The pan offset itself + // persists so the image stays where the user left it — only the + // "actively dragging" flag clears. + if (isDragging) setIsDragging(false); const start = swipeStartRef.current; if (phase === 'dragging' && start && e.changedTouches.length > 0) { const t = e.changedTouches[0];