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; groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null; } 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'], tooltipW = 300, tooltipH = 160): TooltipPos { 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, groupProgress, }) => { const { t } = useTranslation(); const [targetRect, setTargetRect] = useState(null); const tooltipRef = React.useRef(null); const [tooltipSize, setTooltipSize] = useState({ w: 300, h: 160 }); const hint = hints[currentIndex]; const isLast = currentIndex === hints.length - 1; // 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로 const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true); 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 = () => { el.style.cursor = ''; el.removeEventListener('click', onNext); }; } else { cleanupTarget = () => {}; } }; 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]); // 툴팁 DOM 크기 측정 — 힌트가 바뀔 때만 재측정 useEffect(() => { const el = tooltipRef.current; if (!el) return; const { offsetWidth, offsetHeight } = el; if (offsetWidth && offsetHeight) { setTooltipSize(prev => prev.w === offsetWidth && prev.h === offsetHeight ? prev : { w: offsetWidth, h: offsetHeight } ); } }, [currentIndex, hints]); if (!hint) return null; const spotlightPadding = hint.spotlightPadding ?? PADDING; const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null; // 툴팁은 spotlightRect 기준으로 배치해야 스포트라이트와 겹치지 않음 const tooltipPos: TooltipPos = (spotlightRect ?? targetRect) ? calcTooltipPos((spotlightRect ?? targetRect)!, hint.position, tooltipSize.w, tooltipSize.h) : { top: window.innerHeight / 2 - tooltipSize.h / 2, left: window.innerWidth / 2 - tooltipSize.w / 2, }; return (
{spotlightRect ? ( <>
) : null}
e.stopPropagation()} >

{t(hint.titleKey)}

{t(hint.descriptionKey)}

{hint.noteKey && (

{t(hint.noteKey)}

)}
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
{currentIndex > 0 && ( )} {(hint.clickToAdvance !== true || isFinish) && ( )}
); }; 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')}

); };