UNPKG

@lobehub/ui

Version:

Lobe UI is an open-source UI component library for building AIGC web apps

284 lines (283 loc) 9.13 kB
"use client"; import { MotionComponent } from "../../MotionProvider/index.mjs"; import { useAppElement } from "../../ThemeProvider/AppElementContext.mjs"; import { useLayerZIndex } from "../../base-ui/zIndex/useLayerZIndex.mjs"; import { styles } from "../style.mjs"; import { computeFit, unrotatedRect } from "./geometry.mjs"; import { ToastHost } from "../../base-ui/Toast/imperative.mjs"; import { beginClosePreview, endClosePreview } from "./registry.mjs"; import { useFinalFocus } from "./useFinalFocus.mjs"; import { useFlipTransition } from "./useFlipTransition.mjs"; import { useViewerGestures } from "./useViewerGestures.mjs"; import { readNatural, useGalleryNav } from "./useGalleryNav.mjs"; import { useRefitTransition } from "./useRefitTransition.mjs"; import { useZoomPan } from "./useZoomPan.mjs"; import ViewerChrome from "./ViewerChrome.mjs"; import { memo, use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; import { useMergeRefs } from "react-merge-refs"; import { Dialog } from "@base-ui/react/dialog"; //#region src/Image/viewer/ImageViewer.tsx const readViewport = () => ({ height: window.innerHeight, width: window.innerWidth }); const prefersReducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; const ImageViewer = memo(({ entries, index, openerFocusElement, token }) => { const openerEntry = entries[index]; const appElement = useAppElement(); const motionComponent = use(MotionComponent); const [animated] = useState(() => !!motionComponent && !prefersReducedMotion()); const { ref: layerRef, zIndex } = useLayerZIndex("modal"); const [currentIndex, setCurrentIndex] = useState(index); const currentEntry = entries[currentIndex]; const currentEntryRef = useRef(currentEntry); currentEntryRef.current = currentEntry; const [source, setSource] = useState(openerEntry.src); const [natural, setNatural] = useState(() => readNatural(openerEntry.element)); const [viewport, setViewport] = useState(readViewport); const closeRef = useRef(null); const requestClose = useCallback(() => closeRef.current?.(), []); const isClosingIndirectRef = useRef(() => false); const isClosingIndirect = useCallback(() => isClosingIndirectRef.current(), []); const isTransitioningIndirectRef = useRef(() => true); const isTransitioningIndirect = useCallback(() => isTransitioningIndirectRef.current(), []); const fillViewport = Boolean(currentEntry.previewSrc && currentEntry.previewSrc !== currentEntry.src); const { canZoomIn, canZoomOut, dragBy, dragEnd, escIntent, flipHorizontal, flipVertical, flipX, flipY, getInitialScale, handleDoubleClick, handleWheel, isClean, isZoomed, reset, resetCloseTracking, rotate, rotateLeft, rotateRight, rotation, scale, setNatural: syncZoomNatural, setViewport: syncZoomViewport, toggleActualSize, x, y, zoomIn, zoomOut } = useZoomPan({ autoZoomThreshold: currentEntry.options.autoZoomThreshold, defaultZoom: currentEntry.options.defaultZoom, fillViewport, isClosing: isClosingIndirect, isTransitioning: isTransitioningIndirect, maxScale: currentEntry.options.maxScale, natural, onCloseRequest: requestClose, viewport }); const fitRect = useMemo(() => computeFit(natural, viewport, rotation, fillViewport), [ natural, viewport, rotation, fillViewport ]); const fitRectRef = useRef(fitRect); fitRectRef.current = fitRect; const getFitRect = useCallback(() => fitRectRef.current, []); const imageRect = useMemo(() => unrotatedRect(fitRect, rotation), [fitRect, rotation]); const viewportRef = useRef(viewport); viewportRef.current = viewport; const getViewportWidth = useCallback(() => viewportRef.current.width, []); const getCloseSource = useCallback(() => currentEntryRef.current.element, []); const transform = useMemo(() => ({ flipX, flipY, rotate, scale, x, y }), [ flipX, flipY, rotate, scale, x, y ]); const handleClosed = useCallback(() => endClosePreview(token), [token]); const { backdropRef, chromeRef, close, imageRef: flipImageRef, isClosing, isTransitioning, switchTo } = useFlipTransition({ animated, getCloseSource, getFitRect, getInitialScale, getViewportWidth, onClosed: handleClosed, source: openerEntry.element, transform }); isClosingIndirectRef.current = isClosing; isTransitioningIndirectRef.current = isTransitioning; useRefitTransition({ animated, fillViewport, isTransitioning, natural, rotation, scale, viewport, x, y }); const finalFocus = useFinalFocus(openerFocusElement); const isCleanRef = useRef(isClean); isCleanRef.current = isClean; const handleClose = useCallback(() => { beginClosePreview(token); close({ fade: !isCleanRef.current }); }, [close, token]); closeRef.current = handleClose; const handleDialogOpenChange = useCallback((open, eventDetails) => { if (open) return; if (isClosing()) return; if (eventDetails.reason === "escape-key" && escIntent() === "reset") { eventDetails.cancel(); reset(); return; } handleClose(); }, [ escIntent, handleClose, isClosing, reset ]); const gestures = useViewerGestures({ dragBy, dragEnd, handleDoubleClick, handleWheel, isClean, isZoomed, onClose: handleClose, reset, zoomIn, zoomOut }); const { hasNext, hasPrev, next, prev } = useGalleryNav({ cancelPendingClose: gestures.cancelPendingClose, currentIndex, entries, flipX, flipY, getInitialScale, resetCloseTracking, rotate, scale, setCurrentIndex, setNatural, setSource, switchTo, syncZoomNatural, x, y }); const popupNodeRef = useRef(null); const popupRef = useMergeRefs([ layerRef, gestures.popupRef, popupNodeRef ]); const imageRef = useMergeRefs([flipImageRef, gestures.imageRef]); const handleLoad = useCallback((event) => { const loaded = event.currentTarget; if (!loaded.naturalWidth || !loaded.naturalHeight) return; const next = { height: loaded.naturalHeight, width: loaded.naturalWidth }; setNatural((prev) => prev.width === next.width && prev.height === next.height ? prev : next); syncZoomNatural(next); }, [syncZoomNatural]); useEffect(() => { const handleResize = () => { const next = readViewport(); setViewport(next); syncZoomViewport(next); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [syncZoomViewport]); useEffect(() => { const { previewSrc, src } = currentEntry; if (!previewSrc || previewSrc === src) return; let cancelled = false; const loader = new window.Image(); const handleLoaded = () => { if (!cancelled) setSource(previewSrc); }; loader.addEventListener("load", handleLoaded); loader.src = previewSrc; return () => { cancelled = true; loader.removeEventListener("load", handleLoaded); }; }, [currentEntry]); useEffect(() => () => { if (!openerEntry.element.isConnected) endClosePreview(token); }, [openerEntry, token]); return /* @__PURE__ */ jsx(Dialog.Root, { modal: true, open: true, onOpenChange: handleDialogOpenChange, children: /* @__PURE__ */ jsxs(Dialog.Portal, { container: appElement ?? void 0, children: [ /* @__PURE__ */ jsx(Dialog.Backdrop, { forceRender: true, className: styles.viewerBackdrop, ref: backdropRef, style: zIndex === void 0 ? void 0 : { zIndex }, onClick: gestures.onSurfaceClick }), /* @__PURE__ */ jsxs(Dialog.Popup, { "aria-label": currentEntry.element.alt || void 0, className: styles.viewerPopup, finalFocus, initialFocus: popupNodeRef, ref: popupRef, style: zIndex === void 0 ? void 0 : { zIndex: zIndex + 1 }, tabIndex: -1, onClick: gestures.onSurfaceClick, onPointerCancel: gestures.onPointerFinish, onPointerDown: gestures.onPointerDown, onPointerMove: gestures.onPointerMove, onPointerUp: gestures.onPointerFinish, children: [/* @__PURE__ */ jsx("img", { alt: currentEntry.element.alt, className: styles.viewerImage, ref: imageRef, src: source, style: { cursor: gestures.cursor, height: imageRect.height, left: imageRect.x, top: imageRect.y, width: imageRect.width }, onClick: gestures.onImageClick, onDoubleClick: gestures.onImageDoubleClick, onLoad: handleLoad }), /* @__PURE__ */ jsx(ViewerChrome, { canZoomIn, canZoomOut, chromeRef, current: currentIndex, fitRect, flipHorizontal, flipVertical, hasNext, hasPrev, natural, next, prev, rotateLeft, rotateRight, rotation, scale, source, toggleActualSize, toolbarAddon: currentEntry.options.toolbarAddon, total: entries.length, zoomIn, zoomOut, onClose: handleClose })] }), /* @__PURE__ */ jsx(ToastHost, { root: appElement ?? void 0 }) ] }) }); }); ImageViewer.displayName = "ImageViewer"; //#endregion export { ImageViewer as default }; //# sourceMappingURL=ImageViewer.mjs.map