@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/dist/index.d.ts CHANGED
@@ -19,8 +19,19 @@ interface ViewerItem<TData = unknown> {
19
19
  /** Arbitrary per-slide payload, surfaced as `ctx.item.data` in render slots. */
20
20
  data?: TData;
21
21
  }
22
+ /**
23
+ * A rectangle in viewport coordinates. Structurally compatible with the
24
+ * `DOMRect` returned by `element.getBoundingClientRect()`, so you can pass one
25
+ * straight through.
26
+ */
27
+ interface ViewerRect {
28
+ top: number;
29
+ left: number;
30
+ width: number;
31
+ height: number;
32
+ }
22
33
  /** Named, themeable regions of the viewer that accept a `className` override. */
23
- type ViewerSlot = "root" | "backdrop" | "topBar" | "bottomBar" | "image" | "button" | "counter" | "navButton" | "navStart" | "navEnd" | "overlay";
34
+ type ViewerSlot = "root" | "backdrop" | "topBar" | "bottomBar" | "image" | "button" | "counter" | "navButton" | "navStart" | "navEnd" | "overlay" | "spinner";
24
35
  /** Overridable control icons. Each is a React node rendered inside its button. */
25
36
  interface ViewerIcons {
26
37
  close: ReactNode;
@@ -79,12 +90,33 @@ interface ImageViewerProps<TData = unknown> {
79
90
  onNavigate?: (direction: "prev" | "next") => void;
80
91
  /** Called AFTER the exit animation completes. */
81
92
  onClose: () => void;
93
+ /**
94
+ * Enables a shared-element "zoom from thumbnail" open/close transition. Given
95
+ * the active index, return the on-screen rect of the source element (e.g. the
96
+ * gallery thumbnail) — typically `el.getBoundingClientRect()`. The active
97
+ * image expands from that rect on open and collapses back into it on close.
98
+ * Return `null` for an index with no on-screen source (or omit the prop
99
+ * entirely) to fall back to the default fade. Honors reduced-motion.
100
+ */
101
+ getOriginRect?: (index: number) => ViewerRect | null;
82
102
  /** Enable zoom/pan (wheel, pinch, double-tap). Default `true`. */
83
103
  zoom?: boolean;
104
+ /**
105
+ * Anchor wheel- and pinch-zoom on the pointer: scrolling zooms toward the
106
+ * cursor and a pinch zooms toward the gesture midpoint. Set `false` to zoom
107
+ * about the viewport center instead. Default `true`.
108
+ */
109
+ zoomToCursor?: boolean;
84
110
  /** Show the `index / total` counter. Default `true`. */
85
111
  showCounter?: boolean;
86
112
  /** Wrap around at the ends. Default `false`. */
87
113
  loop?: boolean;
114
+ /**
115
+ * Close the viewer when the empty area around the image (the backdrop) is
116
+ * clicked. Clicks on the image, the bars, and the control buttons are
117
+ * unaffected. Default `false`.
118
+ */
119
+ closeOnBackdropClick?: boolean;
88
120
  /** Top-left title area. */
89
121
  renderHeader?: (ctx: ViewerContext<TData>) => ReactNode;
90
122
  /** Extra top-right buttons, rendered before the close button. */
@@ -112,7 +144,7 @@ interface ImageViewerProps<TData = unknown> {
112
144
  * `onIndexChange`; mount it when open and it runs its own enter/exit animation,
113
145
  * calling `onClose` after the exit completes.
114
146
  */
115
- declare function ImageViewer<TData = unknown>({ items, index, onIndexChange, onNavigate, onClose, zoom, showCounter, loop, renderHeader, renderHeaderActions, renderNavStart, renderNavEnd, renderFooter, renderOverlay, classNames, icons, ariaLabel, }: ImageViewerProps<TData>): react.JSX.Element | null;
147
+ declare function ImageViewer<TData = unknown>({ items, index, onIndexChange, onNavigate, onClose, getOriginRect, zoom, zoomToCursor, showCounter, loop, closeOnBackdropClick, renderHeader, renderHeaderActions, renderNavStart, renderNavEnd, renderFooter, renderOverlay, classNames, icons, ariaLabel, }: ImageViewerProps<TData>): react.JSX.Element | null;
116
148
 
117
149
  interface NavButtonProps {
118
150
  direction: "prev" | "next";
@@ -172,7 +204,12 @@ interface ImageZoomPanState {
172
204
  */
173
205
  declare function useImageZoomPan(imgWrapperRef: RefObject<HTMLDivElement | null>, currentIndex: number,
174
206
  /** When false, wheel-zoom and double-click-zoom are disabled. Default true. */
175
- enabled?: boolean): ImageZoomPanState;
207
+ enabled?: boolean,
208
+ /**
209
+ * When true (default), wheel-zoom anchors on the cursor. When false, it zooms
210
+ * about the viewport center.
211
+ */
212
+ zoomToCursor?: boolean): ImageZoomPanState;
176
213
 
177
214
  interface SlideNavigationState {
178
215
  slideTrackRef: React.RefObject<HTMLDivElement | null>;
@@ -195,7 +232,9 @@ interface SlideNavigationState {
195
232
  * preloaded/decoded before navigation commits so the swipe lands on a painted
196
233
  * frame.
197
234
  */
198
- declare function useSlideNavigation(items: ViewerItem[], currentIndex: number, onNavigate: (index: number) => void, onSlideStart?: (direction: "prev" | "next") => void): SlideNavigationState;
235
+ declare function useSlideNavigation(items: ViewerItem[], currentIndex: number, onNavigate: (index: number) => void, onSlideStart?: (direction: "prev" | "next") => void,
236
+ /** When true, navigation wraps around the ends instead of stopping. */
237
+ loop?: boolean): SlideNavigationState;
199
238
 
200
239
  interface GestureHandlers {
201
240
  handlePointerDown: (e: React.PointerEvent) => void;
@@ -215,7 +254,12 @@ interface GestureHandlers {
215
254
  */
216
255
  declare function useGestureHandler(zoomPan: ImageZoomPanState, slide: SlideNavigationState, hasPrev: boolean, hasNext: boolean,
217
256
  /** When false, pinch-zoom and double-tap-zoom are disabled. Default true. */
218
- zoomEnabled?: boolean): GestureHandlers;
257
+ zoomEnabled?: boolean,
258
+ /**
259
+ * When true (default), pinch-zoom anchors on the gesture midpoint. When
260
+ * false, it zooms about the viewport center.
261
+ */
262
+ zoomToCursor?: boolean): GestureHandlers;
219
263
 
220
264
  /**
221
265
  * Measures the height of the top and bottom bars so the image area
@@ -274,4 +318,4 @@ declare function resolveSlideDirection({ offset, elapsedMs, viewportWidth, hasPr
274
318
  */
275
319
  declare function useFocusTrap(containerRef: RefObject<HTMLElement | null>, active: boolean): void;
276
320
 
277
- export { ChevronLeftIcon, ChevronRightIcon, CloseIcon, type Dims, type ImageTransform, ImageViewer, type ImageViewerProps, type ImageZoomPanState, MAX_SCALE, MIN_SCALE, NavButton, type ResolveSlideArgs, type SlideAction, type SlideNavigationState, type ViewerContext, type ViewerIcons, type ViewerItem, type ViewerSlot, ZoomInIcon, ZoomOutIcon, clampTranslate, defaultIcons, resolveSlideDirection, useBarMeasure, useBodyScrollLock, useFocusTrap, useGestureHandler, useImageZoomPan, useSlideNavigation };
321
+ export { ChevronLeftIcon, ChevronRightIcon, CloseIcon, type Dims, type ImageTransform, ImageViewer, type ImageViewerProps, type ImageZoomPanState, MAX_SCALE, MIN_SCALE, NavButton, type ResolveSlideArgs, type SlideAction, type SlideNavigationState, type ViewerContext, type ViewerIcons, type ViewerItem, type ViewerRect, type ViewerSlot, ZoomInIcon, ZoomOutIcon, clampTranslate, defaultIcons, resolveSlideDirection, useBarMeasure, useBodyScrollLock, useFocusTrap, useGestureHandler, useImageZoomPan, useSlideNavigation };
package/dist/index.js CHANGED
@@ -19,6 +19,15 @@ function clampTranslate(x, y, scale, baseDims, viewport) {
19
19
  y: Math.max(-maxY, Math.min(maxY, y))
20
20
  };
21
21
  }
22
+ function zoomToPoint(prevScale, nextScale, prev, focal, viewport) {
23
+ const relX = focal.x - viewport.width / 2;
24
+ const relY = focal.y - viewport.height / 2;
25
+ const k = nextScale / prevScale;
26
+ return {
27
+ x: relX * (1 - k) + k * prev.x,
28
+ y: relY * (1 - k) + k * prev.y
29
+ };
30
+ }
22
31
  function resolveSlideDirection({
23
32
  offset,
24
33
  elapsedMs,
@@ -39,7 +48,7 @@ function resolveSlideDirection({
39
48
  // src/hooks/useImageZoomPan.ts
40
49
  var MIN_SCALE = 1;
41
50
  var MAX_SCALE = 5;
42
- function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true) {
51
+ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true, zoomToCursor = true) {
43
52
  const imgRef = useRef(null);
44
53
  const [displayScale, setDisplayScale] = useState(1);
45
54
  const transformRef = useRef({ scale: 1, x: 0, y: 0 });
@@ -125,12 +134,26 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true) {
125
134
  const step = -(normalized / 100) * 0.05;
126
135
  const factor = 1 + step;
127
136
  const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, t.scale * factor));
128
- const clamped = nextScale <= 1 ? { x: 0, y: 0 } : clampTranslate2(t.x, t.y, nextScale);
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
+ }
129
152
  setTransform({ scale: nextScale, ...clamped });
130
153
  };
131
154
  wrapper.addEventListener("wheel", handleWheel, { passive: false });
132
155
  return () => wrapper.removeEventListener("wheel", handleWheel);
133
- }, [imgWrapperRef, setTransform, clampTranslate2, enabled]);
156
+ }, [imgWrapperRef, setTransform, clampTranslate2, enabled, zoomToCursor]);
134
157
  const handleDoubleClick = useCallback(
135
158
  (e) => {
136
159
  if (!enabled) return;
@@ -161,15 +184,15 @@ function useImageZoomPan(imgWrapperRef, currentIndex, enabled = true) {
161
184
  handleDoubleClick
162
185
  };
163
186
  }
164
- function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
187
+ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart, loop = false) {
165
188
  const slideTrackRef = useRef(null);
166
189
  const swipeOffsetRef = useRef(0);
167
190
  const [swipeOffset, setSwipeOffset] = useState(0);
168
191
  const [slideAnimating, setSlideAnimating] = useState(false);
169
192
  const [slideActive, setSlideActive] = useState(false);
170
193
  const commitLockRef = useRef(false);
171
- const hasPrev = currentIndex > 0;
172
- const hasNext = currentIndex < items.length - 1;
194
+ const hasPrev = loop ? items.length > 1 : currentIndex > 0;
195
+ const hasNext = loop ? items.length > 1 : currentIndex < items.length - 1;
173
196
  const applySlideOffset = useCallback((offset, animate = false) => {
174
197
  swipeOffsetRef.current = offset;
175
198
  const track = slideTrackRef.current;
@@ -215,7 +238,8 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
215
238
  if (cleaned) return;
216
239
  cleaned = true;
217
240
  track?.removeEventListener("transitionend", onTransitionEnd);
218
- const newIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
241
+ let newIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
242
+ if (loop) newIndex = (newIndex + items.length) % items.length;
219
243
  if (newIndex < 0 || newIndex >= items.length) {
220
244
  commitLockRef.current = false;
221
245
  return;
@@ -240,7 +264,7 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
240
264
  }
241
265
  });
242
266
  },
243
- [applySlideOffset, currentIndex, items, onNavigate, onSlideStart]
267
+ [applySlideOffset, currentIndex, items, onNavigate, onSlideStart, loop]
244
268
  );
245
269
  const resolveSlide = useCallback(
246
270
  (gestureStartTime) => {
@@ -287,7 +311,7 @@ function useSlideNavigation(items, currentIndex, onNavigate, onSlideStart) {
287
311
  setSlideActive
288
312
  };
289
313
  }
290
- function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true) {
314
+ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true, zoomToCursor = true) {
291
315
  const { transformRef, clampTranslate: clampTranslate2, setTransform, applyTransform, resetTransform } = zoomPan;
292
316
  const { applySlideOffset, resolveSlide, snapBack, setSlideActive, swipeOffsetRef } = slide;
293
317
  const panRef = useRef({
@@ -442,7 +466,23 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true)
442
466
  const ratio = dist / p.pinchStartDist;
443
467
  const nextScale = Math.min(5, Math.max(1, p.pinchStartScale * ratio));
444
468
  const t = transformRef.current;
445
- const clamped = nextScale <= 1 ? { x: 0, y: 0 } : clampTranslate2(t.x, t.y, nextScale);
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
+ }
446
486
  const next = { scale: nextScale, ...clamped };
447
487
  transformRef.current = next;
448
488
  applyTransform(next);
@@ -461,7 +501,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true)
461
501
  updateSlide(touch.clientX, touch.clientY, 6, 0.8);
462
502
  }
