UNPKG

react-image-viewer-hook

Version:
633 lines (627 loc) 16.2 kB
// 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