278 lines
8.3 KiB
TypeScript
278 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 = () => {
|
|
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]);
|
|
|
|
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>
|
|
);
|
|
};
|