React 和 Framer Motion 实现的图片灯箱组件。文章介绍了如何通过 data-lightbox、事件委托和 Framer Motion,为 Markdown、MDX 等动态内容中的图片提供统一的灯箱体验,并实现图片组切换、缩略图导航、键盘操作、滚轮缩放、拖拽、原图位置过渡以及预览图与高清原图分层加载等功能,同时讲解了动画状态、原图隐藏和页面滚动锁定等实现细节。
LOADING...
React 和 Framer Motion 实现的图片灯箱组件。文章介绍了如何通过 data-lightbox、事件委托和 Framer Motion,为 Markdown、MDX 等动态内容中的图片提供统一的灯箱体验,并实现图片组切换、缩略图导航、键盘操作、滚轮缩放、拖拽、原图位置过渡以及预览图与高清原图分层加载等功能,同时讲解了动画状态、原图隐藏和页面滚动锁定等实现细节。
很久之前我写过一篇 React 实现图片点击放大 ,当时的实现比较简单:给图片加上 data-zoomable 属性,然后在组件挂载时绑定点击事件。点击以后手动创建一个遮罩层,再复制一份图片出来,通过 CSS 做放大和关闭动画。
这个实现用来处理单张图片没有什么问题,代码也比较容易看懂。不过后来 NeutralPress 里的图片使用场景越来越多,文章、普通页面、项目详情、照片详情和后台媒体预览都需要图片查看功能,原来的小组件就有点不够用了。
如果继续在原来的代码上增加上一张、下一张、缩略图、键盘、滚轮缩放和拖拽,最后大概会变成一堆直接操作 DOM 的逻辑。于是这次没有继续修补旧组件,而是重新写了一个基于 React 和 Framer Motion 的图片灯箱。
NeutralPress 的文章内容主要通过 Markdown 和 MDX 渲染,图片并不是在一个固定的 React 列表里生成的。
这就带来了几个问题:
所以我最后把图片接入方式简化成了一个属性:
页面中只需要挂载一次灯箱:
之后所有带有 data-lightbox 属性的图片都会自动拥有点击放大效果。
现在这套图片灯箱主要支持下面这些功能:
data-lightbox 自动识别图片。object-fit、object-position 和圆角。你可以在这里试一试这个图片灯箱的效果:
1369630_20260326213251_1.png
1369630_20260326213308_1.png
1369630_20260326213601_1.png
先安装 Framer Motion:
或者:
然后在页面中挂载灯箱:
如果页面中只有一张图片,可以不设置原图地址:
这时灯箱会直接使用当前图片地址。
如果页面中的图片是缩略图,而灯箱需要展示另一张原图,可以加上 :
在 Markdown 或 MDX 渲染器里,只要让最终生成的 标签带上 即可。NeutralPress 目前就是在 Markdown 图片组件中自动添加这个属性。
下面这份代码是从 NeutralPress 的实际组件中整理出来的单文件版本。
项目里的原始组件还使用了 CMSImage、Footer Store、RemixIcon 和 Tailwind CSS。为了让这份代码可以脱离 NeutralPress 直接复制使用,这里把这些项目内部依赖都去掉了。将下面的内容保存为 :
之前的组件是在 中查找所有图片,然后给每张图片绑定点击事件。
这种方式的问题是,图片如果在组件挂载以后才出现,就不会自动获得点击事件。文章内容、MDX 内容和一些动态组件都可能遇到这个情况。
新组件直接监听 document 的 click 事件:
点击时再通过 判断目标是否来自:
如果是,就重新扫描当前页面中的图片并计算索引。
这样做的好处是,组件不需要关心图片什么时候渲染,只要用户点击时图片已经存在,就可以处理。
另外我这里使用的是捕获阶段:
这样可以在链接跳转或其他点击逻辑执行之前先拦截图片事件。否则如果图片被某个链接包裹,点击时可能先触发页面跳转。
灯箱打开时最重要的一个效果,就是不能突然从屏幕中央出现。
点击图片后,组件会先获取它在页面中的位置:
这个位置就是动画的起点。
随后再根据窗口大小、图片原始尺寸和当前是否显示缩略图,计算灯箱里的目标位置:
最终让 Framer Motion 同时控制:
因此图片可以从原来的尺寸和位置,平滑地移动到屏幕中央。
关闭时则反过来。如果当前图片就是最初打开的图片,就从灯箱位置缩回原位置。如果用户已经切换到了其他图片,则直接在当前灯箱位置淡出。
这里不能只使用 。因为只缩放图片的话,图片的左上角位置并不会自动移动到目标位置,所以还需要一起控制 x、y、width 和 height。
灯箱里的图片实际上是额外渲染到 下的一份图片。
如果不隐藏页面原图,就会出现两个问题:
所以打开灯箱时,会给原图加上:
再通过动态 CSS 设置:
关闭动画完成后,再移除这个属性。
这里没有使用 ,因为 会让元素失去布局尺寸,关闭动画时就无法继续读取原来的位置。只设置透明度,图片仍然占据原来的布局空间,动画会稳定很多。
灯箱打开以后,如果直接等待原图加载,网络较慢时会有一段空白。
因此组件里同时渲染两张图片:
第一张是页面中已经使用的预览图,第二张是灯箱需要加载的原图。
原图还没有加载完成时,先显示预览图:
原图加载完成后,再把预览图淡出:
这样打开时会更快看到内容,高清图加载完成后也不会突然替换。
另外,组件会读取原图片的这些属性:
这样灯箱里的图片不会和页面原图出现明显不同的裁剪方式。
打开图片和切换图片其实是两种不同的动画。
打开时需要从原图片位置放大:
切换图片时则更适合左右滑动:
所以组件通过 和 区分状态:
Framer Motion 的 会根据当前图片的 key 变化,同时执行上一张图片的 exit 动画和下一张图片的 enter 动画。
缩略图切换也是调用同一套逻辑,只是目标索引不再固定为上一张或下一张。
滚轮缩放的实现比较简单:
这里把缩放比例限制在 1 到 5 倍之间。
当缩放比例大于 1 时,才启用拖拽:
现在的拖拽边界还是比较宽松的,因为不同图片的尺寸、窗口大小和缩放比例都不一样。要做得更严谨,就需要根据图片实际尺寸动态计算拖拽范围。
灯箱打开后会锁定 body 滚动:
否则用户在滚轮缩放时,页面背景也可能一起滚动。
组件卸载时还要恢复:
这些清理逻辑看起来比较琐碎,不过灯箱这种会频繁打开和关闭的组件,如果不清理,比较容易留下状态或事件问题。
之前的图片放大组件主要是为了快速实现一个效果,所以直接操作 DOM 也没有什么问题。它代码短,依赖少,如果你只想给一篇文章加单图放大,其实那套实现依然够用。
但是 NeutralPress 后来需要在多个页面中复用图片查看功能,还需要支持图片切换、缩放、拖拽和不同尺寸的图片加载。这个时候继续往旧组件里堆逻辑就不太合适了,所以这次重新使用 React 和 Framer Motion 写了一套。
新组件的代码明显更长,里面也有不少动画和状态处理。不过换来的好处是接入方式统一了,页面只需要给图片加上 ,再挂载一次 ImageLightbox 就可以。
目前它已经覆盖了文章页、普通页面、项目页、照片详情页和后台媒体预览。至于触摸手势、更加精确的拖拽边界、图片预加载和下载功能,后面如果真的需要,再继续补上吧。
data-lightbox-srcimgdata-lightboxImageLightbox.jsxuseEffectclosestscaledocument.bodydisplay: nonedisplay: nonedirectionisSlidingAnimatePresencedata-lightbox<ImageLightbox /><img
src="/thumbnail.jpg"
data-lightbox="true"
data-lightbox-src="/original.jpg"
alt="示例图片"
/>原图片位置 → 屏幕中央当前图片 → 左侧移出
下一张图片 → 右侧进入import ImageLightbox from "./ImageLightbox";
export default function Page() {
return (
<>
<ImageLightbox />
<img
src="/images/example.jpg"
alt="示例图片"
data-lightbox="true"
/>
</>
);
}<img
src="/images/example.jpg"
alt="示例图片"
data-lightbox="true"
/><img
src="/images/example-small.jpg"
data-lightbox="true"
data-lightbox-src="/images/example-original.jpg"
alt="示例图片"
/>"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
const STYLE_ID = "standalone-image-lightbox-styles";
const styles = {
root: {
position: "fixed",
inset: 0,
zIndex: 9999,
color: "#fff",
},
controls: {
position: "fixed",
inset: 0,
zIndex: 10001,
pointerEvents: "none",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
},
topBar: {
width: "100%",
padding: "16px",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: "16px",
pointerEvents: "auto",
background:
"linear-gradient(to bottom, rgba(0, 0, 0, .8), transparent)",
},
topText: {
minWidth: 0,
maxWidth: "80%",
display: "flex",
flexDirection: "column",
gap: "4px",
},
altText: {
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
fontSize: "18px",
lineHeight: 1.4,
fontWeight: 500,
},
counter: {
color: "rgba(255, 255, 255, .7)",
fontSize: "14px",
},
iconButton: {
width: "42px",
height: "42px",
padding: 0,
border: 0,
borderRadius: "999px",
color: "rgba(255, 255, 255, .75)",
background: "rgba(0, 0, 0, .2)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .2s ease",
},
navigationButton: {
position: "absolute",
top: "50%",
width: "48px",
height: "48px",
padding: 0,
border: 0,
borderRadius: "999px",
color: "rgba(255, 255, 255, .8)",
background: "rgba(0, 0, 0, .25)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transform: "translateY(-50%)",
transition: "all .2s ease",
backdropFilter: "blur(8px)",
},
thumbnailBar: {
width: "100%",
height: "96px",
padding: "0 16px",
overflowX: "auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "auto",
background: "rgba(0, 0, 0, .8)",
backdropFilter: "blur(12px)",
},
thumbnailList: {
width: "max-content",
height: "100%",
display: "flex",
alignItems: "center",
gap: "8px",
},
thumbnailButton: {
position: "relative",
height: "64px",
width: "auto",
minWidth: "64px",
padding: 0,
border: "2px solid transparent",
borderRadius: "6px",
overflow: "hidden",
flexShrink: 0,
cursor: "pointer",
background: "transparent",
transition: "all .2s ease",
},
thumbnailImage: {
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
},
};
function Icon({ type, size = 24 }) {
const commonProps = {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
};
if (type === "close") {
return (
<svg {...commonProps}>
<path d="M6 6l12 12" />
<path d="M18 6L6 18" />
</svg>
);
}
if (type === "left") {
return (
<svg {...commonProps}>
<path d="M15 18l-6-6 6-6" />
</svg>
);
}
return (
<svg {...commonProps}>
<path d="M9 18l6-6-6-6" />
</svg>
);
}
function isVisibleImage(img) {
if (!img || !document.body.contains(img)) {
return false;
}
const rects = img.getClientRects();
if (rects.length === 0) {
return false;
}
const style = window.getComputedStyle(img);
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0
);
}
export default function ImageLightbox({
hideImageList = false,
}) {
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [openedIndex, setOpenedIndex] = useState(null);
const [direction, setDirection] = useState(0);
const [isSliding, setIsSliding] = useState(false);
const [scale, setScale] = useState(1);
const [images, setImages] = useState([]);
const [loadedSources, setLoadedSources] = useState({});
const imageElementsRef = useRef([]);
const thumbnailRefs = useRef([]);
const visibilityTimeoutsRef = useRef(new Map());
const markImageLoaded = useCallback((src) => {
setLoadedSources((previous) => {
if (previous[src]) {
return previous;
}
return {
...previous,
[src]: true,
};
});
}, []);
const setOriginalImageVisible = useCallback(
(index, visible) => {
const img = imageElementsRef.current[index];
if (!img || !document.body.contains(img)) {
return;
}
const oldTimeout = visibilityTimeoutsRef.current.get(img);
if (oldTimeout) {
window.clearTimeout(oldTimeout);
visibilityTimeoutsRef.current.delete(img);
}
if (!visible) {
img.dataset.lightboxVisibility = "hidden";
return;
}
img.dataset.lightboxVisibility = "restoring";
window.requestAnimationFrame(() => {
if (!document.body.contains(img)) {
return;
}
const timeout = window.setTimeout(() => {
if (
document.body.contains(img) &&
img.dataset.lightboxVisibility === "restoring"
) {
delete img.dataset.lightboxVisibility;
}
visibilityTimeoutsRef.current.delete(img);
}, 160);
visibilityTimeoutsRef.current.set(img, timeout);
});
},
[],
);
const scanImages = useCallback(() => {
const elements = Array.from(
document.querySelectorAll("img[data-lightbox]"),
).filter(isVisibleImage);
const data = elements.map((img) => {
const computedStyle = window.getComputedStyle(img);
return {
src: img.dataset.lightboxSrc || img.src,
previewSrc: img.currentSrc || img.src,
alt: img.alt || "",
objectFit: computedStyle.objectFit || "fill",
objectPosition: computedStyle.objectPosition || "50% 50%",
borderRadius: computedStyle.borderRadius || "0px",
};
});
return {
elements,
data,
};
}, []);
const openLightbox = useCallback(
(index) => {
const result = scanImages();
if (!result.data[index]) {
return;
}
imageElementsRef.current = result.elements;
setImages(result.data);
setCurrentIndex(index);
setOpenedIndex(index);
setDirection(0);
setIsSliding(false);
setIsClosing(false);
setScale(1);
setOriginalImageVisible(index, false);
setIsOpen(true);
},
[scanImages, setOriginalImageVisible],
);
const closeLightbox = useCallback(() => {
if (!isOpen || isClosing) {
return;
}
setIsClosing(true);
setDirection(0);
setIsSliding(false);
}, [isClosing, isOpen]);
const prevImage = useCallback(
(event) => {
if (images.length <= 1 || hideImageList) {
return;
}
event?.stopPropagation();
const nextIndex =
(currentIndex - 1 + images.length) % images.length;
setOriginalImageVisible(currentIndex, true);
setOriginalImageVisible(nextIndex, false);
setDirection(-1);
setIsSliding(true);
setCurrentIndex(nextIndex);
},
[
currentIndex,
hideImageList,
images.length,
setOriginalImageVisible,
],
);
const nextImage = useCallback(
(event) => {
if (images.length <= 1 || hideImageList) {
return;
}
event?.stopPropagation();
const nextIndex = (currentIndex + 1) % images.length;
setOriginalImageVisible(currentIndex, true);
setOriginalImageVisible(nextIndex, false);
setDirection(1);
setIsSliding(true);
setCurrentIndex(nextIndex);
},
[
currentIndex,
hideImageList,
images.length,
setOriginalImageVisible,
],
);
const jumpToImage = useCallback(
(index) => {
if (index === currentIndex) {
return;
}
setOriginalImageVisible(currentIndex, true);
setOriginalImageVisible(index, false);
setDirection(index > currentIndex ? 1 : -1);
setIsSliding(true);
setCurrentIndex(index);
},
[currentIndex, setOriginalImageVisible],
);
const handleWheel = useCallback(
(event) => {
if (!isOpen) {
return;
}
event.preventDefault();
event.stopPropagation();
setScale((previous) => {
const delta = -event.deltaY * 0.001;
const nextScale = previous + delta;
return Math.min(Math.max(nextScale, 1), 5);
});
},
[isOpen],
);
useEffect(() => {
setMounted(true);
return () => {
document.body.style.overflow = "";
imageElementsRef.current.forEach((img) => {
if (img) {
delete img.dataset.lightboxVisibility;
}
});
visibilityTimeoutsRef.current.forEach((timeout) => {
window.clearTimeout(timeout);
});
visibilityTimeoutsRef.current.clear();
};
}, []);
useEffect(() => {
setScale(1);
}, [currentIndex]);
useEffect(() => {
if (!isOpen || images.length === 0) {
return;
}
const timeout = window.setTimeout(() => {
const button = thumbnailRefs.current[currentIndex];
button?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}, 50);
return () => {
window.clearTimeout(timeout);
};
}, [currentIndex, images.length, isOpen]);
useEffect(() => {
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
img[data-lightbox] {
cursor: zoom-in !important;
transition: opacity .2s ease !important;
}
img[data-lightbox][data-lightbox-visibility="hidden"],
img[data-lightbox][data-lightbox-visibility="hidden"]:hover {
opacity: 0 !important;
}
img[data-lightbox][data-lightbox-visibility="restoring"],
img[data-lightbox][data-lightbox-visibility="restoring"]:hover {
opacity: 1 !important;
}
.standalone-image-lightbox-button:hover {
color: white !important;
background: rgba(255, 255, 255, .12) !important;
}
.standalone-image-lightbox-thumbnail:hover {
opacity: 1 !important;
}
`;
document.head.appendChild(style);
return () => {
const currentStyle = document.getElementById(STYLE_ID);
if (currentStyle) {
currentStyle.remove();
}
};
}, []);
useEffect(() => {
const handleGlobalClick = (event) => {
if (isOpen) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const img = target.closest("img[data-lightbox]");
if (!img || !isVisibleImage(img)) {
return;
}
const result = scanImages();
const index = result.elements.indexOf(img);
if (index === -1) {
return;
}
event.preventDefault();
event.stopPropagation();
openLightbox(index);
};
document.addEventListener("click", handleGlobalClick, true);
return () => {
document.removeEventListener("click", handleGlobalClick, true);
};
}, [isOpen, openLightbox, scanImages]);
useEffect(() => {
if (!isOpen) {
document.body.style.overflow = "";
return;
}
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event) => {
switch (event.key) {
case "Escape":
closeLightbox();
break;
case "ArrowLeft":
prevImage();
break;
case "ArrowRight":
nextImage();
break;
default:
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeLightbox, isOpen, nextImage, prevImage]);
const currentImage = images[currentIndex];
const geometry = useMemo(() => {
if (
!isOpen ||
!currentImage ||
typeof window === "undefined"
) {
return null;
}
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const hasThumbnails =
!hideImageList && images.length > 1;
const maxWidth = viewportWidth * 0.9;
const maxHeight = hasThumbnails
? viewportHeight * 0.75
: viewportHeight * 0.85;
const imgElement = imageElementsRef.current[currentIndex];
const rect = imgElement?.getBoundingClientRect();
const initialRect = rect
? {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
}
: {
top: viewportHeight / 2,
left: viewportWidth / 2,
width: 1,
height: 1,
};
const displayedWidth = Math.max(
initialRect.width,
1,
);
const displayedHeight = Math.max(
initialRect.height,
1,
);
const naturalWidth = Math.max(
imgElement?.naturalWidth || displayedWidth,
1,
);
const naturalHeight = Math.max(
imgElement?.naturalHeight || displayedHeight,
1,
);
const fitScale = Math.min(
maxWidth / naturalWidth,
maxHeight / naturalHeight,
);
let targetWidth = naturalWidth * fitScale;
let targetHeight = naturalHeight * fitScale;
// 如果适配屏幕后比页面原图还小,就至少保持原来的显示尺寸。
if (
targetWidth < displayedWidth ||
targetHeight < displayedHeight
) {
targetWidth = displayedWidth;
targetHeight = displayedHeight;
}
return {
initialRect,
targetRect: {
x: (viewportWidth - targetWidth) / 2,
y:
(viewportHeight - targetHeight) / 2 -
(hasThumbnails ? 40 : 0),
width: targetWidth,
height: targetHeight,
},
};
}, [
currentIndex,
currentImage,
hideImageList,
images.length,
isOpen,
]);
if (!mounted || !geometry || !currentImage) {
return null;
}
const isCurrentImageLoaded = Boolean(
loadedSources[currentImage.src],
);
const animationVariants = {
enter: (custom) => {
const customDirection = custom?.direction || 0;
const sliding = custom?.isSliding || false;
if (sliding && customDirection !== 0) {
return {
x:
customDirection > 0
? window.innerWidth
: -window.innerWidth,
y: geometry.targetRect.y,
width: geometry.targetRect.width,
height: geometry.targetRect.height,
opacity: 1,
scale: 1,
};
}
return {
x: geometry.initialRect.left,
y: geometry.initialRect.top,
width: geometry.initialRect.width,
height: geometry.initialRect.height,
opacity: 1,
borderRadius: currentImage.borderRadius,
scale: 1,
};
},
center: {
x: geometry.targetRect.x,
y: geometry.targetRect.y,
width: geometry.targetRect.width,
height: geometry.targetRect.height,
opacity: 1,
scale,
borderRadius: "2px",
transition: {
type: "spring",
stiffness: 300,
damping: 30,
},
},
closed: () => {
if (currentIndex === openedIndex) {
return {
x: geometry.initialRect.left,
y: geometry.initialRect.top,
width: geometry.initialRect.width,
height: geometry.initialRect.height,
opacity: 1,
scale: 1,
borderRadius: currentImage.borderRadius,
transition: {
duration: 0.25,
ease: "easeInOut",
},
};
}
return {
x: geometry.targetRect.x,
y: geometry.targetRect.y,
width: geometry.targetRect.width,
height: geometry.targetRect.height,
opacity: 0,
scale: 1,
transition: {
duration: 0.2,
},
};
},
exit: (custom) => {
const customDirection = custom?.direction || 0;
const sliding = custom?.isSliding || false;
if (sliding && customDirection !== 0) {
return {
x:
customDirection > 0
? -window.innerWidth * 0.3
: window.innerWidth * 0.3,
opacity: 0,
scale: 0.9,
transition: {
duration: 0.2,
},
};
}
if (currentIndex === openedIndex) {
return {
x: geometry.initialRect.left,
y: geometry.initialRect.top,
width: geometry.initialRect.width,
height: geometry.initialRect.height,
opacity: 1,
borderRadius: currentImage.borderRadius,
transition: {
duration: 0.25,
ease: "easeInOut",
},
};
}
return {
x: geometry.targetRect.x,
y: geometry.targetRect.y,
width: geometry.targetRect.width,
height: geometry.targetRect.height,
opacity: 0,
scale: 1,
transition: {
duration: 0.2,
},
};
},
};
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label="Image lightbox"
style={styles.root}
onWheel={handleWheel}
>
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isClosing ? 0 : 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
onClick={closeLightbox}
style={{
position: "fixed",
inset: 0,
zIndex: 999,
background: "rgba(0, 0, 0, .9)",
backdropFilter: "blur(8px)",
}}
/>
<div style={styles.controls}>
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{
opacity: isClosing ? 0 : 1,
y: isClosing ? -50 : 0,
}}
exit={{ opacity: 0, y: -50 }}
transition={{ duration: 0.3 }}
style={styles.topBar}
>
<div style={styles.topText}>
{currentImage.alt && (
<span style={styles.altText}>
{currentImage.alt}
</span>
)}
{images.length > 1 && !hideImageList && (
<span style={styles.counter}>
{currentIndex + 1} / {images.length}
</span>
)}
</div>
<button
type="button"
aria-label="Close image lightbox"
className="standalone-image-lightbox-button"
style={styles.iconButton}
onClick={closeLightbox}
>
<Icon type="close" />
</button>
</motion.div>
{images.length > 1 && !hideImageList && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isClosing ? 0 : 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<button
type="button"
aria-label="Previous image"
className="standalone-image-lightbox-button"
style={{
...styles.navigationButton,
left: "16px",
}}
onClick={prevImage}
>
<Icon type="left" size={32} />
</button>
<button
type="button"
aria-label="Next image"
className="standalone-image-lightbox-button"
style={{
...styles.navigationButton,
right: "16px",
}}
onClick={nextImage}
>
<Icon type="right" size={32} />
</button>
</motion.div>
)}
{images.length > 1 && !hideImageList && (
<motion.div
initial={{ opacity: 0, y: 100 }}
animate={{
opacity: isClosing ? 0 : 1,
y: isClosing ? 100 : 0,
}}
exit={{ opacity: 0, y: 100 }}
transition={{ duration: 0.3 }}
style={styles.thumbnailBar}
>
<div style={styles.thumbnailList}>
{images.map((image, index) => {
const active = currentIndex === index;
return (
<button
key={`${image.src}-${index}`}
type="button"
title={`Jump to image ${index + 1}`}
ref={(element) => {
thumbnailRefs.current[index] =
element;
}}
onClick={(event) => {
event.stopPropagation();
jumpToImage(index);
}}
className="standalone-image-lightbox-thumbnail"
style={{
...styles.thumbnailButton,
opacity: active ? 1 : 0.5,
borderColor: active
? "#fff"
: "transparent",
transform: active
? "scale(1.05)"
: "scale(1)",
}}
>
<img
src={image.previewSrc}
alt={image.alt}
loading="lazy"
draggable={false}
style={styles.thumbnailImage}
/>
</button>
);
})}
</div>
</motion.div>
)}
</div>
</>
)}
</AnimatePresence>
<AnimatePresence
mode="popLayout"
custom={{ direction, isSliding }}
>
{isOpen && (
<motion.div
key={currentIndex}
initial="enter"
animate={isClosing ? "closed" : "center"}
exit="exit"
variants={animationVariants}
custom={{ direction, isSliding }}
drag={scale > 1}
dragConstraints={{
left: -1000,
right: 1000,
top: -1000,
bottom: 1000,
}}
dragElastic={0.1}
draggable={false}
onClick={(event) => {
event.stopPropagation();
}}
onAnimationComplete={(definition) => {
if (definition !== "closed") {
return;
}
setOriginalImageVisible(currentIndex, true);
setImages([]);
imageElementsRef.current = [];
thumbnailRefs.current = [];
setIsOpen(false);
setIsClosing(false);
}}
style={{
position: "fixed",
top: 0,
left: 0,
zIndex: 1000,
overflow: "hidden",
transformOrigin: "center center",
userSelect: "none",
boxShadow: "0 20px 80px rgba(0, 0, 0, .45)",
cursor: scale > 1 ? "grab" : "default",
}}
>
<div
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
}}
>
<img
src={currentImage.previewSrc}
alt={currentImage.alt}
draggable={false}
loading="eager"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: currentImage.objectFit,
objectPosition: currentImage.objectPosition,
opacity: isCurrentImageLoaded ? 0 : 1,
transition: "opacity .2s ease",
}}
/>
<img
src={currentImage.src}
alt={currentImage.alt}
draggable={false}
loading="eager"
decoding="async"
onLoad={() => {
markImageLoaded(currentImage.src);
}}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: currentImage.objectFit,
objectPosition: currentImage.objectPosition,
opacity: isCurrentImageLoaded ? 1 : 0,
transition: "opacity .2s ease",
}}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>,
document.body,
);
}document.addEventListener("click", handleGlobalClick, true);img[data-lightbox]document.addEventListener("click", handler, true);const rect = imgElement.getBoundingClientRect();const targetX = (viewportWidth - targetWidth) / 2;
const targetY = (viewportHeight - targetHeight) / 2;data-lightbox-visibility="hidden"img[data-lightbox][data-lightbox-visibility="hidden"] {
opacity: 0 !important;
}<img src={currentImage.previewSrc} />
<img src={currentImage.src} />opacity: isCurrentImageLoaded ? 0 : 1opacity: isCurrentImageLoaded ? 1 : 0objectFit
objectPosition
borderRadiussetDirection(1);
setIsSliding(true);
setCurrentIndex(nextIndex);const delta = -event.deltaY * 0.001;
const nextScale = previousScale + delta;
return Math.min(Math.max(nextScale, 1), 5);<motion.div
drag={scale > 1}
dragConstraints={{
left: -1000,
right: 1000,
top: -1000,
bottom: 1000,
}}
/>document.body.style.overflow = "hidden";npm install framer-motionpnpm add framer-motion
评论