import { type MouseEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { Portal } from "@/components/ui/portal"; import { cn } from "@/lib/cn"; import { DefaultGuideDialogPanel } from "./DefaultGuideDialogPanel"; import styles from "./GuideDialog.module.css"; import type { GuideDialogLayout, GuideDialogProps } from "./GuideDialog.types"; const DEFAULT_PANEL_MAX_WIDTH = 560; const VIEWPORT_MARGIN = 32; const BOTTOM_OFFSET = 72; const TOP_OFFSET = 72; interface CoordinateContext { portalTarget: HTMLElement; scale: number; width: number; height: number; } function resolveContainer(container?: GuideDialogProps["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, scale: viewport.scale ?? 1, width: viewport.width, height: viewport.height, }; } const rect = portalTarget.getBoundingClientRect(); return { portalTarget, scale: 1, width: rect.width || window.innerWidth, height: rect.height || window.innerHeight, }; } function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function getPanelPosition( placement: "top" | "center" | "bottom", panel: { width: number; height: number }, context: CoordinateContext, ) { const left = context.width / 2 - panel.width / 2; const topMap = { top: TOP_OFFSET, center: context.height / 2 - panel.height / 2, bottom: context.height - panel.height - BOTTOM_OFFSET, }; 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(left, VIEWPORT_MARGIN, maxLeft), top: clamp(topMap[placement], VIEWPORT_MARGIN, maxTop), }; } export function GuideDialog({ state, actions, children, container, viewport, theme, closeOnOverlayClick = false, closeOnEsc = true, onStepChange, }: GuideDialogProps) { 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 zIndex = theme?.zIndex ?? 1000; const panelMaxWidth = theme?.panelMaxWidth ?? DEFAULT_PANEL_MAX_WIDTH; const isLayoutReady = !!layout && layout.stepKey === stepKey; const activeLayout = isLayoutReady ? layout : 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 context = getCoordinateContext({ container, viewport }); const panelRect = panelRef.current?.getBoundingClientRect(); const panelSize = { width: panelRect ? panelRect.width / context.scale : panelMaxWidth, height: panelRect ? panelRect.height / context.scale : 220, }; const position = getPanelPosition( step.placement ?? "bottom", panelSize, context, ); setLayout({ stepKey, portalTarget: context.portalTarget, width: context.width, height: context.height, panelStyle: { left: `${position.left}px`, top: `${position.top}px`, maxWidth: `${panelMaxWidth}px`, }, }); }, [container, open, panelMaxWidth, step, stepKey, viewport]); useLayoutEffect(() => { measure(); }, [measure]); useEffect(() => { if (!open || !step) return; onStepChange?.(current, step); }, [current, open, onStepChange, step]); useEffect(() => { if (!open) return; const handleResize = () => measure(); const handleKeyDown = (event: KeyboardEvent) => { if (closeOnEsc && event.key === "Escape") { closeAsSkip(); } }; const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => measure()); window.addEventListener("resize", handleResize); window.addEventListener("keydown", handleKeyDown); if (panelRef.current) resizeObserver?.observe(panelRef.current); return () => { window.removeEventListener("resize", handleResize); window.removeEventListener("keydown", handleKeyDown); resizeObserver?.disconnect(); }; }, [closeAsSkip, closeOnEsc, measure, open]); if (!open || !step || typeof document === "undefined") return null; const isFirst = current === 0; const isLast = current === steps.length - 1; const options = step.options ?? []; const hasOptions = options.length > 0; const primaryAction = isLast ? closeAsFinish : actions.next; const dialog = { step, character: step.character, options, progress: { index: current, current: current + 1, total: steps.length, }, status: { isFirst, isLast, hasOptions, }, actions: { prev: actions.prev, next: actions.next, primary: primaryAction, skip: closeAsSkip, finish: closeAsFinish, selectOption: actions.selectOption, }, labels: { prev: step.prevLabel ?? "上一步", next: step.nextLabel ?? "继续", skip: step.skipLabel ?? "跳过", done: step.doneLabel ?? "完成", primary: isLast ? (step.doneLabel ?? "完成") : (step.nextLabel ?? "继续"), }, }; const panelContent = children ? ( children(dialog) ) : ( ); const panelClassName = children ? styles.panelContainer : styles.panel; if (!activeLayout) { return ( ); } const overlayClick = (event: MouseEvent) => { event.stopPropagation(); if (closeOnOverlayClick) { closeAsSkip(); } }; return (