/** * Tutorial system — generic engine, steps, tooltip positioning. */ import { activeTutorial, setActiveTutorial } from '../core/state.js'; import { t } from '../core/i18n.js'; const calibrationTutorialSteps = [ { selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' }, { selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' }, { selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' }, { selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' }, { selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' }, { selector: '#calibration-overlay-btn', textKey: 'calibration.tip.overlay', position: 'bottom' }, { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }, { selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' }, { selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' }, { selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' }, { selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' } ]; const TOUR_KEY = 'tour_completed'; const gettingStartedSteps = [ { selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' }, { selector: '#tab-btn-dashboard', textKey: 'tour.dashboard', position: 'bottom' }, { selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' }, { selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' }, { selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' }, { selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' }, { selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' }, { selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' }, { selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' }, { selector: '#cp-wrap-accent', textKey: 'tour.accent', position: 'bottom' }, { selector: '[onclick*="openSettingsModal"]', textKey: 'tour.settings', position: 'bottom' }, { selector: '#locale-select', textKey: 'tour.language', position: 'bottom' } ]; const dashboardTutorialSteps = [ { selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' }, { selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' }, { selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' }, { selector: '[data-dashboard-section="automations"]', textKey: 'tour.dash.automations', position: 'bottom' } ]; const targetsTutorialSteps = [ { selector: '[data-tree-group="led_group"]', textKey: 'tour.tgt.led_tab', position: 'right' }, { selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' }, { selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' }, { selector: '[data-tree-group="kc_group"]', textKey: 'tour.tgt.kc_tab', position: 'right' } ]; const sourcesTourSteps = [ { selector: '#streams-tree-nav [data-tree-leaf="raw"]', textKey: 'tour.src.raw', position: 'right' }, { selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' }, { selector: '#streams-tree-nav [data-tree-leaf="static_image"]', textKey: 'tour.src.static', position: 'right' }, { selector: '#streams-tree-nav [data-tree-leaf="processed"]', textKey: 'tour.src.processed', position: 'right' }, { selector: '#streams-tree-nav [data-tree-leaf="color_strip"]', textKey: 'tour.src.color_strip', position: 'right' }, { selector: '#streams-tree-nav [data-tree-leaf="audio"]', textKey: 'tour.src.audio', position: 'right' }, { selector: '#streams-tree-nav [data-tree-leaf="value"]', textKey: 'tour.src.value', position: 'right' }, { selector: '#streams-tree-nav [data-tree-leaf="sync"]', textKey: 'tour.src.sync', position: 'right' } ]; const automationsTutorialSteps = [ { selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', position: 'bottom' }, { selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom' }, { selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom' }, { selector: '[data-card-section="scenes"]', textKey: 'tour.auto.scenes_list', position: 'bottom' }, { selector: '[data-cs-add="scenes"]', textKey: 'tour.auto.scenes_add', position: 'bottom' }, { selector: '.card[data-scene-id]', textKey: 'tour.auto.scenes_card', position: 'bottom' }, ]; const _fixedResolve = (step) => { const el = document.querySelector(step.selector); if (!el) return null; // offsetParent is null when element or any ancestor has display:none if (!el.offsetParent) return null; return el; }; const deviceTutorialSteps = [ { selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' }, { selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' }, { selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' }, { selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' }, { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' }, { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' }, { selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' } ]; export function startTutorial(config) { closeTutorial(); // Remove focus from the trigger button so its outline doesn't persist if (document.activeElement) document.activeElement.blur(); const overlay = document.getElementById(config.overlayId); if (!overlay) return; setActiveTutorial({ steps: config.steps, overlay: overlay, mode: config.mode, step: 0, resolveTarget: config.resolveTarget, container: config.container, onClose: config.onClose || null }); // Hide tooltip and ring until first step positions them (prevents flash at 0,0) const tooltip = overlay.querySelector('.tutorial-tooltip'); const ring = overlay.querySelector('.tutorial-ring'); const backdrop = overlay.querySelector('.tutorial-backdrop'); if (tooltip) tooltip.style.visibility = 'hidden'; if (ring) ring.style.visibility = 'hidden'; if (backdrop) backdrop.style.clipPath = 'none'; overlay.classList.add('active'); document.addEventListener('keydown', handleTutorialKey); showTutorialStep(0); } export function startCalibrationTutorial() { const container = document.querySelector('#calibration-modal .modal-body'); if (!container) return; startTutorial({ steps: calibrationTutorialSteps, overlayId: 'calibration-tutorial-overlay', mode: 'absolute', container: container, resolveTarget: (step) => { const el = document.querySelector(step.selector); if (!el) return null; // Skip elements hidden via display:none (e.g. overlay btn in device mode) if (el.style.display === 'none' || getComputedStyle(el).display === 'none') return null; return el; } }); } export function startDeviceTutorial(deviceId) { const selector = deviceId ? `.card[data-device-id="${deviceId}"]` : '.card[data-device-id]'; if (!document.querySelector(selector)) return; startTutorial({ steps: deviceTutorialSteps, overlayId: 'device-tutorial-overlay', mode: 'fixed', container: null, resolveTarget: (step) => { const card = document.querySelector(selector); if (!card) return null; return step.global ? document.querySelector(step.selector) : card.querySelector(step.selector); } }); } export function startGettingStartedTutorial() { startTutorial({ steps: gettingStartedSteps, overlayId: 'getting-started-overlay', mode: 'fixed', container: null, resolveTarget: (step) => document.querySelector(step.selector) || null, onClose: () => localStorage.setItem(TOUR_KEY, '1') }); } export function startDashboardTutorial() { startTutorial({ steps: dashboardTutorialSteps, overlayId: 'getting-started-overlay', mode: 'fixed', container: null, resolveTarget: _fixedResolve }); } export function startTargetsTutorial() { startTutorial({ steps: targetsTutorialSteps, overlayId: 'getting-started-overlay', mode: 'fixed', container: null, resolveTarget: _fixedResolve }); } export function startSourcesTutorial() { startTutorial({ steps: sourcesTourSteps, overlayId: 'getting-started-overlay', mode: 'fixed', container: null, resolveTarget: _fixedResolve }); } export function startAutomationsTutorial() { startTutorial({ steps: automationsTutorialSteps, overlayId: 'getting-started-overlay', mode: 'fixed', container: null, resolveTarget: _fixedResolve }); } export function closeTutorial() { if (!activeTutorial) return; const onClose = activeTutorial.onClose; activeTutorial.overlay.classList.remove('active'); document.querySelectorAll('.tutorial-target').forEach(el => { el.classList.remove('tutorial-target'); el.style.zIndex = ''; }); document.removeEventListener('keydown', handleTutorialKey); setActiveTutorial(null); if (onClose) onClose(); } export function tutorialNext() { if (!activeTutorial) return; if (activeTutorial.step < activeTutorial.steps.length - 1) { showTutorialStep(activeTutorial.step + 1); } else { closeTutorial(); } } export function tutorialPrev() { if (!activeTutorial) return; if (activeTutorial.step > 0) { showTutorialStep(activeTutorial.step - 1, -1); } } function _positionSpotlight(target, overlay, step, index, isFixed) { const targetRect = target.getBoundingClientRect(); const pad = 6; let x, y, w, h; if (isFixed) { x = targetRect.left - pad; y = targetRect.top - pad; w = targetRect.width + pad * 2; h = targetRect.height + pad * 2; } else { const containerRect = activeTutorial.container.getBoundingClientRect(); x = targetRect.left - containerRect.left - pad; y = targetRect.top - containerRect.top - pad; w = targetRect.width + pad * 2; h = targetRect.height + pad * 2; } const backdrop = overlay.querySelector('.tutorial-backdrop'); if (backdrop) { backdrop.style.clipPath = `polygon( 0% 0%, 0% 100%, ${x}px 100%, ${x}px ${y}px, ${x + w}px ${y}px, ${x + w}px ${y + h}px, ${x}px ${y + h}px, ${x}px 100%, 100% 100%, 100% 0%)`; } const ring = overlay.querySelector('.tutorial-ring'); if (ring) { ring.style.left = x + 'px'; ring.style.top = y + 'px'; ring.style.width = w + 'px'; ring.style.height = h + 'px'; } const tooltip = overlay.querySelector('.tutorial-tooltip'); const textEl = overlay.querySelector('.tutorial-tooltip-text'); const counterEl = overlay.querySelector('.tutorial-step-counter'); if (textEl) textEl.textContent = t(step.textKey); if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`; const prevBtn = overlay.querySelector('.tutorial-prev-btn'); const nextBtn = overlay.querySelector('.tutorial-next-btn'); if (prevBtn) prevBtn.disabled = (index === 0); if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192'; if (tooltip) { positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed); tooltip.style.visibility = ''; } if (ring) ring.style.visibility = ''; } /** Wait for scroll to settle (position stops changing). */ function _waitForScrollEnd() { return new Promise(resolve => { let lastY = window.scrollY; let stableFrames = 0; const fallback = setTimeout(resolve, 300); function check() { const y = window.scrollY; if (y === lastY) stableFrames++; else { stableFrames = 0; lastY = y; } if (stableFrames >= 3) { clearTimeout(fallback); resolve(); } else requestAnimationFrame(check); } requestAnimationFrame(check); }); } function showTutorialStep(index, direction = 1) { if (!activeTutorial) return; activeTutorial.step = index; const step = activeTutorial.steps[index]; const overlay = activeTutorial.overlay; const isFixed = activeTutorial.mode === 'fixed'; document.querySelectorAll('.tutorial-target').forEach(el => { el.classList.remove('tutorial-target'); el.style.zIndex = ''; }); const target = activeTutorial.resolveTarget(step); if (!target) { // Auto-skip hidden/missing targets in the current direction const next = index + direction; if (next >= 0 && next < activeTutorial.steps.length) showTutorialStep(next, direction); else closeTutorial(); return; } target.classList.add('tutorial-target'); if (isFixed) target.style.zIndex = '10001'; // Scroll target into view if off-screen or behind sticky header const preRect = target.getBoundingClientRect(); const headerH = isFixed ? (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0) : 0; const needsScroll = preRect.bottom > window.innerHeight || preRect.top < headerH; if (needsScroll) { // Hide tooltip while scrolling to prevent stale position flash const tt = overlay.querySelector('.tutorial-tooltip'); if (tt) tt.style.visibility = 'hidden'; target.scrollIntoView({ behavior: 'smooth', block: 'center' }); _waitForScrollEnd().then(() => { if (activeTutorial && activeTutorial.step === index) { _positionSpotlight(target, overlay, step, index, isFixed); } }); } else { _positionSpotlight(target, overlay, step, index, isFixed); } } function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) { const gap = 12; const tooltipW = 260; tooltip.setAttribute('style', 'left:-9999px;top:-9999px'); const tooltipH = tooltip.offsetHeight || 150; const positions = { top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap }, bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap }, left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 }, right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 } }; let pos = positions[preferred] || positions.bottom; const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth; const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight; if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) { const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; const alt = positions[opposite[preferred]]; if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) { pos = alt; } } pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x)); pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y)); tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`); } function handleTutorialKey(e) { if (!activeTutorial) return; if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); } }