o2o-castad-frontend/src/components/Tutorial/TutorialOverlay.tsx

279 lines
8.3 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TutorialHint } from './tutorialSteps';
interface Rect {
top: number;
left: number;
width: number;
height: number;
}
interface TooltipPos {
top: number;
left: number;
}
interface TutorialOverlayProps {
hints: TutorialHint[];
currentIndex: number;
onNext: () => void;
onPrev: () => void;
onSkip: () => void;
}
const PADDING = 8;
function getTargetRect(selector: string): Rect | null {
const el = document.querySelector(selector);
if (!el) return null;
const r = el.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height };
}
function getSpotlightRect(
rect: Rect,
padding: number,
override?: { top?: number; right?: number; bottom?: number; left?: number }
): Rect {
const pTop = override?.top ?? padding;
const pRight = override?.right ?? padding;
const pBottom = override?.bottom ?? padding;
const pLeft = override?.left ?? padding;
const left = Math.max(0, Math.floor(rect.left - pLeft));
const top = Math.max(0, Math.floor(rect.top - pTop));
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
return {
top,
left,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
}
function calcTooltipPos(rect: Rect, position: TutorialHint['position']): TooltipPos {
const tooltipW = 300;
const tooltipH = 140;
switch (position) {
case 'bottom':
return {
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
};
case 'top':
return {
top: Math.max(rect.top - tooltipH - PADDING, 8),
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
};
case 'right':
return {
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
};
case 'left':
return {
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
left: Math.max(rect.left - tooltipW - PADDING, 8),
};
}
}
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
hints,
currentIndex,
onNext,
onPrev,
onSkip,
}) => {
const { t } = useTranslation();
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const hint = hints[currentIndex];
const isLast = currentIndex === hints.length - 1;
const updateRect = useCallback(() => {
if (!hint) return;
setTargetRect(getTargetRect(hint.targetSelector));
}, [hint]);
useEffect(() => {
updateRect();
window.addEventListener('resize', updateRect);
window.addEventListener('scroll', updateRect, true);
return () => {
window.removeEventListener('resize', updateRect);
window.removeEventListener('scroll', updateRect, true);
};
}, [updateRect]);
useEffect(() => {
if (!hint) return;
let retryTimer: number | undefined;
let rectTimer: number | undefined;
let cleanupTarget: (() => void) | undefined;
const bindToTarget = (el: HTMLElement) => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
rectTimer = window.setTimeout(updateRect, 200);
const shouldClickAdvance = hint.clickToAdvance !== false;
if (shouldClickAdvance) {
el.style.cursor = 'pointer';
el.addEventListener('click', onNext);
}
cleanupTarget = () => {
if (shouldClickAdvance) {
el.style.cursor = '';
el.removeEventListener('click', onNext);
}
};
};
const tryBind = (): boolean => {
const el = document.querySelector(hint.targetSelector) as HTMLElement | null;
if (!el) return false;
bindToTarget(el);
return true;
};
if (!tryBind()) {
retryTimer = window.setInterval(() => {
if (tryBind() && retryTimer) {
window.clearInterval(retryTimer);
retryTimer = undefined;
}
}, 80);
}
return () => {
if (retryTimer) window.clearInterval(retryTimer);
if (rectTimer) window.clearTimeout(rectTimer);
cleanupTarget?.();
};
}, [hint, updateRect, onNext]);
if (!hint) return null;
const tooltipPos: TooltipPos = targetRect
? calcTooltipPos(targetRect, hint.position)
: {
top: window.innerHeight / 2 - 70,
left: window.innerWidth / 2 - 150,
};
const spotlightPadding = hint.spotlightPadding ?? PADDING;
const spotlightRect = targetRect ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
return (
<div className="tutorial-overlay-root">
{spotlightRect ? (
<>
<div
className="tutorial-overlay-blocker"
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top,
left: 0,
width: spotlightRect.left,
height: spotlightRect.height,
}}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top,
left: spotlightRect.left + spotlightRect.width,
right: 0,
height: spotlightRect.height,
}}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top + spotlightRect.height,
left: 0,
right: 0,
bottom: 0,
}}
/>
<div
className="tutorial-spotlight-ring"
style={{
top: spotlightRect.top,
left: spotlightRect.left,
width: spotlightRect.width,
height: spotlightRect.height,
}}
/>
</>
) : (
<div className="tutorial-overlay-blocker" style={{ inset: 0 }} />
)}
<div
className="tutorial-tooltip"
style={{ top: tooltipPos.top, left: tooltipPos.left }}
onClick={(e) => e.stopPropagation()}
>
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
<div className="tutorial-tooltip-footer">
<span className="tutorial-tooltip-counter">
{currentIndex + 1} / {hints.length}
</span>
<div className="tutorial-tooltip-actions">
<button className="tutorial-btn-skip" onClick={onSkip}>
{t('tutorial.skip')}
</button>
{currentIndex > 0 && (
<button className="tutorial-btn-prev" onClick={onPrev}>
{t('tutorial.prev')}
</button>
)}
{(hint.clickToAdvance !== true || isLast) && (
<button className="tutorial-btn-next" onClick={onNext}>
{isLast ? t('tutorial.finish') : t('tutorial.next')}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default TutorialOverlay;
interface TutorialRestartPopupProps {
onConfirm: () => void;
onCancel: () => void;
}
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
const { t } = useTranslation();
return (
<div className="tutorial-restart-backdrop">
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
<div className="tutorial-restart-actions">
<button className="tutorial-restart-cancel" onClick={onCancel}>
{t('tutorial.restart.cancel')}
</button>
<button className="tutorial-restart-confirm" onClick={onConfirm}>
{t('tutorial.restart.confirm')}
</button>
</div>
</div>
</div>
);
};