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