@jekrch/react-viewport-lightbox 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -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);
@@ -552,16 +592,29 @@ function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
552
592
  var lockCount = 0;
553
593
  var previousOverflow = "";
554
594
  var previousPaddingRight = "";
595
+ var previousPosition = "";
596
+ var previousTop = "";
597
+ var previousWidth = "";
598
+ var lockedScrollY = 0;
555
599
  function useBodyScrollLock(isLocked) {
556
600
  react.useEffect(() => {
557
601
  if (!isLocked) return;
558
602
  if (typeof document === "undefined") return;
559
603
  if (lockCount === 0) {
560
604
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
605
+ const rootGutter = window.getComputedStyle(document.documentElement).scrollbarGutter;
606
+ const reservesGutter = typeof rootGutter === "string" && rootGutter.includes("stable");
607
+ lockedScrollY = window.scrollY;
561
608
  previousOverflow = document.body.style.overflow;
562
609
  previousPaddingRight = document.body.style.paddingRight;
610
+ previousPosition = document.body.style.position;
611
+ previousTop = document.body.style.top;
612
+ previousWidth = document.body.style.width;
563
613
  document.body.style.overflow = "hidden";
564
- if (scrollbarWidth > 0) {
614
+ document.body.style.position = "fixed";
615
+ document.body.style.top = `-${lockedScrollY}px`;
616
+ document.body.style.width = "100%";
617
+ if (scrollbarWidth > 0 && !reservesGutter) {
565
618
  const currentPaddingRight = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
566
619
  document.body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
567
620
  }
@@ -572,6 +625,10 @@ function useBodyScrollLock(isLocked) {
572
625
  if (lockCount === 0) {
573
626
  document.body.style.overflow = previousOverflow;
574
627
  document.body.style.paddingRight = previousPaddingRight;
628
+ document.body.style.position = previousPosition;
629
+ document.body.style.top = previousTop;
630
+ document.body.style.width = previousWidth;
631
+ window.scrollTo(0, lockedScrollY);
575
632
  }
576
633
  };
577
634
  }, [isLocked]);
@@ -684,23 +741,48 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
684
741
  }
685
742
  var ANIM_MS = 250;
686
743
  var IMG_PADDING = 44;
744
+ var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
687
745
  function prefersReducedMotion() {
688
746
  if (typeof window === "undefined" || !window.matchMedia) return false;
689
747
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
690
748
  }
749
+ function flipTransform(from, to) {
750
+ const sx = to.width / from.width;
751
+ const sy = to.height / from.height;
752
+ const dx = to.left - from.left;
753
+ const dy = to.top - from.top;
754
+ return `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
755
+ }
756
+ function canAnimate(el) {
757
+ return !!el && typeof el.animate === "function";
758
+ }
759
+ function isRectInViewport(rect) {
760
+ if (typeof window === "undefined") return true;
761
+ return rect.top < window.innerHeight && rect.top + rect.height > 0 && rect.left < window.innerWidth && rect.left + rect.width > 0;
762
+ }
691
763
  function ImageViewer({
692
764
  items,
693
765
  index,
694
766
  onIndexChange,
695
767
  onNavigate,
696
768
  onClose,
769
+ onEscape,
770
+ getOriginRect,
697
771
  zoom = true,
772
+ zoomToCursor = true,
698
773
  showCounter = true,
774
+ showZoomControls = true,
775
+ disableNavigation = false,
699
776
  loop = false,
777
+ closeOnBackdropClick = false,
700
778
  renderHeader,
701
779
  renderHeaderActions,
702
780
  renderNavStart,
703
781
  renderNavEnd,
782
+ navSlotPlacement = "edge",
783
+ navHeight,
784
+ navInset,
785
+ counterFontSize,
704
786
  renderFooter,
705
787
  renderOverlay,
706
788
  classNames,
@@ -709,6 +791,7 @@ function ImageViewer({
709
791
  }) {
710
792
  const [visible, setVisible] = react.useState(false);
711
793
  const [closing, setClosing] = react.useState(false);
794
+ const [collapsing, setCollapsing] = react.useState(false);
712
795
  const [isTouchDevice, setIsTouchDevice] = react.useState(false);
713
796
  const [contentShift, setContentShiftState] = react.useState({ transform: null, animate: true });
714
797
  const containerRef = react.useRef(null);
@@ -728,7 +811,7 @@ function ImageViewer({
728
811
  useBodyScrollLock(true);
729
812
  useFocusTrap(containerRef, visible && !closing);
730
813
  const { topBarH, bottomBarH } = useBarMeasure(topBarRef, bottomBarRef, index);
731
- const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom);
814
+ const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom, zoomToCursor);
732
815
  const {
733
816
  imgRef,
734
817
  displayScale,
@@ -740,11 +823,19 @@ function ImageViewer({
740
823
  measureBaseDims,
741
824
  handleDoubleClick
742
825
  } = zoomPan;
743
- const slide = useSlideNavigation(items, index, onIndexChange, onNavigate);
826
+ const slide = useSlideNavigation(items, index, onIndexChange, onNavigate, loop);
744
827
  const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
745
- const gestures = useGestureHandler(zoomPan, slide, hasPrevLinear, hasNextLinear, zoom);
828
+ const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
746
829
  react.useEffect(() => {
747
- setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
830
+ if (typeof window === "undefined" || !window.matchMedia) {
831
+ setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
832
+ return;
833
+ }
834
+ const mq = window.matchMedia("(hover: none) and (pointer: coarse)");
835
+ const update = () => setIsTouchDevice(mq.matches);
836
+ update();
837
+ mq.addEventListener("change", update);
838
+ return () => mq.removeEventListener("change", update);
748
839
  }, []);
749
840
  react.useEffect(() => {
750
841
  const onResize = () => setViewportWidth(window.innerWidth);
@@ -755,43 +846,125 @@ function ImageViewer({
755
846
  const raf = requestAnimationFrame(() => setVisible(true));
756
847
  return () => cancelAnimationFrame(raf);
757
848
  }, []);
849
+ const zoomTransition = !!getOriginRect;
850
+ const reduceMotion = prefersReducedMotion();
851
+ const gateEntry = zoomTransition && !reduceMotion;
852
+ const [fullLoaded, setFullLoaded] = react.useState(false);
853
+ const [showSpinner, setShowSpinner] = react.useState(false);
854
+ const entryStartedRef = react.useRef(false);
855
+ const entryCleanupRef = react.useRef(null);
856
+ const runZoomEntry = react.useCallback(() => {
857
+ if (entryStartedRef.current) return;
858
+ if (!getOriginRect || prefersReducedMotion()) return;
859
+ const img = imgRef.current;
860
+ const thumb = getOriginRect(index);
861
+ if (!thumb || !canAnimate(img)) return;
862
+ const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
863
+ const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
864
+ img.style.maxHeight = lockedMaxHeight;
865
+ const imgRect = img.getBoundingClientRect();
866
+ if (imgRect.width === 0 || imgRect.height === 0) {
867
+ img.style.maxHeight = "";
868
+ return;
869
+ }
870
+ entryStartedRef.current = true;
871
+ const startTransform = flipTransform(imgRect, thumb);
872
+ img.style.transformOrigin = "top left";
873
+ img.style.transform = startTransform;
874
+ const wrapper = imgWrapperRef.current;
875
+ if (wrapper) wrapper.style.overflow = "visible";
876
+ const anim = img.animate(
877
+ [
878
+ { transformOrigin: "top left", transform: startTransform },
879
+ { transformOrigin: "top left", transform: "none" }
880
+ ],
881
+ { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
882
+ );
883
+ const cleanup = () => {
884
+ img.style.transform = "";
885
+ img.style.transformOrigin = "";
886
+ if (wrapper) wrapper.style.overflow = "";
887
+ img.style.maxHeight = lockedMaxHeight;
888
+ anim.cancel();
889
+ entryCleanupRef.current = null;
890
+ };
891
+ entryCleanupRef.current = cleanup;
892
+ anim.onfinish = cleanup;
893
+ }, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
894
+ const markFullLoaded = react.useCallback(() => {
895
+ measureBaseDims();
896
+ const img = imgRef.current;
897
+ if (img && typeof img.decode === "function") {
898
+ img.decode().then(
899
+ () => setFullLoaded(true),
900
+ () => setFullLoaded(true)
901
+ );
902
+ } else {
903
+ setFullLoaded(true);
904
+ }
905
+ }, [measureBaseDims, imgRef]);
906
+ react.useLayoutEffect(() => {
907
+ const img = imgRef.current;
908
+ if (img && img.complete && img.naturalWidth > 0) markFullLoaded();
909
+ }, []);
910
+ react.useLayoutEffect(() => {
911
+ if (fullLoaded) runZoomEntry();
912
+ }, [fullLoaded]);
913
+ react.useEffect(() => {
914
+ if (!gateEntry || fullLoaded) {
915
+ setShowSpinner(false);
916
+ return;
917
+ }
918
+ const t = setTimeout(() => setShowSpinner(true), 500);
919
+ return () => clearTimeout(t);
920
+ }, [gateEntry, fullLoaded]);
758
921
  const handleClose = react.useCallback(() => {
922
+ const reduce = prefersReducedMotion();
923
+ const origin = !reduce && !isZoomed ? getOriginRect?.(index) ?? null : null;
924
+ const thumb = origin && isRectInViewport(origin) ? origin : null;
925
+ const img = imgRef.current;
926
+ entryCleanupRef.current?.();
759
927
  setClosing(true);
760
928
  setVisible(false);
761
- const delay = prefersReducedMotion() ? 0 : ANIM_MS;
762
- setTimeout(onClose, delay);
763
- }, [onClose]);
929
+ if (thumb && canAnimate(img)) {
930
+ const imgRect = img.getBoundingClientRect();
931
+ const wrapper = imgWrapperRef.current;
932
+ if (wrapper) wrapper.style.overflow = "visible";
933
+ setCollapsing(true);
934
+ img.animate(
935
+ [
936
+ { transformOrigin: "top left", transform: "none" },
937
+ { transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
938
+ ],
939
+ { duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
940
+ );
941
+ }
942
+ setTimeout(onClose, reduce ? 0 : ANIM_MS);
943
+ }, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
764
944
  const navigate = react.useCallback(
765
945
  (dir) => {
766
946
  if (dir === "prev") {
767
- if (hasPrevLinear) commitSlide("prev");
768
- else if (loop) {
769
- onNavigate?.("prev");
770
- onIndexChange(items.length - 1);
771
- }
947
+ if (hasPrev) commitSlide("prev");
772
948
  } else {
773
- if (hasNextLinear) commitSlide("next");
774
- else if (loop) {
775
- onNavigate?.("next");
776
- onIndexChange(0);
777
- }
949
+ if (hasNext) commitSlide("next");
778
950
  }
779
951
  },
780
- [hasPrevLinear, hasNextLinear, loop, commitSlide, onIndexChange, onNavigate, items.length]
952
+ [hasPrev, hasNext, commitSlide]
781
953
  );
782
954
  react.useEffect(() => {
783
955
  const handler = (e) => {
784
956
  if (e.key === "Escape") {
957
+ if (onEscape?.()) return;
785
958
  handleClose();
786
959
  return;
787
960
  }
788
- if (displayScale > 1) return;
961
+ if (disableNavigation || displayScale > 1) return;
789
962
  if (e.key === "ArrowLeft" && hasPrev) navigate("prev");
790
963
  if (e.key === "ArrowRight" && hasNext) navigate("next");
791
964
  };
792
965
  window.addEventListener("keydown", handler);
793
966
  return () => window.removeEventListener("keydown", handler);
794
- }, [handleClose, hasPrev, hasNext, displayScale, navigate]);
967
+ }, [handleClose, hasPrev, hasNext, displayScale, navigate, onEscape, disableNavigation]);
795
968
  const setContentShift = react.useCallback((transform, animate = true) => {
796
969
  setContentShiftState({ transform, animate });
797
970
  }, []);
@@ -800,6 +973,7 @@ function ImageViewer({
800
973
  index,
801
974
  item,
802
975
  total: items.length,
976
+ closing,
803
977
  hasPrev,
804
978
  hasNext,
805
979
  goPrev: () => navigate("prev"),
@@ -832,13 +1006,22 @@ function ImageViewer({
832
1006
  const reservedH = bottomBarH + IMG_PADDING * 2;
833
1007
  const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
834
1008
  const imgStyle = { maxHeight: imgMaxHeight };
1009
+ const dim = (v) => typeof v === "number" ? `${v}px` : v;
1010
+ const rootStyle = {
1011
+ ...navHeight != null && { "--rvl-nav-height": dim(navHeight) },
1012
+ ...navInset != null && { "--rvl-nav-inset": dim(navInset) },
1013
+ ...counterFontSize != null && { "--rvl-counter-font-size": dim(counterFontSize) }
1014
+ };
1015
+ if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
835
1016
  const totalDigits = String(items.length).length;
836
1017
  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;
1018
+ const prevIndex = hasPrevLinear ? index - 1 : hasPrev ? items.length - 1 : -1;
1019
+ const nextIndex = hasNextLinear ? index + 1 : hasNext ? 0 : -1;
1020
+ const prevItem = prevIndex >= 0 ? items[prevIndex] : null;
1021
+ const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
839
1022
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
840
1023
  const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
841
- const showZoomControls = zoom && !isTouchDevice;
1024
+ const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
842
1025
  const headerActions = renderHeaderActions?.(ctx);
843
1026
  const navStart = renderNavStart?.(ctx);
844
1027
  const navEnd = renderNavEnd?.(ctx);
@@ -853,12 +1036,13 @@ function ImageViewer({
853
1036
  "aria-modal": "true",
854
1037
  "aria-label": ariaLabel ?? item.alt ?? "Image viewer",
855
1038
  tabIndex: -1,
1039
+ style: rootStyle,
856
1040
  children: [
857
1041
  /* @__PURE__ */ jsxRuntime.jsx(
858
1042
  "div",
859
1043
  {
860
1044
  className: cx("rvl-backdrop", cn("backdrop")),
861
- onClick: handleClose,
1045
+ onClick: closeOnBackdropClick ? handleClose : void 0,
862
1046
  "aria-hidden": "true"
863
1047
  }
864
1048
  ),
@@ -866,7 +1050,7 @@ function ImageViewer({
866
1050
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-header", cn("topBar")), children: renderHeader?.(ctx) }),
867
1051
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-header-actions", children: [
868
1052
  headerActions,
869
- showZoomControls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
1053
+ showZoomCtrls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
870
1054
  "button",
871
1055
  {
872
1056
  type: "button",
@@ -883,7 +1067,7 @@ function ImageViewer({
883
1067
  ]
884
1068
  }
885
1069
  ),
886
- showZoomControls && /* @__PURE__ */ jsxRuntime.jsx(
1070
+ showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
887
1071
  "button",
888
1072
  {
889
1073
  type: "button",
@@ -897,7 +1081,7 @@ function ImageViewer({
897
1081
  children: mergedIcons.zoomIn
898
1082
  }
899
1083
  ),
900
- showZoomControls && /* @__PURE__ */ jsxRuntime.jsx(
1084
+ showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
901
1085
  "button",
902
1086
  {
903
1087
  type: "button",
@@ -931,6 +1115,9 @@ function ImageViewer({
931
1115
  "div",
932
1116
  {
933
1117
  className: "rvl-stage",
1118
+ onClick: closeOnBackdropClick ? (e) => {
1119
+ if (e.target === e.currentTarget) handleClose();
1120
+ } : void 0,
934
1121
  style: {
935
1122
  transform: contentShift.transform ?? "translateY(0)",
936
1123
  // animate=false snaps with no transition (overrides the CSS transition)
@@ -940,7 +1127,15 @@ function ImageViewer({
940
1127
  "div",
941
1128
  {
942
1129
  ref: slideTrackRef,
943
- className: cx("rvl-track", visible && !closing && "rvl-track-visible"),
1130
+ className: cx(
1131
+ "rvl-track",
1132
+ // During a thumbnail zoom the track is opaque from the first frame
1133
+ // (the image itself is hidden until the zoom starts), so the picture
1134
+ // flies in crisply instead of cross-fading. On close it only stays
1135
+ // opaque while a FLIP collapse is animating the image back; otherwise
1136
+ // it fades out (so a zoomed close still animates instead of vanishing).
1137
+ (closing ? collapsing : zoomTransition || visible) && "rvl-track-visible"
1138
+ ),
944
1139
  children: [
945
1140
  showAdjacent && prevItem && /* @__PURE__ */ jsxRuntime.jsx(
946
1141
  "div",
@@ -982,11 +1177,13 @@ function ImageViewer({
982
1177
  className: cx("rvl-img", cn("image")),
983
1178
  style: imgStyle,
984
1179
  draggable: false,
985
- onLoad: measureBaseDims
1180
+ onLoad: markFullLoaded,
1181
+ onError: () => setFullLoaded(true)
986
1182
  }
987
1183
  )
988
1184
  }
989
1185
  ),
1186
+ 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
1187
  showAdjacent && nextItem && /* @__PURE__ */ jsxRuntime.jsx(
991
1188
  "div",
992
1189
  {
@@ -1010,7 +1207,7 @@ function ImageViewer({
1010
1207
  }
1011
1208
  ),
1012
1209
  /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: bottomBarRef, className: cx("rvl-bar", "rvl-bottom-bar", cn("bottomBar")), children: [
1013
- showNavRow && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-inner", children: [
1210
+ showNavRow && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cx("rvl-nav-inner", navSlotPlacement === "inline" && "rvl-nav-inline"), children: [
1014
1211
  navStart != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-nav-start", cn("navStart")), children: navStart }),
1015
1212
  hasNavGroup && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-group", children: [
1016
1213
  /* @__PURE__ */ jsxRuntime.jsx(