forked from zhongyidian/yx-react-template
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
637 lines
15 KiB
TypeScript
637 lines
15 KiB
TypeScript
import {
|
|
type MouseEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Portal } from "@/components/ui/portal";
|
|
import { cn } from "@/lib/cn";
|
|
import { DefaultGuideSpotlightPanel } from "./DefaultGuideSpotlightPanel";
|
|
import styles from "./GuideSpotlight.module.css";
|
|
import type {
|
|
GuideSpotlightProps,
|
|
Rect,
|
|
SpotlightLayout,
|
|
SpotlightStep,
|
|
} from "./GuideSpotlight.types";
|
|
|
|
const DEFAULT_OFFSET = 16;
|
|
const DEFAULT_PADDING = 8;
|
|
const DEFAULT_RADIUS = 12;
|
|
const VIEWPORT_MARGIN = 16;
|
|
|
|
interface CoordinateContext {
|
|
portalTarget: HTMLElement;
|
|
originLeft: number;
|
|
originTop: number;
|
|
scale: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
function resolveContainer(container?: GuideSpotlightProps["container"]) {
|
|
if (!container) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof container === "function") {
|
|
return container();
|
|
}
|
|
|
|
return container;
|
|
}
|
|
|
|
function getCoordinateContext({
|
|
container,
|
|
viewport,
|
|
}: Pick<GuideSpotlightProps, "container" | "viewport">): CoordinateContext {
|
|
const portalTarget = resolveContainer(container) ?? document.body;
|
|
|
|
if (viewport) {
|
|
return {
|
|
portalTarget,
|
|
originLeft: viewport.originLeft ?? 0,
|
|
originTop: viewport.originTop ?? 0,
|
|
scale: viewport.scale ?? 1,
|
|
width: viewport.width,
|
|
height: viewport.height,
|
|
};
|
|
}
|
|
|
|
const rect = portalTarget.getBoundingClientRect();
|
|
|
|
return {
|
|
portalTarget,
|
|
originLeft: rect.left,
|
|
originTop: rect.top,
|
|
scale: 1,
|
|
width: rect.width || window.innerWidth,
|
|
height: rect.height || window.innerHeight,
|
|
};
|
|
}
|
|
|
|
function toCanvasRect(rect: DOMRect, context: CoordinateContext): Rect {
|
|
const top = (rect.top - context.originTop) / context.scale;
|
|
const left = (rect.left - context.originLeft) / context.scale;
|
|
const width = rect.width / context.scale;
|
|
const height = rect.height / context.scale;
|
|
|
|
return {
|
|
top,
|
|
left,
|
|
width,
|
|
height,
|
|
right: left + width,
|
|
bottom: top + height,
|
|
};
|
|
}
|
|
|
|
function getTargetSelector(step: SpotlightStep) {
|
|
if (typeof step.target === "string") {
|
|
return step.target;
|
|
}
|
|
|
|
if (step.target.dataGuide) {
|
|
return `[data-guide="${step.target.dataGuide}"]`;
|
|
}
|
|
|
|
return step.target.selector ?? "";
|
|
}
|
|
|
|
function getTargetElement(step: SpotlightStep) {
|
|
const selector = getTargetSelector(step);
|
|
if (!selector) return null;
|
|
return document.querySelector<HTMLElement>(selector);
|
|
}
|
|
|
|
function getHighlightRect(
|
|
element: HTMLElement,
|
|
padding: number,
|
|
context: CoordinateContext,
|
|
): Rect {
|
|
const rect = toCanvasRect(element.getBoundingClientRect(), context);
|
|
const top = Math.floor(Math.max(rect.top - padding, 0));
|
|
const left = Math.floor(Math.max(rect.left - padding, 0));
|
|
const right = Math.ceil(Math.min(rect.right + padding, context.width));
|
|
const bottom = Math.ceil(Math.min(rect.bottom + padding, context.height));
|
|
|
|
return {
|
|
top,
|
|
left,
|
|
width: right - left,
|
|
height: bottom - top,
|
|
right,
|
|
bottom,
|
|
};
|
|
}
|
|
|
|
function getOverflowScore(
|
|
left: number,
|
|
top: number,
|
|
width: number,
|
|
height: number,
|
|
context: CoordinateContext,
|
|
) {
|
|
const overflowLeft = Math.max(VIEWPORT_MARGIN - left, 0);
|
|
const overflowTop = Math.max(VIEWPORT_MARGIN - top, 0);
|
|
const overflowRight = Math.max(
|
|
left + width + VIEWPORT_MARGIN - context.width,
|
|
0,
|
|
);
|
|
const overflowBottom = Math.max(
|
|
top + height + VIEWPORT_MARGIN - context.height,
|
|
0,
|
|
);
|
|
|
|
return overflowLeft + overflowTop + overflowRight + overflowBottom;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function getRoundedRectPath(rect: Rect, radius: number) {
|
|
const right = rect.left + rect.width;
|
|
const bottom = rect.top + rect.height;
|
|
const safeRadius = Math.min(radius, rect.width / 2, rect.height / 2);
|
|
|
|
if (safeRadius <= 0) {
|
|
return `M ${rect.left} ${rect.top} H ${right} V ${bottom} H ${rect.left} Z`;
|
|
}
|
|
|
|
return [
|
|
`M ${rect.left + safeRadius} ${rect.top}`,
|
|
`H ${right - safeRadius}`,
|
|
`Q ${right} ${rect.top} ${right} ${rect.top + safeRadius}`,
|
|
`V ${bottom - safeRadius}`,
|
|
`Q ${right} ${bottom} ${right - safeRadius} ${bottom}`,
|
|
`H ${rect.left + safeRadius}`,
|
|
`Q ${rect.left} ${bottom} ${rect.left} ${bottom - safeRadius}`,
|
|
`V ${rect.top + safeRadius}`,
|
|
`Q ${rect.left} ${rect.top} ${rect.left + safeRadius} ${rect.top}`,
|
|
"Z",
|
|
].join(" ");
|
|
}
|
|
|
|
function getPanelPosition(
|
|
target: Rect,
|
|
panel: { width: number; height: number },
|
|
placement: SpotlightStep["placement"],
|
|
offset: number,
|
|
context: CoordinateContext,
|
|
) {
|
|
const positions = {
|
|
top: {
|
|
left: target.left + target.width / 2 - panel.width / 2,
|
|
top: target.top - panel.height - offset,
|
|
},
|
|
bottom: {
|
|
left: target.left + target.width / 2 - panel.width / 2,
|
|
top: target.bottom + offset,
|
|
},
|
|
left: {
|
|
left: target.left - panel.width - offset,
|
|
top: target.top + target.height / 2 - panel.height / 2,
|
|
},
|
|
right: {
|
|
left: target.right + offset,
|
|
top: target.top + target.height / 2 - panel.height / 2,
|
|
},
|
|
};
|
|
|
|
const preferred = placement && placement !== "auto" ? placement : "bottom";
|
|
const ordered = [
|
|
preferred,
|
|
...(["bottom", "top", "right", "left"] as const).filter(
|
|
(item) => item !== preferred,
|
|
),
|
|
];
|
|
|
|
const best = ordered
|
|
.map((item) => ({
|
|
placement: item,
|
|
...positions[item],
|
|
score: getOverflowScore(
|
|
positions[item].left,
|
|
positions[item].top,
|
|
panel.width,
|
|
panel.height,
|
|
context,
|
|
),
|
|
}))
|
|
.sort((a, b) => a.score - b.score)[0];
|
|
|
|
const maxLeft = Math.max(
|
|
context.width - panel.width - VIEWPORT_MARGIN,
|
|
VIEWPORT_MARGIN,
|
|
);
|
|
const maxTop = Math.max(
|
|
context.height - panel.height - VIEWPORT_MARGIN,
|
|
VIEWPORT_MARGIN,
|
|
);
|
|
|
|
return {
|
|
left: clamp(best.left, VIEWPORT_MARGIN, maxLeft),
|
|
top: clamp(best.top, VIEWPORT_MARGIN, maxTop),
|
|
};
|
|
}
|
|
|
|
export function GuideSpotlight({
|
|
state,
|
|
actions,
|
|
children,
|
|
container,
|
|
viewport,
|
|
theme,
|
|
closeOnOverlayClick = false,
|
|
closeOnEsc = true,
|
|
skipIfTargetMissing = true,
|
|
onStepChange,
|
|
}: GuideSpotlightProps) {
|
|
const { open, steps, current } = state;
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const [layout, setLayout] = useState<SpotlightLayout | null>(null);
|
|
|
|
const step = steps[current];
|
|
const stepKey = step ? `${current}:${step.id}` : "";
|
|
const offset = theme?.offset ?? DEFAULT_OFFSET;
|
|
const radius = step?.radius ?? theme?.radius ?? DEFAULT_RADIUS;
|
|
const padding = step?.padding ?? DEFAULT_PADDING;
|
|
const zIndex = theme?.zIndex ?? 1000;
|
|
const panelMaxWidth = theme?.panelMaxWidth ?? 320;
|
|
const isLayoutReady = !!layout && layout.stepKey === stepKey;
|
|
const highlightRect = isLayoutReady ? layout.highlightRect : null;
|
|
const targetMissingSelector = isLayoutReady
|
|
? layout.targetMissingSelector
|
|
: null;
|
|
|
|
const closeAsFinish = useCallback(() => {
|
|
actions.finish();
|
|
}, [actions]);
|
|
|
|
const closeAsSkip = useCallback(() => {
|
|
actions.skip();
|
|
}, [actions]);
|
|
|
|
const measure = useCallback(() => {
|
|
if (!open || !step || typeof document === "undefined") {
|
|
setLayout(null);
|
|
return;
|
|
}
|
|
|
|
const selector = getTargetSelector(step);
|
|
const context = getCoordinateContext({ container, viewport });
|
|
const target = getTargetElement(step);
|
|
if (!target) {
|
|
console.warn(`[GuideSpotlight] target not found: ${selector}`);
|
|
|
|
if (skipIfTargetMissing) {
|
|
if (current >= steps.length - 1) {
|
|
closeAsFinish();
|
|
} else {
|
|
actions.next();
|
|
}
|
|
return;
|
|
}
|
|
|
|
setLayout({
|
|
stepKey,
|
|
portalTarget: context.portalTarget,
|
|
width: context.width,
|
|
height: context.height,
|
|
highlightRect: null,
|
|
panelStyle: {
|
|
maxWidth: `${panelMaxWidth}px`,
|
|
},
|
|
targetMissingSelector: selector,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const nextHighlightRect = getHighlightRect(target, padding, context);
|
|
const panelElement = panelRef.current;
|
|
const panelRect = panelElement?.getBoundingClientRect();
|
|
const panelSize = {
|
|
width: panelElement
|
|
? Math.max(
|
|
panelRect?.width ?? 0,
|
|
panelElement.scrollWidth * context.scale,
|
|
) / context.scale
|
|
: panelMaxWidth,
|
|
height: panelElement
|
|
? Math.max(
|
|
panelRect?.height ?? 0,
|
|
panelElement.scrollHeight * context.scale,
|
|
) / context.scale
|
|
: 180,
|
|
};
|
|
const position = getPanelPosition(
|
|
nextHighlightRect,
|
|
panelSize,
|
|
step.placement ?? "auto",
|
|
offset,
|
|
context,
|
|
);
|
|
|
|
setLayout({
|
|
stepKey,
|
|
portalTarget: context.portalTarget,
|
|
width: context.width,
|
|
height: context.height,
|
|
highlightRect: nextHighlightRect,
|
|
panelStyle: {
|
|
left: `${position.left}px`,
|
|
top: `${position.top}px`,
|
|
maxWidth: `${panelMaxWidth}px`,
|
|
},
|
|
targetMissingSelector: null,
|
|
});
|
|
}, [
|
|
actions,
|
|
closeAsFinish,
|
|
container,
|
|
current,
|
|
offset,
|
|
open,
|
|
padding,
|
|
panelMaxWidth,
|
|
skipIfTargetMissing,
|
|
step,
|
|
stepKey,
|
|
steps.length,
|
|
viewport,
|
|
]);
|
|
|
|
useLayoutEffect(() => {
|
|
measure();
|
|
}, [measure]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !step) return;
|
|
onStepChange?.(current, step);
|
|
}, [current, open, onStepChange, step]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const scrollKeys = new Set([
|
|
"ArrowDown",
|
|
"ArrowLeft",
|
|
"ArrowRight",
|
|
"ArrowUp",
|
|
"End",
|
|
"Home",
|
|
"PageDown",
|
|
"PageUp",
|
|
" ",
|
|
]);
|
|
const previousOverflow = document.body.style.overflow;
|
|
const previousOverscrollBehavior = document.body.style.overscrollBehavior;
|
|
const preventScroll = (event: Event) => event.preventDefault();
|
|
const preventKeyboardScroll = (event: KeyboardEvent) => {
|
|
if (scrollKeys.has(event.key)) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
document.body.style.overflow = "hidden";
|
|
document.body.style.overscrollBehavior = "none";
|
|
window.addEventListener("wheel", preventScroll, {
|
|
capture: true,
|
|
passive: false,
|
|
});
|
|
window.addEventListener("touchmove", preventScroll, {
|
|
capture: true,
|
|
passive: false,
|
|
});
|
|
window.addEventListener("keydown", preventKeyboardScroll, true);
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
document.body.style.overscrollBehavior = previousOverscrollBehavior;
|
|
window.removeEventListener("wheel", preventScroll, true);
|
|
window.removeEventListener("touchmove", preventScroll, true);
|
|
window.removeEventListener("keydown", preventKeyboardScroll, true);
|
|
};
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const handleResize = () => measure();
|
|
const handleScroll = () => measure();
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (closeOnEsc && event.key === "Escape") {
|
|
closeAsSkip();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
window.addEventListener("scroll", handleScroll, true);
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
const target = step ? getTargetElement(step) : null;
|
|
const resizeObserver =
|
|
typeof ResizeObserver === "undefined"
|
|
? null
|
|
: new ResizeObserver(() => measure());
|
|
if (target) resizeObserver?.observe(target);
|
|
if (panelRef.current) resizeObserver?.observe(panelRef.current);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleResize);
|
|
window.removeEventListener("scroll", handleScroll, true);
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
resizeObserver?.disconnect();
|
|
};
|
|
}, [closeAsSkip, closeOnEsc, measure, open, step]);
|
|
|
|
if (!open || !step || typeof document === "undefined") return null;
|
|
|
|
const maskStyle = {
|
|
zIndex,
|
|
};
|
|
const isFirst = current === 0;
|
|
const isLast = current === steps.length - 1;
|
|
|
|
const overlayClick = (event: MouseEvent<SVGPathElement>) => {
|
|
event.stopPropagation();
|
|
if (closeOnOverlayClick) {
|
|
closeAsSkip();
|
|
}
|
|
};
|
|
|
|
const primaryAction = isLast ? closeAsFinish : actions.next;
|
|
const spotlight = {
|
|
step,
|
|
progress: {
|
|
index: current,
|
|
current: current + 1,
|
|
total: steps.length,
|
|
},
|
|
status: {
|
|
isFirst,
|
|
isLast,
|
|
},
|
|
actions: {
|
|
prev: actions.prev,
|
|
next: actions.next,
|
|
primary: primaryAction,
|
|
skip: closeAsSkip,
|
|
finish: closeAsFinish,
|
|
},
|
|
labels: {
|
|
prev: step.prevLabel ?? "上一步",
|
|
next: step.nextLabel ?? "下一步",
|
|
skip: step.skipLabel ?? "跳过",
|
|
done: step.doneLabel ?? "完成",
|
|
primary: isLast
|
|
? (step.doneLabel ?? "完成")
|
|
: (step.nextLabel ?? "下一步"),
|
|
},
|
|
highlight: {
|
|
rect: highlightRect,
|
|
targetMissingSelector,
|
|
},
|
|
};
|
|
const panelContent = children ? (
|
|
children(spotlight)
|
|
) : (
|
|
<DefaultGuideSpotlightPanel spotlight={spotlight} />
|
|
);
|
|
|
|
const panelClassName = children ? styles.panelContainer : styles.panel;
|
|
|
|
if (!highlightRect && !targetMissingSelector) {
|
|
return (
|
|
<Portal
|
|
container={getCoordinateContext({ container, viewport }).portalTarget}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
aria-hidden="true"
|
|
className={cn(
|
|
panelClassName,
|
|
styles.measurePanel,
|
|
theme?.panelClassName,
|
|
step.panelClassName,
|
|
)}
|
|
style={{
|
|
maxWidth: `${panelMaxWidth}px`,
|
|
}}
|
|
>
|
|
{panelContent}
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
const activeLayout = layout;
|
|
if (!activeLayout) return null;
|
|
|
|
if (targetMissingSelector) {
|
|
return (
|
|
<Portal container={activeLayout.portalTarget}>
|
|
<div
|
|
aria-modal="true"
|
|
className={styles.missingPanel}
|
|
role="dialog"
|
|
style={{ zIndex: zIndex + 3 }}
|
|
>
|
|
<div className={styles.missingBadge}>DEV</div>
|
|
<h2 className={styles.title}>引导目标未找到</h2>
|
|
<p className={styles.description}>
|
|
请检查当前步骤的 data-guide 或 selector 是否和页面元素一致。
|
|
</p>
|
|
<code className={styles.missingSelector}>
|
|
{targetMissingSelector}
|
|
</code>
|
|
<div className={styles.footer}>
|
|
<span className={styles.counter}>
|
|
{current + 1} / {steps.length}
|
|
</span>
|
|
<div className={styles.actions}>
|
|
<Button variant="ghost" size="sm" onClick={closeAsSkip}>
|
|
跳过
|
|
</Button>
|
|
<Button size="sm" onClick={primaryAction}>
|
|
{isLast ? "完成" : "下一步"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
if (!highlightRect) return null;
|
|
|
|
const activeRect = highlightRect;
|
|
|
|
return (
|
|
<Portal container={activeLayout.portalTarget}>
|
|
<svg
|
|
aria-hidden="true"
|
|
className={cn(styles.mask, theme?.overlayClassName)}
|
|
style={maskStyle}
|
|
viewBox={`0 0 ${activeLayout.width} ${activeLayout.height}`}
|
|
>
|
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: click area must follow the SVG cutout path so the target hole stays interactive. */}
|
|
<path
|
|
d={`M 0 0 H ${activeLayout.width} V ${activeLayout.height} H 0 Z ${getRoundedRectPath(activeRect, radius)}`}
|
|
fill={theme?.overlayColor ?? "rgb(0 0 0 / 0.6)"}
|
|
fillRule="evenodd"
|
|
onClick={overlayClick}
|
|
/>
|
|
</svg>
|
|
|
|
{!step.allowTargetClick && (
|
|
<div
|
|
className={styles.blocker}
|
|
style={{
|
|
zIndex: zIndex + 1,
|
|
top: `${activeRect.top}px`,
|
|
left: `${activeRect.left}px`,
|
|
width: `${activeRect.width}px`,
|
|
height: `${activeRect.height}px`,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
key={`highlight-${stepKey}`}
|
|
className={cn(styles.highlight, theme?.highlightClassName)}
|
|
style={{
|
|
zIndex: zIndex + 2,
|
|
top: `${activeRect.top}px`,
|
|
left: `${activeRect.left}px`,
|
|
width: `${activeRect.width}px`,
|
|
height: `${activeRect.height}px`,
|
|
borderRadius: `${radius}px`,
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
key={`panel-${stepKey}`}
|
|
ref={panelRef}
|
|
aria-modal="true"
|
|
className={cn(
|
|
panelClassName,
|
|
theme?.panelClassName,
|
|
step.panelClassName,
|
|
)}
|
|
role="dialog"
|
|
style={{
|
|
zIndex: zIndex + 3,
|
|
...activeLayout.panelStyle,
|
|
}}
|
|
>
|
|
{panelContent}
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|