@jekrch/react-viewport-lightbox 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -28,6 +28,29 @@ function zoomToPoint(prevScale, nextScale, prev, focal, viewport) {
28
28
  y: relY * (1 - k) + k * prev.y
29
29
  };
30
30
  }
31
+ function computeZoomTransform({
32
+ prevScale,
33
+ nextScale,
34
+ prev,
35
+ focal,
36
+ viewport,
37
+ baseDims,
38
+ zoomToCursor,
39
+ focalPan
40
+ }) {
41
+ if (nextScale <= 1) return { x: 0, y: 0 };
42
+ if (zoomToCursor) {
43
+ const f = zoomToPoint(prevScale, nextScale, prev, focal, viewport);
44
+ return clampTranslate(
45
+ f.x + (focalPan?.x ?? 0),
46
+ f.y + (focalPan?.y ?? 0),
47
+ nextScale,
48
+ baseDims,
49
+ viewport
50
+ );
51
+ }
52
+ return clampTranslate(prev.x, prev.y, nextScale, baseDims, viewport);
53
+ }
31
54
  function resolveSlideDirection({
32
55
  offset,
33
56
  elapsedMs,
@@ -48,6 +71,16 @@ function resolveSlideDirection({
48
71
  // src/hooks/useImageZoomPan.ts
49
72
  var MIN_SCALE = 1;
50
73
  var MAX_SCALE = 5;
74
+ var DOUBLE_CLICK_ZOOM_SCALE = 1.8;
75
+ function resetWrapperStyles(wrapper) {
76
+ wrapper.style.transform = "none";
77
+ wrapper.style.position = "";
78
+ wrapper.style.inset = "";
79
+ wrapper.style.zIndex = "";
80
+ wrapper.style.backgroundColor = "";
81
+ wrapper.style.cursor = "";
82
+ wrapper.style.willChange = "";
83
+ }
51
84
  function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCursor = true) {
52
85
  const imgRef = useRef(null);
53
86
  const [displayScale, setDisplayScale] = useState(1);
@@ -57,14 +90,9 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
57
90
  (t, animate = false) => {
58
91
  const wrapper = imgWrapperRef.current;
59
92
  if (!wrapper) return;
60
- wrapper.style.transition = animate ? "transform 0.2s ease-out" : "none";
93
+ wrapper.style.transition = typeof animate === "string" ? animate : animate ? "transform 0.2s ease-out" : "none";
61
94
  if (t.scale <= 1) {
62
- wrapper.style.transform = "none";
63
- wrapper.style.position = "";
64
- wrapper.style.inset = "";
65
- wrapper.style.zIndex = "";
66
- wrapper.style.backgroundColor = "";
67
- wrapper.style.cursor = "";
95
+ resetWrapperStyles(wrapper);
68
96
  } else {
69
97
  wrapper.style.transform = `scale(${t.scale}) translate(${t.x / t.scale}px, ${t.y / t.scale}px)`;
70
98
  wrapper.style.position = "absolute";
@@ -72,6 +100,7 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
72
100
  wrapper.style.zIndex = "30";
73
101
  wrapper.style.backgroundColor = "black";
74
102
  wrapper.style.cursor = "grab";
103
+ wrapper.style.willChange = "transform";
75
104
  }
76
105
  setDisplayScale(t.scale);
77
106
  },
@@ -93,6 +122,9 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
93
122
  if (!img) return;
94
123
  baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
95
124
  }, []);
125
+ const ensureBaseDims = useCallback(() => {
126
+ if (baseDimsRef.current.width === 0) measureBaseDims();
127
+ }, [measureBaseDims]);
96
128
  const clampTranslate2 = useCallback(
97
129
  (x, y, scale) => clampTranslate(x, y, scale, baseDimsRef.current, {
98
130
  width: window.innerWidth,
@@ -104,12 +136,7 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
104
136
  const wrapper = imgWrapperRef.current;
105
137
  if (wrapper) {
106
138
  wrapper.style.transition = "none";
107
- wrapper.style.transform = "none";
108
- wrapper.style.position = "";
109
- wrapper.style.inset = "";
110
- wrapper.style.zIndex = "";
111
- wrapper.style.backgroundColor = "";
112
- wrapper.style.cursor = "";
139
+ resetWrapperStyles(wrapper);
113
140
  }
114
141
  transformRef.current = { scale: 1, x: 0, y: 0 };
115
142
  }, [currentIndex, imgWrapperRef]);
@@ -122,10 +149,7 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
122
149
  if (!wrapper) return;
123
150
  const handleWheel = (e) => {
124
151
  e.preventDefault();
125
- if (baseDimsRef.current.width === 0) {
126
- const img = imgRef.current;
127
- if (img) baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
128
- }
152
+ ensureBaseDims();
129
153
  const t = transformRef.current;
130
154
  let dy = e.deltaY;
131
155
  if (e.deltaMode === 1) dy *= 16;
@@ -134,41 +158,32 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
134
158
  const step = -(normalized / 100) * 0.05;
135
159
  const factor = 1 + step;
136
160
  const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, t.scale * factor));
137
- let clamped;
138
- if (nextScale <= 1) {
139
- clamped = { x: 0, y: 0 };
140
- } else if (zoomToCursor) {
141
- const focal = zoomToPoint(
142
- t.scale,
143
- nextScale,
144
- { x: t.x, y: t.y },
145
- { x: e.clientX, y: e.clientY },
146
- { width: window.innerWidth, height: window.innerHeight }
147
- );
148
- clamped = clampTranslate2(focal.x, focal.y, nextScale);
149
- } else {
150
- clamped = clampTranslate2(t.x, t.y, nextScale);
151
- }
152
- setTransform({ scale: nextScale, ...clamped });
161
+ const clamped = computeZoomTransform({
162
+ prevScale: t.scale,
163
+ nextScale,
164
+ prev: { x: t.x, y: t.y },
165
+ focal: { x: e.clientX, y: e.clientY },
166
+ viewport: { width: window.innerWidth, height: window.innerHeight },
167
+ baseDims: baseDimsRef.current,
168
+ zoomToCursor
169
+ });
170
+ setTransform({ scale: nextScale, ...clamped }, "transform 0.1s ease-out");
153
171
  };
154
172
  wrapper.addEventListener("wheel", handleWheel, { passive: false });
155
173
  return () => wrapper.removeEventListener("wheel", handleWheel);
156
- }, [imgWrapperRef, setTransform, clampTranslate2, enabled, zoomToCursor]);
174
+ }, [imgWrapperRef, setTransform, enabled, zoomToCursor, ensureBaseDims]);
157
175
  const handleDoubleClick = useCallback(
158
176
  (e) => {
159
177
  if (!enabled) return;
160
178
  e.stopPropagation();
161
- if (baseDimsRef.current.width === 0) {
162
- const img = imgRef.current;
163
- if (img) baseDimsRef.current = { width: img.offsetWidth, height: img.offsetHeight };
164
- }
179
+ ensureBaseDims();
165
180
  if (transformRef.current.scale > 1) {
166
181
  resetTransform();
167
182
  } else {
168
- setTransform({ scale: 1.8, x: 0, y: 0 }, true);
183
+ setTransform({ scale: DOUBLE_CLICK_ZOOM_SCALE, x: 0, y: 0 }, true);
169
184
  }
170
185
  },
171
- [resetTransform, setTransform, enabled]
186
+ [resetTransform, setTransform, enabled, ensureBaseDims]
172
187
  );
