feat:init
This commit is contained in:
1 parent
53d0eeac6b
commit
ae0c050010
494 files changed
+17701
-2318
No files matched your search
@@ -0,0 +1,514 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import { getBlockingDivs } from "@/components/spotlight/utils";
|
||||
|
||||
export interface OverlayProps {
|
||||
/** CSS 选择器,支持多个元素(逗号分隔或数组) */
|
||||
selectors: string | string[];
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 子元素内容 */
|
||||
children?: ReactNode;
|
||||
/** 遮罩层背景色(默认:rgba(0, 0, 0, 0.5)) */
|
||||
maskColor?: string;
|
||||
/** 高亮边框颜色(默认:#3b82f6) */
|
||||
highlightColor?: string;
|
||||
/** 高亮边框宽度(默认:3px) */
|
||||
highlightWidth?: number;
|
||||
/** 高亮圆角(默认:8px) */
|
||||
borderRadius?: number;
|
||||
/** 是否显示阴影/光晕效果 */
|
||||
showShadow?: boolean;
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
/** 自定义样式 */
|
||||
style?: React.CSSProperties;
|
||||
/** z-index 层级(默认:40,低于 GuideSteps 的 z-50) */
|
||||
zIndex?: number;
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
/** 是否显示遮罩层(默认:true) */
|
||||
showMask?: boolean;
|
||||
}
|
||||
|
||||
interface ElementRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay 组件 - 用于高亮页面中的特定元素
|
||||
*
|
||||
* 功能:
|
||||
* - 通过 CSS 选择器定位目标元素(支持多个)
|
||||
* - 计算元素位置并创建高亮区域
|
||||
* - 支持自定义样式(边框、阴影、遮罩颜色等)
|
||||
* - 支持 children,可以包裹其他组件(如 GuideSteps)
|
||||
* - 使用 Portal 渲染到 document.body,确保层级正确
|
||||
*/
|
||||
const Overlay = ({
|
||||
selectors,
|
||||
visible = true,
|
||||
children,
|
||||
maskColor = "rgba(0, 0, 0, 0.5)",
|
||||
highlightColor = "#3b82f6",
|
||||
highlightWidth = 3,
|
||||
borderRadius = 8,
|
||||
showShadow = false,
|
||||
className,
|
||||
style,
|
||||
zIndex = 40,
|
||||
onMaskClick,
|
||||
showMask = true,
|
||||
}: OverlayProps) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [elementRects, setElementRects] = useState<ElementRect[]>([]);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
const elementsCacheRef = useRef<Element[]>([]);
|
||||
const prevRectsRef = useRef<ElementRect[]>([]);
|
||||
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
|
||||
// 生成唯一的 mask ID
|
||||
const maskId = useMemo(
|
||||
() => `overlay-mask-${Math.random().toString(36).substr(2, 9)}`,
|
||||
[]
|
||||
);
|
||||
|
||||
// 防抖函数
|
||||
const debounce = useCallback((fn: () => void, delay: number) => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = window.setTimeout(fn, delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 获取所有匹配的元素(带缓存)
|
||||
const getElements = useCallback(() => {
|
||||
if (typeof document === "undefined") return [];
|
||||
|
||||
const selectorArray =
|
||||
typeof selectors === "string" ? [selectors] : selectors;
|
||||
const elements: Element[] = [];
|
||||
|
||||
selectorArray.forEach((selector) => {
|
||||
try {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
elements.push(...Array.from(matches));
|
||||
} catch (error) {
|
||||
console.warn(`Invalid selector: ${selector}`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 缓存元素列表
|
||||
elementsCacheRef.current = elements;
|
||||
return elements;
|
||||
}, [selectors]);
|
||||
|
||||
// 计算元素位置(带浅比较优化)
|
||||
const calculateRects = useCallback(() => {
|
||||
const elements =
|
||||
elementsCacheRef.current.length > 0
|
||||
? elementsCacheRef.current
|
||||
: getElements();
|
||||
const rects: ElementRect[] = [];
|
||||
|
||||
elements.forEach((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
rects.push({
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 浅比较,避免不必要的更新
|
||||
const hasChanged =
|
||||
rects.length !== prevRectsRef.current.length ||
|
||||
rects.some((rect, index) => {
|
||||
const prev = prevRectsRef.current[index];
|
||||
return (
|
||||
!prev ||
|
||||
rect.x !== prev.x ||
|
||||
rect.y !== prev.y ||
|
||||
rect.width !== prev.width ||
|
||||
rect.height !== prev.height
|
||||
);
|
||||
});
|
||||
|
||||
if (hasChanged) {
|
||||
prevRectsRef.current = rects;
|
||||
setElementRects(rects);
|
||||
}
|
||||
}, [getElements]);
|
||||
|
||||
// 使用 requestAnimationFrame 批量更新位置
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (rafRef.current) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
calculateRects();
|
||||
rafRef.current = null;
|
||||
});
|
||||
}, [calculateRects]);
|
||||
|
||||
// 监听窗口变化(带防抖优化)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 150);
|
||||
const handleResize = debouncedUpdate;
|
||||
const handleScroll = debouncedUpdate;
|
||||
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
// 初始计算
|
||||
scheduleUpdate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible, mounted, scheduleUpdate, debounce]);
|
||||
|
||||
// 使用 ResizeObserver 监听元素尺寸变化(带防抖优化)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
|
||||
const elements = getElements();
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 150);
|
||||
const observer = new ResizeObserver(debouncedUpdate);
|
||||
|
||||
elements.forEach((element) => {
|
||||
observer.observe(element);
|
||||
});
|
||||
|
||||
resizeObserverRef.current = observer;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
};
|
||||
}, [visible, mounted, getElements, scheduleUpdate, debounce]);
|
||||
|
||||
// 使用 MutationObserver 监听 DOM 变化(优化:只监听目标元素及其父级,带防抖)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
|
||||
const elements =
|
||||
elementsCacheRef.current.length > 0
|
||||
? elementsCacheRef.current
|
||||
: getElements();
|
||||
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 200);
|
||||
const observer = new MutationObserver(debouncedUpdate);
|
||||
|
||||
// 只监听目标元素及其父级,而不是整个 body
|
||||
const observedNodes = new Set<Node>();
|
||||
elements.forEach((element) => {
|
||||
let current: Node | null = element;
|
||||
while (current && current !== document.body) {
|
||||
if (!observedNodes.has(current)) {
|
||||
observer.observe(current, {
|
||||
childList: true,
|
||||
subtree: false,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
});
|
||||
observedNodes.add(current);
|
||||
}
|
||||
current = current.parentNode;
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserverRef.current = observer;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mutationObserverRef.current = null;
|
||||
};
|
||||
}, [visible, mounted, getElements, scheduleUpdate, debounce]);
|
||||
|
||||
// 生成 SVG mask 的挖洞路径(使用 useMemo 缓存,转换为设计稿坐标)
|
||||
const maskHoles = useMemo(() => {
|
||||
if (elementRects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const padding = highlightWidth + borderRadius;
|
||||
const holes: string[] = [];
|
||||
|
||||
elementRects.forEach((rect) => {
|
||||
// 将屏幕坐标转换为设计稿坐标
|
||||
const designX = (rect.x - offsetX) / scale;
|
||||
const designY = (rect.y - offsetY) / scale;
|
||||
const designWidth = rect.width / scale;
|
||||
const designHeight = rect.height / scale;
|
||||
const designPadding = padding / scale;
|
||||
const designR = borderRadius / scale;
|
||||
|
||||
const x = designX - designPadding;
|
||||
const y = designY - designPadding;
|
||||
const width = designWidth + designPadding * 2;
|
||||
const height = designHeight + designPadding * 2;
|
||||
const r = designR;
|
||||
|
||||
// 创建圆角矩形路径(用于挖洞)
|
||||
const path =
|
||||
`M ${x} ${y + r} ` +
|
||||
`A ${r} ${r} 0 0 1 ${x + r} ${y} ` +
|
||||
`L ${x + width - r} ${y} ` +
|
||||
`A ${r} ${r} 0 0 1 ${x + width} ${y + r} ` +
|
||||
`L ${x + width} ${y + height - r} ` +
|
||||
`A ${r} ${r} 0 0 1 ${x + width - r} ${y + height} ` +
|
||||
`L ${x + r} ${y + height} ` +
|
||||
`A ${r} ${r} 0 0 1 ${x} ${y + height - r} Z`;
|
||||
|
||||
holes.push(path);
|
||||
});
|
||||
|
||||
return holes;
|
||||
}, [elementRects, highlightWidth, borderRadius, scale, offsetX, offsetY]);
|
||||
|
||||
// 生成阻止点击的 div 块(使用 Spotlight 的共享工具函数)
|
||||
// 因为 SVG mask 不能阻止点击,需要使用 div 块覆盖非高亮区域
|
||||
const blockingDivs = useMemo(() => {
|
||||
if (elementRects.length === 0) return [];
|
||||
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0;
|
||||
const viewportHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight : 0;
|
||||
|
||||
const padding = highlightWidth + borderRadius;
|
||||
// 计算带 padding 的矩形区域(转换为 Spotlight 需要的格式)
|
||||
const paddedRects = elementRects.map((rect) => ({
|
||||
top: rect.y - padding,
|
||||
left: rect.x - padding,
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2,
|
||||
}));
|
||||
|
||||
return getBlockingDivs(paddedRects, viewportWidth, viewportHeight);
|
||||
}, [elementRects, highlightWidth, borderRadius]);
|
||||
|
||||
// 缓存视口尺寸(使用设计稿尺寸,应用缩放)
|
||||
const viewportSize = useMemo(
|
||||
() => ({
|
||||
width: DESIGN_WIDTH,
|
||||
height: DESIGN_HEIGHT,
|
||||
}),
|
||||
[DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
// 缩放容器样式(应用 ScaleContext 的缩放)- 必须在条件返回之前
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
pointerEvents: "auto", // 允许点击事件穿透
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
// 渲染逻辑:
|
||||
// 1. 如果有高亮元素,显示遮罩和高亮边框
|
||||
// 2. 如果有 children,显示遮罩(即使没有高亮元素,用于包裹其他组件)
|
||||
// 3. 如果 showMask 为 false,则不显示遮罩
|
||||
const hasHighlightElements = elementRects.length > 0;
|
||||
const shouldRenderMask =
|
||||
showMask &&
|
||||
visible &&
|
||||
mounted &&
|
||||
(hasHighlightElements || children !== undefined);
|
||||
const shouldRenderChildren = visible && mounted && children !== undefined;
|
||||
|
||||
if (!shouldRenderMask && !shouldRenderChildren) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 外层容器样式(全屏,不缩放)
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex,
|
||||
pointerEvents: "none",
|
||||
...style,
|
||||
};
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{visible && mounted && (
|
||||
<motion.div
|
||||
key="overlay"
|
||||
style={overlayStyle}
|
||||
className={className}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
{/* 阻止点击层 - 使用 fixed 定位覆盖非高亮区域(集成自 Spotlight) */}
|
||||
{hasHighlightElements &&
|
||||
blockingDivs.map((block, index) => (
|
||||
<div
|
||||
key={`block-${index}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
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",
|
||||
zIndex: zIndex - 1, // 在遮罩层下方,但能阻止点击
|
||||
}}
|
||||
onClick={onMaskClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={scaledContainerStyle}>
|
||||
{/* SVG 遮罩层 */}
|
||||
{shouldRenderMask && (
|
||||
<svg
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 0,
|
||||
pointerEvents: "none", // SVG 不处理点击,由 blocking divs 处理
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<mask id={maskId}>
|
||||
{/* 全屏白色区域(显示遮罩) */}
|
||||
<rect
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
fill="white"
|
||||
/>
|
||||
{/* 黑色区域(挖洞,隐藏遮罩)- 只在有高亮元素时显示 */}
|
||||
{hasHighlightElements &&
|
||||
maskHoles.map((hole, _index) => (
|
||||
<path key={_index} d={hole} fill="black" />
|
||||
))}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
fill={maskColor}
|
||||
mask={`url(#${maskId})`}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* children 内容 */}
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 高亮边框层 - 只在有高亮元素时显示 */}
|
||||
{hasHighlightElements &&
|
||||
elementRects.map((rect) => {
|
||||
// 将屏幕坐标转换为设计稿坐标(考虑缩放和偏移)
|
||||
const designX = (rect.x - offsetX) / scale;
|
||||
const designY = (rect.y - offsetY) / scale;
|
||||
const designWidth = rect.width / scale;
|
||||
const designHeight = rect.height / scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${rect.x}-${rect.y}-${rect.width}-${rect.height}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${designX}px`,
|
||||
top: `${designY}px`,
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
border: `${
|
||||
highlightWidth / scale
|
||||
}px solid ${highlightColor}`,
|
||||
borderRadius: `${borderRadius / scale}px`,
|
||||
pointerEvents: "none",
|
||||
zIndex: 2,
|
||||
boxShadow: showShadow
|
||||
? `0 0 0 ${
|
||||
(highlightWidth * 2) / scale
|
||||
}px ${highlightColor}40, 0 0 ${
|
||||
(highlightWidth * 4) / scale
|
||||
}px ${highlightColor}60`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default Overlay;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Overlay } from "./Overlay";
|
||||
export type { OverlayProps } from "./Overlay";
|
||||
|
||||
Reference in new issue
Block a user