@jekrch/react-viewport-lightbox 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @jekrch/react-viewport-lightbox
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@jekrch/react-viewport-lightbox.svg)](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
3
+ [![npm version](https://img.shields.io/npm/v/@jekrch/react-viewport-lightbox.svg?color=blue)](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
4
4
  [![bundle size](https://img.shields.io/bundlephobia/minzip/@jekrch/react-viewport-lightbox)](https://bundlephobia.com/package/@jekrch/react-viewport-lightbox)
5
5
  [![coverage](https://codecov.io/gh/jekrch/react-viewport-lightbox/branch/main/graph/badge.svg)](https://codecov.io/gh/jekrch/react-viewport-lightbox)
6
6
  [![types](https://img.shields.io/npm/types/@jekrch/react-viewport-lightbox.svg)](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
7
- [![license](https://img.shields.io/npm/l/@jekrch/react-viewport-lightbox.svg)](./LICENSE)
7
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
8
 
9
9
  A touch-friendly React image viewer and lightbox with zoom, pan, pinch, and swipe.
10
10
  It ships an `<ImageViewer>` shell with render slots for headers, footers, and
@@ -75,25 +75,28 @@ animation and calls `onClose` after the exit completes.
75
75
 
76
76
  ## Props
77
77
 
78
- | Prop | Type | Default | Description |
79
- | --------------------- | --------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- |
80
- | `items` | `ViewerItem[]` | required | Images to display. |
81
- | `index` | `number` | required | Controlled index of the active item. |
82
- | `onIndexChange` | `(index: number) => void` | required | Called when navigation changes the index. |
83
- | `onNavigate` | `(direction: "prev" \| "next") => void` | optional | Fired when a slide starts (before `onIndexChange`), so overlays can animate out in sync with the image. |
84
- | `onClose` | `() => void` | required | Called after the exit animation completes. |
85
- | `zoom` | `boolean` | `true` | Enable wheel/pinch/double-tap zoom + pan. |
86
- | `showCounter` | `boolean` | `true` | Show the `index / total` counter. |
87
- | `loop` | `boolean` | `false` | Wrap around at the ends (buttons + arrow keys). |
88
- | `renderHeader` | `(ctx) => ReactNode` | optional | Top-left title area. |
89
- | `renderHeaderActions` | `(ctx) => ReactNode` | optional | Extra top-right buttons (before Close). |
90
- | `renderNavStart` | `(ctx) => ReactNode` | optional | Pinned to the left edge of the nav row (e.g. a details toggle); costs no extra height, nav group stays centered. |
91
- | `renderNavEnd` | `(ctx) => ReactNode` | optional | Pinned to the right edge of the nav row. |
92
- | `renderFooter` | `(ctx) => ReactNode` | optional | Content below the nav row. |
93
- | `renderOverlay` | `(ctx) => ReactNode` | optional | Drawers and graphs layered over the image. |
94
- | `classNames` | `Partial<Record<ViewerSlot, string>>` | optional | Per-slot `className` overrides. |
95
- | `icons` | `Partial<ViewerIcons>` | optional | Override `close`, `zoomIn`, `zoomOut`, `prev`, and `next`. |
96
- | `ariaLabel` | `string` | item `alt` | Dialog label. |
78
+ | Prop | Type | Default | Description |
79
+ | ---------------------- | --------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
+ | `items` | `ViewerItem[]` | required | Images to display. |
81
+ | `index` | `number` | required | Controlled index of the active item. |
82
+ | `onIndexChange` | `(index: number) => void` | required | Called when navigation changes the index. |
83
+ | `onNavigate` | `(direction: "prev" \| "next") => void` | optional | Fired when a slide starts (before `onIndexChange`), so overlays can animate out in sync with the image. |
84
+ | `onClose` | `() => void` | required | Called after the exit animation completes. |
85
+ | `getOriginRect` | `(index: number) => ViewerRect \| null` | optional | Enables a shared-element "zoom from thumbnail" open/close transition. Return the source element's rect (e.g. `el.getBoundingClientRect()`) for the given index, or `null` to fall back to the fade. Honors reduced-motion. |
86
+ | `zoom` | `boolean` | `true` | Enable wheel/pinch/double-tap zoom + pan. |
87
+ | `zoomToCursor` | `boolean` | `true` | Anchor wheel/pinch zoom on the pointer: scrolling zooms toward the cursor and a pinch zooms toward the gesture midpoint. Set `false` to zoom about the viewport center. |
88
+ | `showCounter` | `boolean` | `true` | Show the `index / total` counter. |
89
+ | `loop` | `boolean` | `false` | Wrap around at the ends (buttons + arrow keys). |
90
+ | `closeOnBackdropClick` | `boolean` | `false` | Close the viewer when the empty area around the image is clicked. Image, bars, and controls are unaffected. |
91
+ | `renderHeader` | `(ctx) => ReactNode` | optional | Top-left title area. |
92
+ | `renderHeaderActions` | `(ctx) => ReactNode` | optional | Extra top-right buttons (before Close). |
93
+ | `renderNavStart` | `(ctx) => ReactNode` | optional | Pinned to the left edge of the nav row (e.g. a details toggle); costs no extra height, nav group stays centered. |
94
+ | `renderNavEnd` | `(ctx) => ReactNode` | optional | Pinned to the right edge of the nav row. |
95
+ | `renderFooter` | `(ctx) => ReactNode` | optional | Content below the nav row. |
96
+ | `renderOverlay` | `(ctx) => ReactNode` | optional | Drawers and graphs layered over the image. |
97
+ | `classNames` | `Partial<Record<ViewerSlot, string>>` | optional | Per-slot `className` overrides. |
98
+ | `icons` | `Partial<ViewerIcons>` | optional | Override `close`, `zoomIn`, `zoomOut`, `prev`, and `next`. |
99
+ | `ariaLabel` | `string` | item `alt` | Dialog label. |
97
100
 
98
101
  ### `ViewerItem`
99
102
 
@@ -149,6 +152,38 @@ const items: ViewerItem<Detail>[] = [
149
152
  `ViewerItem`, `ViewerContext`, and `ImageViewerProps` are all generic over `TData`
150
153
  (defaulting to `unknown`), so this is fully type-safe and entirely opt-in.
151
154
 
155
+ ### Zoom from thumbnail
156
+
157
+ Pass `getOriginRect` to make the viewer expand out of the clicked thumbnail on open
158
+ and collapse back into it on close (a shared-element transition), instead of the
159
+ default fade. Keep a ref to each thumbnail and return its current on-screen rect for
160
+ the given index; return `null` (or omit the prop) to fall back to the fade. It's
161
+ called again with the active index on close, so navigating then closing collapses
162
+ into the right thumbnail, and it honors `prefers-reduced-motion`.
163
+
164
+ ```tsx
165
+ const thumbs = useRef<(HTMLElement | null)[]>([]);
166
+
167
+ {
168
+ items.map((it, i) => (
169
+ <button key={it.id} ref={(el) => (thumbs.current[i] = el)} onClick={() => open(i)}>
170
+ <img src={it.thumbnail ?? it.src} alt={it.alt} />
171
+ </button>
172
+ ));
173
+ }
174
+
175
+ <ImageViewer
176
+ items={items}
177
+ index={index}
178
+ onIndexChange={setIndex}
179
+ onClose={() => setOpen(false)}
180
+ getOriginRect={(i) => thumbs.current[i]?.getBoundingClientRect() ?? null}
181
+ />;
182
+ ```
183
+
184
+ The transition reads most seamlessly when the thumbnail and full image share an
185
+ aspect ratio (the thumbnail is, after all, the same picture).
186
+
152
187
  ## Slots & `ViewerContext`
153
188
 
154
189
  Every `render*` slot receives a `ViewerContext` with navigation, zoom, and layout
package/dist/index.cjs CHANGED
@@ -21,6 +21,15 @@ function clampTranslate(x, y, scale, baseDims, viewport) {
21
21
  y: Math.max(-maxY, Math.min(maxY, y))
22
22
  };
23
23
  }
24
+ function zoomToPoint(prevScale, nextScale, prev, focal, viewport) {
25
+ const relX = focal.x - viewport.width / 2;
26
+ const relY = focal.y - viewport.height / 2;
27
+ const k = nextScale / prevScale;
28
+ return {
29
+ x: relX * (1 - k) + k * prev.x,
30
+ y: relY * (1 - k) + k * prev.y
31
+ };
32
+ }
24
33
  function resolveSlideDirection({
25
34
  offset,
26
35
  elapsedMs,
@@ -41,7 +50,7 @@ function resolveSlideDirection({
41
50
  // src/hooks/useImageZoomPan.ts
42
51
  var MIN_SCALE = 1;
43
52
  var MAX_SCALE = 5;
44
- function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true) {
53
+ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCursor = true) {
45
54
  const imgRef = react.useRef(null);
46
55
  const [displayScale, setDisplayScale] = react.useState(1);
47
56
  const transformRef = react.useRef({ scale: 1, x: 0, y: 0 });
@@ -127,12 +136,26 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true) {
127
136
  const step = -(normalized / 100) * 0.05;
128
137
  const factor = 1 + step;
129
138
  const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, t.scale * factor));
130
- const clamped = nextScale <= 1 ? { x: 0, y: 0 } : clampTranslate2(t.x, t.y, nextScale);
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
+ }
131
154
  setTransform({ scale: nextScale, ...clamped });
132
155
  };
133
156
  wrapper.addEventListener("wheel", handleWheel, { passive: false });
134
157
  return () => wrapper.removeEventListener("wheel", handleWheel);
135
- }, [imgWrapperRef, setTransform, clampTranslate2, enabled]);
158
+ }, [imgWrapperRef, setTransform, clampTranslate2, enabled, zoomToCursor]);
136
159
  const handleDoubleClick = react.useCallback(
137
160
  (e) => {
138
161
  if (!enabled) return;
@@ -163,15 +186,15 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true) {
163
186
  handleDoubleClick
164
187
  };
165
188
  }
166
- function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
189
+ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop = false) {
167
190
  const slideTrackRef = react.useRef(null);
168
191
  const swipeOffsetRef = react.useRef(0);
169
192
  const [swipeOffset, setSwipeOffset] = react.useState(0);
170
193
  const [slideAnimating, setSlideAnimating] = react.useState(false);
171
194
  const [slideActive, setSlideActive] = react.useState(false);
172
195
  const commitLockRef = react.useRef(false);
173
- const hasPrev = currentIndex > 0;
174
- const hasNext = currentIndex < items.length - 1;
196
+ const hasPrev = loop ? items.length > 1 : currentIndex > 0;
197
+ const hasNext = loop ? items.length > 1 : currentIndex < items.length - 1;
175
198
  const applySlideOffset = react.useCallback((offset, animate = false) => {
176
199
  swipeOffsetRef.current = offset;
177
200
  const track = slideTrackRef.current;
@@ -217,7 +240,8 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
217
240
  if (cleaned) return;
218
241
  cleaned = true;
219
242
  track?.removeEventListener("transitionend", onTransitionEnd);
220
- const newIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
243
+ let newIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
244
+ if (loop) newIndex = (newIndex + items.length) % items.length;
221
245
  if (newIndex < 0 || newIndex >= items.length) {
222
246
  commitLockRef.current = false;
223
247
  return;
@@ -242,7 +266,7 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
242
266
  }
243
267
  });
244
268
  },
245
- [applySlideOffset, currentIndex, items, onNavigate, onSlideStart]
269
+ [applySlideOffset, currentIndex, items, onNavigate, onSlideStart, loop]
246
270
  );
247
271
  const resolveSlide = react.useCallback(
248
272
  (gestureStartTime) => {
@@ -289,7 +313,7 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
289
313
  setSlideActive
290
314
  };
291
315
  }
292
- function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true) {
316
+ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true, zoomToCursor = true) {
293
317
  const { transformRef, clampTranslate: clampTranslate2, setTransform, applyTransform, resetTransform } = zoomPan;
294
318
  const { applySlideOffset, resolveSlide, snapBack, setSlideActive, swipeOffsetRef } = slide;
295
319
  const panRef = react.useRef({
@@ -444,7 +468,23 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true)
444
468
  const ratio = dist / p.pinchStartDist;
445
469
  const nextScale = Math.min(5, Math.max(1, p.pinchStartScale * ratio));
446
470
  const t = transformRef.current;
447
- const clamped = nextScale <= 1 ? { x: 0, y: 0 } : clampTranslate2(t.x, t.y, nextScale);
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
+ }
448
488
  const next = { scale: nextScale, ...clamped };
449
489
  transformRef.current = next;
450
490
  applyTransform(next);
@@ -463,7 +503,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true)
463
503
  updateSlide(touch.clientX, touch.clientY, 6, 0.8);
464
504
  }
465
505
  },
466
- [transformRef, clampTranslate2, applyTransform, updateSlide]
506
+ [transformRef, clampTranslate2, applyTransform, updateSlide, zoomToCursor]
467
507
  );
468
508
  const handleTouchEnd = react.useCallback(
469
509
  (e) => {
@@ -536,7 +576,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true)
536
576
  function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
537
577
  const [topBarH, setTopBarH] = react.useState(0);
538
578
  const [bottomBarH, setBottomBarH] = react.useState(0);
539
- react.useEffect(() => {
579
+ react.useLayoutEffect(() => {
540
580
  const measure = () => {
541
581
  if (topBarRef.current) setTopBarH(topBarRef.current.offsetHeight);
542
582
  if (bottomBarRef.current) setBottomBarH(bottomBarRef.current.offsetHeight);
@@ -684,19 +724,37 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
684
724
  }
685
725
  var ANIM_MS = 250;
686
726
  var IMG_PADDING = 44;
727
+ var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
687
728
  function prefersReducedMotion() {
688
729
  if (typeof window === "undefined" || !window.matchMedia) return false;
689
730
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
690
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
+ }
691
746
  function ImageViewer({
692
747
  items,
693
748
  index,
694
749
  onIndexChange,
695
750
  onNavigate,
696
751
  onClose,
752
+ getOriginRect,
697
753
  zoom = true,
754
+ zoomToCursor = true,
698
755
  showCounter = true,
699
756
  loop = false,
757
+ closeOnBackdropClick = false,
700
758
  renderHeader,
701
759
  renderHeaderActions,
702
760
  renderNavStart,
@@ -709,6 +767,7 @@ function ImageViewer({
709
767
  }) {
710
768
  const [visible, setVisible] = react.useState(false);
711
769
  const [closing, setClosing] = react.useState(false);
770
+ const [collapsing, setCollapsing] = react.useState(false);
712
771
  const [isTouchDevice, setIsTouchDevice] = react.useState(false);
713
772
  const [contentShift, setContentShiftState] = react.useState({ transform: null, animate: true });
714
773
  const containerRef = react.useRef(null);
@@ -728,7 +787,7 @@ function ImageViewer({
728
787
  useBodyScrollLock(true);
729
788
  useFocusTrap(containerRef, visible && !closing);
730
789
  const { topBarH, bottomBarH } = useBarMeasure(topBarRef, bottomBarRef, index);
731
- const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom);
790
+ const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom, zoomToCursor);
732
791
  const {
733
792
  imgRef,
734
793
  displayScale,
@@ -740,9 +799,9 @@ function ImageViewer({
740
799
  measureBaseDims,
741
800
  handleDoubleClick
742
801
  } = zoomPan;
743
- const slide = useSlideNavigation(items, index, onIndexChange, onNavigate);
802
+ const slide = useSlideNavigation(items, index, onIndexChange, onNavigate, loop);
744
803
  const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
745
- const gestures = useGestureHandler(zoomPan, slide, hasPrevLinear, hasNextLinear, zoom);
804
+ const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
746
805
  react.useEffect(() => {
747
806
  setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
748
807
  }, []);
@@ -755,29 +814,110 @@ function ImageViewer({
755
814
  const raf = requestAnimationFrame(() => setVisible(true));
756
815
  return () => cancelAnimationFrame(raf);
757
816
  }, []);
817
+ const zoomTransition = !!getOriginRect;
818
+ const reduceMotion = prefersReducedMotion();
819
+ const gateEntry = zoomTransition && !reduceMotion;
820
+ const [fullLoaded, setFullLoaded] = react.useState(false);
821
+ const [showSpinner, setShowSpinner] = react.useState(false);
822
+ const entryStartedRef = react.useRef(false);
823
+ const entryCleanupRef = react.useRef(null);
824
+ const runZoomEntry = react.useCallback(() => {
825
+ if (entryStartedRef.current) return;
826
+ if (!getOriginRect || prefersReducedMotion()) return;
827
+ const img = imgRef.current;
828
+ const thumb = getOriginRect(index);
829
+ if (!thumb || !canAnimate(img)) return;
830
+ const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
831
+ const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
832
+ img.style.maxHeight = lockedMaxHeight;
833
+ const imgRect = img.getBoundingClientRect();
834
+ if (imgRect.width === 0 || imgRect.height === 0) {
835
+ img.style.maxHeight = "";
836
+ return;
837
+ }
838
+ entryStartedRef.current = true;
839
+ const startTransform = flipTransform(imgRect, thumb);
840
+ img.style.transformOrigin = "top left";
841
+ img.style.transform = startTransform;
842
+ const wrapper = imgWrapperRef.current;
843
+ if (wrapper) wrapper.style.overflow = "visible";
844
+ const anim = img.animate(
845
+ [
846
+ { transformOrigin: "top left", transform: startTransform },
847
+ { transformOrigin: "top left", transform: "none" }
848
+ ],
849
+ { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
850
+ );
851
+ const cleanup = () => {
852
+ img.style.transform = "";
853
+ img.style.transformOrigin = "";
854
+ if (wrapper) wrapper.style.overflow = "";
855
+ img.style.maxHeight = lockedMaxHeight;
856
+ anim.cancel();
857
+ entryCleanupRef.current = null;
858
+ };
859
+ entryCleanupRef.current = cleanup;
860
+ anim.onfinish = cleanup;
861
+ }, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
862
+ const markFullLoaded = react.useCallback(() => {
863
+ measureBaseDims();
864
+ const img = imgRef.current;
865
+ if (img && typeof img.decode === "function") {
866
+ img.decode().then(
867
+ () => setFullLoaded(true),
868
+ () => setFullLoaded(true)
869
+ );
870
+ } else {
871
+ setFullLoaded(true);
872
+ }
873
+ }, [measureBaseDims, imgRef]);
874
+ react.useLayoutEffect(() => {
875
+ const img = imgRef.current;
876
+ if (img && img.complete && img.naturalWidth > 0) markFullLoaded();
877
+ }, []);
878
+ react.useLayoutEffect(() => {
879
+ if (fullLoaded) runZoomEntry();
880
+ }, [fullLoaded]);
881
+ react.useEffect(() => {
882
+ if (!gateEntry || fullLoaded) {
883
+ setShowSpinner(false);
884
+ return;
885
+ }
886
+ const t = setTimeout(() => setShowSpinner(true), 500);
887
+ return () => clearTimeout(t);
888
+ }, [gateEntry, fullLoaded]);
758
889
  const handleClose = react.useCallback(() => {
890
+ const reduce = prefersReducedMotion();
891
+ const origin = !reduce && !isZoomed ? getOriginRect?.(index) ?? null : null;
892
+ const thumb = origin && isRectInViewport(origin) ? origin : null;
893
+ const img = imgRef.current;
894
+ entryCleanupRef.current?.();
759
895
  setClosing(true);
760
896
  setVisible(false);
761
- const delay = prefersReducedMotion() ? 0 : ANIM_MS;
762
- setTimeout(onClose, delay);
763
- }, [onClose]);
897
+ if (thumb && canAnimate(img)) {
898
+ const imgRect = img.getBoundingClientRect();
899
+ const wrapper = imgWrapperRef.current;
900
+ if (wrapper) wrapper.style.overflow = "visible";
901
+ setCollapsing(true);
902
+ img.animate(
903
+ [
904
+ { transformOrigin: "top left", transform: "none" },
905
+ { transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
906
+ ],
907
+ { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
908
+ );
909
+ }
910
+ setTimeout(onClose, reduce ? 0 : ANIM_MS);
911
+ }, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
764
912
  const navigate = react.useCallback(
765
913
  (dir) => {
766
914
  if (dir === "prev") {
767
- if (hasPrevLinear) commitSlide("prev");
768
- else if (loop) {
769
- onNavigate?.("prev");
770
- onIndexChange(items.length - 1);
771
- }
915
+ if (hasPrev) commitSlide("prev");
772
916
  } else {
773
- if (hasNextLinear) commitSlide("next");
774
- else if (loop) {
775
- onNavigate?.("next");
776
- onIndexChange(0);
777
- }
917
+ if (hasNext) commitSlide("next");
778
918
  }
779
919
  },
780
- [hasPrevLinear, hasNextLinear, loop, commitSlide, onIndexChange, onNavigate, items.length]
920
+ [hasPrev, hasNext, commitSlide]
781
921
  );
782
922
  react.useEffect(() => {
783
923
  const handler = (e) => {
@@ -832,10 +972,13 @@ function ImageViewer({
832
972
  const reservedH = bottomBarH + IMG_PADDING * 2;
833
973
  const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
834
974
  const imgStyle = { maxHeight: imgMaxHeight };
975
+ if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
835
976
  const totalDigits = String(items.length).length;
836
977
  const counterMinWidth = `${totalDigits * 2 * 0.6 + 1.5}em`;
837
- const prevItem = hasPrevLinear ? items[index - 1] : null;
838
- const nextItem = hasNextLinear ? items[index + 1] : null;
978
+ const prevIndex = hasPrevLinear ? index - 1 : hasPrev ? items.length - 1 : -1;
979
+ const nextIndex = hasNextLinear ? index + 1 : hasNext ? 0 : -1;
980
+ const prevItem = prevIndex >= 0 ? items[prevIndex] : null;
981
+ const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
839
982
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
840
983
  const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
841
984
  const showZoomControls = zoom && !isTouchDevice;
@@ -858,7 +1001,7 @@ function ImageViewer({
858
1001
  "div",
859
1002
  {
860
1003
  className: cx("rvl-backdrop", cn("backdrop")),
861
- onClick: handleClose,
1004
+ onClick: closeOnBackdropClick ? handleClose : void 0,
862
1005
  "aria-hidden": "true"
863
1006
  }
864
1007
  ),
@@ -931,6 +1074,9 @@ function ImageViewer({
931
1074
  "div",
932
1075
  {
933
1076
  className: "rvl-stage",
1077
+ onClick: closeOnBackdropClick ? (e) => {
1078
+ if (e.target === e.currentTarget) handleClose();
1079
+ } : void 0,
934
1080
  style: {
935
1081
  transform: contentShift.transform ?? "translateY(0)",
936
1082
  // animate=false snaps with no transition (overrides the CSS transition)
@@ -940,7 +1086,15 @@ function ImageViewer({
940
1086
  "div",
941
1087
  {
942
1088
  ref: slideTrackRef,
943
- className: cx("rvl-track", visible && !closing && "rvl-track-visible"),
1089
+ className: cx(
1090
+ "rvl-track",
1091
+ // During a thumbnail zoom the track is opaque from the first frame
1092
+ // (the image itself is hidden until the zoom starts), so the picture
1093
+ // flies in crisply instead of cross-fading. On close it only stays
1094
+ // opaque while a FLIP collapse is animating the image back; otherwise
1095
+ // it fades out (so a zoomed close still animates instead of vanishing).
1096
+ (closing ? collapsing : zoomTransition || visible) && "rvl-track-visible"
1097
+ ),
944
1098
  children: [
945
1099
  showAdjacent && prevItem && /* @__PURE__ */ jsxRuntime.jsx(
946
1100
  "div",
@@ -982,11 +1136,13 @@ function ImageViewer({
982
1136
  className: cx("rvl-img", cn("image")),
983
1137
  style: imgStyle,
984
1138
  draggable: false,
985
- onLoad: measureBaseDims
1139
+ onLoad: markFullLoaded,
1140
+ onError: () => setFullLoaded(true)
986
1141
  }
987
1142
  )
988
1143
  }
989
1144
  ),
1145
+ gateEntry && showSpinner && !fullLoaded && /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-spinner", cn("spinner")), role: "status", "aria-label": "Loading", children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rvl-spinner-ring", "aria-hidden": "true" }) }),
990
1146
  showAdjacent && nextItem && /* @__PURE__ */ jsxRuntime.jsx(
991
1147
  "div",
992
1148
  {