| 98 | 155 | | } |
| 99 | 156 | | }, [state]); |
| 100 | 157 | | |
| 158 | + | // Recalculate on resize / orientation change while lightbox is open. |
| 159 | + | useEffect(() => { |
| 160 | + | if (state !== "open") return; |
| 161 | + | let rafId = 0; |
| 162 | + | const onResize = () => { |
| 163 | + | cancelAnimationFrame(rafId); |
| 164 | + | rafId = requestAnimationFrame(() => { |
| 165 | + | setResizeTick((t) => t + 1); |
| 166 | + | }); |
| 167 | + | }; |
| 168 | + | window.addEventListener("resize", onResize); |
| 169 | + | window.addEventListener("orientationchange", onResize); |
| 170 | + | window.visualViewport?.addEventListener("resize", onResize); |
| 171 | + | return () => { |
| 172 | + | cancelAnimationFrame(rafId); |
| 173 | + | window.removeEventListener("resize", onResize); |
| 174 | + | window.removeEventListener("orientationchange", onResize); |
| 175 | + | window.visualViewport?.removeEventListener("resize", onResize); |
| 176 | + | }; |
| 177 | + | }, [state]); |
| 178 | + | |
| 179 | + | // -- Pointer event handlers for pinch-zoom and pan --------------------- |
| 180 | + | const handlePointerDown = useCallback( |
| 181 | + | (e) => { |
| 182 | + | if (state !== "open") return; |
| 183 | + | const g = gestureRef.current; |
| 184 | + | g.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); |
| 185 | + | g.didGesture = false; |
| 186 | + | |
| 187 | + | if (g.pointers.size === 2) { |
| 188 | + | // Start pinch |
| 189 | + | const [a, b] = [...g.pointers.values()]; |
| 190 | + | g.initialPinchDist = Math.hypot(a.x - b.x, a.y - b.y); |
| 191 | + | g.initialScale = g.scale; |
| 192 | + | g.isPanning = false; |
| 193 | + | } else if (g.pointers.size === 1 && g.scale > 1) { |
| 194 | + | // Start pan (only when zoomed in) |
| 195 | + | g.isPanning = true; |
| 196 | + | g.panStartX = e.clientX; |
| 197 | + | g.panStartY = e.clientY; |
| 198 | + | g.startTranslateX = g.translateX; |
| 199 | + | g.startTranslateY = g.translateY; |
| 200 | + | } |
| 201 | + | |
| 202 | + | // Capture pointer so we get move/up even outside the element |
| 203 | + | e.currentTarget.setPointerCapture(e.pointerId); |
| 204 | + | }, |
| 205 | + | [state] |
| 206 | + | ); |
| 207 | + | |
| 208 | + | const handlePointerMove = useCallback( |
| 209 | + | (e) => { |
| 210 | + | const g = gestureRef.current; |
| 211 | + | if (!g.pointers.has(e.pointerId)) return; |
| 212 | + | g.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); |
| 213 | + | |
| 214 | + | if (g.pointers.size === 2) { |
| 215 | + | // Pinch zoom |
| 216 | + | const [a, b] = [...g.pointers.values()]; |
| 217 | + | const dist = Math.hypot(a.x - b.x, a.y - b.y); |
| 218 | + | if (g.initialPinchDist > 0) { |
| 219 | + | g.scale = Math.max( |
| 220 | + | 1, |
| 221 | + | Math.min(5, g.initialScale * (dist / g.initialPinchDist)) |
| 222 | + | ); |
| 223 | + | g.didGesture = true; |
| 224 | + | // If zoomed back to 1×, reset translation |
| 225 | + | if (g.scale === 1) { |
| 226 | + | g.translateX = 0; |
| 227 | + | g.translateY = 0; |
| 228 | + | } |
| 229 | + | applyTransform(); |
| 230 | + | } |
| 231 | + | } else if (g.pointers.size === 1 && g.isPanning) { |
| 232 | + | // Pan |
| 233 | + | const dx = e.clientX - g.panStartX; |
| 234 | + | const dy = e.clientY - g.panStartY; |
| 235 | + | if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { |
| 236 | + | g.didGesture = true; |
| 237 | + | } |
| 238 | + | g.translateX = g.startTranslateX + dx; |
| 239 | + | g.translateY = g.startTranslateY + dy; |
| 240 | + | applyTransform(); |
| 241 | + | } |
| 242 | + | }, |
| 243 | + | [applyTransform] |
| 244 | + | ); |
| 245 | + | |
| 246 | + | const handlePointerUp = useCallback( |
| 247 | + | (e) => { |
| 248 | + | const g = gestureRef.current; |
| 249 | + | g.pointers.delete(e.pointerId); |
| 250 | + | |
| 251 | + | if (g.pointers.size < 2) { |
| 252 | + | g.initialPinchDist = 0; |
| 253 | + | } |
| 254 | + | |
| 255 | + | // When the last pointer lifts and no gesture occurred, treat as a tap |
| 256 | + | if (g.pointers.size === 0) { |
| 257 | + | if (!g.didGesture) { |
| 258 | + | const now = Date.now(); |
| 259 | + | const timeSinceLastTap = now - g.lastTapTime; |
| 260 | + | g.lastTapTime = now; |
| 261 | + | |
| 262 | + | if (timeSinceLastTap < 300) { |
| 263 | + | // Double-tap: toggle between 1× and 2× zoom |
| 264 | + | if (g.scale > 1) { |
| 265 | + | g.scale = 1; |
| 266 | + | g.translateX = 0; |
| 267 | + | g.translateY = 0; |
| 268 | + | } else { |
| 269 | + | g.scale = 2; |
| 270 | + | // Zoom towards tap position |
| 271 | + | const el = imgRef.current; |
| 272 | + | if (el) { |
| 273 | + | const rect = el.getBoundingClientRect(); |
| 274 | + | const cx = rect.left + rect.width / 2; |
| 275 | + | const cy = rect.top + rect.height / 2; |
| 276 | + | g.translateX = (cx - e.clientX) * (g.scale - 1); |
| 277 | + | g.translateY = (cy - e.clientY) * (g.scale - 1); |
| 278 | + | } |
| 279 | + | } |
| 280 | + | applyTransform(); |
| 281 | + | g.lastTapTime = 0; // Reset so triple-tap doesn't re-trigger |
| 282 | + | } |
| 283 | + | // Single tap close is handled after a short delay to wait for |
| 284 | + | // potential double-tap — see the timeout below |
| 285 | + | } |
| 286 | + | g.isPanning = false; |
| 287 | + | } |
| 288 | + | }, |
| 289 | + | [applyTransform] |
| 290 | + | ); |
| 291 | + | |
| 292 | + | // Single-tap-to-close: fire only when not zoomed and no double-tap follows. |
| 293 | + | // We handle this in a separate effect-based handler because the pointer-up |
| 294 | + | // handler can't set a timeout that reliably accesses the latest handleClose. |
| 295 | + | const backdropRef = useRef(null); |
| 296 | + | useEffect(() => { |
| 297 | + | if (state !== "open") return; |
| 298 | + | const el = backdropRef.current; |
| 299 | + | if (!el) return; |
| 300 | + | let tapTimer = 0; |
| 301 | + | const onPointerUp = () => { |
| 302 | + | const g = gestureRef.current; |
| 303 | + | // Only consider when all pointers are up and no gesture happened |
| 304 | + | if (g.pointers.size !== 0 || g.didGesture) return; |
| 305 | + | // Wait to rule out double-tap |
| 306 | + | clearTimeout(tapTimer); |
| 307 | + | tapTimer = setTimeout(() => { |
| 308 | + | // If lastTapTime was reset to 0, a double-tap was handled — skip |
| 309 | + | if (g.lastTapTime === 0) return; |
| 310 | + | // Only close when not zoomed in |
| 311 | + | if (g.scale <= 1) { |
| 312 | + | handleClose(); |
| 313 | + | } |
| 314 | + | }, 300); |
| 315 | + | }; |
| 316 | + | el.addEventListener("pointerup", onPointerUp); |
| 317 | + | return () => { |
| 318 | + | clearTimeout(tapTimer); |
| 319 | + | el.removeEventListener("pointerup", onPointerUp); |
| 320 | + | }; |
| 321 | + | }, [state, handleClose]); |
| 322 | + | |
| 101 | 323 | | // Compute the target (centered) rect for the lightbox image |
| 102 | 324 | | const getTargetStyle = useCallback(() => { |
| 103 | | - | if (!sourceRect || sourceRect.width === 0 || sourceRect.height === 0) |
| 104 | | - | return null; |
| 105 | | - | const vw = window.innerWidth * 0.9; |
| 106 | | - | const vh = window.innerHeight * 0.9; |
| 107 | | - | const aspect = sourceRect.width / sourceRect.height; |
| 325 | + | const aspect = |
| 326 | + | naturalAspect || |
| 327 | + | (sourceRect && sourceRect.height > 0 |
| 328 | + | ? sourceRect.width / sourceRect.height |
| 329 | + | : null); |
| 330 | + | if (!aspect) return null; |
| 331 | + | const padding = |
| 332 | + | Math.min(window.innerWidth, window.innerHeight) < 600 ? 0.95 : 0.9; |
| 333 | + | const vw = window.innerWidth * padding; |
| 334 | + | const vh = window.innerHeight * padding; |
| 108 | 335 | | let w, h; |
| 109 | 336 | | if (vw / vh > aspect) { |
| 110 | 337 | | h = vh; |