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 = ({ hints, currentIndex, onNext, onPrev, onSkip, }) => { const { t } = useTranslation(); const [targetRect, setTargetRect] = useState(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 (
{spotlightRect ? ( <>
) : (
)}
e.stopPropagation()} >

{t(hint.titleKey)}

{t(hint.descriptionKey)}

{currentIndex + 1} / {hints.length}
{currentIndex > 0 && ( )} {(hint.clickToAdvance !== true || isLast) && ( )}
); }; export default TutorialOverlay; interface TutorialRestartPopupProps { onConfirm: () => void; onCancel: () => void; } export const TutorialRestartPopup: React.FC = ({ onConfirm, onCancel }) => { const { t } = useTranslation(); return (
e.stopPropagation()}>

{t('tutorial.restart.title')}

{t('tutorial.restart.desc')}

); };