UNPKG

react-image-viewer-hook

Version:
762 lines (755 loc) 21.7 kB
"use strict"; 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