react-image-viewer-hook
Version:
Image viewer (aka Lightbox) React Hook
633 lines (627 loc) • 16.2 kB
JavaScript
// src/ImageViewer.tsx
import React4, {
useEffect,
useRef,
useState,
useCallback
} from "react";
import { useSprings, useSpring, animated } from "@react-spring/web";
import { useGesture } from "@use-gesture/react";
import { RemoveScroll } from "react-remove-scroll";
import FocusLock from "react-focus-lock";
// src/styles.ts
var 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
};
var DIALOG_STYLE = {
position: "fixed",
width: "100vw",
height: "100vh",
top: 0,
left: 0,
display: "flex",
overflow: "hidden",
marginRight: 32,
zIndex: 9999
};
var SLIDE_STYLE = {
width: "100vw",
height: "100vh",
overflow: "hidden",
flexShrink: 0,
position: "absolute",
justifyContent: "center",
alignItems: "center",
touchAction: "none"
};
var IMAGE_STYLE = {
touchAction: "none",
userSelect: "none",
maxWidth: "100vw",
maxHeight: "100vh"
};
// src/icons/CloseIcon.tsx
import React from "react";
function CloseIcon() {
return /* @__PURE__ */ React.createElement(
"svg",
{
width: "24",
height: "24",
fill: "none",
viewBox: "0 0 24 24",
"aria-hidden": "true"
},
/* @__PURE__ */ React.createElement(
"path",
{
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
d: "M17.25 6.75L6.75 17.25"
}
),
/* @__PURE__ */ React.createElement(
"path",
{
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
d: "M6.75 6.75L17.25 17.25"
}
)
);
}
// src/icons/ChevronLeftIcon.tsx
import React2 from "react";
function ChevronLeftIcon() {
return /* @__PURE__ */ React2.createElement(
"svg",
{
width: "18",
height: "24",
fill: "none",
viewBox: "0 0 18 24",
"aria-hidden": "true"
},
/* @__PURE__ */ React2.createElement(
"path",
{
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
d: "M13.696,20.721l-9.392,-8.721l9.392,-8.721"
}
)
);
}
// src/icons/ChevronRightIcon.tsx
import React3 from "react";
function ChevronRightIcon() {
return /* @__PURE__ */ React3.createElement(
"svg",
{
width: "18",
height: "24",
fill: "none",
viewBox: "0 0 18 24",
"aria-hidden": "true"
},
/* @__PURE__ */ React3.createElement(
"path",
{
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
d: "M4.304,3.279l9.392,8.721l-9.392,8.721"
}
)
);
}
// src/ImageViewer.tsx
function ImageViewer({
images,
defaultIndex,
onClose,
children
}) {
const [index, setIndex] = useState(
clamp(defaultIndex ?? 0, 0, images.length - 1)
);
const [isClosing, setClosing] = useState(false);
useEffect(() => {
setIndex((index2) => clamp(index2, 0, images.length - 1));
}, [images.length]);
const mode = useRef(
null
);
const offset = useRef([0, 0]);
const [[windowWidth, windowHeight], setWindowSize] = useState([
window.innerWidth,
window.innerHeight
]);
useEffect(() => {
function handleResize() {
setWindowSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const [backdropProps, backdropApi] = useSpring(() => ({
backgroundColor: "rgba(0, 0, 0, 0)"
}));
const [buttonProps, buttonApi] = useSpring(() => ({
display: "none"
}));
const [props, api] = 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"
}));
useEffect(() => {
api.start((i) => {
if (i === index) {
return {
opacity: 1,
scale: 1
};
}
});
backdropApi.start({
backgroundColor: `rgba(0, 0, 0, 1)`
});
buttonApi.start({
display: "block"
});
}, []);
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"
});
}
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));
}
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 = 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 = 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__ */ React4.createElement(FocusLock, { autoFocus: true, returnFocus: true }, /* @__PURE__ */ React4.createElement(RemoveScroll, null, /* @__PURE__ */ React4.createElement(
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__ */ React4.createElement(
animated.div,
{
...bind(),
key: i,
style: {
...SLIDE_STYLE,
display,
x: h
}
},
/* @__PURE__ */ React4.createElement("picture", null, Object.entries(images[i][1]?.sources ?? {}).map(
([type, srcSet]) => /* @__PURE__ */ React4.createElement("source", { key: type, type, srcSet })
), /* @__PURE__ */ React4.createElement(
animated.img,
{
style: {
...IMAGE_STYLE,
x,
y,
scale,
opacity
},
loading: Math.abs(index - i) > 1 ? "lazy" : "eager",
src: images[i][0],
draggable: false
}
))
))
), /* @__PURE__ */ React4.createElement(
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__ */ React4.createElement(
animated.button,
{
"aria-label": "close image viewer",
style: {
...BUTTON_STYLE,
width: 40,
top: 16,
right: 16
},
onClick: close
},
/* @__PURE__ */ React4.createElement(CloseIcon, null)
),
!children && index > 0 && /* @__PURE__ */ React4.createElement(
animated.button,
{
"aria-label": "previous image",
style: {
...BUTTON_STYLE,
top: "50%",
width: 24,
left: 16,
marginTop: -20
},
onClick: previousImage
},
/* @__PURE__ */ React4.createElement(ChevronLeftIcon, null)
),
!children && index < images.length - 1 && /* @__PURE__ */ React4.createElement(
animated.button,
{
"aria-label": "next image",
style: {
...BUTTON_STYLE,
top: "50%",
width: 24,
right: 16,
marginTop: -20
},
onClick: nextImage
},
/* @__PURE__ */ React4.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);
}
export {
ImageViewer as default
};
//# sourceMappingURL=ImageViewer-5KR27HWJ.js.map