@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
284 lines (283 loc) • 9.13 kB
JavaScript
"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