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 = ({ visible = false, targetSelector = null, padding = 8, borderRadius = 16, }) => { const [rects, setRects] = useState([]); 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(
{/* SVG 视觉遮罩 */} {rects.map((rect, index) => ( ))} {/* 阻止点击层 - 使用多个div块覆盖非孔区域 */} {blockingDivs.map((block, index) => (
))} {/* 为每个孔添加高亮边框 */} {rects.map((rect, index) => (
))}
, document.body ); }; export default Spotlight;