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): 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(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(null); const [layout, setLayout] = useState(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) => { 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) ) : ( ); const panelClassName = children ? styles.panelContainer : styles.panel; if (!highlightRect && !targetMissingSelector) { return ( ); } const activeLayout = layout; if (!activeLayout) return null; if (targetMissingSelector) { return (
DEV

引导目标未找到

请检查当前步骤的 data-guide 或 selector 是否和页面元素一致。

{targetMissingSelector}
{current + 1} / {steps.length}
); } if (!highlightRect) return null; const activeRect = highlightRect; return ( {!step.allowTargetClick && (
)}
{panelContent}
); }