@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.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);
@@ -550,16 +590,29 @@ function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
550
590
  var lockCount = 0;
551
591
  var previousOverflow = "";
552
592
  var previousPaddingRight = "";
593
+ var previousPosition = "";
594
+ var previousTop = "";
595
+ var previousWidth = "";
596
+ var lockedScrollY = 0;
553
597
  function useBodyScrollLock(isLocked) {
554
598
  useEffect(() => {
555
599
  if (!isLocked) return;
556
600
  if (typeof document === "undefined") return;
557
601
  if (lockCount === 0) {
558
602
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
603
+ const rootGutter = window.getComputedStyle(document.documentElement).scrollbarGutter;
604
+ const reservesGutter = typeof rootGutter === "string" && rootGutter.includes("stable");
605
+ lockedScrollY = window.scrollY;
559
606
  previousOverflow = document.body.style.overflow;
560
607
  previousPaddingRight = document.body.style.paddingRight;
608
+ previousPosition = document.body.style.position;
609
+ previousTop = document.body.style.top;
610
+ previousWidth = document.body.style.width;
561
611
  document.body.style.overflow = "hidden";
562
- if (scrollbarWidth > 0) {
612
+ document.body.style.position = "fixed";
613
+ document.body.style.top = `-${lockedScrollY}px`;
614
+ document.body.style.width = "100%";
615
+ if (scrollbarWidth > 0 && !reservesGutter) {
563
616
  const currentPaddingRight = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
564
617
  document.body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
565
618
  }
@@ -570,6 +623,10 @@ function useBodyScrollLock(isLocked) {
570
623
  if (lockCount === 0) {
571
624
  document.body.style.overflow = previousOverflow;
572
625
  document.body.style.paddingRight = previousPaddingRight;
626
+ document.body.style.position = previousPosition;
627
+ document.body.style.top = previousTop;
628
+ document.body.style.width = previousWidth;
629
+ window.scrollTo(0, lockedScrollY);
573
630
  }
574
631
  };
575
632
  }, [isLocked]);
@@ -682,23 +739,48 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
682
739
  }
683
740
  var ANIM_MS = 250;
684
741
  var IMG_PADDING = 44;
742
+ var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
685
743
  function prefersReducedMotion() {
686
744
  if (typeof window === "undefined" || !window.matchMedia) return false;
687
745
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
688
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;
760
+ }
689
761
  function ImageViewer({
690
762
  items,
691
763
  index,
692
764
  onIndexChange,
693
765
  onNavigate,
694
766
  onClose,
767
+ onEscape,
768
+ getOriginRect,
695
769
  zoom = true,
770
+ zoomToCursor = true,
696
771
  showCounter = true,
772
+ showZoomControls = true,
773
+ disableNavigation = false,
697
774
  loop = false,
775
+ closeOnBackdropClick = false,
698
776
  renderHeader,
699
777
  renderHeaderActions,
700
778
  renderNavStart,
701
779
  renderNavEnd,
780
+ navSlotPlacement = "edge",
781
+ navHeight,
782
+ navInset,
783
+ counterFontSize,
702
784
  renderFooter,
703
785
  renderOverlay,
704
786
  classNames,
@@ -707,6 +789,7 @@ function ImageViewer({
707
789
  }) {
708
790
  const [visible, setVisible] = useState(false);
709
791
  const [closing, setClosing] = useState(false);
792
+ const [collapsing, setCollapsing] = useState(false);
710
793
  const [isTouchDevice, setIsTouchDevice] = useState(false);
711
794
  const [contentShift, setContentShiftState] = useState({ transform: null, animate: true });
712
795
  const containerRef = useRef(null);
@@ -726,7 +809,7 @@ function ImageViewer({
726
809
  useBodyScrollLock(true);
727
810
  useFocusTrap(containerRef, visible && !closing);
728
811
  const { topBarH, bottomBarH } = useBarMeasure(topBarRef, bottomBarRef, index);
729
- const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom);
812
+ const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom, zoomToCursor);
730
813
  const {
731
814
  imgRef,
732
815
  displayScale,
@@ -738,11 +821,19 @@ function ImageViewer({
738
821
  measureBaseDims,
739
822
  handleDoubleClick
740
823
  } = zoomPan;
741
- const slide = useSlideNavigation(items, index, onIndexChange, onNavigate);
824
+ const slide = useSlideNavigation(items, index, onIndexChange, onNavigate, loop);
742
825
  const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
743
- const gestures = useGestureHandler(zoomPan, slide, hasPrevLinear, hasNextLinear, zoom);
826
+ const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
744
827
  useEffect(() => {
745
- setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
828
+ if (typeof window === "undefined" || !window.matchMedia) {
829
+ setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
830
+ return;
831
+ }
832
+ const mq = window.matchMedia("(hover: none) and (pointer: coarse)");
833
+ const update = () => setIsTouchDevice(mq.matches);
834
+ update();
835
+ mq.addEventListener("change", update);
836
+ return () => mq.removeEventListener("change", update);
746
837
  }, []);
747
838
  useEffect(() => {
748
839
  const onResize = () => setViewportWidth(window.innerWidth);
@@ -753,43 +844,125 @@ function ImageViewer({
753
844
  const raf = requestAnimationFrame(() => setVisible(true));
754
845
  return () => cancelAnimationFrame(raf);
755
846
  }, []);
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]);
756
919
  const handleClose = useCallback(() => {
920
+ 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?.();
757
925
  setClosing(true);
758
926
  setVisible(false);
759
- const delay = prefersReducedMotion() ? 0 : ANIM_MS;
760
- setTimeout(onClose, delay);
761
- }, [onClose]);
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
+ }
940
+ setTimeout(onClose, reduce ? 0 : ANIM_MS);
941
+ }, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
762
942
  const navigate = useCallback(
763
943
  (dir) => {
764
944
  if (dir === "prev") {
765
- if (hasPrevLinear) commitSlide("prev");
766
- else if (loop) {
767
- onNavigate?.("prev");
768
- onIndexChange(items.length - 1);
769
- }
945
+ if (hasPrev) commitSlide("prev");
770
946
  } else {
771
- if (hasNextLinear) commitSlide("next");
772
- else if (loop) {
773
- onNavigate?.("next");
774
- onIndexChange(0);
775
- }
947
+ if (hasNext) commitSlide("next");
776
948
  }
777
949
  },
778
- [hasPrevLinear, hasNextLinear, loop, commitSlide, onIndexChange, onNavigate, items.length]
950
+ [hasPrev, hasNext, commitSlide]
779
951
  );
780
952
  useEffect(() => {
781
953
  const handler = (e) => {
782
954
  if (e.key === "Escape") {
955
+ if (onEscape?.()) return;
783
956
  handleClose();
784
957
  return;
785
958
  }
786
- if (displayScale > 1) return;
959
+ if (disableNavigation || displayScale > 1) return;
787
960
  if (e.key === "ArrowLeft" && hasPrev) navigate("prev");
788
961
  if (e.key === "ArrowRight" && hasNext) navigate("next");
789
962
  };
790
963
  window.addEventListener("keydown", handler);
791
964
  return () => window.removeEventListener("keydown", handler);
792
- }, [handleClose, hasPrev, hasNext, displayScale, navigate]);
965
+ }, [handleClose, hasPrev, hasNext, displayScale, navigate, onEscape, disableNavigation]);
793
966
  const setContentShift = useCallback((transform, animate = true) => {
794
967
  setContentShiftState({ transform, animate });
795
968
  }, []);
@@ -798,6 +971,7 @@ function ImageViewer({
798
971
  index,
799
972
  item,
800
973
  total: items.length,
974
+ closing,
801
975
  hasPrev,
802
976
  hasNext,
803
977
  goPrev: () => navigate("prev"),
@@ -830,13 +1004,22 @@ function ImageViewer({
830
1004
  const reservedH = bottomBarH + IMG_PADDING * 2;
831
1005
  const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
832
1006
  const imgStyle = { maxHeight: imgMaxHeight };
1007
+ const dim = (v) => typeof v === "number" ? `${v}px` : v;
1008
+ const rootStyle = {
1009
+ ...navHeight != null && { "--rvl-nav-height": dim(navHeight) },
1010
+ ...navInset != null && { "--rvl-nav-inset": dim(navInset) },
1011
+ ...counterFontSize != null && { "--rvl-counter-font-size": dim(counterFontSize) }
1012
+ };
1013
+ if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
833
1014
  const totalDigits = String(items.length).length;
834
1015
  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;
1016
+ const prevIndex = hasPrevLinear ? index - 1 : hasPrev ? items.length - 1 : -1;
1017
+ const nextIndex = hasNextLinear ? index + 1 : hasNext ? 0 : -1;
1018
+ const prevItem = prevIndex >= 0 ? items[prevIndex] : null;
1019
+ const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
837
1020
  const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
838
1021
  const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
839
- const showZoomControls = zoom && !isTouchDevice;
1022
+ const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
840
1023
  const headerActions = renderHeaderActions?.(ctx);
841
1024
  const navStart = renderNavStart?.(ctx);
842
1025
  const navEnd = renderNavEnd?.(ctx);
@@ -851,12 +1034,13 @@ function ImageViewer({
851
1034
  "aria-modal": "true",
852
1035
  "aria-label": ariaLabel ?? item.alt ?? "Image viewer",
853
1036
  tabIndex: -1,
1037
+ style: rootStyle,
854
1038
  children: [
855
1039
  /* @__PURE__ */ jsx(
856
1040
  "div",
857
1041
  {
858
1042
  className: cx("rvl-backdrop", cn("backdrop")),
859
- onClick: handleClose,
1043
+ onClick: closeOnBackdropClick ? handleClose : void 0,
860
1044
  "aria-hidden": "true"
861
1045
  }
862
1046
  ),
@@ -864,7 +1048,7 @@ function ImageViewer({
864
1048
  /* @__PURE__ */ jsx("div", { className: cx("rvl-header", cn("topBar")), children: renderHeader?.(ctx) }),
865
1049
  /* @__PURE__ */ jsxs("div", { className: "rvl-header-actions", children: [
866
1050
  headerActions,
867
- showZoomControls && isZoomed && /* @__PURE__ */ jsxs(
1051
+ showZoomCtrls && isZoomed && /* @__PURE__ */ jsxs(
868
1052
  "button",
869
1053
  {
870
1054
  type: "button",
@@ -881,7 +1065,7 @@ function ImageViewer({
881
1065
  ]
882
1066
  }
883
1067
  ),
884
- showZoomControls && /* @__PURE__ */ jsx(
1068
+ showZoomCtrls && /* @__PURE__ */ jsx(
885
1069
  "button",
886
1070
  {
887
1071
  type: "button",
@@ -895,7 +1079,7 @@ function ImageViewer({
895
1079
  children: mergedIcons.zoomIn
896
1080
  }
897
1081
  ),
898
- showZoomControls && /* @__PURE__ */ jsx(
1082
+ showZoomCtrls && /* @__PURE__ */ jsx(
899
1083
  "button",
900
1084
  {
901
1085
  type: "button",
@@ -929,6 +1113,9 @@ function ImageViewer({
929
1113
  "div",
930
1114
  {
931
1115
  className: "rvl-stage",
1116
+ onClick: closeOnBackdropClick ? (e) => {
1117
+ if (e.target === e.currentTarget) handleClose();
1118
+ } : void 0,
932
1119
  style: {
933
1120
  transform: contentShift.transform ?? "translateY(0)",
934
1121
  // animate=false snaps with no transition (overrides the CSS transition)
@@ -938,7 +1125,15 @@ function ImageViewer({
938
1125
  "div",
939
1126
  {
940
1127
  ref: slideTrackRef,
941
- className: cx("rvl-track", visible && !closing && "rvl-track-visible"),
1128
+ className: cx(
1129
+ "rvl-track",
1130
+ // During a thumbnail zoom the track is opaque from the first frame
1131
+ // (the image itself is hidden until the zoom starts), so the picture
1132
+ // flies in crisply instead of cross-fading. On close it only stays
1133
+ // opaque while a FLIP collapse is animating the image back; otherwise
1134
+ // it fades out (so a zoomed close still animates instead of vanishing).
1135
+ (closing ? collapsing : zoomTransition || visible) && "rvl-track-visible"
1136
+ ),
942
1137
  children: [
943
1138
  showAdjacent && prevItem && /* @__PURE__ */ jsx(
944
1139
  "div",
@@ -980,11 +1175,13 @@ function ImageViewer({
980
1175
  className: cx("rvl-img", cn("image")),
981
1176
  style: imgStyle,
982
1177
  draggable: false,
983
- onLoad: measureBaseDims
1178
+ onLoad: markFullLoaded,
1179
+ onError: () => setFullLoaded(true)
984
1180
  }
985
1181
  )
986
1182
  }
987
1183
  ),
1184
+ 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
1185
  showAdjacent && nextItem && /* @__PURE__ */ jsx(
989
1186
  "div",
990
1187
  {
@@ -1008,7 +1205,7 @@ function ImageViewer({
1008
1205
  }
1009
1206
  ),
1010
1207
  /* @__PURE__ */ jsxs("div", { ref: bottomBarRef, className: cx("rvl-bar", "rvl-bottom-bar", cn("bottomBar")), children: [
1011
- showNavRow && /* @__PURE__ */ jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxs("div", { className: "rvl-nav-inner", children: [
1208
+ showNavRow && /* @__PURE__ */ jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxs("div", { className: cx("rvl-nav-inner", navSlotPlacement === "inline" && "rvl-nav-inline"), children: [
1012
1209
  navStart != null && /* @__PURE__ */ jsx("div", { className: cx("rvl-nav-start", cn("navStart")), children: navStart }),
1013
1210
  hasNavGroup && /* @__PURE__ */ jsxs("div", { className: "rvl-nav-group", children: [
1014
1211
  /* @__PURE__ */ jsx(