Files
yx-react-template/src/components/course-interactions/guide-spotlight/GuideSpotlight.tsx
T
2026-08-18 13:39:36 +08:00

626 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 panelRect = panelRef.current?.getBoundingClientRect();
const panelSize = {
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
height: panelRect ? panelRect.height / 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(
styles.panel,
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>
);
}