173
188
  return {
174
189
  imgRef,
@@ -184,13 +199,36 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCurs
184
199
  handleDoubleClick
185
200
  };
186
201
  }
202
+ function measureSlideDistance(track) {
203
+ const vw = track?.clientWidth || window.innerWidth;
204
+ const img = track?.querySelector(".rvl-img-wrapper > img");
205
+ const imgW = img?.offsetWidth ?? 0;
206
+ if (!imgW) return vw;
207
+ return Math.min(vw, (vw + imgW) / 2);
208
+ }
209
+ function onTransitionEndOnce(el, cb, fallbackMs) {
210
+ if (!el) return;
211
+ let done = false;
212
+ const run = () => {
213
+ if (done) return;
214
+ done = true;
215
+ el.removeEventListener("transitionend", run);
216
+ cb();
217
+ };
218
+ el.addEventListener("transitionend", run, { once: true });
219
+ setTimeout(run, fallbackMs);
220
+ }
187
221
  function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop = false) {
188
222
  const slideTrackRef = useRef(null);
189
223
  const swipeOffsetRef = useRef(0);
190
224
  const [swipeOffset, setSwipeOffset] = useState(0);
191
225
  const [slideAnimating, setSlideAnimating] = useState(false);
192
226
  const [slideActive, setSlideActive] = useState(false);
227
+ const [slideDistance, setSlideDistance] = useState(0);
193
228
  const commitLockRef = useRef(false);
229
+ const refreshSlideDistance = useCallback(() => {
230
+ setSlideDistance(measureSlideDistance(slideTrackRef.current));
231
+ }, []);
194
232
  const hasPrev = loop ? items.length > 1 : currentIndex > 0;
195
233
  const hasNext = loop ? items.length > 1 : currentIndex < items.length - 1;
196
234
  const applySlideOffset = useCallback((offset, animate = false) => {
@@ -205,19 +243,14 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop
205
243
  const snapBack = useCallback(() => {
206
244
  setSlideAnimating(true);
207
245
  applySlideOffset(0, true);
208
- const track = slideTrackRef.current;
209
- let done = false;
210
- const onEnd = () => {
211
- if (done) return;
212
- done = true;
213
- track?.removeEventListener("transitionend", onEnd);
214
- setSlideAnimating(false);
215
- setSlideActive(false);
216
- };
217
- if (track) {
218
- track.addEventListener("transitionend", onEnd, { once: true });
219
- setTimeout(onEnd, 350);
220
- }
246
+ onTransitionEndOnce(
247
+ slideTrackRef.current,
248
+ () => {
249
+ setSlideAnimating(false);
250
+ setSlideActive(false);
251
+ },
252
+ 350
253
+ );
221
254
  }, [applySlideOffset]);
222
255
  const readyRef = useRef(true);
223
256
  const commitSlide = useCallback(
@@ -225,43 +258,38 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop
225
258
  if (commitLockRef.current || !readyRef.current) return;
226
259
  commitLockRef.current = true;
227
260
  readyRef.current = false;
228
- const vw = window.innerWidth;
229
- const targetOffset = direction === "prev" ? vw : -vw;
261
+ const distance = measureSlideDistance(slideTrackRef.current);
262
+ setSlideDistance(distance);
263
+ const targetOffset = direction === "prev" ? distance : -distance;
230
264
  setSlideActive(true);
231
265
  setSlideAnimating(true);
232
266
  onSlideStart?.(direction);
233
267
  requestAnimationFrame(() => {
234
268
  applySlideOffset(targetOffset, true);
235
- const track = slideTrackRef.current;
236
- let cleaned = false;
237
- const cleanup = () => {
238
- if (cleaned) return;
239
- cleaned = true;
240
- track?.removeEventListener("transitionend", onTransitionEnd);
241
- let newIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
242
- if (loop) newIndex = (newIndex + items.length) % items.length;
243
- if (newIndex < 0 || newIndex >= items.length) {
244
- commitLockRef.current = false;
245
- return;
246
- }
247
- const newItem = items[newIndex];
248
- const preload = new Image();
249
- preload.src = newItem.src;
250
- const doNavigate = () => onNavigate(newIndex);
251
- const timeout = setTimeout(doNavigate, 300);
252
- preload.decode().then(() => {
253
- clearTimeout(timeout);
254
- doNavigate();
255
- }).catch(() => {
256
- clearTimeout(timeout);
257
- doNavigate();
258
- });
259
- };
260
- const onTransitionEnd = () => cleanup();
261
- if (track) {
262
- track.addEventListener("transitionend", onTransitionEnd, { once: true });
263
- setTimeout(cleanup, 400);
264
- }
269
+ onTransitionEndOnce(
270
+ slideTrackRef.current,
271
+ () => {
272
+ let newIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
273
+ if (loop) newIndex = (newIndex + items.length) % items.length;
274
+ if (newIndex < 0 || newIndex >= items.length) {
275
+ commitLockRef.current = false;
276
+ return;
277
+ }
278
+ const newItem = items[newIndex];
279
+ const preload = new Image();
280
+ preload.src = newItem.src;
281
+ const doNavigate = () => onNavigate(newIndex);
282
+ const timeout = setTimeout(doNavigate, 300);
283
+ preload.decode().then(() => {
284
+ clearTimeout(timeout);
285
+ doNavigate();
286
+ }).catch(() => {
287
+ clearTimeout(timeout);
288
+ doNavigate();
289
+ });
290
+ },
291
+ 400
292
+ );
265
293
  });
266
294
  },
267
295
  [applySlideOffset, currentIndex, items, onNavigate, onSlideStart, loop]
@@ -290,11 +318,11 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop
290
318
  }
291
319
  swipeOffsetRef.current = 0;
292
320
  commitLockRef.current = false;
293
- }, [currentIndex]);
294
- useEffect(() => {
295
321
  setSwipeOffset(0);
296
322
  setSlideAnimating(false);
297
323
  setSlideActive(false);
324
+ }, [currentIndex]);
325
+ useEffect(() => {
298
326
  readyRef.current = true;
299
327
  }, [currentIndex]);
