@jekrch/react-viewport-lightbox 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -21
- package/dist/index.cjs +190 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -6
- package/dist/index.d.ts +50 -6
- package/dist/index.js +190 -34
- package/dist/index.js.map +1 -1
- package/dist/styles.css +33 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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 (
|
|
766
|
-
else if (loop) {
|
|
767
|
-
onNavigate?.("prev");
|
|
768
|
-
onIndexChange(items.length - 1);
|
|
769
|
-
}
|
|
913
|
+
if (hasPrev) commitSlide("prev");
|
|
770
914
|
} else {
|
|
771
|
-
if (
|
|
772
|
-
else if (loop) {
|
|
773
|
-
onNavigate?.("next");
|
|
774
|
-
onIndexChange(0);
|
|
775
|
-
}
|
|
915
|
+
if (hasNext) commitSlide("next");
|
|
776
916
|
}
|
|
777
917
|
},
|
|
778
|
-
[
|
|
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
|
|
836
|
-
const
|
|
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(
|
|
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:
|
|
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
|
{
|