Files
shanghai-residences/src/components/spotlight/Spotlight.tsx
T
zhongyidian 76c67b492e feat: 更新 ESLint 和 Prettier 配置,增强代码质量管理
- 在 .gitignore 中添加 public/audio/* 以忽略音频文件。
- 更新 bun.lock,添加 eslint-config-prettier 和 eslint-plugin-prettier 依赖,确保 ESLint 与 Prettier 的兼容性。
- 修改 eslint.config.ts,整合 Prettier 规则,优化代码风格一致性。
- 在 package.json 中添加 lint:fix 和 format 脚本,简化代码格式化和修复流程。
- 更新 tailwind.config.ts 和其他组件文件,统一代码风格,提升可读性。
2026-03-05 13:50:47 +08:00

187 lines
5.2 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