@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.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,27 +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 ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
743
- function prefersReducedMotion() {
744
- if (typeof window === "undefined" || !window.matchMedia) return false;
745
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
746
- }
747
- function flipTransform(from, to) {
748
- const sx = to.width / from.width;
749
- const sy = to.height / from.height;
750
- const dx = to.left - from.left;
751
- const dy = to.top - from.top;
752
- return `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
753
- }
754
- function canAnimate(el) {
755
- return !!el && typeof el.animate === "function";
756
- }
757
- function isRectInViewport(rect) {
758
- if (typeof window === "undefined") return true;
759
- 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
+ );
760
953
  }
954
+ var GHOST_CLICK_MS = 700;
761
955
  function ImageViewer({
762
956
  items,
763
957
  index,
@@ -789,9 +983,13 @@ function ImageViewer({
789
983
  }) {
790
984
  const [visible, setVisible] = useState(false);
791
985
  const [closing, setClosing] = useState(false);
792
- const [collapsing, setCollapsing] = useState(false);
793
986
  const [isTouchDevice, setIsTouchDevice] = useState(false);
794
987
  const [contentShift, setContentShiftState] = useState({ transform: null, animate: true });
988
+ const openedAtRef = useRef(Date.now());
989
+ const isGhostMouseEvent = useCallback(
990
+ () => Date.now() - openedAtRef.current < GHOST_CLICK_MS,
991
+ []
992
+ );
795
993
  const containerRef = useRef(null);
796
994
  const imgWrapperRef = useRef(null);
797
995
  const topBarRef = useRef(null);
@@ -822,8 +1020,27 @@ function ImageViewer({
822
1020
  handleDoubleClick
823
1021
  } = zoomPan;
824
1022
  const slide = useSlideNavigation(items, index, onIndexChange, onNavigate, loop);
825
- const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
1023
+ const { slideTrackRef, slideActive, slideAnimating, swipeOffset, slideDistance, commitSlide } = slide;
826
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
+ });
827
1044
  useEffect(() => {
828
1045
  if (typeof window === "undefined" || !window.matchMedia) {
829
1046
  setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
@@ -844,101 +1061,53 @@ function ImageViewer({
844
1061
  const raf = requestAnimationFrame(() => setVisible(true));
845
1062
  return () => cancelAnimationFrame(raf);
846
1063
  }, []);
847
- const zoomTransition = !!getOriginRect;
848
- const reduceMotion = prefersReducedMotion();
849
- const gateEntry = zoomTransition && !reduceMotion;
850
- const [fullLoaded, setFullLoaded] = useState(false);
851
- const [showSpinner, setShowSpinner] = useState(false);
852
- const entryStartedRef = useRef(false);
853
- const entryCleanupRef = useRef(null);
854
- const runZoomEntry = useCallback(() => {
855
- if (entryStartedRef.current) return;
856
- if (!getOriginRect || prefersReducedMotion()) return;
857
- const img = imgRef.current;
858
- const thumb = getOriginRect(index);
859
- if (!thumb || !canAnimate(img)) return;
860
- const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
861
- const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
862
- img.style.maxHeight = lockedMaxHeight;
863
- const imgRect = img.getBoundingClientRect();
864
- if (imgRect.width === 0 || imgRect.height === 0) {
865
- img.style.maxHeight = "";
866
- return;
867
- }
868
- entryStartedRef.current = true;
869
- const startTransform = flipTransform(imgRect, thumb);
870
- img.style.transformOrigin = "top left";
871
- img.style.transform = startTransform;
872
- const wrapper = imgWrapperRef.current;
873
- if (wrapper) wrapper.style.overflow = "visible";
874
- const anim = img.animate(
875
- [
876
- { transformOrigin: "top left", transform: startTransform },
877
- { transformOrigin: "top left", transform: "none" }
878
- ],
879
- { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
880
- );
881
- const cleanup = () => {
882
- img.style.transform = "";
883
- img.style.transformOrigin = "";
884
- if (wrapper) wrapper.style.overflow = "";
885
- img.style.maxHeight = lockedMaxHeight;
886
- anim.cancel();
887
- entryCleanupRef.current = null;
888
- };
889
- entryCleanupRef.current = cleanup;
890
- anim.onfinish = cleanup;
891
- }, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
892
- const markFullLoaded = useCallback(() => {
893
- measureBaseDims();
894
- const img = imgRef.current;
895
- if (img && typeof img.decode === "function") {
896
- img.decode().then(
897
- () => setFullLoaded(true),
898
- () => setFullLoaded(true)
899
- );
900
- } else {
901
- setFullLoaded(true);
902
- }
903
- }, [measureBaseDims, imgRef]);
904
- useLayoutEffect(() => {
905
- const img = imgRef.current;
906
- if (img && img.complete && img.naturalWidth > 0) markFullLoaded();
907
- }, []);
908
- useLayoutEffect(() => {
909
- if (fullLoaded) runZoomEntry();
910
- }, [fullLoaded]);
911
- useEffect(() => {
912
- if (!gateEntry || fullLoaded) {
913
- setShowSpinner(false);
914
- return;
915
- }
916
- const t = setTimeout(() => setShowSpinner(true), 500);
917
- return () => clearTimeout(t);
918
- }, [gateEntry, fullLoaded]);
919
1064
  const handleClose = useCallback(() => {
920
1065
  const reduce = prefersReducedMotion();
921
- const origin = !reduce && !isZoomed ? getOriginRect?.(index) ?? null : null;
922
- const thumb = origin && isRectInViewport(origin) ? origin : null;
923
- const img = imgRef.current;
924
- entryCleanupRef.current?.();
1066
+ settleEntry();
925
1067
  setClosing(true);
926
1068
  setVisible(false);
927
- if (thumb && canAnimate(img)) {
928
- const imgRect = img.getBoundingClientRect();
929
- const wrapper = imgWrapperRef.current;
930
- if (wrapper) wrapper.style.overflow = "visible";
931
- setCollapsing(true);
932
- img.animate(
933
- [
934
- { transformOrigin: "top left", transform: "none" },
935
- { transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
936
- ],
937
- { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
938
- );
939
- }
1069
+ playCollapse();
940
1070
  setTimeout(onClose, reduce ? 0 : ANIM_MS);
941
- }, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
1071
+ }, [onClose, settleEntry, playCollapse]);
1072
+ const handleBackdropTouchEnd = useCallback(
1073
+ (e) => {
1074
+ if (e.target !== e.currentTarget) return;
1075
+ e.preventDefault();
1076
+ handleClose();
1077
+ },
1078
+ [handleClose]
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
+ );
1100
+ const handleDoubleClickGuarded = useCallback(
1101
+ (e) => {
1102
+ if (isGhostMouseEvent()) return;
1103
+ handleDoubleClick(e);
1104
+ },
1105
+ [handleDoubleClick, isGhostMouseEvent]
1106
+ );
1107
+ const handleBackdropClick = useCallback(() => {
1108
+ if (isGhostMouseEvent()) return;
1109
+ handleClose();
1110
+ }, [handleClose, isGhostMouseEvent]);
942
1111
  const navigate = useCallback(
943
1112
  (dir) => {
944
1113
  if (dir === "prev") {
@@ -1018,7 +1187,9 @@ function ImageViewer({
1018
1187
  const prevItem = prevIndex >= 0 ? items[prevIndex] : null;
1019
1188
  const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
1020
1189
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
1021
- 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";
1022
1193
  const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
1023
1194
  const headerActions = renderHeaderActions?.(ctx);
1024
1195
  const navStart = renderNavStart?.(ctx);
@@ -1040,7 +1211,8 @@ function ImageViewer({
1040
1211
  "div",
1041
1212
  {
1042
1213
  className: cx("rvl-backdrop", cn("backdrop")),
1043
- onClick: closeOnBackdropClick ? handleClose : void 0,
1214
+ onClick: closeOnBackdropClick ? handleBackdropClick : void 0,
1215
+ onTouchEnd: closeOnBackdropClick ? handleBackdropTouchEnd : void 0,
1044
1216
  "aria-hidden": "true"
1045
1217
  }
1046
1218
  ),
@@ -1049,16 +1221,12 @@ function ImageViewer({
1049
1221
  /* @__PURE__ */ jsxs("div", { className: "rvl-header-actions", children: [
1050
1222
  headerActions,
1051
1223
  showZoomCtrls && isZoomed && /* @__PURE__ */ jsxs(
1052
- "button",
1224
+ ChromeButton,
1053
1225
  {
1054
- type: "button",
1055
- className: cx("rvl-btn", "rvl-btn-scale", cn("button")),
1056
- onClick: (e) => {
1057
- e.stopPropagation();
1058
- resetTransform();
1059
- },
1226
+ className: cx("rvl-btn-scale", cn("button")),
1227
+ onClick: resetTransform,
1060
1228
  title: "Reset zoom",
1061
- "aria-label": "Reset zoom",
1229
+ ariaLabel: "Reset zoom",
1062
1230
  children: [
1063
1231
  Math.round(displayScale * 100),
1064
1232
  "%"
@@ -1066,44 +1234,32 @@ function ImageViewer({
1066
1234
  }
1067
1235
  ),
1068
1236
  showZoomCtrls && /* @__PURE__ */ jsx(
1069
- "button",
1237
+ ChromeButton,
1070
1238
  {
1071
- type: "button",
1072
- className: cx("rvl-btn", cn("button")),
1073
- onClick: (e) => {
1074
- e.stopPropagation();
1075
- ctx.zoomIn();
1076
- },
1239
+ className: cn("button"),
1240
+ onClick: ctx.zoomIn,
1077
1241
  title: "Zoom in",
1078
- "aria-label": "Zoom in",
1242
+ ariaLabel: "Zoom in",
1079
1243
  children: mergedIcons.zoomIn
1080
1244
  }
1081
1245
  ),
1082
1246
  showZoomCtrls && /* @__PURE__ */ jsx(
1083
- "button",
1247
+ ChromeButton,
1084
1248
  {
1085
- type: "button",
1086
- className: cx("rvl-btn", cn("button")),
1087
- onClick: (e) => {
1088
- e.stopPropagation();
1089
- ctx.zoomOut();
1090
- },
1249
+ className: cn("button"),
1250
+ onClick: ctx.zoomOut,
1091
1251
  title: "Zoom out",
1092
- "aria-label": "Zoom out",
1252
+ ariaLabel: "Zoom out",
1093
1253
  children: mergedIcons.zoomOut
1094
1254
  }
1095
1255
  ),
1096
1256
  /* @__PURE__ */ jsx(
1097
- "button",
1257
+ ChromeButton,
1098
1258
  {
1099
- type: "button",
1100
- className: cx("rvl-btn", cn("button")),
1101
- onClick: (e) => {
1102
- e.stopPropagation();
1103
- handleClose();
1104
- },
1259
+ className: cn("button"),
1260
+ onClick: handleClose,
1105
1261
  title: "Close (Esc)",
1106
- "aria-label": "Close",
1262
+ ariaLabel: "Close",
1107
1263
  children: mergedIcons.close
1108
1264
  }
1109
1265
  )
@@ -1113,9 +1269,6 @@ function ImageViewer({
1113
1269
  "div",
1114
1270
  {
1115
1271
  className: "rvl-stage",
1116
- onClick: closeOnBackdropClick ? (e) => {
1117
- if (e.target === e.currentTarget) handleClose();
1118
- } : void 0,
1119
1272
  style: {
1120
1273
  transform: contentShift.transform ?? "translateY(0)",
1121
1274
  // animate=false snaps with no transition (overrides the CSS transition)
@@ -1125,8 +1278,19 @@ function ImageViewer({
1125
1278
  "div",
1126
1279
  {
1127
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,
1128
1289
  className: cx(
1129
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",
1130
1294
  // During a thumbnail zoom the track is opaque from the first frame
1131
1295
  // (the image itself is hidden until the zoom starts), so the picture
1132
1296
  // flies in crisply instead of cross-fading. On close it only stays
@@ -1139,7 +1303,11 @@ function ImageViewer({
1139
1303
  "div",
1140
1304
  {
1141
1305
  className: "rvl-adjacent",
1142
- style: { transform: `translateX(-${viewportWidth}px)`, opacity: adjacentOpacity },
1306
+ style: {
1307
+ transform: `translateX(${-adjacentOffset}px)`,
1308
+ opacity: swipeOffset > 0 ? adjacentOpacity : 0,
1309
+ transition: adjacentTransition
1310
+ },
1143
1311
  children: /* @__PURE__ */ jsx(
1144
1312
  "img",
1145
1313
  {
@@ -1158,14 +1326,7 @@ function ImageViewer({
1158
1326
  ref: imgWrapperRef,
1159
1327
  className: "rvl-img-wrapper",
1160
1328
  onClick: (e) => e.stopPropagation(),
1161
- onDoubleClick: handleDoubleClick,
1162
- onPointerDown: gestures.handlePointerDown,
1163
- onPointerMove: gestures.handlePointerMove,
1164
- onPointerUp: gestures.handlePointerUp,
1165
- onPointerLeave: gestures.handlePointerUp,
1166
- onTouchStart: gestures.handleTouchStart,
1167
- onTouchMove: gestures.handleTouchMove,
1168
- onTouchEnd: gestures.handleTouchEnd,
1329
+ onDoubleClick: handleDoubleClickGuarded,
1169
1330
  children: /* @__PURE__ */ jsx(
1170
1331
  "img",
1171
1332
  {
@@ -1175,8 +1336,8 @@ function ImageViewer({
1175
1336
  className: cx("rvl-img", cn("image")),
1176
1337
  style: imgStyle,
1177
1338
  draggable: false,
1178
- onLoad: markFullLoaded,
1179
- onError: () => setFullLoaded(true)
1339
+ onLoad: onImageLoad,
1340
+ onError: onImageError
1180
1341
  }
1181
1342
  )
1182
1343
  }
@@ -1186,7 +1347,11 @@ function ImageViewer({
1186
1347
  "div",
1187
1348
  {
1188
1349
  className: "rvl-adjacent",
1189
- style: { transform: `translateX(${viewportWidth}px)`, opacity: adjacentOpacity },
1350
+ style: {
1351
+ transform: `translateX(${adjacentOffset}px)`,
1352
+ opacity: swipeOffset < 0 ? adjacentOpacity : 0,
1353
+ transition: adjacentTransition
1354
+ },
1190
1355
  children: /* @__PURE__ */ jsx(
1191
1356
  "img",
1192
1357
  {