import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { createPortal } from "react-dom"; import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons"; import "../styles/components/onboarding.css"; // ─── Types ─────────────────────────────────────────────────── export type TourPhaseId = "chat" | "image" | "video"; interface TooltipStep { target: string; title: string; description: string; /** Which side of the target to place the tooltip on (preferred). */ placement?: "top" | "bottom" | "left" | "right"; /** If true, this step requires the user to interact with the element to proceed. */ interactive?: boolean; /** Shown as hint text when interactive. */ actionHint?: string; } interface TourPhase { id: TourPhaseId; label: string; steps: TooltipStep[]; } interface OnboardingTourProps { active: boolean; phase: TourPhaseId; stepIndex: number; onNext: (phase: TourPhaseId, stepIndex: number) => void; onSkip: (phase: TourPhaseId) => void; onDone: () => void; } // ─── Tour definitions ──────────────────────────────────────── const PHASES: Record = { chat: { id: "chat", label: "对话模式", steps: [ { target: "onboarding-chat-upload", title: "参考素材上传", description: "点击或拖拽上传图片、视频、音频等参考素材,帮助 AI 更好地理解你的需求。", placement: "right", }, { target: "onboarding-chat-model", title: "AI 模型选择", description: "在这里选择对话使用的 AI 模型,不同模型有不同的擅长领域和风格。", placement: "bottom", }, { target: "onboarding-chat-speed", title: "思考速度", description: "「思考速度:高」回复更迅速简洁;「思考速度:急速」适合快速问答场景。", placement: "bottom", }, { target: "onboarding-chat-depth", title: "推理深度", description: "「推理深度:强」进行更深层逻辑推理;「推理深度:极限」适合复杂多步骤问题。", placement: "bottom", }, { target: "onboarding-chat-input", title: "提示词输入框", description: "在这里输入你的问题或创作需求,按 Enter 发送,Shift + Enter 换行。", placement: "top", }, { target: "onboarding-mode-selector", title: "切换到图像生成模式", description: "点击「下一步」自动切换,或点击这个按钮手动选择「图像生成」进入下一阶段。", placement: "bottom", }, ], }, image: { id: "image", label: "图像生成", steps: [ { target: "onboarding-image-upload", title: "参考图上传", description: "上传参考图片,AI 将基于参考图的风格和内容生成新图像。支持 PNG / JPG / WebP。", placement: "right", }, { target: "onboarding-image-model", title: "图像模型选择", description: "选择用于图像生成的 AI 模型,不同模型在风格、精度和速度上有所侧重。", placement: "bottom", }, { target: "onboarding-image-settings", title: "比例与分辨率", description: "设置生成图像的宽高比(如 16:9、1:1)和清晰度(1K/2K),根据使用场景选择。", placement: "bottom", }, { target: "onboarding-image-grid", title: "单图 / 多宫格模式", description: "「单图」生成一张完整图像;「多宫格」一次生成多张变体供你挑选最佳方案。", placement: "bottom", }, { target: "onboarding-image-input", title: "图像提示词", description: "描述你想要的图像内容、风格和细节,越具体效果越好。", placement: "top", }, { target: "onboarding-mode-selector", title: "切换到视频生成模式", description: "点击「下一步」自动切换,或点击这个按钮手动选择「视频生成」进入下一阶段。", placement: "bottom", }, ], }, video: { id: "video", label: "视频生成", steps: [ { target: "onboarding-video-upload", title: "参考素材上传", description: "上传参考图片或视频片段,帮助 AI 确定视频的风格、色调和内容方向。", placement: "right", }, { target: "onboarding-video-model", title: "视频模型选择", description: "选择视频生成模型。不同模型在画质、时长、运动流畅度上各有优势。", placement: "bottom", }, { target: "onboarding-video-frame", title: "生成方式:全能 / 首尾帧", description: "「全能参考」根据描述直接生成;「首尾帧」通过设定起始和结束画面精确控制转场。", placement: "bottom", }, { target: "onboarding-video-ratio", title: "视频画面比例", description: "选择画面比例。9:16 适合手机短视频(抖音/Reels),16:9 适合横屏展示。", placement: "bottom", }, { target: "onboarding-video-duration", title: "视频时长设置", description: "设置生成视频的秒数。时长越长,生成时间越久,建议从 5 秒开始尝试。", placement: "bottom", }, { target: "onboarding-video-quality", title: "分辨率与画质", description: "选择视频清晰度。720P 生成更快适合预览,1080P 画质更高适合最终成品。", placement: "bottom", }, { target: "onboarding-video-generate", title: "一切就绪,开始创作!", description: "设置完毕后,点击发送按钮(或按 Enter)即可开始你的首次视频生成。祝你创作愉快!", placement: "top", }, ], }, }; // ─── Connector line calculation ────────────────────────────── interface ConnectorPoints { x1: number; y1: number; // tooltip edge center x2: number; y2: number; // target edge center } function calcConnector( tooltipRect: DOMRect, targetRect: DOMRect, placement: TooltipStep["placement"], ): ConnectorPoints { const tx = targetRect.left + targetRect.width / 2; const ty = targetRect.top + targetRect.height / 2; const tcx = tooltipRect.left + tooltipRect.width / 2; const tcy = tooltipRect.top + tooltipRect.height / 2; switch (placement) { case "top": return { x1: tcx, y1: tooltipRect.bottom, x2: tx, y2: targetRect.top }; case "bottom": return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom }; case "left": return { x1: tooltipRect.right, y1: tcy, x2: targetRect.left, y2: ty }; case "right": return { x1: tooltipRect.left, y1: tcy, x2: targetRect.right, y2: ty }; default: return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom }; } } // ─── Placement engine ───────────────────────────────────────── interface PlacementResult { left: number; top: number; actualPlacement: TooltipStep["placement"]; } /** Score a candidate — lower is better. Penalises covering the target or overflow. */ function scorePlacement( left: number, top: number, tw: number, th: number, targetRect: DOMRect, vw: number, vh: number, ): number { let score = 0; // Overflow penalty if (left < 0) score += Math.abs(left); if (top < 0) score += Math.abs(top); if (left + tw > vw) score += (left + tw - vw); if (top + th > vh) score += (top + th - vh); // Overlap with target penalty (avoid covering the highlighted element) const overlapX = Math.max(0, Math.min(left + tw, targetRect.right) - Math.max(left, targetRect.left)); const overlapY = Math.max(0, Math.min(top + th, targetRect.bottom) - Math.max(top, targetRect.top)); if (overlapX > 0 && overlapY > 0) score += overlapX * overlapY * 0.01; return score; } function findBestPlacement( targetRect: DOMRect, tw: number, th: number, preferred: TooltipStep["placement"], ): PlacementResult { const gap = 144; const vw = window.innerWidth; const vh = window.innerHeight; const all: Array = [ preferred ?? "bottom", ...(["bottom", "top", "right", "left"] as const).filter((p) => p !== (preferred ?? "bottom")), ]; let best: PlacementResult = { left: 0, top: 0, actualPlacement: "bottom" }; let bestScore = Infinity; for (const p of all) { let left = 0, top = 0; switch (p) { case "bottom": left = targetRect.left + targetRect.width / 2 - tw / 2; top = targetRect.bottom + gap; break; case "top": left = targetRect.left + targetRect.width / 2 - tw / 2; top = targetRect.top - th - gap; break; case "right": left = targetRect.right + gap; top = targetRect.top + targetRect.height / 2 - th / 2; break; case "left": left = targetRect.left - tw - gap; top = targetRect.top + targetRect.height / 2 - th / 2; break; } left = Math.max(12, Math.min(left, vw - tw - 12)); top = Math.max(12, Math.min(top, vh - th - 12)); const s = scorePlacement(left, top, tw, th, targetRect, vw, vh); if (s < bestScore) { bestScore = s; best = { left, top, actualPlacement: p }; } if (s === 0) break; // perfect } return best; } // ─── Component ──────────────────────────────────────────────── export default function OnboardingTour({ active, phase, stepIndex, onNext, onSkip, onDone, }: OnboardingTourProps) { const [pos, setPos] = useState({ left: 0, top: 0, actualPlacement: "bottom" }); const [targetRect, setTargetRect] = useState(null); const [visible, setVisible] = useState(false); const [connector, setConnector] = useState(null); const tooltipRef = useRef(null); const prevPhaseRef = useRef(phase); const prevStepRef = useRef(stepIndex); const phaseDef = PHASES[phase]; const currentStep = phaseDef?.steps[stepIndex]; const totalSteps = phaseDef?.steps.length ?? 0; const isLastStep = stepIndex >= totalSteps - 1; const isVideoLastStep = phase === "video" && isLastStep; const stepChanged = prevPhaseRef.current !== phase || prevStepRef.current !== stepIndex; prevPhaseRef.current = phase; prevStepRef.current = stepIndex; const recalc = useCallback(() => { if (!currentStep) return; const el = document.querySelector(`[data-onboarding="${currentStep.target}"]`) as HTMLElement | null; if (!el) return; // Will be retried by the polling loop const rect = el.getBoundingClientRect(); setTargetRect(rect); const tooltip = tooltipRef.current; if (!tooltip) return; const tr = tooltip.getBoundingClientRect(); const best = findBestPlacement(rect, tr.width, tr.height, currentStep.placement); setPos(best); // Recalculate tooltip rect after position update (use the same best pos) const virtualTooltipRect = new DOMRect(best.left, best.top, tr.width, tr.height); setConnector(calcConnector(virtualTooltipRect, rect, best.actualPlacement)); }, [currentStep]); useEffect(() => { if (!active) { setVisible(false); return; } const t = setTimeout(() => { setVisible(true); recalc(); }, 120); return () => clearTimeout(t); }, [active, phase, stepIndex, recalc]); // Reposition and retry when elements aren't ready useEffect(() => { if (!active || !visible) return; const h = () => recalc(); window.addEventListener("resize", h); window.addEventListener("scroll", h, true); const obs = new MutationObserver(h); obs.observe(document.body, { childList: true, subtree: true, attributes: true }); // Polling retry: keep looking for the target element if not found yet let retryId: number | null = null; let attempts = 0; const poll = () => { recalc(); attempts += 1; if (attempts < 40) retryId = requestAnimationFrame(poll); }; // Start polling after a short delay const startTimer = setTimeout(() => { poll(); }, 200); return () => { window.removeEventListener("resize", h); window.removeEventListener("scroll", h, true); obs.disconnect(); clearTimeout(startTimer); if (retryId !== null) cancelAnimationFrame(retryId); }; }, [active, visible, recalc]); // Animate in on step change useEffect(() => { if (!active || !visible || !stepChanged) return; const el = tooltipRef.current; if (!el) return; el.classList.remove("onboarding-tooltip--pop"); void el.offsetWidth; // force reflow el.classList.add("onboarding-tooltip--pop"); }, [active, visible, stepChanged, phase, stepIndex]); if (!active || !currentStep) return null; const connectorPath = connector ? `M ${connector.x1} ${connector.y1} L ${connector.x2} ${connector.y2}` : ""; const arrowAngle = connector ? Math.atan2(connector.y2 - connector.y1, connector.x2 - connector.x1) * (180 / Math.PI) : 0; const clipPath = targetRect ? `polygon(0% 0%, 0% 100%, ${targetRect.left - 6}px 100%, ${targetRect.left - 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px 100%, 100% 100%, 100% 0%)` : ""; return createPortal(
{/* Overlay */}
{/* Spotlight ring */} {targetRect && (
{/* Animated pulse ring */}
)} {/* Connector SVG line */} {connector && ( )} {/* Tooltip card */}
{/* Arrow pointing toward target */}
{phaseDef.label} {stepIndex + 1} / {totalSteps}
{currentStep.title}

{currentStep.description}

{stepIndex > 0 && ( )} {isVideoLastStep ? ( ) : isLastStep && phase !== "video" ? ( ) : ( )}
{/* Bottom progress bar */} , document.body, ); }