import { CssProps, RefProps } from 'lupine.web'; export interface TourStep { target: string; title: string; content: string; placement?: 'top' | 'bottom' | 'left' | 'right'; } export interface TourProps { steps: TourStep[]; onFinish?: () => void; onClose?: () => void; zIndex?: number; } export const Tour = (props: TourProps) => { const zIndex = props.zIndex || 9999; const css: CssProps = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', pointerEvents: 'none', zIndex: `${zIndex}`, transition: 'opacity 0.3s', opacity: 0, '&.is-active': { opacity: 1, pointerEvents: 'auto', }, // Overlay background using SVG mask or clip-path or huge box-shadow // We'll use absolute elements for precise highlighting '.&-overlay-bg': { position: 'absolute', backgroundColor: 'rgba(0, 0, 0, 0.6)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', pointerEvents: 'auto', // intercept clicks }, '.&-overlay-top': { top: 0, left: 0, right: 0 }, '.&-overlay-bottom': { bottom: 0, left: 0, right: 0 }, '.&-overlay-left': { left: 0 }, '.&-overlay-right': { right: 0 }, '.&-highlight': { position: 'absolute', backgroundColor: 'transparent', borderRadius: '4px', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', pointerEvents: 'none', }, '.&-popover': { position: 'absolute', backgroundColor: 'var(--primary-bg-color, white)', borderRadius: '8px', padding: '16px', width: '280px', boxShadow: 'var(--cover-box-shadow, 0 4px 12px rgba(0,0,0,0.15))', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', color: 'var(--primary-color, #333)', boxSizing: 'border-box', }, '.&-title': { fontSize: '16px', fontWeight: 'bold', marginBottom: '8px', color: 'var(--primary-color)', }, '.&-content': { fontSize: '14px', lineHeight: '1.5', marginBottom: '16px', color: 'var(--secondary-color, #666)', }, '.&-footer': { display: 'flex', justifyContent: 'space-between', alignItems: 'center', }, '.&-steps-count': { fontSize: '12px', color: 'var(--secondary-color, #666)', }, '.&-buttons': { display: 'flex', gap: '8px', }, '.&-btn': { padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', border: 'none', backgroundColor: 'transparent', color: 'var(--primary-color, #333)', }, '.&-btn-primary': { backgroundColor: 'var(--primary-accent-color, #1890ff)', color: 'var(--primary-bg-color, white)', }, '.&-btn-primary:active': { opacity: 0.8, }, }; const ref: RefProps = {}; let currentStepIndex = 0; const updatePosition = () => { const root = ref.current as HTMLElement; if (!root) return; if (props.steps.length === 0 || currentStepIndex >= props.steps.length) { root.classList.remove('is-active'); return; } const step = props.steps[currentStepIndex]; let targetEl = document.querySelector(step.target) as HTMLElement; // Use body center if target not found if (!targetEl) { console.warn(`Tour target not found: ${step.target}`); targetEl = document.body; } const rect = targetEl.getBoundingClientRect(); const hl = ref.$('&-highlight') as HTMLElement; const bgTop = ref.$('&-overlay-top') as HTMLElement; const bgBottom = ref.$('&-overlay-bottom') as HTMLElement; const bgLeft = ref.$('&-overlay-left') as HTMLElement; const bgRight = ref.$('&-overlay-right') as HTMLElement; const popover = ref.$('&-popover') as HTMLElement; const titleEl = ref.$('&-title') as HTMLElement; const contentEl = ref.$('&-content') as HTMLElement; const countEl = ref.$('&-steps-count') as HTMLElement; const nextBtn = ref.$('&-next-btn') as HTMLElement; titleEl.textContent = step.title; contentEl.textContent = step.content; countEl.textContent = `${currentStepIndex + 1} / ${props.steps.length}`; if (currentStepIndex === props.steps.length - 1) { nextBtn.textContent = 'Finish'; } else { nextBtn.textContent = 'Next'; } const padding = targetEl === document.body ? 0 : 4; // padding around highlighted element let ht = 0, hl_l = 0, hw = 0, hh = 0; if (targetEl === document.body) { // Target not found, pseudo highlight center hl.style.width = '0px'; hl.style.height = '0px'; hl.style.top = '50%'; hl.style.left = '50%'; ht = window.innerHeight / 2; hl_l = window.innerWidth / 2; } else { ht = rect.top - padding; hl_l = rect.left - padding; hw = rect.width + padding * 2; hh = rect.height + padding * 2; hl.style.width = `${hw}px`; hl.style.height = `${hh}px`; hl.style.top = `${ht}px`; hl.style.left = `${hl_l}px`; } // Set 4 dark overlays around the highlight bgTop.style.height = `${ht}px`; bgBottom.style.top = `${ht + hh}px`; bgBottom.style.bottom = '0px'; bgLeft.style.top = `${ht}px`; bgLeft.style.height = `${hh}px`; bgLeft.style.width = `${hl_l}px`; bgRight.style.top = `${ht}px`; bgRight.style.height = `${hh}px`; bgRight.style.left = `${hl_l + hw}px`; bgRight.style.right = '0px'; // Simpler more robust placement logic: const placement = step.placement || 'bottom'; const spacing = 12; requestAnimationFrame(() => { const pRect = popover.getBoundingClientRect(); let pTop = 0; let pLeft = 0; switch (placement) { case 'top': pTop = ht - spacing - pRect.height; pLeft = hl_l; break; case 'bottom': pTop = ht + hh + spacing; pLeft = hl_l; break; case 'left': pTop = ht; pLeft = hl_l - spacing - pRect.width; break; case 'right': pTop = ht; pLeft = hl_l + hw + spacing; break; } // basic viewport collision adjustment if (pLeft + pRect.width > window.innerWidth) { pLeft = window.innerWidth - pRect.width - spacing; } if (pLeft < 0) { pLeft = spacing; } if (pTop + pRect.height > window.innerHeight) { pTop = window.innerHeight - pRect.height - spacing; } if (pTop < 0) { pTop = spacing; } popover.style.top = `${pTop}px`; popover.style.left = `${pLeft}px`; // Ensure scrolling to view if needed if (targetEl !== document.body) { targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }); }; // Mount logic setTimeout(() => { updatePosition(); const root = ref.current as HTMLElement; if (root) { root.classList.add('is-active'); } // Listen to resize window.addEventListener('resize', updatePosition); }, 50); const cleanup = () => { window.removeEventListener('resize', updatePosition); }; const handleNext = () => { if (currentStepIndex < props.steps.length - 1) { currentStepIndex++; updatePosition(); } else { ref.current.classList.remove('is-active'); cleanup(); setTimeout(() => { ref.current.remove(); if (props.onFinish) props.onFinish(); }, 300); } }; const handleClose = () => { ref.current.classList.remove('is-active'); cleanup(); setTimeout(() => { ref.current.remove(); if (props.onClose) props.onClose(); }, 300); }; return (