198 lines
5.4 KiB
TypeScript
198 lines
5.4 KiB
TypeScript
import { useEffect, useState, useCallback } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { getBlockingDivs, type Rect } from "./utils";
|
|
|
|
interface SpotlightProps {
|
|
/** 是否可见 */
|
|
visible?: boolean;
|
|
/** 目标元素选择器(支持单个或多个) */
|
|
targetSelector?: string | string[] | null;
|
|
/** 额外内边距 */
|
|
padding?: number;
|
|
/** 圆角 */
|
|
borderRadius?: number;
|
|
}
|
|
|
|
const Spotlight: React.FC<SpotlightProps> = ({
|
|
visible = false,
|
|
targetSelector = null,
|
|
padding = 8,
|
|
borderRadius = 16,
|
|
}) => {
|
|
const [rects, setRects] = useState<Rect[]>([]);
|
|
|
|
const measure = useCallback(() => {
|
|
if (!visible || !targetSelector) {
|
|
setRects([]);
|
|
return;
|
|
}
|
|
|
|
const selectors = Array.isArray(targetSelector)
|
|
? targetSelector
|
|
: [targetSelector];
|
|
const pad = Math.max(0, padding);
|
|
const newRects: Rect[] = [];
|
|
|
|
selectors.forEach((selector) => {
|
|
const elements = document.querySelectorAll(selector);
|
|
elements.forEach((el) => {
|
|
const htmlEl = el as HTMLElement;
|
|
const r = htmlEl.getBoundingClientRect();
|
|
// 使用视口坐标(fixed层),不叠加滚动偏移
|
|
newRects.push({
|
|
top: r.top - pad,
|
|
left: r.left - pad,
|
|
width: r.width + pad * 2,
|
|
height: r.height + pad * 2,
|
|
});
|
|
});
|
|
});
|
|
|
|
setRects(newRects);
|
|
}, [visible, targetSelector, padding]);
|
|
|
|
useEffect(() => {
|
|
// 初始测量
|
|
requestAnimationFrame(measure);
|
|
}, [visible, targetSelector, padding, measure]);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => measure();
|
|
const onScroll = () => measure();
|
|
|
|
window.addEventListener("resize", onResize);
|
|
window.addEventListener("scroll", onScroll, {
|
|
passive: true,
|
|
} as AddEventListenerOptions);
|
|
|
|
// 使用 ResizeObserver 监听目标元素尺寸变化
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
measure();
|
|
});
|
|
|
|
// 观察所有目标元素
|
|
if (visible && targetSelector) {
|
|
const selectors = Array.isArray(targetSelector)
|
|
? targetSelector
|
|
: [targetSelector];
|
|
|
|
selectors.forEach((selector) => {
|
|
const elements = document.querySelectorAll(selector);
|
|
elements.forEach((el) => {
|
|
resizeObserver.observe(el);
|
|
});
|
|
});
|
|
}
|
|
|
|
// 初始测量(延迟一帧确保 DOM 已渲染)
|
|
requestAnimationFrame(measure);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", onResize);
|
|
window.removeEventListener("scroll", onScroll);
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [measure, visible, targetSelector]);
|
|
|
|
if (!visible || rects.length === 0) return null;
|
|
|
|
// 生成唯一ID用于SVG mask
|
|
const maskId = `spotlight-mask-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
// 计算阻止点击的区域(使用共享工具函数)
|
|
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0;
|
|
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
|
|
const blockingDivs = getBlockingDivs(rects, viewportWidth, viewportHeight);
|
|
|
|
return createPortal(
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
pointerEvents: "none",
|
|
zIndex: 100000,
|
|
}}
|
|
>
|
|
{/* SVG 视觉遮罩 */}
|
|
<svg
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
<defs>
|
|
<mask id={maskId}>
|
|
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
|
{rects.map((rect, index) => (
|
|
<rect
|
|
key={index}
|
|
x={rect.left}
|
|
y={rect.top}
|
|
width={rect.width}
|
|
height={rect.height}
|
|
rx={borderRadius}
|
|
ry={borderRadius}
|
|
fill="black"
|
|
/>
|
|
))}
|
|
</mask>
|
|
</defs>
|
|
<rect
|
|
x="0"
|
|
y="0"
|
|
width="100%"
|
|
height="100%"
|
|
fill="rgba(0, 0, 0, 0.6)"
|
|
mask={`url(#${maskId})`}
|
|
/>
|
|
</svg>
|
|
|
|
{/* 阻止点击层 - 使用多个div块覆盖非孔区域 */}
|
|
{blockingDivs.map((block, index) => (
|
|
<div
|
|
key={`block-${index}`}
|
|
style={{
|
|
position: "absolute",
|
|
top: `${block.top}px`,
|
|
left:
|
|
typeof block.left === "number" ? `${block.left}px` : block.left,
|
|
width:
|
|
typeof block.width === "number"
|
|
? `${block.width}px`
|
|
: block.width,
|
|
height:
|
|
typeof block.height === "number"
|
|
? `${block.height}px`
|
|
: block.height,
|
|
pointerEvents: "auto",
|
|
// backgroundColor: "rgba(255, 0, 0, 0.1)", // 调试用
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* 为每个孔添加高亮边框 */}
|
|
{rects.map((rect, index) => (
|
|
<div
|
|
key={`border-${index}`}
|
|
style={{
|
|
position: "absolute",
|
|
top: `${rect.top}px`,
|
|
left: `${rect.left}px`,
|
|
width: `${rect.width}px`,
|
|
height: `${rect.height}px`,
|
|
borderRadius: `${borderRadius}px`,
|
|
border: "2px solid rgba(255, 255, 255, 0.3)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|
|
|
|
export default Spotlight;
|