forked from zhongyidian/yx-react-template
feat: 引入 yuxiang-kit 课程组件源码与运行时
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
77742d5b02
commit
3995656175
41 files changed
+2752
No files matched your search
@@ -0,0 +1,625 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user