295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import { useAppStore } from '../store/useAppStore';
|
|
import { useI18n } from '../lib/useI18n';
|
|
|
|
const ONBOARDING_KEY = 'author-onboarding-done';
|
|
|
|
export default function TourOverlay({ onOpenHelp }) {
|
|
const { startTour, setStartTour, language, visualTheme } = useAppStore();
|
|
const { t } = useI18n();
|
|
const [status, setStatus] = useState('hidden'); // hidden, intro, tour
|
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
|
const [targetRect, setTargetRect] = useState(null);
|
|
const [windowSize, setWindowSize] = useState({ w: 0, h: 0 });
|
|
|
|
// 根据当前语言动态生成 TOUR_STEPS
|
|
const TOUR_STEPS = useMemo(() => [
|
|
{
|
|
targetId: 'tour-new-chapter',
|
|
title: t('tour.step1Title'),
|
|
content: t('tour.step1Content'),
|
|
placement: 'right'
|
|
},
|
|
{
|
|
targetId: 'tour-editor',
|
|
title: t('tour.step2Title'),
|
|
content: t('tour.step2Content'),
|
|
placement: 'center'
|
|
},
|
|
{
|
|
targetId: 'tour-ai-btn',
|
|
title: t('tour.step3Title'),
|
|
content: t('tour.step3Content'),
|
|
placement: 'left'
|
|
},
|
|
{
|
|
targetId: 'tour-settings',
|
|
title: t('tour.step4Title'),
|
|
content: t('tour.step4Content'),
|
|
placement: 'top'
|
|
},
|
|
{
|
|
targetId: 'tour-help',
|
|
title: t('tour.step5Title'),
|
|
content: t('tour.step5Content'),
|
|
placement: 'top'
|
|
},
|
|
{
|
|
targetId: 'tour-github',
|
|
title: t('tour.step6Title'),
|
|
content: t('tour.step6Content'),
|
|
placement: 'top'
|
|
}
|
|
], [t]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const isDone = localStorage.getItem(ONBOARDING_KEY);
|
|
if (!isDone) {
|
|
if (language && visualTheme) {
|
|
setStatus('intro');
|
|
}
|
|
}
|
|
setWindowSize({ w: window.innerWidth, h: window.innerHeight });
|
|
}
|
|
}, [language, visualTheme]);
|
|
|
|
const updateRect = useCallback((index) => {
|
|
const step = TOUR_STEPS[index];
|
|
if (!step) return;
|
|
|
|
let el = document.getElementById(step.targetId);
|
|
if (!el && step.targetId === 'tour-editor') {
|
|
el = document.querySelector('.editor-container');
|
|
}
|
|
|
|
if (el) {
|
|
const rect = el.getBoundingClientRect();
|
|
setTargetRect({
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height
|
|
});
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
|
} else {
|
|
setTimeout(() => {
|
|
const retryEl = document.getElementById(step.targetId) || document.querySelector('.editor-container');
|
|
if (retryEl) {
|
|
const r = retryEl.getBoundingClientRect();
|
|
setTargetRect({ x: r.x, y: r.y, width: r.width, height: r.height });
|
|
} else {
|
|
// Fallback: show tooltip centered when target element not found
|
|
const cx = window.innerWidth / 2;
|
|
const cy = window.innerHeight / 2;
|
|
setTargetRect({ x: cx - 1, y: cy - 1, width: 2, height: 2 });
|
|
}
|
|
}, 300);
|
|
}
|
|
}, [TOUR_STEPS]);
|
|
|
|
const finishTour = () => {
|
|
localStorage.setItem(ONBOARDING_KEY, 'true');
|
|
setStatus('hidden');
|
|
};
|
|
|
|
const skipToHelp = () => {
|
|
setStatus('tour');
|
|
const helpStepIndex = TOUR_STEPS.length - 2;
|
|
setCurrentStepIndex(helpStepIndex);
|
|
updateRect(helpStepIndex);
|
|
};
|
|
|
|
const beginTour = useCallback(() => {
|
|
setStatus('tour');
|
|
setCurrentStepIndex(0);
|
|
updateRect(0);
|
|
}, [updateRect]);
|
|
|
|
useEffect(() => {
|
|
if (startTour) {
|
|
beginTour();
|
|
setStartTour(false);
|
|
}
|
|
}, [startTour, beginTour, setStartTour]);
|
|
|
|
// 监听窗口缩放和滚动
|
|
useEffect(() => {
|
|
if (status !== 'tour') return;
|
|
|
|
const handleResize = () => {
|
|
setWindowSize({ w: window.innerWidth, h: window.innerHeight });
|
|
updateRect(currentStepIndex);
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
window.addEventListener('scroll', handleResize, true);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
window.removeEventListener('scroll', handleResize, true);
|
|
};
|
|
}, [status, currentStepIndex, updateRect]);
|
|
|
|
const nextStep = () => {
|
|
if (currentStepIndex === TOUR_STEPS.length - 1) {
|
|
finishTour();
|
|
} else {
|
|
setCurrentStepIndex(i => i + 1);
|
|
updateRect(currentStepIndex + 1);
|
|
}
|
|
};
|
|
|
|
const prevStep = () => {
|
|
if (currentStepIndex > 0) {
|
|
setCurrentStepIndex(i => i - 1);
|
|
updateRect(currentStepIndex - 1);
|
|
}
|
|
};
|
|
|
|
if (status === 'hidden') return null;
|
|
|
|
// ================= Intro 弹窗 =================
|
|
if (status === 'intro') {
|
|
return (
|
|
<div className="tour-overlay-bg">
|
|
<div className="tour-intro-modal">
|
|
<div className="tour-intro-icon">✨</div>
|
|
<h2>{t('tour.introTitle')}</h2>
|
|
<p>{t('tour.introSubtitle')}</p>
|
|
<p className="tour-intro-sub">{t('tour.introHint')}</p>
|
|
<div className="tour-intro-actions">
|
|
<button className="tour-btn ghost" onClick={skipToHelp}>{t('tour.btnSkip')}</button>
|
|
<button className="tour-btn primary" onClick={beginTour}>{t('tour.btnStart')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ================= 具体步骤向导 (Tour) =================
|
|
const step = TOUR_STEPS[currentStepIndex];
|
|
if (!step) return null;
|
|
|
|
const padding = 8;
|
|
const rX = targetRect ? targetRect.x - padding : 0;
|
|
const rY = targetRect ? targetRect.y - padding : 0;
|
|
const rW = targetRect ? targetRect.width + padding * 2 : 0;
|
|
const rH = targetRect ? targetRect.height + padding * 2 : 0;
|
|
const radius = 8;
|
|
|
|
let tooltipStyle = { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
|
if (targetRect) {
|
|
if (step.placement === 'right') {
|
|
tooltipStyle = { top: rY + rH / 2, left: rX + rW + 20, transform: 'translateY(-50%)' };
|
|
} else if (step.placement === 'left') {
|
|
tooltipStyle = { top: rY + rH / 2, right: windowSize.w - rX + 20, transform: 'translateY(-50%)' };
|
|
} else if (step.placement === 'bottom') {
|
|
tooltipStyle = { top: rY + rH + 20, left: rX + rW / 2, transform: 'translateX(-50%)' };
|
|
} else if (step.placement === 'top') {
|
|
tooltipStyle = { bottom: windowSize.h - rY + 20, left: rX + rW / 2, transform: 'translateX(-50%)' };
|
|
} else if (step.placement === 'center') {
|
|
tooltipStyle = { top: rY + rH / 2, left: rX + rW / 2, transform: 'translate(-50%, -50%)' };
|
|
}
|
|
}
|
|
|
|
const tooltipHeightGuess = 200;
|
|
|
|
if (tooltipStyle.top && typeof tooltipStyle.top === 'number') {
|
|
if (tooltipStyle.top < tooltipHeightGuess / 2) {
|
|
tooltipStyle.top = tooltipHeightGuess / 2;
|
|
} else if (tooltipStyle.top > windowSize.h - tooltipHeightGuess / 2) {
|
|
tooltipStyle.top = windowSize.h - tooltipHeightGuess / 2;
|
|
}
|
|
}
|
|
|
|
if (tooltipStyle.bottom && typeof tooltipStyle.bottom === 'number') {
|
|
if (tooltipStyle.bottom < 20) {
|
|
tooltipStyle.bottom = 20;
|
|
}
|
|
}
|
|
|
|
const validateStyle = { ...tooltipStyle };
|
|
|
|
return (
|
|
<div className="tour-portal">
|
|
<svg
|
|
width="100%"
|
|
height="100%"
|
|
className="tour-svg-mask"
|
|
preserveAspectRatio="none"
|
|
>
|
|
<defs>
|
|
<mask id="tour-hole">
|
|
<rect width="100%" height="100%" fill="white" />
|
|
{targetRect && (
|
|
<rect
|
|
x={rX} y={rY} width={rW} height={rH}
|
|
rx={radius} ry={radius}
|
|
fill="black"
|
|
/>
|
|
)}
|
|
</mask>
|
|
</defs>
|
|
<rect
|
|
width="100%" height="100%"
|
|
fill="var(--tour-overlay-color, rgba(0,0,0,0.6))"
|
|
mask="url(#tour-hole)"
|
|
/>
|
|
|
|
{targetRect && (
|
|
<rect
|
|
x={rX} y={rY} width={rW} height={rH}
|
|
rx={radius} ry={radius}
|
|
fill="none"
|
|
stroke="var(--accent)"
|
|
strokeWidth="2"
|
|
className="tour-target-outline"
|
|
/>
|
|
)}
|
|
</svg>
|
|
|
|
{targetRect && (
|
|
<div className="tour-tooltip" style={validateStyle}>
|
|
<div className="tour-tooltip-header">
|
|
<h3>{step.title}</h3>
|
|
<span className="tour-step-counter">{currentStepIndex + 1} / {TOUR_STEPS.length}</span>
|
|
</div>
|
|
<div className="tour-tooltip-content">
|
|
{step.content}
|
|
</div>
|
|
<div className="tour-tooltip-footer">
|
|
{currentStepIndex === TOUR_STEPS.length - 1 ? (
|
|
<div />
|
|
) : (
|
|
<button className="tour-skip-text" onClick={skipToHelp}>{t('tour.btnEndTour')}</button>
|
|
)}
|
|
<div className="tour-footer-right" style={{ display: 'flex', gap: '8px' }}>
|
|
{currentStepIndex > 0 && (
|
|
<button className="tour-btn ghost" onClick={prevStep}>{t('tour.btnPrev')}</button>
|
|
)}
|
|
{currentStepIndex === TOUR_STEPS.length - 1 ? (
|
|
<button className="tour-btn primary" onClick={finishTour}>{t('tour.btnFinish')}</button>
|
|
) : (
|
|
<button className="tour-btn primary" onClick={nextStep}>{t('tour.btnNext')}</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|