Split monolithic app.js into native ES modules
Replace the single 7034-line app.js with 17 ES module files organized into core/ (state, api, i18n, ui) and features/ (calibration, dashboard, device-discovery, devices, displays, kc-targets, pattern-templates, profiles, streams, tabs, targets, tutorials) with an app.js entry point that registers ~90 onclick globals on window. No bundler needed — FastAPI serves modules directly via <script type="module">. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
214
server/src/wled_controller/static/js/features/tutorials.js
Normal file
214
server/src/wled_controller/static/js/features/tutorials.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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: '.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 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();
|
||||
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
|
||||
});
|
||||
|
||||
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) => document.querySelector(step.selector)
|
||||
});
|
||||
}
|
||||
|
||||
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 closeTutorial() {
|
||||
if (!activeTutorial) return;
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function showTutorialStep(index) {
|
||||
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) return;
|
||||
target.classList.add('tutorial-target');
|
||||
if (isFixed) target.style.zIndex = '10001';
|
||||
|
||||
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(); }
|
||||
}
|
||||
Reference in New Issue
Block a user