@jekrch/react-viewport-lightbox 0.2.0 → 0.3.1
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 +30 -22
- package/dist/index.cjs +88 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +58 -1
- package/dist/index.d.ts +58 -1
- package/dist/index.js +88 -14
- package/dist/index.js.map +1 -1
- package/dist/styles.css +64 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,28 +75,35 @@ animation and calls `onClose` after the exit completes.
|
|
|
75
75
|
|
|
76
76
|
## Props
|
|
77
77
|
|
|
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
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
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
|
+
| `onEscape` | `() => boolean` | optional | Called on Escape before the viewer closes. Return `true` to mark the key handled and veto the default close (e.g. dismiss your own overlay first); `false`/`undefined` falls through to closing. |
|
|
86
|
+
| `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. |
|
|
87
|
+
| `zoom` | `boolean` | `true` | Enable wheel/pinch/double-tap zoom + pan. |
|
|
88
|
+
| `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. |
|
|
89
|
+
| `showCounter` | `boolean` | `true` | Show the `index / total` counter. |
|
|
90
|
+
| `showZoomControls` | `boolean` | `true` | Show the built-in zoom in/out/reset buttons. Independent of `zoom` (the gestures): set `false` to keep zoom/pan while a consumer overlay owns the chrome. Auto-hidden on touch-primary devices and while content is shifted. |
|
|
91
|
+
| `disableNavigation` | `boolean` | `false` | Suppress built-in arrow-key navigation and swipe commit without tearing the viewer down (e.g. while an overlay handles left/right itself). Does not hide the on-screen nav buttons. |
|
|
92
|
+
| `loop` | `boolean` | `false` | Wrap around at the ends (buttons + arrow keys). |
|
|
93
|
+
| `closeOnBackdropClick` | `boolean` | `false` | Close the viewer when the empty area around the image is clicked. Image, bars, and controls are unaffected. |
|
|
94
|
+
| `renderHeader` | `(ctx) => ReactNode` | optional | Top-left title area. |
|
|
95
|
+
| `renderHeaderActions` | `(ctx) => ReactNode` | optional | Extra top-right buttons (before Close). |
|
|
96
|
+
| `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. |
|
|
97
|
+
| `renderNavEnd` | `(ctx) => ReactNode` | optional | Pinned to the right edge of the nav row. |
|
|
98
|
+
| `navSlotPlacement` | `"edge" \| "inline"` | `"edge"` | Where `renderNavStart`/`renderNavEnd` sit: `"edge"` pins them to the row edges (nav group stays optically centered); `"inline"` places them directly flanking the arrows as one centered cluster. |
|
|
99
|
+
| `navHeight` | `number \| string` | `2.375rem` | Size of the prev/next nav arrows. A number is pixels; a string is used verbatim. Sets `--rvl-nav-height`, so it can also be themed in CSS. |
|
|
100
|
+
| `navInset` | `number \| string` | `1.3rem` | Gap between the bottom nav controls and the viewport's bottom edge (floored by the safe-area inset). Number is pixels; string verbatim. Sets `--rvl-nav-inset`. |
|
|
101
|
+
| `counterFontSize` | `number \| string` | `0.29×` | Overrides the counter font size (which otherwise scales with `navHeight`). Number is pixels; string verbatim. Sets `--rvl-counter-font-size`. |
|
|
102
|
+
| `renderFooter` | `(ctx) => ReactNode` | optional | Content below the nav row. |
|
|
103
|
+
| `renderOverlay` | `(ctx) => ReactNode` | optional | Drawers and graphs layered over the image. |
|
|
104
|
+
| `classNames` | `Partial<Record<ViewerSlot, string>>` | optional | Per-slot `className` overrides. |
|
|
105
|
+
| `icons` | `Partial<ViewerIcons>` | optional | Override `close`, `zoomIn`, `zoomOut`, `prev`, and `next`. |
|
|
106
|
+
| `ariaLabel` | `string` | item `alt` | Dialog label. |
|
|
100
107
|
|
|
101
108
|
### `ViewerItem`
|
|
102
109
|
|
|
@@ -195,6 +202,7 @@ interface ViewerContext<TData = unknown> {
|
|
|
195
202
|
index: number;
|
|
196
203
|
item: ViewerItem<TData>; // item.data holds your per-slide payload
|
|
197
204
|
total: number;
|
|
205
|
+
closing: boolean; // true once the exit animation starts, so overlays can fade out in step
|
|
198
206
|
|
|
199
207
|
hasPrev: boolean;
|
|
200
208
|
hasNext: boolean;
|
package/dist/index.cjs
CHANGED
|
@@ -592,16 +592,29 @@ function useBarMeasure(topBarRef, bottomBarRef, measureKey) {
|
|
|
592
592
|
var lockCount = 0;
|
|
593
593
|
var previousOverflow = "";
|
|
594
594
|
var previousPaddingRight = "";
|
|
595
|
+
var previousPosition = "";
|
|
596
|
+
var previousTop = "";
|
|
597
|
+
var previousWidth = "";
|
|
598
|
+
var lockedScrollY = 0;
|
|
595
599
|
function useBodyScrollLock(isLocked) {
|
|
596
600
|
react.useEffect(() => {
|
|
597
601
|
if (!isLocked) return;
|
|
598
602
|
if (typeof document === "undefined") return;
|
|
599
603
|
if (lockCount === 0) {
|
|
600
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;
|
|
601
608
|
previousOverflow = document.body.style.overflow;
|
|
602
609
|
previousPaddingRight = document.body.style.paddingRight;
|
|
610
|
+
previousPosition = document.body.style.position;
|
|
611
|
+
previousTop = document.body.style.top;
|
|
612
|
+
previousWidth = document.body.style.width;
|
|
603
613
|
document.body.style.overflow = "hidden";
|
|
604
|
-
|
|
614
|
+
document.body.style.position = "fixed";
|
|
615
|
+
document.body.style.top = `-${lockedScrollY}px`;
|
|
616
|
+
document.body.style.width = "100%";
|
|
617
|
+
if (scrollbarWidth > 0 && !reservesGutter) {
|
|
605
618
|
const currentPaddingRight = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
|
|
606
619
|
document.body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
|
|
607
620
|
}
|
|
@@ -612,6 +625,10 @@ function useBodyScrollLock(isLocked) {
|
|
|
612
625
|
if (lockCount === 0) {
|
|
613
626
|
document.body.style.overflow = previousOverflow;
|
|
614
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);
|
|
615
632
|
}
|
|
616
633
|
};
|
|
617
634
|
}, [isLocked]);
|
|
@@ -724,6 +741,7 @@ function NavButton({ direction, enabled, onClick, icon, className }) {
|
|
|
724
741
|
}
|
|
725
742
|
var ANIM_MS = 250;
|
|
726
743
|
var IMG_PADDING = 44;
|
|
744
|
+
var GHOST_CLICK_MS = 700;
|
|
727
745
|
var ZOOM_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
728
746
|
function prefersReducedMotion() {
|
|
729
747
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
@@ -749,16 +767,23 @@ function ImageViewer({
|
|
|
749
767
|
onIndexChange,
|
|
750
768
|
onNavigate,
|
|
751
769
|
onClose,
|
|
770
|
+
onEscape,
|
|
752
771
|
getOriginRect,
|
|
753
772
|
zoom = true,
|
|
754
773
|
zoomToCursor = true,
|
|
755
774
|
showCounter = true,
|
|
775
|
+
showZoomControls = true,
|
|
776
|
+
disableNavigation = false,
|
|
756
777
|
loop = false,
|
|
757
778
|
closeOnBackdropClick = false,
|
|
758
779
|
renderHeader,
|
|
759
780
|
renderHeaderActions,
|
|
760
781
|
renderNavStart,
|
|
761
782
|
renderNavEnd,
|
|
783
|
+
navSlotPlacement = "edge",
|
|
784
|
+
navHeight,
|
|
785
|
+
navInset,
|
|
786
|
+
counterFontSize,
|
|
762
787
|
renderFooter,
|
|
763
788
|
renderOverlay,
|
|
764
789
|
classNames,
|
|
@@ -770,6 +795,11 @@ function ImageViewer({
|
|
|
770
795
|
const [collapsing, setCollapsing] = react.useState(false);
|
|
771
796
|
const [isTouchDevice, setIsTouchDevice] = react.useState(false);
|
|
772
797
|
const [contentShift, setContentShiftState] = react.useState({ transform: null, animate: true });
|
|
798
|
+
const openedAtRef = react.useRef(Date.now());
|
|
799
|
+
const isGhostMouseEvent = react.useCallback(
|
|
800
|
+
() => Date.now() - openedAtRef.current < GHOST_CLICK_MS,
|
|
801
|
+
[]
|
|
802
|
+
);
|
|
773
803
|
const containerRef = react.useRef(null);
|
|
774
804
|
const imgWrapperRef = react.useRef(null);
|
|
775
805
|
const topBarRef = react.useRef(null);
|
|
@@ -803,7 +833,15 @@ function ImageViewer({
|
|
|
803
833
|
const { slideTrackRef, slideActive, slideAnimating, swipeOffset, commitSlide } = slide;
|
|
804
834
|
const gestures = useGestureHandler(zoomPan, slide, hasPrev, hasNext, zoom, zoomToCursor);
|
|
805
835
|
react.useEffect(() => {
|
|
806
|
-
|
|
836
|
+
if (typeof window === "undefined" || !window.matchMedia) {
|
|
837
|
+
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const mq = window.matchMedia("(hover: none) and (pointer: coarse)");
|
|
841
|
+
const update = () => setIsTouchDevice(mq.matches);
|
|
842
|
+
update();
|
|
843
|
+
mq.addEventListener("change", update);
|
|
844
|
+
return () => mq.removeEventListener("change", update);
|
|
807
845
|
}, []);
|
|
808
846
|
react.useEffect(() => {
|
|
809
847
|
const onResize = () => setViewportWidth(window.innerWidth);
|
|
@@ -909,6 +947,33 @@ function ImageViewer({
|
|
|
909
947
|
}
|
|
910
948
|
setTimeout(onClose, reduce ? 0 : ANIM_MS);
|
|
911
949
|
}, [onClose, getOriginRect, index, isZoomed, imgRef, imgWrapperRef]);
|
|
950
|
+
const handleBackdropTouchEnd = react.useCallback(
|
|
951
|
+
(e) => {
|
|
952
|
+
if (e.target !== e.currentTarget) return;
|
|
953
|
+
e.preventDefault();
|
|
954
|
+
handleClose();
|
|
955
|
+
},
|
|
956
|
+
[handleClose]
|
|
957
|
+
);
|
|
958
|
+
const handleDoubleClickGuarded = react.useCallback(
|
|
959
|
+
(e) => {
|
|
960
|
+
if (isGhostMouseEvent()) return;
|
|
961
|
+
handleDoubleClick(e);
|
|
962
|
+
},
|
|
963
|
+
[handleDoubleClick, isGhostMouseEvent]
|
|
964
|
+
);
|
|
965
|
+
const handleBackdropClick = react.useCallback(() => {
|
|
966
|
+
if (isGhostMouseEvent()) return;
|
|
967
|
+
handleClose();
|
|
968
|
+
}, [handleClose, isGhostMouseEvent]);
|
|
969
|
+
const handleStageClick = react.useCallback(
|
|
970
|
+
(e) => {
|
|
971
|
+
if (e.target !== e.currentTarget) return;
|
|
972
|
+
if (isGhostMouseEvent()) return;
|
|
973
|
+
handleClose();
|
|
974
|
+
},
|
|
975
|
+
[handleClose, isGhostMouseEvent]
|
|
976
|
+
);
|
|
912
977
|
const navigate = react.useCallback(
|
|
913
978
|
(dir) => {
|
|
914
979
|
if (dir === "prev") {
|
|
@@ -922,16 +987,17 @@ function ImageViewer({
|
|
|
922
987
|
react.useEffect(() => {
|
|
923
988
|
const handler = (e) => {
|
|
924
989
|
if (e.key === "Escape") {
|
|
990
|
+
if (onEscape?.()) return;
|
|
925
991
|
handleClose();
|
|
926
992
|
return;
|
|
927
993
|
}
|
|
928
|
-
if (displayScale > 1) return;
|
|
994
|
+
if (disableNavigation || displayScale > 1) return;
|
|
929
995
|
if (e.key === "ArrowLeft" && hasPrev) navigate("prev");
|
|
930
996
|
if (e.key === "ArrowRight" && hasNext) navigate("next");
|
|
931
997
|
};
|
|
932
998
|
window.addEventListener("keydown", handler);
|
|
933
999
|
return () => window.removeEventListener("keydown", handler);
|
|
934
|
-
}, [handleClose, hasPrev, hasNext, displayScale, navigate]);
|
|
1000
|
+
}, [handleClose, hasPrev, hasNext, displayScale, navigate, onEscape, disableNavigation]);
|
|
935
1001
|
const setContentShift = react.useCallback((transform, animate = true) => {
|
|
936
1002
|
setContentShiftState({ transform, animate });
|
|
937
1003
|
}, []);
|
|
@@ -940,6 +1006,7 @@ function ImageViewer({
|
|
|
940
1006
|
index,
|
|
941
1007
|
item,
|
|
942
1008
|
total: items.length,
|
|
1009
|
+
closing,
|
|
943
1010
|
hasPrev,
|
|
944
1011
|
hasNext,
|
|
945
1012
|
goPrev: () => navigate("prev"),
|
|
@@ -972,6 +1039,12 @@ function ImageViewer({
|
|
|
972
1039
|
const reservedH = bottomBarH + IMG_PADDING * 2;
|
|
973
1040
|
const imgMaxHeight = `calc(100vh - ${reservedH}px)`;
|
|
974
1041
|
const imgStyle = { maxHeight: imgMaxHeight };
|
|
1042
|
+
const dim = (v) => typeof v === "number" ? `${v}px` : v;
|
|
1043
|
+
const rootStyle = {
|
|
1044
|
+
...navHeight != null && { "--rvl-nav-height": dim(navHeight) },
|
|
1045
|
+
...navInset != null && { "--rvl-nav-inset": dim(navInset) },
|
|
1046
|
+
...counterFontSize != null && { "--rvl-counter-font-size": dim(counterFontSize) }
|
|
1047
|
+
};
|
|
975
1048
|
if (gateEntry && !fullLoaded) imgStyle.opacity = 0;
|
|
976
1049
|
const totalDigits = String(items.length).length;
|
|
977
1050
|
const counterMinWidth = `${totalDigits * 2 * 0.6 + 1.5}em`;
|
|
@@ -981,7 +1054,7 @@ function ImageViewer({
|
|
|
981
1054
|
const nextItem = nextIndex >= 0 ? items[nextIndex] : null;
|
|
982
1055
|
const showAdjacent = slideActive || slideAnimating || swipeOffset !== 0;
|
|
983
1056
|
const adjacentOpacity = Math.min(1, Math.abs(swipeOffset) / (viewportWidth * 0.8 || 1));
|
|
984
|
-
const
|
|
1057
|
+
const showZoomCtrls = zoom && !isTouchDevice && showZoomControls && !contentShift.transform;
|
|
985
1058
|
const headerActions = renderHeaderActions?.(ctx);
|
|
986
1059
|
const navStart = renderNavStart?.(ctx);
|
|
987
1060
|
const navEnd = renderNavEnd?.(ctx);
|
|
@@ -996,12 +1069,14 @@ function ImageViewer({
|
|
|
996
1069
|
"aria-modal": "true",
|
|
997
1070
|
"aria-label": ariaLabel ?? item.alt ?? "Image viewer",
|
|
998
1071
|
tabIndex: -1,
|
|
1072
|
+
style: rootStyle,
|
|
999
1073
|
children: [
|
|
1000
1074
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1001
1075
|
"div",
|
|
1002
1076
|
{
|
|
1003
1077
|
className: cx("rvl-backdrop", cn("backdrop")),
|
|
1004
|
-
onClick: closeOnBackdropClick ?
|
|
1078
|
+
onClick: closeOnBackdropClick ? handleBackdropClick : void 0,
|
|
1079
|
+
onTouchEnd: closeOnBackdropClick ? handleBackdropTouchEnd : void 0,
|
|
1005
1080
|
"aria-hidden": "true"
|
|
1006
1081
|
}
|
|
1007
1082
|
),
|
|
@@ -1009,7 +1084,7 @@ function ImageViewer({
|
|
|
1009
1084
|
/* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-header", cn("topBar")), children: renderHeader?.(ctx) }),
|
|
1010
1085
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-header-actions", children: [
|
|
1011
1086
|
headerActions,
|
|
1012
|
-
|
|
1087
|
+
showZoomCtrls && isZoomed && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
1013
1088
|
"button",
|
|
1014
1089
|
{
|
|
1015
1090
|
type: "button",
|
|
@@ -1026,7 +1101,7 @@ function ImageViewer({
|
|
|
1026
1101
|
]
|
|
1027
1102
|
}
|
|
1028
1103
|
),
|
|
1029
|
-
|
|
1104
|
+
showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
|
|
1030
1105
|
"button",
|
|
1031
1106
|
{
|
|
1032
1107
|
type: "button",
|
|
@@ -1040,7 +1115,7 @@ function ImageViewer({
|
|
|
1040
1115
|
children: mergedIcons.zoomIn
|
|
1041
1116
|
}
|
|
1042
1117
|
),
|
|
1043
|
-
|
|
1118
|
+
showZoomCtrls && /* @__PURE__ */ jsxRuntime.jsx(
|
|
1044
1119
|
"button",
|
|
1045
1120
|
{
|
|
1046
1121
|
type: "button",
|
|
@@ -1074,9 +1149,8 @@ function ImageViewer({
|
|
|
1074
1149
|
"div",
|
|
1075
1150
|
{
|
|
1076
1151
|
className: "rvl-stage",
|
|
1077
|
-
onClick: closeOnBackdropClick ?
|
|
1078
|
-
|
|
1079
|
-
} : void 0,
|
|
1152
|
+
onClick: closeOnBackdropClick ? handleStageClick : void 0,
|
|
1153
|
+
onTouchEnd: closeOnBackdropClick ? handleBackdropTouchEnd : void 0,
|
|
1080
1154
|
style: {
|
|
1081
1155
|
transform: contentShift.transform ?? "translateY(0)",
|
|
1082
1156
|
// animate=false snaps with no transition (overrides the CSS transition)
|
|
@@ -1119,7 +1193,7 @@ function ImageViewer({
|
|
|
1119
1193
|
ref: imgWrapperRef,
|
|
1120
1194
|
className: "rvl-img-wrapper",
|
|
1121
1195
|
onClick: (e) => e.stopPropagation(),
|
|
1122
|
-
onDoubleClick:
|
|
1196
|
+
onDoubleClick: handleDoubleClickGuarded,
|
|
1123
1197
|
onPointerDown: gestures.handlePointerDown,
|
|
1124
1198
|
onPointerMove: gestures.handlePointerMove,
|
|
1125
1199
|
onPointerUp: gestures.handlePointerUp,
|
|
@@ -1166,7 +1240,7 @@ function ImageViewer({
|
|
|
1166
1240
|
}
|
|
1167
1241
|
),
|
|
1168
1242
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { ref: bottomBarRef, className: cx("rvl-bar", "rvl-bottom-bar", cn("bottomBar")), children: [
|
|
1169
|
-
showNavRow && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvl-nav-row", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-inner", children: [
|
|
1243
|
+
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: [
|
|
1170
1244
|
navStart != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: cx("rvl-nav-start", cn("navStart")), children: navStart }),
|
|
1171
1245
|
hasNavGroup && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvl-nav-group", children: [
|
|
1172
1246
|
/* @__PURE__ */ jsxRuntime.jsx(
|