299 lines
9.6 KiB
TypeScript
299 lines
9.6 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;
|
|
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<TutorialOverlayProps> = ({
|
|
hints,
|
|
currentIndex,
|
|
onNext,
|
|
onPrev,
|
|
onSkip,
|
|
groupProgress,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
|
const tooltipRef = React.useRef<HTMLDivElement>(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 (
|
|
<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,
|
|
}}
|
|
/>
|
|
</>
|
|
) : null}
|
|
|
|
<div
|
|
ref={tooltipRef}
|
|
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
|
|
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>
|
|
{hint.noteKey && (
|
|
<p className="tutorial-tooltip-note">{t(hint.noteKey)}</p>
|
|
)}
|
|
|
|
<div className="tutorial-tooltip-footer">
|
|
<span className="tutorial-tooltip-counter">
|
|
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : 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 || isFinish) && (
|
|
<button className="tutorial-btn-next" onClick={onNext}>
|
|
{isFinish ? 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>
|
|
);
|
|
};
|