- Remove legacy migration code: profiles→automations key fallbacks, segments array
fallback, standby_interval compat, profile_id compat, wled→led type mapping,
legacy calibration field, audio CSS migration, default template migration,
loadTargets alias, wled sub-tab mapping
- Scroll tutorial step targets into view when off-screen
- Mock device URL changed from mock://{led_count} to mock://{device_id},
hide mock URL badge on device cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
347 lines
14 KiB
JavaScript
347 lines
14 KiB
JavaScript
/**
|
|
* 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: '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-target-sub-tab="led"]', textKey: 'tour.tgt.led_tab', position: 'bottom' },
|
|
{ selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' },
|
|
{ selector: '[data-card-section="led-css"]', textKey: 'tour.tgt.css', position: 'bottom' },
|
|
{ selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' },
|
|
{ selector: '[data-target-sub-tab="key_colors"]', textKey: 'tour.tgt.kc_tab', position: 'bottom' }
|
|
];
|
|
|
|
const sourcesTourSteps = [
|
|
{ selector: '[data-stream-tab="raw"]', textKey: 'tour.src.raw', position: 'bottom' },
|
|
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' },
|
|
{ selector: '[data-stream-tab="static_image"]', textKey: 'tour.src.static', position: 'bottom' },
|
|
{ selector: '[data-stream-tab="processed"]', textKey: 'tour.src.processed', position: 'bottom' },
|
|
{ selector: '[data-stream-tab="audio"]', textKey: 'tour.src.audio', position: 'bottom' },
|
|
{ selector: '[data-stream-tab="value"]', textKey: 'tour.src.value', position: 'bottom' }
|
|
];
|
|
|
|
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
|
|
});
|
|
|
|
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: '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 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
|
|
const preRect = target.getBoundingClientRect();
|
|
if (preRect.bottom > window.innerHeight || preRect.top < 0) {
|
|
target.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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(); }
|
|
}
|