300
328
  return {
@@ -304,16 +332,33 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop
304
332
  swipeOffset,
305
333
  swipeOffsetRef,
306
334
  commitLockRef,
335
+ slideDistance,
307
336
  applySlideOffset,
308
337
  commitSlide,
309
338
  snapBack,
310
339
  resolveSlide,
311
- setSlideActive
340
+ setSlideActive,
341
+ refreshSlideDistance
312
342
  };
313
343
  }
344
+ var DOUBLE_TAP_ZOOM_SCALE = 2.5;
314
345
  function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true, zoomToCursor = true) {
315
- const { transformRef, clampTranslate: clampTranslate2, setTransform, applyTransform, resetTransform } = zoomPan;
316
- const { applySlideOffset, resolveSlide, snapBack, setSlideActive, swipeOffsetRef } = slide;
346
+ const {
347
+ transformRef,
348
+ baseDimsRef,
349
+ clampTranslate: clampTranslate2,
350
+ setTransform,
351
+ applyTransform,
352
+ resetTransform
353
+ } = zoomPan;
354
+ const {
355
+ applySlideOffset,
356
+ resolveSlide,
357
+ snapBack,
358
+ setSlideActive,
359
+ swipeOffsetRef,
360
+ refreshSlideDistance
361
+ } = slide;
317
362
  const panRef = useRef({
318
363
  isDragging: false,
319
364
  pointerStart: { x: 0, y: 0 },
@@ -336,9 +381,11 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
336
381
  x: 0,
337
382
  y: 0
338
383
  });
384
+ const gestureMovedRef = useRef(false);
339
385
  const beginSlide = useCallback(
340
386
  (x, y) => {
341
387
  setSlideActive(true);
388
+ refreshSlideDistance();
342
389
  const sg = slideRef.current;
343
390
  sg.active = true;
344
391
  sg.startX = x;
@@ -347,7 +394,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
347
394
  sg.locked = false;
348
395
  sg.rejected = false;
349
396
  },
350
- [setSlideActive]
397
+ [setSlideActive, refreshSlideDistance]
351
398
  );
352
399
  const updateSlide = useCallback(
353
400
  (clientX, clientY, lockThreshold, angleBias) => {
@@ -359,6 +406,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
359
406
  const absDx = Math.abs(dx);
360
407
  const absDy = Math.abs(dy);
361
408
  if (absDx < lockThreshold && absDy < lockThreshold) return;
409
+ gestureMovedRef.current = true;
362
410
  if (absDy > absDx * angleBias) {
363
411
  sg.rejected = true;
364
412
  return;
@@ -390,6 +438,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
390
438
  const handlePointerDown = useCallback(
391
439
  (e) => {
392
440
  if (e.pointerType === "touch") return;
441
+ gestureMovedRef.current = false;
393
442
  if (transformRef.current.scale > 1) {
394
443
  e.preventDefault();
395
444
  const p = panRef.current;
@@ -409,6 +458,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
409
458
  if (p.isDragging && transformRef.current.scale > 1) {
410
459
  const dx = e.clientX - p.pointerStart.x;
411
460
  const dy = e.clientY - p.pointerStart.y;
461
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) gestureMovedRef.current = true;
412
462
  const t = transformRef.current;
413
463
  const clamped = clampTranslate2(p.translateStart.x + dx, p.translateStart.y + dy, t.scale);
414
464
  setTransform({ scale: t.scale, ...clamped });
@@ -430,6 +480,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
430
480
  (e) => {
431
481
  const p = panRef.current;
432
482
  if (e.touches.length === 2 && zoomEnabled) {
483
+ gestureMovedRef.current = true;
433
484
  const dx = e.touches[0].clientX - e.touches[1].clientX;
434
485
  const dy = e.touches[0].clientY - e.touches[1].clientY;
435
486
  p.pinchStartDist = Math.hypot(dx, dy);
@@ -444,6 +495,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
444
495
  snapBack();
445
496
  }
446
497
  } else if (e.touches.length === 1) {
498
+ gestureMovedRef.current = false;
447
499
  if (transformRef.current.scale > 1) {
448
500
  p.lastTouchPos = {
449
501
  x: e.touches[0].clientX,
@@ -464,25 +516,26 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
464
516
  const dy = e.touches[0].clientY - e.touches[1].clientY;
465
517
  const dist = Math.hypot(dx, dy);
466
518
  const ratio = dist / p.pinchStartDist;
467
- const nextScale = Math.min(5, Math.max(1, p.pinchStartScale * ratio));
519
+ const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, p.pinchStartScale * ratio));
468
520
  const t = transformRef.current;
469
- let clamped;
470
- if (nextScale <= 1) {
471
- clamped = { x: 0, y: 0 };
472
- } else if (zoomToCursor) {
473
- const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
474
- const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
475
- const focal = zoomToPoint(
476
- t.scale,
477
- nextScale,
478
- { x: t.x, y: t.y },
479
- { x: midX, y: midY },
480
- { width: window.innerWidth, height: window.innerHeight }
481
- );
482
- clamped = clampTranslate2(focal.x, focal.y, nextScale);
483
- } else {
484
- clamped = clampTranslate2(t.x, t.y, nextScale);
485
- }
521
+ const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
522
+ const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
523
+ const prevMid = p.pinchMidpoint;
524
+ const focalPan = {
525
+ x: prevMid ? midX - prevMid.x : 0,
526
+ y: prevMid ? midY - prevMid.y : 0
527
+ };
528
+ p.pinchMidpoint = { x: midX, y: midY };
529
+ const clamped = computeZoomTransform({
530
+ prevScale: t.scale,
531
+ nextScale,
532
+ prev: { x: t.x, y: t.y },
533
+ focal: { x: midX, y: midY },
534
+ viewport: { width: window.innerWidth, height: window.innerHeight },
535
+ baseDims: baseDimsRef.current,
536
+ zoomToCursor,
537
+ focalPan
538
+ });
486
539
  const next = { scale: nextScale, ...clamped };
487
540
  transformRef.current = next;
488
541
  applyTransform(next);
@@ -490,6 +543,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
490
543
  const touch = e.touches[0];
491
544
  const dx = touch.clientX - p.lastTouchPos.x;
492
545
  const dy = touch.clientY - p.lastTouchPos.y;
546
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) gestureMovedRef.current = true;
493
547
  p.lastTouchPos = { x: touch.clientX, y: touch.clientY };
494
548
  const t = transformRef.current;
495
549
  const clamped = clampTranslate2(t.x + dx, t.y + dy, t.scale);
@@ -501,7 +555,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
501
555
  updateSlide(touch.clientX, touch.clientY, 6, 0.8);
502
556
  }
503
557
  },
504
- [transformRef, clampTranslate2, applyTransform, updateSlide, zoomToCursor]
558
+ [transformRef, baseDimsRef, clampTranslate2, applyTransform, updateSlide, zoomToCursor]
505
559
  );
506
560
  const handleTouchEnd = useCallback(
507
561
  (e) => {
@@ -541,7 +595,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
541
595
  if (transformRef.current.scale > 1) {
542
596
  resetTransform();
543
597
  } else {
544
- setTransform({ scale: 2.5, x: 0, y: 0 }, true);
598
+ setTransform({ scale: DOUBLE_TAP_ZOOM_SCALE, x: 0, y: 0 }, true);
545
599
  }
546
600
  } else {
547
601
  lastTapRef.current = { time: now, x: touch.clientX, y: touch.clientY };
@@ -568,7 +622,8 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true,
568
622
  handlePointerUp,
569
623
  handleTouchStart,
570
624
  handleTouchMove,
571
- handleTouchEnd
625
+ handleTouchEnd,
626
+ gestureMovedRef
572
627
  };
573
628
  }
574
629
  function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
@@ -667,6 +722,143 @@ function useFocusTrap(containerRef, active) {
667
722
  };
668
723
  }, [containerRef, active]);
669
724
  }
725
+ var ANIM_MS = 250;
726
+ var IMG_PADDING = 44;
727
+ var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
728
+ function prefersReducedMotion() {
729
+ if (typeof window === "undefined" || !window.matchMedia) return false;
730
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
731
+ }
732
+ function flipTransform(from, to) {
733
+ const sx = to.width / from.width;
734
+ const sy = to.height / from.height;
735
+ const dx = to.left - from.left;
736
+ const dy = to.top - from.top;
737
+ return `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
738
+ }
739
+ function canAnimate(el) {
740
+ return !!el && typeof el.animate === "function";
741
+ }
742
+ function isRectInViewport(rect) {
743
+ if (typeof window === "undefined") return true;
744
+ return rect.top < window.innerHeight && rect.top + rect.height > 0 && rect.left < window.innerWidth && rect.left + rect.width > 0;
745
+ }
746
+ function useSharedElementZoom({
747
+ getOriginRect,
748
+ index,
749
+ isZoomed,
750
+ imgRef,
751
+ imgWrapperRef,
752
+ bottomBarRef,
753
+ measureBaseDims
754
+ }) {
755
+ const zoomTransition = !!getOriginRect;
756
+ const reduceMotion = prefersReducedMotion();
757
+ const gateEntry = zoomTransition && !reduceMotion;
758
+ const [fullLoaded, setFullLoaded] = useState(false);
759
+ const [showSpinner, setShowSpinner] = useState(false);
760
+ const [collapsing, setCollapsing] = useState(false);
761
+ const entryStartedRef = useRef(false);
762
+ const entryCleanupRef = useRef(null);
763
+ const runZoomEntry = useCallback(() => {
764
+ if (entryStartedRef.current) return;
765
+ if (!getOriginRect || prefersReducedMotion()) return;
766
+ const img = imgRef.current;
767
+ const thumb = getOriginRect(index);
768
+ if (!thumb || !canAnimate(img)) return;
769
+ const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
770
+ const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
771
+ img.style.maxHeight = lockedMaxHeight;
772
+ const imgRect = img.getBoundingClientRect();
773
+ if (imgRect.width === 0 || imgRect.height === 0) {
774
+ img.style.maxHeight = "";
775
+ return;
776
+ }
777
+ entryStartedRef.current = true;
778
+ const startTransform = flipTransform(imgRect, thumb);
779
+ img.style.transformOrigin = "top left";
780
+ img.style.transform = startTransform;
781
+ const wrapper = imgWrapperRef.current;
782
+ if (wrapper) wrapper.style.overflow = "visible";
783
+ const anim = img.animate(
784
+ [
785
+ { transformOrigin: "top left", transform: startTransform },
786
+ { transformOrigin: "top left", transform: "none" }
787
+ ],
788
+ { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
789
+ );
790
+ const cleanup = () => {
791
+ img.style.transform = "";
792
+ img.style.transformOrigin = "";
793
+ if (wrapper) wrapper.style.overflow = "";
794
+ img.style.maxHeight = lockedMaxHeight;
795
+ anim.cancel();
796
+ entryCleanupRef.current = null;
797
+ };
798
+ entryCleanupRef.current = cleanup;
799
+ anim.onfinish = cleanup;
800
+ }, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
801
+ const onImageLoad = useCallback(() => {
802
+ measureBaseDims();
803
+ const img = imgRef.current;
804
+ if (img && typeof img.decode === "function") {
805
+ img.decode().then(
806
+ () => setFullLoaded(true),
807
+ () => setFullLoaded(true)
808
+ );
809
+ } else {
810
+ setFullLoaded(true);
811
+ }
812
+ }, [measureBaseDims, imgRef]);
813
+ const onImageError = useCallback(() => setFullLoaded(true), []);
814
+ useLayoutEffect(() => {
815
+ const img = imgRef.current;
816
+ if (img && img.complete && img.naturalWidth > 0) onImageLoad();
817
+ }, []);
818
+ useLayoutEffect(() => {
819
+ if (fullLoaded) runZoomEntry();
820
+ }, [fullLoaded]);
821
+ useEffect(() => {
822
+ if (!gateEntry || fullLoaded) {
823
+ setShowSpinner(false);
824
+ return;
825
+ }
826
+ const t = setTimeout(() => setShowSpinner(true), 500);
827
+ return () => clearTimeout(t);
828
+ }, [gateEntry, fullLoaded]);
829
+ const settleEntry = useCallback(() => {
830
+ entryCleanupRef.current?.();
831
+ }, []);
832
+ const playCollapse = useCallback(() => {
833
+ const reduce = prefersReducedMotion();
834
+ const origin = !reduce && !isZoomed ? getOriginRect?.(index) ?? null : null;
835
+ const thumb = origin && isRectInViewport(origin) ? origin : null;
836
+ const img = imgRef.current;
837
+ if (!thumb || !canAnimate(img)) return;
838
+ const imgRect = img.getBoundingClientRect();
839
+ const wrapper = imgWrapperRef.current;
840
+ if (wrapper) wrapper.style.overflow = "visible";
841
+ setCollapsing(true);
842
+ img.animate(
843
+ [
844
+ { transformOrigin: "top left", transform: "none" },
845
+ { transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
846
+ ],
847
+ { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
848
+ );
849
+ }, [getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
850
+ return {
851
+ gateEntry,
852
+ zoomTransition,
853
+ fullLoaded,
854
+ showSpinner,
855
+ collapsing,
856
+ onImageLoad,
857
+ onImageError,
858
+ settleEntry,
859
+ playCollapse
860
+ };
861
+ }
670
862
  function base(props) {
671
863
  return {
672
864
  width: 18,
@@ -737,28 +929,29 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
737
929
  }
738
930
  );
739
931
  }
740
- var ANIM_MS = 250;
741
- var IMG_PADDING = 44;
742
- var GHOST_CLICK_MS = 700;
743
- var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
744
- function prefersReducedMotion() {
745
- if (typeof window === "undefined" || !window.matchMedia) return false;
746
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
747
- }
748
- function flipTransform(from, to) {
749
- const sx = to.width / from.width;
750
- const sy = to.height / from.height;
751
- const dx = to.left - from.left;
752
- const dy = to.top - from.top;
753
- return `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
754
- }
755
- function canAnimate(el) {
756
- return !!el && typeof el.animate === "function";
757
- }
758
- function isRectInViewport(rect) {
759
- if (typeof window === "undefined") return true;
760
- return rect.top < window.innerHeight && rect.top + rect.height > 0 && rect.left < window.innerWidth && rect.left + rect.width > 0;
932
+ function ChromeButton({
933
+ onClick,
934
+ title,
935
+ ariaLabel,
936
+ className,
937
+ children
938
+ }) {
939
+ return /* @__PURE__ */ jsx(
940
+ "button",
941
+ {
942
+ type: "button",
943
+ className: cx("rvl-btn", className),
944
+ onClick: (e) => {
945
+ e.stopPropagation();
946
+ onClick();
947
+ },
948
+ title,
949
+ "aria-label": ariaLabel,
950
+ children
951
+ }
952
+ );
761
953
  }
954
+ var GHOST_CLICK_MS = 700;
762
955
  function ImageViewer({
763
956
  items,
764
957
  index,
@@ -790,7 +983,6 @@ function ImageViewer({
790
983
  }) {
791
984
  const [visible, setVisible] = useState(false);
792
985
  const [closing, setClosing] = useState(false);
793
- const [collapsing, setCollapsing] = useState(false);
794
986
  const [isTouchDevice, setIsTouchDevice] = useState(false);
795
987
  const [contentShift, setContentShiftState] = useState({ transform: null, animate: true });
796
988
  const openedAtRef = useRef(Date.now());
@@ -828,8 +1020,27 @@ function ImageViewer({
828
1020
  handleDoubleClick
829
1021
  } = zoomPan;
830
1022
  const slide = useSlideNavigation(items, index, onIndexChange, onNavigate, loop);
831
- const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
1023
+ const { slideTrackRef, slideActive, slideAnimating, swipeOffset, slideDistance, commitSlide } = slide;
832
1024
  const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
1025
+ const {
1026
+ gateEntry,
1027
+ zoomTransition,
1028
+ fullLoaded,
1029
+ showSpinner,
1030
+ collapsing,
1031
+ onImageLoad,
1032
+ onImageError,
1033
+ settleEntry,
1034
+ playCollapse
1035
+ } = useSharedElementZoom({
1036
+ getOriginRect,
1037
+ index,
1038
+ isZoomed,
1039
+ imgRef,
1040
+ imgWrapperRef,
1041
+ bottomBarRef,
1042
+ measureBaseDims
1043
+ });
833
1044
  useEffect(() => {
834
1045
  if (typeof window === "undefined" || !window.matchMedia) {
835
1046
  setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
@@ -850,101 +1061,14 @@ function ImageViewer({
850
1061
  const raf = requestAnimationFrame(() => setVisible(true));
851
1062
  return () => cancelAnimationFrame(raf);
852
1063
  }, []);
853
- const zoomTransition = !!getOriginRect;
854
- const reduceMotion = prefersReducedMotion();
855
- const gateEntry = zoomTransition && !reduceMotion;
856
- const [fullLoaded, setFullLoaded] = useState(false);
857
- const [showSpinner, setShowSpinner] = useState(false);
858
- const entryStartedRef = useRef(false);
859
- const entryCleanupRef = useRef(null);
860
- const runZoomEntry = useCallback(() => {
861
- if (entryStartedRef.current) return;
862
- if (!getOriginRect || prefersReducedMotion()) return;
863
- const img = imgRef.current;
864
- const thumb = getOriginRect(index);
865
- if (!thumb || !canAnimate(img)) return;
866
- const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
867
- const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
868
- img.style.maxHeight = lockedMaxHeight;
869
- const imgRect = img.getBoundingClientRect();
870
- if (imgRect.width === 0 || imgRect.height === 0) {
871
- img.style.maxHeight = "";
872
- return;
873
- }
874
- entryStartedRef.current = true;
875
- const startTransform = flipTransform(imgRect, thumb);
876
- img.style.transformOrigin = "top left";
877
- img.style.transform = startTransform;
878
- const wrapper = imgWrapperRef.current;
879
- if (wrapper) wrapper.style.overflow = "visible";
880
- const anim = img.animate(
881
- [
882
- { transformOrigin: "top left", transform: startTransform },
883
- { transformOrigin: "top left", transform: "none" }
884
- ],
885
- { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
886
- );
887
- const cleanup = () => {
888
- img.style.transform = "";
889
- img.style.transformOrigin = "";
890
- if (wrapper) wrapper.style.overflow = "";
891
- img.style.maxHeight = lockedMaxHeight;
892
- anim.cancel();
893
- entryCleanupRef.current = null;
894
- };
895
- entryCleanupRef.current = cleanup;
896
- anim.onfinish = cleanup;
897
- }, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
898
- const markFullLoaded = useCallback(() => {
899
- measureBaseDims();
900
- const img = imgRef.current;
901
- if (img && typeof img.decode === "function") {
902
- img.decode().then(
903
- () => setFullLoaded(true),
904
- () => setFullLoaded(true)
905
- );
906
- } else {
907
- setFullLoaded(true);
908
- }
909
- }, [measureBaseDims, imgRef]);
910
- useLayoutEffect(() => {
911
- const img = imgRef.current;
912
- if (img && img.complete && img.naturalWidth > 0) markFullLoaded();
913
- }, []);
914
- useLayoutEffect(() => {
915
- if (fullLoaded) runZoomEntry();
916
- }, [fullLoaded]);
917
- useEffect(() => {
918
- if (!gateEntry || fullLoaded) {
919
- setShowSpinner(false);
920
- return;
921
- }
922
- const t = setTimeout(() => setShowSpinner(true), 500);
923
- return () => clearTimeout(t);
924
- }, [gateEntry, fullLoaded]);
925
1064
  const handleClose = useCallback(() => {
926
1065
  const reduce = prefersReducedMotion();
927
- const origin = !reduce && !isZoomed ? getOriginRect?.(index) ?? null : null;
928
- const thumb = origin && isRectInViewport(origin) ? origin : null;
929
- const img = imgRef.current;
930
- entryCleanupRef.current?.();
1066
+ settleEntry();
931
1067
  setClosing(true);
932
1068
  setVisible(false);
933
- if (thumb && canAnimate(img)) {
934
- const imgRect = img.getBoundingClientRect();
935
- const wrapper = imgWrapperRef.current;
936
- if (wrapper) wrapper.style.overflow = "visible";
937
- setCollapsing(true);
938
- img.animate(
939
- [
940
- { transformOrigin: "top left", transform: "none" },
941
- { transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
942
- ],
943
- { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
944
- );
945
- }
1069
+ playCollapse();
946
1070
  setTimeout(onClose, reduce ? 0 : ANIM_MS);
947
- }, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
1071
+ }, [onClose, settleEntry, playCollapse]);
948
1072
  const handleBackdropTouchEnd = useCallback(
949
1073
  (e) => {
950
1074
  if (e.target !== e.currentTarget) return;
@@ -953,6 +1077,26 @@ function ImageViewer({
953
1077
  },
954
1078
  [handleClose]
955
1079
  );
1080
+ const handleTrackTouchEnd = useCallback(
1081
+ (e) => {
1082
+ gestures.handleTouchEnd(e);
1083
+ if (!closeOnBackdropClick) return;
1084
+ if (e.target !== e.currentTarget) return;
1085
+ if (gestures.gestureMovedRef.current) return;
1086
+ e.preventDefault();
1087
+ handleClose();
1088
+ },
1089
+ [gestures, closeOnBackdropClick, handleClose]
1090
+ );
1091
+ const handleTrackClick = useCallback(
1092
+ (e) => {
1093
+ if (e.target !== e.currentTarget) return;
1094
+ if (isGhostMouseEvent()) return;
1095
+ if (gestures.gestureMovedRef.current) return;
1096
+ handleClose();
1097
+ },
1098
+ [gestures, isGhostMouseEvent, handleClose]
1099
+ );
956
1100
  const handleDoubleClickGuarded = useCallback(
957
1101
  (e) => {
958
1102
  if (isGhostMouseEvent()) return;
@@ -964,14 +1108,6 @@ function ImageViewer({
964
1108
  if (isGhostMouseEvent()) return;
965
1109
  handleClose();
966
1110
  }, [handleClose, isGhostMouseEvent]);
967
- const handleStageClick = useCallback(
968
- (e) => {
969
- if (e.target !== e.currentTarget) return;
970
- if (isGhostMouseEvent()) return;
971
- handleClose();
972
- },
973
- [handleClose, isGhostMouseEvent]
974
- );
975
1111
  const navigate = useCallback(
976
1112
  (dir) => {
977
1113
  if (dir === "prev") {
@@ -1051,7 +1187,9 @@ function ImageViewer({
1051
1187
  const prevItem = prevIndex >= 0 ? items[prevIndex] : null;
1052
1188
  const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
1053
1189
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
1054
- const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
1190
+ const adjacentOffset = slideDistance || viewportWidth;
1191
+ const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (adjacentOffset * 0.8 || 1));
1192
+ const adjacentTransition = slideAnimating ? "opacity 0.28s cubic-bezier(0.2, 0, 0, 1)" : "none";
1055
1193
  const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
1056
1194
  const headerActions = renderHeaderActions?.(ctx);
1057
1195
  const navStart = renderNavStart?.(ctx);
@@ -1083,16 +1221,12 @@ function ImageViewer({
1083
1221
  /* @__PURE__ */ jsxs("div", { className: "rvl-header-actions", children: [
1084
1222
  headerActions,
1085
1223
  showZoomCtrls && isZoomed && /* @__PURE__ */ jsxs(
1086
- "button",
1224
+ ChromeButton,
1087
1225
  {
1088
- type: "button",
1089
- className: cx("rvl-btn", "rvl-btn-scale", cn("button")),
1090
- onClick: (e) => {
1091
- e.stopPropagation();
1092
- resetTransform();
1093
- },
1226
+ className: cx("rvl-btn-scale", cn("button")),
1227
+ onClick: resetTransform,
1094
1228
  title: "Reset zoom",
1095
- "aria-label": "Reset zoom",
1229
+ ariaLabel: "Reset zoom",
1096
1230
  children: [
1097
1231
  Math.round(displayScale * 100),
1098
1232
  "%"
@@ -1100,44 +1234,32 @@ function ImageViewer({
1100
1234
  }
1101
1235
  ),
1102
1236
  showZoomCtrls && /* @__PURE__ */ jsx(
1103
- "button",
1237
+ ChromeButton,
1104
1238
  {
1105
- type: "button",
1106
- className: cx("rvl-btn", cn("button")),
1107
- onClick: (e) => {
1108
- e.stopPropagation();
1109
- ctx.zoomIn();
1110
- },
1239
+ className: cn("button"),
1240
+ onClick: ctx.zoomIn,
1111
1241
  title: "Zoom in",
1112
- "aria-label": "Zoom in",
1242
+ ariaLabel: "Zoom in",
1113
1243
  children: mergedIcons.zoomIn
1114
1244
  }
1115
1245
  ),
1116
1246
  showZoomCtrls && /* @__PURE__ */ jsx(
1117
- "button",
1247
+ ChromeButton,
1118
1248
  {
1119
- type: "button",
1120
- className: cx("rvl-btn", cn("button")),
1121
- onClick: (e) => {
1122
- e.stopPropagation();
1123
- ctx.zoomOut();
1124
- },
1249
+ className: cn("button"),
1250
+ onClick: ctx.zoomOut,
1125
1251
  title: "Zoom out",
1126
- "aria-label": "Zoom out",
1252
+ ariaLabel: "Zoom out",
1127
1253
  children: mergedIcons.zoomOut
1128
1254
  }
1129
1255
  ),
1130
1256
  /* @__PURE__ */ jsx(
1131
- "button",
1257
+ ChromeButton,
1132
1258
  {
1133
- type: "button",
1134
- className: cx("rvl-btn", cn("button")),
1135
- onClick: (e) => {
1136
- e.stopPropagation();
1137
- handleClose();
1138
- },
1259
+ className: cn("button"),
1260
+ onClick: handleClose,
1139
1261
  title: "Close (Esc)",
1140
- "aria-label": "Close",
1262
+ ariaLabel: "Close",
1141
1263
  children: mergedIcons.close
1142
1264
  }
1143
1265
  )
@@ -1147,8 +1269,6 @@ function ImageViewer({
1147
1269
  "div",
1148
1270
  {
1149
1271
  className: "rvl-stage",
1150
- onClick: closeOnBackdropClick ? handleStageClick : void 0,
1151
- onTouchEnd: closeOnBackdropClick ? handleBackdropTouchEnd : void 0,
1152
1272
  style: {
1153
1273
  transform: contentShift.transform ?? "translateY(0)",
1154
1274
  // animate=false snaps with no transition (overrides the CSS transition)
@@ -1158,8 +1278,19 @@ function ImageViewer({
1158
1278
  "div",
1159
1279
  {
1160
1280
  ref: slideTrackRef,
1281
+ onPointerDown: gestures.handlePointerDown,
1282
+ onPointerMove: gestures.handlePointerMove,
1283
+ onPointerUp: gestures.handlePointerUp,
1284
+ onPointerLeave: gestures.handlePointerUp,
1285
+ onTouchStart: gestures.handleTouchStart,
1286
+ onTouchMove: gestures.handleTouchMove,
1287
+ onTouchEnd: handleTrackTouchEnd,
1288
+ onClick: closeOnBackdropClick ? handleTrackClick : void 0,
1161
1289
  className: cx(
1162
1290
  "rvl-track",
1291
+ // Promote the track only while a swipe is live (drag + commit/snap
1292
+ // animation), then release the layer. Matches `showAdjacent`.
1293
+ showAdjacent && "rvl-track-swiping",
1163
1294
  // During a thumbnail zoom the track is opaque from the first frame
1164
1295
  // (the image itself is hidden until the zoom starts), so the picture
1165
1296
  // flies in crisply instead of cross-fading. On close it only stays
@@ -1172,7 +1303,11 @@ function ImageViewer({
1172
1303
  "div",
1173
1304
  {
1174
1305
  className: "rvl-adjacent",
1175
- style: { transform: `translateX(-${viewportWidth}px)`, opacity: adjacentOpacity },
1306
+ style: {
1307
+ transform: `translateX(${-adjacentOffset}px)`,
1308
+ opacity: swipeOffset > 0 ? adjacentOpacity : 0,
1309
+ transition: adjacentTransition
1310
+ },
1176
1311
  children: /* @__PURE__ */ jsx(
1177
1312
  "img",
1178
1313
  {
@@ -1192,13 +1327,6 @@ function ImageViewer({
1192
1327
  className: "rvl-img-wrapper",
1193
1328
  onClick: (e) => e.stopPropagation(),
1194
1329
  onDoubleClick: handleDoubleClickGuarded,
1195
- onPointerDown: gestures.handlePointerDown,
1196
- onPointerMove: gestures.handlePointerMove,
1197
- onPointerUp: gestures.handlePointerUp,
1198
- onPointerLeave: gestures.handlePointerUp,
1199
- onTouchStart: gestures.handleTouchStart,
1200
- onTouchMove: gestures.handleTouchMove,
1201
- onTouchEnd: gestures.handleTouchEnd,
1202
1330
  children: /* @__PURE__ */ jsx(
1203
1331
  "img",
1204
1332
  {
@@ -1208,8 +1336,8 @@ function ImageViewer({
1208
1336
  className: cx("rvl-img", cn("image")),
1209
1337
  style: imgStyle,
1210
1338
  draggable: false,
1211
- onLoad: markFullLoaded,
1212
- onError: () => setFullLoaded(true)
1339
+ onLoad: onImageLoad,
1340
+ onError: onImageError
1213
1341
  }
1214
1342
  )
1215
1343
  }
@@ -1219,7 +1347,11 @@ function ImageViewer({
1219
1347
  "div",
1220
1348
  {
1221
1349
  className: "rvl-adjacent",
1222
- style: { transform: `translateX(${viewportWidth}px)`, opacity: adjacentOpacity },
1350
+ style: {
1351
+ transform: `translateX(${adjacentOffset}px)`,
1352
+ opacity: swipeOffset < 0 ? adjacentOpacity : 0,
1353
+ transition: adjacentTransition
1354
+ },
1223
1355
  children: /* @__PURE__ */ jsx(
1224
1356
  "img",
1225
1357
  {