@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/README.md +64 -21
- package/dist/index.cjs +240 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +107 -6
- package/dist/index.d.ts +107 -6
- package/dist/index.js +240 -43
- package/dist/index.js.map +1 -1
- package/dist/styles.css +93 -7
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
828
|
+
const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
|
|
746
829
|
react.useEffect(() => {
|
|
747
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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 (
|
|
768
|
-
else if (loop) {
|
|
769
|
-
onNavigate?.("prev");
|
|
770
|
-
onIndexChange(items.length - 1);
|
|
771
|
-
}
|
|
947
|
+
if (hasPrev) commitSlide("prev");
|
|
772
948
|
} else {
|
|
773
|
-
if (
|
|
774
|
-
else if (loop) {
|
|
775
|
-
onNavigate?.("next");
|
|
776
|
-
onIndexChange(0);
|
|
777
|
-
}
|
|
949
|
+
if (hasNext) commitSlide("next");
|
|
778
950
|
}
|
|
779
951
|
},
|
|
780
|
-
[
|
|
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
|
|
838
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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(
|