react-image-viewer-hook
Version:
Image viewer (aka Lightbox) React Hook
762 lines (755 loc) • 21.7 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/styles.ts
var BUTTON_STYLE, DIALOG_STYLE, SLIDE_STYLE, IMAGE_STYLE;
var init_styles = __esm({
"src/styles.ts"() {
"use strict";
BUTTON_STYLE = {
position: "fixed",
backgroundColor: "rgba(0, 0, 0, 0.3)",
border: "none",
color: "white",
borderRadius: 4,
display: "flex",
height: 40,
justifyContent: "center",
alignItems: "center",
padding: 0,
zIndex: 9999
};
DIALOG_STYLE = {
position: "fixed",
width: "100vw",
height: "100vh",
top: 0,
left: 0,
display: "flex",
overflow: "hidden",
marginRight: 32,
zIndex: 9999
};
SLIDE_STYLE = {
width: "100vw",
height: "100vh",
overflow: "hidden",
flexShrink: 0,
position: "absolute",
justifyContent: "center",
alignItems: "center",
touchAction: "none"
};
IMAGE_STYLE = {
touchAction: "none",
userSelect: "none",
maxWidth: "100vw",
maxHeight: "100vh"
};
}
});
// src/icons/CloseIcon.tsx
function CloseIcon() {
return /* @__PURE__ */ import_react.default.createElement(
"svg",
{
width: "24",
height: "24",
fill: "none",
viewBox: "0 0 24 24",
"aria-hidden": "true"
},
/* @__PURE__ */ import_react.default.createElement(
"path",
{
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
d: "M17.25 6.75L6.75 17.25"
}
),
/* @__PURE__ */ import_react.default.createElement(
"path",
{
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
d: "M6.75 6.75L17.25 17.25"
}
)
);
}
var import_react;
var init_CloseIcon = __esm({
"src/icons/CloseIcon.tsx"() {
"use strict";
import_react = __toESM(require("react"));
}
});
// src/icons/ChevronLeftIcon.tsx
function ChevronLeftIcon() {
return /* @__PURE__ */ import_react2.default.createElement(
"svg",
{
width: "18",
height: "24",
fill: "none",
viewBox: "0 0 18 24",
"aria-hidden": "true"
},
/* @__PURE__ */ import_react2.default.createElement(
"path",
{
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
d: "M13.696,20.721l-9.392,-8.721l9.392,-8.721"
}
)
);
}
var import_react2;
var init_ChevronLeftIcon = __esm({
"src/icons/ChevronLeftIcon.tsx"() {
"use strict";
import_react2 = __toESM(require("react"));
}
});
// src/icons/ChevronRightIcon.tsx
function ChevronRightIcon() {
return /* @__PURE__ */ import_react3.default.createElement(
"svg",
{
width: "18",
height: "24",
fill: "none",
viewBox: "0 0 18 24",
"aria-hidden": "true"
},
/* @__PURE__ */ import_react3.default.createElement(
"path",
{
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
d: "M4.304,3.279l9.392,8.721l-9.392,8.721"
}
)
);
}
var import_react3;
var init_ChevronRightIcon = __esm({
"src/icons/ChevronRightIcon.tsx"() {
"use strict";
import_react3 = __toESM(require("react"));
}
});
// src/ImageViewer.tsx
var ImageViewer_exports = {};
__export(ImageViewer_exports, {
default: () => ImageViewer
});
function ImageViewer({
images,
defaultIndex,
onClose,
children
}) {
const [index, setIndex] = (0, import_react4.useState)(
clamp(defaultIndex ?? 0, 0, images.length - 1)
);
const [isClosing, setClosing] = (0, import_react4.useState)(false);
(0, import_react4.useEffect)(() => {
setIndex((index2) => clamp(index2, 0, images.length - 1));
}, [images.length]);
const mode = (0, import_react4.useRef)(
null
);
const offset = (0, import_react4.useRef)([0, 0]);
const [[windowWidth, windowHeight], setWindowSize] = (0, import_react4.useState)([
window.innerWidth,
window.innerHeight
]);
(0, import_react4.useEffect)(() => {
function handleResize() {
setWindowSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const [backdropProps, backdropApi] = (0, import_web.useSpring)(() => ({
backgroundColor: "rgba(0, 0, 0, 0)"
}));
const [buttonProps, buttonApi] = (0, import_web.useSpring)(() => ({
display: "none"
}));
const [props, api] = (0, import_web.useSprings)(images.length, (i) => ({
h: horizontalPosition(i, index, windowWidth),
x: 0,
y: 0,
// Prepare the enter animation of the active image.
scale: i === index ? 0.2 : 1,
opacity: i === index ? 0 : 1,
display: i === index ? "flex" : "none"
}));
(0, import_react4.useEffect)(() => {
api.start((i) => {
if (i === index) {
return {
opacity: 1,
scale: 1
};
}
});
backdropApi.start({
backgroundColor: `rgba(0, 0, 0, 1)`
});
buttonApi.start({
display: "block"
});
}, []);
(0, import_react4.useEffect)(() => {
mode.current = null;
api.start((i) => {
if (i < index - 1 || i > index + 1) {
return { display: "none" };
}
return {
h: horizontalPosition(i, index, windowWidth),
x: 0,
y: 0,
scale: 1,
display: "flex"
};
});
}, [index]);
function close() {
if (isClosing) {
return;
}
setClosing(true);
let onCloseCalled = false;
function onRest() {
if (onCloseCalled) {
return;
}
onCloseCalled = true;
onClose();
}
const config = {
mass: 0.5,
friction: 10
};
api.start((i) => {
if (i !== index) {
return;
}
return {
opacity: 0,
scale: 0.2,
x: 0,
y: 0,
sx: 0,
sy: 0,
onRest,
config
};
});
backdropApi.start({
backgroundColor: `rgba(0, 0, 0, 0)`,
onRest,
config
});
buttonApi.start({
display: "none"
});
}
(0, import_react4.useEffect)(() => {
if (images.length === 0 && !isClosing) {
close();
}
}, [images.length, close, isClosing]);
function nextImage() {
setIndex((index2) => clamp(index2 + 1, 0, images.length - 1));
}
function previousImage() {
setIndex((index2) => clamp(index2 - 1, 0, images.length - 1));
}
(0, import_react4.useEffect)(() => {
function handleKeyDown(e) {
switch (e.code) {
case "Escape":
close();
break;
case "ArrowLeft":
previousImage();
break;
case "ArrowRight":
nextImage();
break;
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
function startPinch() {
mode.current = "pinch";
buttonApi.start({ display: "none" });
}
function stopPinch() {
offset.current = [0, 0];
mode.current = null;
buttonApi.start({ display: "block" });
}
function handleDoubleClick(e) {
if (isClosing) {
return;
}
let img = e.target;
if (!img || !(img instanceof HTMLImageElement)) {
img = e.currentTarget.querySelector(`img[src="${images[index][0]}"]`);
}
if (!img || !(img instanceof HTMLImageElement)) {
console.warn("Failed to determine active image during double tap");
return;
}
api.start((i, ctrl) => {
if (i !== index) {
return;
}
if (!img) {
return;
}
if (mode.current === "pinch") {
stopPinch();
return { scale: 1, x: 0, y: 0 };
} else {
const newScale = img.naturalWidth / img.width;
if (newScale < 1) {
return;
}
const originOffsetX = e.clientX - (windowWidth / 2 + offset.current[0]);
const originOffsetY = e.clientY - (windowHeight / 2 + offset.current[1]);
const scale = ctrl.get().scale;
const refX = originOffsetX / scale;
const refY = originOffsetY / scale;
const transformOriginX = refX * newScale - originOffsetX;
const transformOriginY = refY * newScale - originOffsetY;
offset.current[0] -= transformOriginX;
offset.current[1] -= transformOriginY;
startPinch();
return {
scale: newScale,
x: offset.current[0],
y: offset.current[1]
};
}
});
}
const bind = (0, import_react5.useGesture)(
{
onDrag({ last, active, movement: [mx, my], cancel, swipe, pinching }) {
if (pinching) {
cancel();
return;
}
if (mode.current === null) {
mode.current = deriveMode(mx, my);
} else if (mode.current === "startSlide" && (Math.abs(mx) > 16 || Math.abs(my) > 16)) {
mode.current = "slide";
}
let newIndex = index;
switch (mode.current) {
case "startSlide":
case "slide":
if (swipe[0] !== 0) {
newIndex = clamp(index - swipe[0], 0, images.length - 1);
setIndex(newIndex);
} else if (last && Math.abs(mx) > windowWidth / 2) {
newIndex = clamp(index + (mx > 0 ? -1 : 1), 0, images.length - 1);
setIndex(newIndex);
}
break;
case "dismiss":
if (last && my > 0 && my / windowHeight > 0.1) {
close();
return;
} else {
backdropApi.start({
backgroundColor: `rgba(0, 0, 0, ${Math.max(
0,
1 - Math.abs(my) / windowHeight * 2
)})`
});
}
break;
}
api.start((i) => {
const boundary = mode.current === "startSlide" || mode.current === "slide" ? 1 : 0;
if (i < newIndex - boundary || i > newIndex + boundary) {
return { display: "none" };
}
const h = horizontalPosition(i, newIndex, windowWidth) + (active ? mx : 0);
switch (mode.current) {
case "startSlide":
case "slide":
return { h, y: 0, display: "flex", immediate: active };
case "dismiss":
const y = active ? my : 0;
const scale = active ? Math.max(1 - Math.abs(my) / windowHeight / 2, 0.8) : 1;
return { h, y, scale, display: "flex", immediate: active };
case "pinch":
return {
x: offset.current[0] + mx,
y: offset.current[1] + my,
display: "flex",
immediate: active
};
}
});
if (last) {
if (mode.current === "pinch") {
offset.current = [offset.current[0] + mx, offset.current[1] + my];
} else {
mode.current = null;
}
backdropApi.start({ backgroundColor: "rgba(0, 0, 0, 1)" });
}
},
onPinch({
origin: [ox, oy],
first,
last,
offset: [scale],
memo,
cancel
}) {
if (mode.current !== null && mode.current !== "startSlide" && mode.current !== "pinch") {
cancel();
return;
}
if (mode.current !== "pinch") {
startPinch();
}
if (first || !memo) {
const originOffsetX = ox - (windowWidth / 2 + offset.current[0]);
const originOffsetY = oy - (windowHeight / 2 + offset.current[1]);
memo = {
origin: {
x: ox,
y: oy
},
offset: {
refX: originOffsetX / scale,
refY: originOffsetY / scale,
x: originOffsetX,
y: originOffsetY
}
};
}
const transformOriginX = memo.offset.refX * scale - memo.offset.x;
const transformOriginY = memo.offset.refY * scale - memo.offset.y;
const mx = ox - memo.origin.x - transformOriginX;
const my = oy - memo.origin.y - transformOriginY;
api.start((i) => {
if (i !== index) {
return;
}
if (last && scale <= 1.1) {
return {
x: 0,
y: 0,
scale: 1
};
} else {
return {
h: 0,
scale,
x: offset.current[0] + mx,
y: offset.current[1] + my,
immediate: true
};
}
});
if (last) {
if (scale <= 1.1) {
stopPinch();
} else {
offset.current = [offset.current[0] + mx, offset.current[1] + my];
}
}
return memo;
}
},
{
drag: {
enabled: !isClosing
},
pinch: {
enabled: !isClosing,
scaleBounds: { min: 1, max: Infinity },
from: () => [api.current[index].get().scale, 0]
}
}
);
const current = (0, import_react4.useCallback)(() => {
if (index >= images.length) {
return void 0;
}
const [, opts] = images[index];
if (opts && isOptsWithData(opts)) {
return opts.data;
} else {
return void 0;
}
}, [images, index]);
return /* @__PURE__ */ import_react4.default.createElement(import_react_focus_lock.default, { autoFocus: true, returnFocus: true }, /* @__PURE__ */ import_react4.default.createElement(import_react_remove_scroll.RemoveScroll, null, /* @__PURE__ */ import_react4.default.createElement(
import_web.animated.div,
{
role: "dialog",
"aria-label": "image viewer",
style: {
...DIALOG_STYLE,
...backdropProps,
// Pass through pointer events so that the closing animation isn't stopping the user
// from already interacting with the elements behind the viewer.
pointerEvents: isClosing ? "none" : "auto"
},
onDoubleClick: handleDoubleClick
},
props.map(({ h, x, y, scale, opacity, display }, i) => /* @__PURE__ */ import_react4.default.createElement(
import_web.animated.div,
{
...bind(),
key: i,
style: {
...SLIDE_STYLE,
display,
x: h
}
},
/* @__PURE__ */ import_react4.default.createElement("picture", null, Object.entries(images[i][1]?.sources ?? {}).map(
([type, srcSet]) => /* @__PURE__ */ import_react4.default.createElement("source", { key: type, type, srcSet })
), /* @__PURE__ */ import_react4.default.createElement(
import_web.animated.img,
{
style: {
...IMAGE_STYLE,
x,
y,
scale,
opacity
},
loading: Math.abs(index - i) > 1 ? "lazy" : "eager",
src: images[i][0],
draggable: false
}
))
))
), /* @__PURE__ */ import_react4.default.createElement(
import_web.animated.div,
{
style: {
...buttonProps,
position: "fixed",
top: 0,
left: 0,
width: 0,
height: 0,
zIndex: 9999
}
},
children ? children({
current,
close,
previous: index > 0 ? previousImage : void 0,
next: index < images.length - 1 ? nextImage : void 0
}) : /* @__PURE__ */ import_react4.default.createElement(
import_web.animated.button,
{
"aria-label": "close image viewer",
style: {
...BUTTON_STYLE,
width: 40,
top: 16,
right: 16
},
onClick: close
},
/* @__PURE__ */ import_react4.default.createElement(CloseIcon, null)
),
!children && index > 0 && /* @__PURE__ */ import_react4.default.createElement(
import_web.animated.button,
{
"aria-label": "previous image",
style: {
...BUTTON_STYLE,
top: "50%",
width: 24,
left: 16,
marginTop: -20
},
onClick: previousImage
},
/* @__PURE__ */ import_react4.default.createElement(ChevronLeftIcon, null)
),
!children && index < images.length - 1 && /* @__PURE__ */ import_react4.default.createElement(
import_web.animated.button,
{
"aria-label": "next image",
style: {
...BUTTON_STYLE,
top: "50%",
width: 24,
right: 16,
marginTop: -20
},
onClick: nextImage
},
/* @__PURE__ */ import_react4.default.createElement(ChevronRightIcon, null)
)
)));
}
function isOptsWithData(opts) {
return "data" in opts;
}
function deriveMode(mx, my) {
if (mx === 0 && my === 0) {
return null;
}
if (my > 0 && my > Math.abs(mx)) {
return "dismiss";
}
return "startSlide";
}
function clamp(n, l, h) {
if (h < 0) {
return 0;
}
if (n < l) {
return l;
}
if (n > h) {
return h;
}
return n;
}
function horizontalPosition(itemIndex, activeIndex, width) {
return (itemIndex - activeIndex) * (width + 32);
}
var import_react4, import_web, import_react5, import_react_remove_scroll, import_react_focus_lock;
var init_ImageViewer = __esm({
"src/ImageViewer.tsx"() {
"use strict";
import_react4 = __toESM(require("react"));
import_web = require("@react-spring/web");
import_react5 = require("@use-gesture/react");
import_react_remove_scroll = require("react-remove-scroll");
import_react_focus_lock = __toESM(require("react-focus-lock"));
init_styles();
init_CloseIcon();
init_ChevronLeftIcon();
init_ChevronRightIcon();
}
});
// src/useImageViewer.tsx
var useImageViewer_exports = {};
__export(useImageViewer_exports, {
useImageViewer: () => useImageViewer
});
module.exports = __toCommonJS(useImageViewer_exports);
var import_react6 = __toESM(require("react"));
var LazyImageViewer = (0, import_react6.lazy)(() => Promise.resolve().then(() => (init_ImageViewer(), ImageViewer_exports)));
function useImageViewer() {
const images = (0, import_react6.useRef)([]);
images.current = [];
const setOpens = (0, import_react6.useRef)(
/* @__PURE__ */ new Set()
);
const ImageViewer2 = (0, import_react6.useCallback)(
function ImageViewer3({
fallback,
children
}) {
const [isOpen, setOpen] = (0, import_react6.useState)(void 0);
(0, import_react6.useEffect)(() => {
setOpens.current.add(setOpen);
return () => {
setOpens.current.delete(setOpen);
};
}, [setOpen]);
const handleClose = (0, import_react6.useCallback)(() => setOpen(void 0), []);
if (isOpen === void 0 || typeof window === "undefined") {
return null;
}
const Viewer = LazyImageViewer;
return /* @__PURE__ */ import_react6.default.createElement(import_react6.Suspense, { fallback: fallback ?? null }, /* @__PURE__ */ import_react6.default.createElement(
Viewer,
{
images: images.current,
onClose: handleClose,
defaultIndex: isOpen,
children
}
));
},
[]
);
return {
/**
* Create an `onClick` event handler meant to add an image to viewer as well as opening the
* the viewer once invoked.
* @param url The URL of the image that should be added to the image viewer.
*/
getOnClick(url, opts) {
const index = images.current.length;
if (url) {
images.current.push([url, opts]);
}
return (e) => {
e.preventDefault();
for (const setOpen of setOpens.current) {
setOpen(index);
}
};
},
/**
* Render the image viewer. It will only actually render something if the viewer is opened. Can
* be added everywhere you like including into portals.
*/
ImageViewer: ImageViewer2
};
}
//# sourceMappingURL=useImageViewer.cjs.js.map
;