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