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>
215 lines
8.2 KiB
JavaScript
215 lines
8.2 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: '.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(); }
|
|
}
|