feat:init

This commit is contained in:
zhongyidian committed 2025-12-29 19:19:10 +08:00
1 parent 53d0eeac6b
commit ae0c050010
494 files changed
+17701 -2318

No files matched your search

+514
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
export { default as Overlay } from "./Overlay";
export type { OverlayProps } from "./Overlay";