@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/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @jekrch/react-viewport-lightbox
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
|
|
3
|
+
[](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
|
|
4
4
|
[](https://bundlephobia.com/package/@jekrch/react-viewport-lightbox)
|
|
5
5
|
[](https://codecov.io/gh/jekrch/react-viewport-lightbox)
|
|
6
6
|
[](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
|
|
7
|
-
[](./LICENSE)
|
|
8
8
|
|
|
9
9
|
A touch-friendly React image viewer and lightbox with zoom, pan, pinch, and swipe.
|
|
10
10
|
It ships an `<ImageViewer>` shell with render slots for headers, footers, and
|
|
@@ -75,25 +75,28 @@ animation and calls `onClose` after the exit completes.
|
|
|
75
75
|
|
|
76
76
|
## Props
|
|
77
77
|
|
|
78
|
-
| Prop
|
|
79
|
-
|
|
|
80
|
-
| `items`
|
|
81
|
-
| `index`
|
|
82
|
-
| `onIndexChange`
|
|
83
|
-
| `onNavigate`
|
|
84
|
-
| `onClose`
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
78
|
+
| Prop | Type | Default | Description |
|
|
79
|
+
| ---------------------- | --------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `items` | `ViewerItem[]` | required | Images to display. |
|
|
81
|
+
| `index` | `number` | required | Controlled index of the active item. |
|
|
82
|
+
| `onIndexChange` | `(index: number) => void` | required | Called when navigation changes the index. |
|
|
83
|
+
| `onNavigate` | `(direction: "prev" \| "next") => void` | optional | Fired when a slide starts (before `onIndexChange`), so overlays can animate out in sync with the image. |
|
|
84
|
+
| `onClose` | `() => void` | required | Called after the exit animation completes. |
|
|
85
|
+
| `getOriginRect` | `(index: number) => ViewerRect \| null` | optional | Enables a shared-element "zoom from thumbnail" open/close transition. Return the source element's rect (e.g. `el.getBoundingClientRect()`) for the given index, or `null` to fall back to the fade. Honors reduced-motion. |
|
|
86
|
+
| `zoom` | `boolean` | `true` | Enable wheel/pinch/double-tap zoom + pan. |
|
|
87
|
+
| `zoomToCursor` | `boolean` | `true` | Anchor wheel/pinch zoom on the pointer: scrolling zooms toward the cursor and a pinch zooms toward the gesture midpoint. Set `false` to zoom about the viewport center. |
|
|
88
|
+
| `showCounter` | `boolean` | `true` | Show the `index / total` counter. |
|
|
89
|
+
| `loop` | `boolean` | `false` | Wrap around at the ends (buttons + arrow keys). |
|
|
90
|
+
| `closeOnBackdropClick` | `boolean` | `false` | Close the viewer when the empty area around the image is clicked. Image, bars, and controls are unaffected. |
|
|
91
|
+
| `renderHeader` | `(ctx) => ReactNode` | optional | Top-left title area. |
|
|
92
|
+
| `renderHeaderActions` | `(ctx) => ReactNode` | optional | Extra top-right buttons (before Close). |
|
|
93
|
+
| `renderNavStart` | `(ctx) => ReactNode` | optional | Pinned to the left edge of the nav row (e.g. a details toggle); costs no extra height, nav group stays centered. |
|
|
94
|
+
| `renderNavEnd` | `(ctx) => ReactNode` | optional | Pinned to the right edge of the nav row. |
|
|
95
|
+
| `renderFooter` | `(ctx) => ReactNode` | optional | Content below the nav row. |
|
|
96
|
+
| `renderOverlay` | `(ctx) => ReactNode` | optional | Drawers and graphs layered over the image. |
|
|
97
|
+
| `classNames` | `Partial<Record<ViewerSlot, string>>` | optional | Per-slot `className` overrides. |
|
|
98
|
+
| `icons` | `Partial<ViewerIcons>` | optional | Override `close`, `zoomIn`, `zoomOut`, `prev`, and `next`. |
|
|
99
|
+
| `ariaLabel` | `string` | item `alt` | Dialog label. |
|
|
97
100
|
|
|
98
101
|
### `ViewerItem`
|
|
99
102
|
|
|
@@ -149,6 +152,38 @@ const items: ViewerItem<Detail>[] = [
|
|
|
149
152
|
`ViewerItem`, `ViewerContext`, and `ImageViewerProps` are all generic over `TData`
|
|
150
153
|
(defaulting to `unknown`), so this is fully type-safe and entirely opt-in.
|
|
151
154
|
|
|
155
|
+
### Zoom from thumbnail
|
|
156
|
+
|
|
157
|
+
Pass `getOriginRect` to make the viewer expand out of the clicked thumbnail on open
|
|
158
|
+
and collapse back into it on close (a shared-element transition), instead of the
|
|
159
|
+
default fade. Keep a ref to each thumbnail and return its current on-screen rect for
|
|
160
|
+
the given index; return `null` (or omit the prop) to fall back to the fade. It's
|
|
161
|
+
called again with the active index on close, so navigating then closing collapses
|
|
162
|
+
into the right thumbnail, and it honors `prefers-reduced-motion`.
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
const thumbs = useRef<(HTMLElement | null)[]>([]);
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
items.map((it, i) => (
|
|
169
|
+
<button key={it.id} ref={(el) => (thumbs.current[i] = el)} onClick={() => open(i)}>
|
|
170
|
+
<img src={it.thumbnail ?? it.src} alt={it.alt} />
|
|
171
|
+
</button>
|
|
172
|
+
));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
<ImageViewer
|
|
176
|
+
items={items}
|
|
177
|
+
index={index}
|
|
178
|
+
onIndexChange={setIndex}
|
|
179
|
+
onClose={() => setOpen(false)}
|
|
180
|
+
getOriginRect={(i) => thumbs.current[i]?.getBoundingClientRect() ?? null}
|
|
181
|
+
/>;
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The transition reads most seamlessly when the thumbnail and full image share an
|
|
185
|
+
aspect ratio (the thumbnail is, after all, the same picture).
|
|
186
|
+
|
|
152
187
|
## Slots & `ViewerContext`
|
|
153
188
|
|
|
154
189
|
Every `render*` slot receives a `ViewerContext` with navigation, zoom, and layout
|
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);
|
|
@@ -684,19 +724,37 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
|
|
|
684
724
|
}
|
|
685
725
|
var ANIM_MS = 250;
|
|
686
726
|
var IMG_PADDING = 44;
|
|
727
|
+
var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
687
728
|
function prefersReducedMotion() {
|
|
688
729
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
689
730
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
690
731
|
}
|
|
732
|
+
function flipTransform(from, to) {
|
|
733
|
+
const sx = to.width / from.width;
|
|
734
|
+
const sy = to.height / from.height;
|
|
735
|
+
const dx = to.left - from.left;
|
|
736
|
+
const dy = to.top - from.top;
|
|
737
|
+
return `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
|
|
738
|
+
}
|
|
739
|
+
function canAnimate(el) {
|
|
740
|
+
return !!el && typeof el.animate === "function";
|
|
741
|
+
}
|
|
742
|
+
function isRectInViewport(rect) {
|
|
743
|
+
if (typeof window === "undefined") return true;
|
|
744
|
+
return rect.top < window.innerHeight && rect.top + rect.height > 0 && rect.left < window.innerWidth && rect.left + rect.width > 0;
|
|
745
|
+
}
|
|
691
746
|
function ImageViewer({
|
|
692
747
|
items,
|
|
693
748
|
index,
|
|
694
749
|
onIndexChange,
|
|
695
750
|
onNavigate,
|
|
696
751
|
onClose,
|
|
752
|
+
getOriginRect,
|
|
697
753
|
zoom = true,
|
|
754
|
+
zoomToCursor = true,
|
|
698
755
|
showCounter = true,
|
|
699
756
|
loop = false,
|
|
757
|
+
closeOnBackdropClick = false,
|
|
700
758
|
renderHeader,
|
|
701
759
|
renderHeaderActions,
|
|
702
760
|
renderNavStart,
|
|
@@ -709,6 +767,7 @@ function ImageViewer({
|
|
|
709
767
|
}) {
|
|
710
768
|
const [visible, setVisible] = react.useState(false);
|
|
711
769
|
const [closing, setClosing] = react.useState(false);
|
|
770
|
+
const [collapsing, setCollapsing] = react.useState(false);
|
|
712
771
|
const [isTouchDevice, setIsTouchDevice] = react.useState(false);
|
|
713
772
|
const [contentShift, setContentShiftState] = react.useState({ transform: null, animate: true });
|
|
714
773
|
const containerRef = react.useRef(null);
|
|
@@ -728,7 +787,7 @@ function ImageViewer({
|
|
|
728
787
|
useBodyScrollLock(true);
|
|
729
788
|
useFocusTrap(containerRef, visible && !closing);
|
|
730
789
|
const { topBarH, bottomBarH } = useBarMeasure(topBarRef, bottomBarRef, index);
|
|
731
|
-
const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom);
|
|
790
|
+
const zoomPan = useImageZoomPan(imgWrapperRef, index, zoom, zoomToCursor);
|
|
732
791
|
const {
|
|
733
792
|
imgRef,
|
|
734
793
|
displayScale,
|
|
@@ -740,9 +799,9 @@ function ImageViewer({
|
|
|
740
799
|
measureBaseDims,
|
|
741
800
|
handleDoubleClick
|
|
742
801
|
} = zoomPan;
|
|
743
|
-
const slide = useSlideNavigation(items, index, onIndexChange, onNavigate);
|
|
802
|
+
const slide = useSlideNavigation(items, index, onIndexChange, onNavigate, loop);
|
|
744
803
|
const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
|
|
745
|
-
const gestures = useGestureHandler(zoomPan, slide,
|
|
804
|
+
const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
|
|
746
805
|
react.useEffect(() => {
|
|
747
806
|
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
|
748
807
|
}, []);
|
|
@@ -755,29 +814,110 @@ function ImageViewer({
|
|
|
755
814
|
const raf = requestAnimationFrame(() => setVisible(true));
|
|
756
815
|
return () => cancelAnimationFrame(raf);
|
|
757
816
|
}, []);
|
|
817
|
+
const zoomTransition = !!getOriginRect;
|
|
818
|
+
const reduceMotion = prefersReducedMotion();
|
|
819
|
+
const gateEntry = zoomTransition && !reduceMotion;
|
|
820
|
+
const [fullLoaded, setFullLoaded] = react.useState(false);
|
|
821
|
+
const [showSpinner, setShowSpinner] = react.useState(false);
|
|
822
|
+
const entryStartedRef = react.useRef(false);
|
|
823
|
+
const entryCleanupRef = react.useRef(null);
|
|
824
|
+
const runZoomEntry = react.useCallback(() => {
|
|
825
|
+
if (entryStartedRef.current) return;
|
|
826
|
+
if (!getOriginRect || prefersReducedMotion()) return;
|
|
827
|
+
const img = imgRef.current;
|
|
828
|
+
const thumb = getOriginRect(index);
|
|
829
|
+
if (!thumb || !canAnimate(img)) return;
|
|
830
|
+
const bottomH = bottomBarRef.current?.offsetHeight ?? 0;
|
|
831
|
+
const lockedMaxHeight = `calc(100vh - ${bottomH + IMG_PADDING * 2}px)`;
|
|
832
|
+
img.style.maxHeight = lockedMaxHeight;
|
|
833
|
+
const imgRect = img.getBoundingClientRect();
|
|
834
|
+
if (imgRect.width === 0 || imgRect.height === 0) {
|
|
835
|
+
img.style.maxHeight = "";
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
entryStartedRef.current = true;
|
|
839
|
+
const startTransform = flipTransform(imgRect, thumb);
|
|
840
|
+
img.style.transformOrigin = "top left";
|
|
841
|
+
img.style.transform = startTransform;
|
|
842
|
+
const wrapper = imgWrapperRef.current;
|
|
843
|
+
if (wrapper) wrapper.style.overflow = "visible";
|
|
844
|
+
const anim = img.animate(
|
|
845
|
+
[
|
|
846
|
+
{ transformOrigin: "top left", transform: startTransform },
|
|
847
|
+
{ transformOrigin: "top left", transform: "none" }
|
|
848
|
+
],
|
|
849
|
+
{ duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
|
|
850
|
+
);
|
|
851
|
+
const cleanup = () => {
|
|
852
|
+
img.style.transform = "";
|
|
853
|
+
img.style.transformOrigin = "";
|
|
854
|
+
if (wrapper) wrapper.style.overflow = "";
|
|
855
|
+
img.style.maxHeight = lockedMaxHeight;
|
|
856
|
+
anim.cancel();
|
|
857
|
+
entryCleanupRef.current = null;
|
|
858
|
+
};
|
|
859
|
+
entryCleanupRef.current = cleanup;
|
|
860
|
+
anim.onfinish = cleanup;
|
|
861
|
+
}, [getOriginRect, index, imgRef, imgWrapperRef, bottomBarRef]);
|
|
862
|
+
const markFullLoaded = react.useCallback(() => {
|
|
863
|
+
measureBaseDims();
|
|
864
|
+
const img = imgRef.current;
|
|
865
|
+
if (img && typeof img.decode === "function") {
|
|
866
|
+
img.decode().then(
|
|
867
|
+
() => setFullLoaded(true),
|
|
868
|
+
() => setFullLoaded(true)
|
|
869
|
+
);
|
|
870
|
+
} else {
|
|
871
|
+
setFullLoaded(true);
|
|
872
|
+
}
|
|
873
|
+
}, [measureBaseDims, imgRef]);
|
|
874
|
+
react.useLayoutEffect(() => {
|
|
875
|
+
const img = imgRef.current;
|
|
876
|
+
if (img && img.complete && img.naturalWidth > 0) markFullLoaded();
|
|
877
|
+
}, []);
|
|
878
|
+
react.useLayoutEffect(() => {
|
|
879
|
+
if (fullLoaded) runZoomEntry();
|
|
880
|
+
}, [fullLoaded]);
|
|
881
|
+
react.useEffect(() => {
|
|
882
|
+
if (!gateEntry || fullLoaded) {
|
|
883
|
+
setShowSpinner(false);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const t = setTimeout(() => setShowSpinner(true), 500);
|
|
887
|
+
return () => clearTimeout(t);
|
|
888
|
+
}, [gateEntry, fullLoaded]);
|
|
758
889
|
const handleClose = react.useCallback(() => {
|
|
890
|
+
const reduce = prefersReducedMotion();
|
|
891
|
+
const origin = !reduce && !isZoomed ? getOriginRect?.(index) ?? null : null;
|
|
892
|
+
const thumb = origin && isRectInViewport(origin) ? origin : null;
|
|
893
|
+
const img = imgRef.current;
|
|
894
|
+
entryCleanupRef.current?.();
|
|
759
895
|
setClosing(true);
|
|
760
896
|
setVisible(false);
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
897
|
+
if (thumb && canAnimate(img)) {
|
|
898
|
+
const imgRect = img.getBoundingClientRect();
|
|
899
|
+
const wrapper = imgWrapperRef.current;
|
|
900
|
+
if (wrapper) wrapper.style.overflow = "visible";
|
|
901
|
+
setCollapsing(true);
|
|
902
|
+
img.animate(
|
|
903
|
+
[
|
|
904
|
+
{ transformOrigin: "top left", transform: "none" },
|
|
905
|
+
{ transformOrigin: "top left", transform: flipTransform(imgRect, thumb) }
|
|
906
|
+
],
|
|
907
|
+
{ duration: ANIM_MS, easing: ZOOM_EASE, fill: "forwards" }
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
setTimeout(onClose, reduce ? 0 : ANIM_MS);
|
|
911
|
+
}, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
|
|
764
912
|
const navigate = react.useCallback(
|
|
765
913
|
(dir) => {
|
|
766
914
|
if (dir === "prev") {
|
|
767
|
-
if (
|
|
768
|
-
else if (loop) {
|
|
769
|
-
onNavigate?.("prev");
|
|
770
|
-
onIndexChange(items.length - 1);
|
|
771
|
-
}
|
|
915
|
+
if (hasPrev) commitSlide("prev");
|
|
772
916
|
} else {
|
|
773
|
-
if (
|
|
774
|
-
else if (loop) {
|
|
775
|
-
onNavigate?.("next");
|
|
776
|
-
onIndexChange(0);
|
|
777
|
-
}
|
|
917
|
+
if (hasNext) commitSlide("next");
|
|
778
918
|
}
|
|
779
919
|
},
|
|
780
|
-
[
|
|
920
|
+
[hasPrev, hasNext, commitSlide]
|
|
781
921
|
);
|
|
782
922
|
react.useEffect(() => {
|
|
783
923
|
const handler = (e) => {
|
|
@@ -832,10 +972,13 @@ function ImageViewer({
|
|
|
832
972
|
const reservedH = bottomBarH + IMG_PADDING * 2;
|
|
833
973
|
const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
|
|
834
974
|
const imgStyle = { maxHeight: imgMaxHeight };
|
|
975
|
+
if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
|
|
835
976
|
const totalDigits = String(items.length).length;
|
|
836
977
|
const counterMinWidth = `${totalDigits * 2 * 0.6 + 1.5}em`;
|
|
837
|
-
const
|
|
838
|
-
const
|
|
978
|
+
const prevIndex = hasPrevLinear ? index - 1 : hasPrev ? items.length - 1 : -1;
|
|
979
|
+
const nextIndex = hasNextLinear ? index + 1 : hasNext ? 0 : -1;
|
|
980
|
+
const prevItem = prevIndex >= 0 ? items[prevIndex] : null;
|
|
981
|
+
const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
|
|
839
982
|
const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
|
|
840
983
|
const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
|
|
841
984
|
const showZoomControls = zoom && !isTouchDevice;
|
|
@@ -858,7 +1001,7 @@ function ImageViewer({
|
|
|
858
1001
|
"div",
|
|
859
1002
|
{
|
|
860
1003
|
className: cx("rvl-backdrop", cn("backdrop")),
|
|
861
|
-
onClick: handleClose,
|
|
1004
|
+
onClick: closeOnBackdropClick ? handleClose : void 0,
|
|
862
1005
|
"aria-hidden": "true"
|
|
863
1006
|
}
|
|
864
1007
|
),
|
|
@@ -931,6 +1074,9 @@ function ImageViewer({
|
|
|
931
1074
|
"div",
|
|
932
1075
|
{
|
|
933
1076
|
className: "rvl-stage",
|
|
1077
|
+
onClick: closeOnBackdropClick ? (e) => {
|
|
1078
|
+
if (e.target === e.currentTarget) handleClose();
|
|
1079
|
+
} : void 0,
|
|
934
1080
|
style: {
|
|
935
1081
|
transform: contentShift.transform ?? "translateY(0)",
|
|
936
1082
|
// animate=false snaps with no transition (overrides the CSS transition)
|
|
@@ -940,7 +1086,15 @@ function ImageViewer({
|
|
|
940
1086
|
"div",
|
|
941
1087
|
{
|
|
942
1088
|
ref: slideTrackRef,
|
|
943
|
-
className: cx(
|
|
1089
|
+
className: cx(
|
|
1090
|
+
"rvl-track",
|
|
1091
|
+
// During a thumbnail zoom the track is opaque from the first frame
|
|
1092
|
+
// (the image itself is hidden until the zoom starts), so the picture
|
|
1093
|
+
// flies in crisply instead of cross-fading. On close it only stays
|
|
1094
|
+
// opaque while a FLIP collapse is animating the image back; otherwise
|
|
1095
|
+
// it fades out (so a zoomed close still animates instead of vanishing).
|
|
1096
|
+
(closing ? collapsing : zoomTransition || visible) && "rvl-track-visible"
|
|
1097
|
+
),
|
|
944
1098
|
children: [
|
|
945
1099
|
showAdjacent && prevItem && /* @__PURE__ */ jsxRuntime.jsx(
|
|
946
1100
|
"div",
|
|
@@ -982,11 +1136,13 @@ function ImageViewer({
|
|
|
982
1136
|
className: cx("rvl-img", cn("image")),
|
|
983
1137
|
style: imgStyle,
|
|
984
1138
|
draggable: false,
|
|
985
|
-
onLoad:
|
|
1139
|
+
onLoad: markFullLoaded,
|
|
1140
|
+
onError: () => setFullLoaded(true)
|
|
986
1141
|
}
|
|
987
1142
|
)
|
|
988
1143
|
}
|
|
989
1144
|
),
|
|
1145
|
+
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
1146
|
showAdjacent && nextItem && /* @__PURE__ */ jsxRuntime.jsx(
|
|
991
1147
|
"div",
|
|
992
1148
|
{
|