463
503
  },
464
- [transformRef, clampTranslate2, applyTransform, updateSlide]
504
+ [transformRef, clampTranslate2, applyTransform, updateSlide, zoomToCursor]
465
505
  );
466
506
  const handleTouchEnd = useCallback(
467
507
  (e) => {
@@ -534,7 +574,7 @@ function useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoomEnabled = true)
534
574
  function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
535
575
  const [topBarH, setTopBarH] = useState(0);
536
576
  const [bottomBarH, setBottomBarH] = useState(0);
537
- useEffect(() => {
577
+ useLayoutEffect(() => {
538
578
  const measure = () => {
539
579
  if (topBarRef.current) setTopBarH(topBarRef.current.offsetHeight);
540
580
  if (bottomBarRef.current) setBottomBarH(bottomBarRef.current.offsetHeight);
@@ -682,19 +722,37 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
682
722
  }
683
723
  var ANIM_MS = 250;
684
724
  var IMG_PADDING = 44;
725
+ var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
685
726
  function prefersReducedMotion() {
686
727
  if (typeof window === "undefined" || !window.matchMedia) return false;
687
728
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
688
729
  }
730
+ function flipTransform(from, to) {
731
+ const sx = to.width / from.width;
732
+ const sy = to.height / from.height;
733
+ const dx = to.left - from.left;
734
+ const dy = to.top - from.top;
735
+ return `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
736
+ }
737
+ function canAnimate(el) {
738
+ return !!el && typeof el.animate === "function";
739
+ }
740
+ function isRectInViewport(rect) {
741
+ if (typeof window === "undefined") return true;
742
+ return rect.top < window.innerHeight && rect.top + rect.height > 0 && rect.left < window.innerWidth && rect.left + rect.width > 0;
743
+ }
689
744
  function ImageViewer({
690
745
  items,
691
746
  index,
692
747
  onIndexChange,
693
748
  onNavigate,
694
749
  onClose,
750
+ getOriginRect,
695
751
  zoom = true,
752
+ zoomToCursor = true,
696
753
  showCounter = true,
697
754
  loop = false,
755
+ closeOnBackdropClick = false,
698
756
  renderHeader,
699
757
  renderHeaderActions,
700
758
  renderNavStart,
@@ -707,6 +765,7 @@ function ImageViewer({
707
765
  }) {
708
766
  const [visible, setVisible] = useState(false);
709
767
  const [closing, setClosing] = useState(false);
768
+ const [collapsing, setCollapsing] = useState(false);
710
769
  const [isTouchDevice, setIsTouchDevice] = useState(false);
711
770
  const [contentShift, setContentShiftState] = useState({ transform: null, animate: true });
712
771
  const containerRef = useRef(null);
@@ -726,7 +785,7 @@ function ImageViewer({
726
785
  useBodyScrollLock(true);
727
786
  useFocusTrap(containerRef, visible && !closing);
728
787
  const { topBarH, bottomBarH } = useBarMeasure(topBarRef, bottomBarRef, index);
729
- const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom);
788
+ const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom, zoomToCursor);
730
789
  const {
731
790
  imgRef,
732
791
  displayScale,
@@ -738,9 +797,9 @@ function ImageViewer({
738
797
  measureBaseDims,
739
798
  handleDoubleClick
740
799
  } = zoomPan;
741
- const slide = useSlideNavigation(items, index, onIndexChange, onNavigate);
800
+ const slide = useSlideNavigation(items, index, onIndexChange, onNavigate, loop);
742
801
  const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
743
- const gestures = useGestureHandler(zoomPan, slide, hasPrevLinear, hasNextLinear, zoom);
802
+ const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
744
803
  useEffect(() => {
745
804
  setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
746
805
  }, []);
@@ -753,29 +812,110 @@ function ImageViewer({
753
812
  const raf = requestAnimationFrame(() => setVisible(true));
754
813
  return () => cancelAnimationFrame(raf);
755
814
  }, []);
815
+ const zoomTransition = !!getOriginRect;
816
+ const reduceMotion = prefersReducedMotion();
817
+ const gateEntry = zoomTransition && !reduceMotion;
818
+ const [fullLoaded, setFullLoaded] = useState(false);
819
+ const [showSpinner, setShowSpinner] = useState(false);
820
+ const entryStartedRef = useRef(false);
821
+ const entryCleanupRef = useRef(null);
822
+ const runZoomEntry = useCallback(() => {
823
+ if (entryStartedRef.current) return;
824
+ if (!getOriginRect || prefersReducedMotion()) return;
825
+ const img = imgRef.current;
826
+ const thumb = getOriginRect(index);
827
+ if (!thumb || !canAnimate(img)) return;
828
+ const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
829
+ const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
830
+ img.style.maxHeight = lockedMaxHeight;
831
+ const imgRect = img.getBoundingClientRect();
832
+ if (imgRect.width === 0 || imgRect.height === 0) {
833
+ img.style.maxHeight = "";
834
+ return;
835
+ }
836
+ entryStartedRef.current = true;
837
+ const startTransform = flipTransform(imgRect, thumb);
838
+ img.style.transformOrigin = "top left";
839
+ img.style.transform = startTransform;
840
+ const wrapper = imgWrapperRef.current;
841
+ if (wrapper) wrapper.style.overflow = "visible";
842
+ const anim = img.animate(
843
+ [
844
+ { transformOrigin: "top left", transform: startTransform },
845
+ { transformOrigin: "top left", transform: "none" }
846
+ ],
847
+ { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
848
+ );
849
+ const cleanup = () => {
850
+ img.style.transform = "";
851
+ img.style.transformOrigin = "";
852
+ if (wrapper) wrapper.style.overflow = "";
853
+ img.style.maxHeight = lockedMaxHeight;
854
+ anim.cancel();
855
+ entryCleanupRef.current = null;
856
+ };
857
+ entryCleanupRef.current = cleanup;
858
+ anim.onfinish = cleanup;
859
+ }, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
860
+ const markFullLoaded = useCallback(() => {
861
+ measureBaseDims();
862
+ const img = imgRef.current;
863
+ if (img && typeof img.decode === "function") {
864
+ img.decode().then(
865
+ () => setFullLoaded(true),
866
+ () => setFullLoaded(true)
867
+ );
868
+ } else {
869
+ setFullLoaded(true);
870
+ }
871
+ }, [measureBaseDims, imgRef]);
872
+ useLayoutEffect(() => {
873
+ const img = imgRef.current;
874
+ if (img && img.complete && img.naturalWidth > 0) markFullLoaded();
875
+ }, []);
876
+ useLayoutEffect(() => {
877
+ if (fullLoaded) runZoomEntry();
878
+ }, [fullLoaded]);
879
+ useEffect(() => {
880
+ if (!gateEntry || fullLoaded) {
881
+ setShowSpinner(false);
882
+ return;
883
+ }
884
+ const t = setTimeout(() => setShowSpinner(true), 500);
885
+ return () => clearTimeout(t);
886
+ }, [gateEntry, fullLoaded]);
756
887
  const handleClose = useCallback(() => {
888
+ const reduce = prefersReducedMotion();
889
+ const origin = !reduce && !isZoomed ? getOriginRect?.(index) ?? null : null;
890
+ const thumb = origin && isRectInViewport(origin) ? origin : null;
891
+ const img = imgRef.current;
892
+ entryCleanupRef.current?.();
757
893
  setClosing(true);
758
894
  setVisible(false);
759
- const delay = prefersReducedMotion() ? 0 : ANIM_MS;
760
- setTimeout(onClose, delay);
761
- }, [onClose]);
895
+ if (thumb && canAnimate(img)) {
896
+ const imgRect = img.getBoundingClientRect();
897
+ const wrapper = imgWrapperRef.current;
898
+ if (wrapper) wrapper.style.overflow = "visible";
899
+ setCollapsing(true);
900
+ img.animate(
901
+ [
902
+ { transformOrigin: "top left", transform: "none" },
903
+ { transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
904
+ ],
905
+ { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
906
+ );
907
+ }
908
+ setTimeout(onClose, reduce ? 0 : ANIM_MS);
909
+ }, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
762
910
  const navigate = useCallback(
763
911
  (dir) => {
764
912
  if (dir === "prev") {
765
- if (hasPrevLinear) commitSlide("prev");
766
- else if (loop) {
767
- onNavigate?.("prev");
768
- onIndexChange(items.length - 1);
769
- }
913
+ if (hasPrev) commitSlide("prev");
770
914
  } else {
771
- if (hasNextLinear) commitSlide("next");
772
- else if (loop) {
773
- onNavigate?.("next");
774
- onIndexChange(0);
775
- }
915
+ if (hasNext) commitSlide("next");
776
916
  }
777
917
  },
778
- [hasPrevLinear, hasNextLinear, loop, commitSlide, onIndexChange, onNavigate, items.length]
918
+ [hasPrev, hasNext, commitSlide]
779
919
  );
780
920
  useEffect(() => {
781
921
  const handler = (e) => {
@@ -830,10 +970,13 @@ function ImageViewer({
830
970
  const reservedH = bottomBarH + IMG_PADDING * 2;
831
971
  const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
832
972
  const imgStyle = { maxHeight: imgMaxHeight };
973
+ if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
833
974
  const totalDigits = String(items.length).length;
834
975
  const counterMinWidth = `${totalDigits * 2 * 0.6 + 1.5}em`;
835
- const prevItem = hasPrevLinear ? items[index - 1] : null;
836
- const nextItem = hasNextLinear ? items[index + 1] : null;
976
+ const prevIndex = hasPrevLinear ? index - 1 : hasPrev ? items.length - 1 : -1;
977
+ const nextIndex = hasNextLinear ? index + 1 : hasNext ? 0 : -1;
978
+ const prevItem = prevIndex >= 0 ? items[prevIndex] : null;
979
+ const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
837
980
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
838
981
  const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
839
982
  const showZoomControls = zoom && !isTouchDevice;
@@ -856,7 +999,7 @@ function ImageViewer({
856
999
  "div",
857
1000
  {
858
1001
  className: cx("rvl-backdrop", cn("backdrop")),
859
- onClick: handleClose,
1002
+ onClick: closeOnBackdropClick ? handleClose : void 0,
860
1003
  "aria-hidden": "true"
861
1004
  }
862
1005
  ),
@@ -929,6 +1072,9 @@ function ImageViewer({
929
1072
  "div",
930
1073
  {
931
1074
  className: "rvl-stage",
1075
+ onClick: closeOnBackdropClick ? (e) => {
1076
+ if (e.target === e.currentTarget) handleClose();
1077
+ } : void 0,
932
1078
  style: {
933
1079
  transform: contentShift.transform ?? "translateY(0)",
934
1080
  // animate=false snaps with no transition (overrides the CSS transition)
@@ -938,7 +1084,15 @@ function ImageViewer({
938
1084
  "div",
939
1085
  {
940
1086
  ref: slideTrackRef,
941
- className: cx("rvl-track", visible && !closing && "rvl-track-visible"),
1087
+ className: cx(
1088
+ "rvl-track",
1089
+ // During a thumbnail zoom the track is opaque from the first frame
1090
+ // (the image itself is hidden until the zoom starts), so the picture
1091
+ // flies in crisply instead of cross-fading. On close it only stays
1092
+ // opaque while a FLIP collapse is animating the image back; otherwise
1093
+ // it fades out (so a zoomed close still animates instead of vanishing).
1094
+ (closing ? collapsing : zoomTransition || visible) && "rvl-track-visible"
1095
+ ),
942
1096
  children: [
943
1097
  showAdjacent && prevItem && /* @__PURE__ */ jsx(
944
1098
  "div",
@@ -980,11 +1134,13 @@ function ImageViewer({
980
1134
  className: cx("rvl-img", cn("image")),
981
1135
  style: imgStyle,
982
1136
  draggable: false,
983
- onLoad: measureBaseDims
1137
+ onLoad: markFullLoaded,
1138
+ onError: () => setFullLoaded(true)
984
1139
  }
985
1140
  )
986
1141
  }
987
1142
  ),
1143
+ gateEntry && showSpinner && !fullLoaded && /* @__PURE__ */ jsx("div", { className: cx("rvl-spinner", cn("spinner")), role: "status", "aria-label": "Loading", children: /* @__PURE__ */ jsx("span", { className: "rvl-spinner-ring", "aria-hidden": "true" }) }),
988
1144
  showAdjacent && nextItem && /* @__PURE__ */ jsx(
989
1145
  "div",
990
1146
  {