Files
LEDMatrix/web_interface/static/v3/js/composer/composer-app.js
T
ChuckBuildsandClaude Opus 5 2f42d179f6 fix(composer): point the align toolbar at the anchor-clearing path
Two alignment implementations existed and the toolbar used the wrong one.

alignElement(dir) set el.x/el.y and stopped there. resolveAnchor turns
anchor='right' into `dim - val`, so with xAnchor='right' an "align left"
(el.x = 0) resolved to x = MATRIX_W and the element jumped to the far right
edge -- the opposite of what was asked. It also never touched el.x0/el.y0, so
a line's endpoints were left where they were.

_alignElement already did both correctly: it clears the anchor so the stored
value is absolute, and moves x0/y0 for lines. Its six wrappers -- alignLeft,
alignHCenter, alignRight, alignTop, alignVCenter, alignBottom -- existed and
had no callers at all.

All six toolbar buttons now call the wrappers, and the legacy method is
removed rather than left to drift back into use.

Tests: the toolbar calls each wrapper and no longer calls alignElement, the
legacy definition is gone, and _alignElement still clears the anchor and moves
line endpoints. Two of them fail against the previous markup.

Full suite 4062 passed, the one failure being test_install_lowmem
(pre-existing, awaiting #492).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-22 15:13:27 -04:00

1398 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* composerApp() — AlpineJS component for the Plugin Composer.
*
* Key architecture notes:
* - All mutations call _snapshot() for undo/redo support
* - Elements with anchors: drag stores offset from anchor, not raw px
* - localStorage autosaves on every mutation (debounced 1.5s)
* - composer_version in payload allows future server-side migration
*/
// ── Template library ─────────────────────────────────────────────────────────
const COMPOSER_TEMPLATES = [
{
id: 'blank',
label: 'Blank Canvas',
description: 'Start from scratch',
icon: 'fas fa-plus-square',
preset: '128×32',
elements: [],
dataModel: { configVars: [], dataSources: [], computedVars: [] },
},
{
id: 'clock',
label: 'Digital Clock',
description: 'Large clock with date below a divider',
icon: 'fas fa-clock',
preset: '128×32',
elements: [
{ id:1, type:'clock', x:44, y:4, format:'%H:%M', font:'press_start', r:100, g:255, b:100, xAnchor:null, yAnchor:null, minWidth:0, label:'time', conditions:[] },
{ id:2, type:'divider', x:0, y:18, orientation:'horizontal', r:50, g:50, b:50, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] },
{ id:3, type:'clock', x:32, y:22, format:'%A %b %d', font:'four_by_six', r:150, g:150, b:150, xAnchor:null, yAnchor:null, minWidth:0, label:'date', conditions:[] },
],
dataModel: { configVars:[], dataSources:[], computedVars:[] },
},
{
id: 'announcement',
label: 'Announcement',
description: 'Static title + configurable message text',
icon: 'fas fa-bullhorn',
preset: '128×32',
elements: [
{ id:1, type:'text', x:4, y:2, text:'ANNOUNCEMENT', font:'four_by_six', r:255, g:200, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'title', conditions:[] },
{ id:2, type:'divider', x:0, y:11, orientation:'horizontal', r:80, g:80, b:0, xAnchor:null, yAnchor:null, minWidth:0, label:'', conditions:[] },
{ id:3, type:'dynamic_text', x:4, y:15, binding:{source:'config',key:'message',format:null}, font:'four_by_six', r:255, g:255, b:255, xAnchor:null, yAnchor:null, minWidth:0, label:'message', conditions:[] },
],
dataModel: {
configVars:[{ key:'message', label:'Message', type:'string', default:'Hello World!', description:'Message to display' }],
dataSources:[], computedVars:[],
},
},
{
id: 'scoreboard',
label: 'Scoreboard',
description: 'Two-team scores with status line',
icon: 'fas fa-trophy',
preset: '128×32',
elements: [
{ id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'home_team',format:null}, font:'four_by_six', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'home team', conditions:[] },
{ id:2, type:'dynamic_text', x:2, y:12, binding:{source:'config',key:'home_score',format:null}, font:'press_start', r:255,g:220,b:50, xAnchor:null,yAnchor:null,minWidth:0, label:'home score', conditions:[] },
{ id:3, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:4, type:'dynamic_text', x:68, y:2, binding:{source:'config',key:'away_team',format:null}, font:'four_by_six', r:200,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away team', conditions:[] },
{ id:5, type:'dynamic_text', x:68, y:12, binding:{source:'config',key:'away_score',format:null}, font:'press_start', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'away score', conditions:[] },
{ id:6, type:'dynamic_text', x:44, y:25, binding:{source:'config',key:'game_status',format:null}, font:'four_by_six', r:150,g:150,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'status', conditions:[] },
],
dataModel: {
configVars:[
{key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbreviation'},
{key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'},
{key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbreviation'},
{key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'},
{key:'game_status',label:'Game Status', type:'string', default:'LIVE', description:'Status label'},
],
dataSources:[], computedVars:[],
},
},
{
id: 'weather',
label: 'Weather Card',
description: 'Temperature + condition + location',
icon: 'fas fa-cloud-sun',
preset: '128×32',
elements: [
{ id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'temperature',format:null}, font:'press_start', r:255,g:140,b:40, xAnchor:null,yAnchor:null,minWidth:0, label:'temp', conditions:[] },
{ id:2, type:'divider', x:0, y:16, orientation:'horizontal', r:50,g:50,b:80, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:3, type:'dynamic_text', x:2, y:20, binding:{source:'config',key:'condition',format:null}, font:'four_by_six', r:180,g:200,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'condition', conditions:[] },
{ id:4, type:'dynamic_text', x:80, y:20, binding:{source:'config',key:'location',format:null}, font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'location', conditions:[] },
],
dataModel: {
configVars:[
{key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'},
{key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'},
{key:'location', label:'Location', type:'string', default:'My City', description:'City name'},
],
dataSources:[], computedVars:[],
},
},
{
id: 'crypto',
label: 'Crypto Ticker',
description: 'Asset price + 24h change percentage',
icon: 'fas fa-chart-line',
preset: '128×32',
elements: [
{ id:1, type:'dynamic_text', x:2, y:2, binding:{source:'config',key:'ticker',format:null}, font:'press_start', r:255,g:165,b:0, xAnchor:null,yAnchor:null,minWidth:0, label:'ticker', conditions:[] },
{ id:2, type:'dynamic_text', x:2, y:16, binding:{source:'config',key:'price',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'price', conditions:[] },
{ id:3, type:'dynamic_text', x:88, y:16, binding:{source:'config',key:'change_pct',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'change', conditions:[] },
],
dataModel: {
configVars:[
{key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'},
{key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'},
{key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'},
],
dataSources:[], computedVars:[],
},
},
{
id: 'system',
label: 'System Monitor',
description: 'CPU, memory, and IP address',
icon: 'fas fa-server',
preset: '128×32',
elements: [
{ id:1, type:'text', x:2, y:2, text:'CPU', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:2, type:'dynamic_text', x:22, y:2, binding:{source:'config',key:'cpu_usage',format:null}, font:'four_by_six', r:100,g:255,b:100, xAnchor:null,yAnchor:null,minWidth:0, label:'cpu', conditions:[] },
{ id:3, type:'text', x:2, y:12, text:'MEM', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:4, type:'dynamic_text', x:22, y:12, binding:{source:'config',key:'mem_usage',format:null}, font:'four_by_six', r:100,g:180,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'mem', conditions:[] },
{ id:5, type:'text', x:2, y:22, text:'IP', font:'four_by_six', r:120,g:120,b:120, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:6, type:'dynamic_text', x:14, y:22, binding:{source:'config',key:'ip_address',format:null}, font:'four_by_six', r:255,g:255,b:150, xAnchor:null,yAnchor:null,minWidth:0, label:'ip', conditions:[] },
],
dataModel: {
configVars:[
{key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'},
{key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'},
{key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'},
],
dataSources:[], computedVars:[],
},
},
{
id: 'split_panel',
label: 'Split Panel',
description: 'Left label + right value, divided',
icon: 'fas fa-columns',
preset: '128×32',
elements: [
{ id:1, type:'dynamic_text', x:2, y:4, binding:{source:'config',key:'left_label',format:null}, font:'four_by_six', r:180,g:180,b:180, xAnchor:null,yAnchor:null,minWidth:0, label:'label', conditions:[] },
{ id:2, type:'divider', x:64, y:0, orientation:'vertical', r:60,g:60,b:60, xAnchor:null,yAnchor:null,minWidth:0, label:'', conditions:[] },
{ id:3, type:'dynamic_text', x:68, y:4, binding:{source:'config',key:'right_value',format:null}, font:'press_start', r:255,g:255,b:255, xAnchor:null,yAnchor:null,minWidth:0, label:'value', conditions:[] },
],
dataModel: {
configVars:[
{key:'left_label', label:'Label', type:'string', default:'LABEL', description:'Left panel label'},
{key:'right_value', label:'Value', type:'string', default:'--', description:'Right panel value'},
],
dataSources:[], computedVars:[],
},
},
];
// ── LED display color palette ────────────────────────────────────────────────
const LED_PALETTE = [
{ label: 'White', hex: '#ffffff', r: 255, g: 255, b: 255 },
{ label: 'Red', hex: '#ff2020', r: 255, g: 32, b: 32 },
{ label: 'Green', hex: '#20ff40', r: 32, g: 255, b: 64 },
{ label: 'Blue', hex: '#4060ff', r: 64, g: 96, b: 255 },
{ label: 'Yellow', hex: '#ffee00', r: 255, g: 238, b: 0 },
{ label: 'Orange', hex: '#ff8000', r: 255, g: 128, b: 0 },
{ label: 'Cyan', hex: '#00eeff', r: 0, g: 238, b: 255 },
{ label: 'Magenta', hex: '#ff00cc', r: 255, g: 0, b: 204 },
{ label: 'Amber', hex: '#ffb400', r: 255, g: 180, b: 0 },
{ label: 'Dim white', hex: '#888888', r: 136, g: 136, b: 136 },
{ label: 'Dark gray', hex: '#444444', r: 68, g: 68, b: 68 },
{ label: 'Off/Black', hex: '#000000', r: 0, g: 0, b: 0 },
];
// ── Autosave helpers ─────────────────────────────────────────────────────────
const LS_KEY = 'ledmatrix_composer_draft';
let _autosaveTimer = null;
function _debouncedAutosave(payload) {
clearTimeout(_autosaveTimer);
_autosaveTimer = setTimeout(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify(payload)); } catch (_) {}
}, 1500);
}
// ── Main component ───────────────────────────────────────────────────────────
function composerApp() {
return {
// ── Plugin metadata ───────────────────────────────────────────────
metadata: {
id: '',
name: '',
author: '',
version: '1.0.0',
description: '',
category: 'custom',
display_duration: 15,
update_interval: 60,
api_requirements: [],
bgColor: { r: 0, g: 0, b: 0 },
},
// ── Canvas / display preset ───────────────────────────────────────
SCALE: 4,
MATRIX_W: 128,
MATRIX_H: 32,
currentPreset: '128×32',
// ── Elements ──────────────────────────────────────────────────────
elements: [],
_nextId: 1,
// ── Data model ───────────────────────────────────────────────────
dataModel: {
configVars: [],
dataSources: [],
computedVars: [],
},
// ── Undo/redo ─────────────────────────────────────────────────────
_history: [],
_historyIndex: -1,
// ── Selection & interaction ───────────────────────────────────────
selectedId: null,
_drag: { active: false },
_resize: { active: false },
// ── Canvas options ────────────────────────────────────────────────
snapToGrid: false,
snapSize: 4,
// ── Clipboard ─────────────────────────────────────────────────────
_clipboard: null,
_styleClipboard: null,
// ── UI state ──────────────────────────────────────────────────────
isDirty: false,
statusMsg: '',
statusType: 'info',
generateStatus: 'idle',
installStatus: 'idle',
showGridOverlay: true,
showGuides: false,
blinkAnimating: false,
_blinkPhase: false,
_blinkTimer: null,
_animTick: 0,
_previewValues: {},
showHelpModal: false,
_recentColors: [],
showRuler: false,
showConfigVarModal: false,
showOpenModal: false,
showTemplateModal: false,
showCodeModal: false,
_idAutoGenerated: true,
hoverInfo: '',
installedPlugins: [],
loadPluginsStatus: 'idle',
codeFiles: {},
codeTab: 'manager.py',
loadingCode: false,
newConfigVar: { key: '', label: '', type: 'string', default: '', description: '' },
// ── Computed ──────────────────────────────────────────────────────
get selectedElement() {
return this.elements.find(e => e.id === this.selectedId) ?? null;
},
get canExport() {
return this.metadata.name.trim() && this.metadata.id.trim() && this.elements.length > 0;
},
get canUndo() { return this._historyIndex > 0; },
get canRedo() { return this._historyIndex < this._history.length - 1; },
get templates() { return COMPOSER_TEMPLATES; },
get displayPresets() { return window.ComposerCanvas.DISPLAY_PRESETS; },
get palette() { return LED_PALETTE; },
// ── Pre-made variable library ─────────────────────────────────────
preMadeVarCategories: [
{ label: 'Display', vars: [
{key:'title_text', label:'Title', type:'string', default:'My Plugin', description:'Main title'},
{key:'subtitle', label:'Subtitle', type:'string', default:'', description:'Secondary line'},
{key:'message', label:'Message', type:'string', default:'Hello!', description:'Dynamic message'},
]},
{ label: 'Sports / Score', vars: [
{key:'home_score', label:'Home Score', type:'string', default:'0', description:'Home team score'},
{key:'away_score', label:'Away Score', type:'string', default:'0', description:'Away team score'},
{key:'home_team', label:'Home Team', type:'string', default:'HOME', description:'Home team abbr'},
{key:'away_team', label:'Away Team', type:'string', default:'AWAY', description:'Away team abbr'},
{key:'game_status', label:'Game Status', type:'string', default:'LIVE', description:'Status label'},
{key:'period', label:'Period', type:'string', default:'1st', description:'Period/quarter'},
{key:'time_left', label:'Time Left', type:'string', default:'15:00',description:'Game clock'},
]},
{ label: 'Weather', vars: [
{key:'temperature', label:'Temperature', type:'string', default:'72°F', description:'Current temperature'},
{key:'condition', label:'Condition', type:'string', default:'Sunny', description:'Weather condition'},
{key:'humidity', label:'Humidity', type:'string', default:'50%', description:'Humidity'},
{key:'location', label:'Location', type:'string', default:'My City',description:'Location name'},
]},
{ label: 'System / Stats', vars: [
{key:'cpu_usage', label:'CPU %', type:'string', default:'0%', description:'CPU usage'},
{key:'mem_usage', label:'Memory %', type:'string', default:'0%', description:'Memory usage'},
{key:'ip_address', label:'IP Address',type:'string', default:'0.0.0.0', description:'Network IP'},
{key:'uptime', label:'Uptime', type:'string', default:'0d 0h', description:'System uptime'},
]},
{ label: 'Crypto / Finance', vars: [
{key:'ticker', label:'Ticker', type:'string', default:'BTC', description:'Asset symbol'},
{key:'price', label:'Price', type:'string', default:'$0.00', description:'Asset price'},
{key:'change_pct', label:'Change %', type:'string', default:'+0.00%', description:'24h change'},
{key:'volume', label:'Volume', type:'string', default:'0', description:'Trading volume'},
]},
],
elementTypes: [
{ id: 'text', label: 'Text', icon: 'fas fa-font' },
{ id: 'dynamic_text', label: 'Dynamic Text', icon: 'fas fa-code' },
{ id: 'clock', label: 'Clock', icon: 'fas fa-clock' },
{ id: 'rectangle', label: 'Rectangle', icon: 'fas fa-square' },
{ id: 'rounded_rectangle', label: 'Rounded Rect', icon: 'fas fa-stop' },
{ id: 'ellipse', label: 'Ellipse', icon: 'fas fa-circle' },
{ id: 'arc', label: 'Arc', icon: 'fas fa-circle-notch' },
{ id: 'pixel', label: 'Pixel', icon: 'fas fa-dot-circle' },
{ id: 'line', label: 'Line', icon: 'fas fa-minus' },
{ id: 'divider', label: 'Divider', icon: 'fas fa-grip-lines' },
{ id: 'progress_bar', label: 'Progress Bar', icon: 'fas fa-tasks' },
{ id: 'countdown', label: 'Countdown', icon: 'fas fa-hourglass-half' },
{ id: 'marquee', label: 'Marquee', icon: 'fas fa-arrows-alt-h' },
{ id: 'gauge', label: 'Gauge', icon: 'fas fa-tachometer-alt' },
{ id: 'sparkline', label: 'Sparkline', icon: 'fas fa-chart-bar' },
{ id: 'pips', label: 'Pips / Rating', icon: 'fas fa-star-half-alt' },
{ id: 'section', label: 'Section Label', icon: 'fas fa-tag' },
],
COLOR_THEMES: [
{ label: 'Matrix', colors: [{ r:0,g:255,b:70 }, { r:0,g:180,b:50 }, { r:0,g:100,b:30 }] },
{ label: 'Neon', colors: [{ r:255,g:0,b:200 }, { r:0,g:240,b:255 }, { r:255,g:230,b:0 }] },
{ label: 'Warm', colors: [{ r:255,g:160,b:0 }, { r:255,g:80,b:0 }, { r:255,g:220,b:80 }] },
{ label: 'Arctic', colors: [{ r:100,g:200,b:255},{ r:150,g:230,b:255},{ r:200,g:240,b:255}] },
{ label: 'Retro', colors: [{ r:255,g:140,b:0 }, { r:255,g:60,b:60 }, { r:80,g:200,b:120 }] },
{ label: 'Mono', colors: [{ r:255,g:255,b:255},{ r:170,g:170,b:170},{ r:90,g:90,b:90 }] },
],
// ── Lifecycle ─────────────────────────────────────────────────────
init() {
this.$nextTick(() => {
const canvas = document.getElementById('led-canvas');
if (canvas) {
window.ComposerCanvas.init(canvas);
window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE);
this._loadFonts().then(() => {
this._tryRestoreDraft();
this._loadRecentColors();
this._snapshot();
this.render();
});
}
document.addEventListener('keydown', (e) => this._onKeyDown(e));
});
},
async _loadFonts() {
try {
const ff = new FontFace('PressStart2P', 'url(/composer/api/fonts/PressStart2P-Regular.ttf)');
document.fonts.add(await ff.load());
} catch (e) {
console.warn('PressStart2P load failed:', e);
}
},
_loadRecentColors() {
try {
const raw = localStorage.getItem('ledmatrix_composer_recent_colors');
if (raw) this._recentColors = JSON.parse(raw).slice(0, 8);
} catch (_) {}
},
_trackColor(r, g, b) {
this._recentColors = [
{ r, g, b },
...this._recentColors.filter(c => !(c.r === r && c.g === g && c.b === b)),
].slice(0, 8);
try { localStorage.setItem('ledmatrix_composer_recent_colors', JSON.stringify(this._recentColors)); } catch (_) {}
},
toggleRuler() {
this.showRuler = !this.showRuler;
this.render();
},
_tryRestoreDraft() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
if (!draft?.composer_version) return;
if (!confirm('Resume your last unsaved session?')) {
localStorage.removeItem(LS_KEY);
return;
}
this._applyState(draft);
this._setStatus('Draft restored from last session', 'info');
} catch (_) {}
},
// ── Undo / Redo ───────────────────────────────────────────────────
_snapshot() {
const state = JSON.stringify({
metadata: this.metadata,
elements: this.elements,
dataModel: this.dataModel,
currentPreset: this.currentPreset,
});
this._history.splice(this._historyIndex + 1);
this._history.push(state);
if (this._history.length > 50) this._history.shift();
else this._historyIndex++;
_debouncedAutosave(this._buildPayload());
},
undo() {
if (!this.canUndo) return;
this._historyIndex--;
const state = JSON.parse(this._history[this._historyIndex]);
this._applyState(state);
this._setStatus('Undo', 'info');
this.render();
},
redo() {
if (!this.canRedo) return;
this._historyIndex++;
const state = JSON.parse(this._history[this._historyIndex]);
this._applyState(state);
this._setStatus('Redo', 'info');
this.render();
},
_applyState(state) {
if (state.metadata) this.metadata = state.metadata;
if (state.elements) this.elements = state.elements.map(el => ({
xAnchor: null, yAnchor: null, minWidth: 0, conditions: [],
...el,
id: el.id ?? (this._nextId++),
}));
if (state.dataModel) this.dataModel = state.dataModel;
// _buildPayload writes this as `preset`; older drafts and hand-edited
// files may carry `currentPreset`. Reading only the latter meant every
// saved design restored onto the default 128x32 canvas, with every
// element then drawn at the wrong place.
const savedPreset = state.preset ?? state.currentPreset;
if (savedPreset && savedPreset !== this.currentPreset) {
this.applyPresetLabel(savedPreset, { silent: true });
}
this._nextId = Math.max(...this.elements.map(e => e.id + 1), 1);
this.selectedId = null;
this.isDirty = true;
},
// ── Display preset ────────────────────────────────────────────────
/**
* Restore any saved canvas label, preset or custom.
*
* setCustomSize() stores labels like "200×50" that are deliberately not in
* DISPLAY_PRESETS, so changePreset() alone cannot round-trip them: its
* find() misses and it returns without doing anything, silently.
*/
applyPresetLabel(label, opts = {}) {
if (!label) return;
const known = window.ComposerCanvas.DISPLAY_PRESETS.some(p => p.label === label);
if (known) { this.changePreset(label, opts); return; }
const m = String(label).match(/(\d+)[×xX*,\s]+(\d+)/);
if (!m) return;
const w = Math.max(8, Math.min(512, parseInt(m[1], 10)));
const h = Math.max(8, Math.min(256, parseInt(m[2], 10)));
this.MATRIX_W = w;
this.MATRIX_H = h;
this.currentPreset = `${w}×${h}`;
this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2;
this._applyScale();
if (!opts.silent) {
this.isDirty = true;
this._snapshot();
this.render();
}
},
changePreset(presetLabel, opts = {}) {
const preset = window.ComposerCanvas.DISPLAY_PRESETS.find(p => p.label === presetLabel);
if (!preset) return;
this.currentPreset = presetLabel;
this.MATRIX_W = preset.w;
this.MATRIX_H = preset.h;
this.SCALE = preset.w <= 64 ? 6 : preset.w <= 128 ? 4 : 2;
const canvas = document.getElementById('led-canvas');
if (canvas) {
window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE);
canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px';
canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px';
}
if (!opts.silent) {
this.isDirty = true;
this._snapshot();
this.render();
}
},
// ── Rendering ─────────────────────────────────────────────────────
render(opts = {}) {
window.ComposerCanvas.render(
this.elements, this.selectedId, this.MATRIX_W, this.MATRIX_H, this.SCALE,
{ bgColor: this.metadata.bgColor, showGuides: this.showGuides, showRuler: this.showRuler, blinkOff: this.blinkAnimating && this._blinkPhase, animTick: this.blinkAnimating ? this._animTick : null, previewValues: this._previewValues, ...opts },
);
},
toggleGrid() {
this.showGridOverlay = !this.showGridOverlay;
window.ComposerCanvas.setGrid(this.showGridOverlay);
this.render();
},
toggleGuides() {
this.showGuides = !this.showGuides;
this.render();
},
toggleBlinkPreview() {
this.blinkAnimating = !this.blinkAnimating;
if (this.blinkAnimating) {
this._blinkPhase = false;
this._animTick = 0;
let blinkTick = 0;
this._blinkTimer = setInterval(() => {
this._animTick++;
blinkTick++;
if (blinkTick >= 3) { this._blinkPhase = !this._blinkPhase; blinkTick = 0; }
this.render();
}, 200);
} else {
clearInterval(this._blinkTimer);
this._blinkTimer = null;
this._blinkPhase = false;
this._animTick = 0;
this.render();
}
},
downloadPng() {
const canvas = document.getElementById('led-canvas');
if (!canvas) return;
const link = document.createElement('a');
link.download = (this.metadata.id || 'composer-preview') + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
},
exportDesign() {
const payload = this._buildPayload();
const json = JSON.stringify(payload, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = (this.metadata.id || 'composer-design') + '.composer.json';
link.href = url;
link.click();
URL.revokeObjectURL(url);
},
importDesign() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.composer.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (!data.composer_version) throw new Error('Not a composer file');
if (this.isDirty && !confirm('Replace current design?')) return;
// Carry the canvas size through too -- an imported 256x64 design
// laid out on a 128x32 canvas puts every element in the wrong place.
this._applyState({
metadata: data.metadata, elements: data.elements,
dataModel: data.dataModel,
preset: data.preset ?? data.currentPreset,
});
this.isDirty = false;
this._setStatus('Design loaded', 'success');
this.render();
} catch (err) {
this._setStatus('Failed to load: ' + err.message, 'error');
}
};
reader.readAsText(file);
};
input.click();
},
// ── Zoom controls ─────────────────────────────────────────────────
zoomIn() {
if (this.SCALE >= 8) return;
this.SCALE++;
this._applyScale();
},
zoomOut() {
if (this.SCALE <= 1) return;
this.SCALE--;
this._applyScale();
},
_applyScale() {
const canvas = document.getElementById('led-canvas');
if (!canvas) return;
window.ComposerCanvas.updateCanvasSize(this.MATRIX_W, this.MATRIX_H, this.SCALE);
canvas.style.width = (this.MATRIX_W * this.SCALE) + 'px';
canvas.style.height = (this.MATRIX_H * this.SCALE) + 'px';
this.render();
},
setCustomSize() {
const input = prompt(
'Enter canvas size as WxH (e.g. 96×48, 192×64):',
`${this.MATRIX_W}×${this.MATRIX_H}`
);
if (!input) return;
const m = input.match(/(\d+)[×xX*,\s]+(\d+)/);
if (!m) { this._setStatus('Invalid size — use WxH format', 'error'); return; }
const w = Math.max(8, Math.min(512, parseInt(m[1])));
const h = Math.max(8, Math.min(256, parseInt(m[2])));
this.MATRIX_W = w;
this.MATRIX_H = h;
this.currentPreset = `${w}×${h}`;
this.SCALE = w <= 64 ? 6 : w <= 128 ? 4 : 2;
this._applyScale();
// currentPreset is part of the snapshot; a custom size set here was
// otherwise dropped on reload and could not be undone.
this.isDirty = true;
this._snapshot();
this._setStatus(`Canvas set to ${w}×${h}`, 'info');
},
zoomFit() {
// Fit canvas to center column: subtract left (~224px) + right (~288px) panels + margins
const avW = Math.max(80, window.innerWidth - 560);
// Subtract header (44) + toolbar (~40) + layers (~180) + status (28) + padding
const avH = Math.max(40, window.innerHeight - 340);
const fitW = Math.floor(avW / this.MATRIX_W);
const fitH = Math.floor(avH / this.MATRIX_H);
this.SCALE = Math.max(1, Math.min(8, Math.min(fitW, fitH)));
this._applyScale();
},
onBgColorChange(hex) {
this.metadata.bgColor = {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16),
};
// _snapshot serialises metadata and is the only caller of
// _debouncedAutosave, so without this the background colour was lost on
// reload and could not be undone. Same defect as onColorChange.
this.isDirty = true;
this._snapshot();
this.render();
},
// ── Canvas events ─────────────────────────────────────────────────
onCanvasMouseDown(event) {
const { lx, ly } = this._canvasToLed(event);
// Priority 1: resize handle on selected rectangle (skip if locked)
// Gate on the same list the canvas draws handles from, or the editor
// advertises handles it will not honour.
if (window.ComposerCanvas.RESIZABLE_TYPES.includes(this.selectedElement?.type)
&& !this.selectedElement.locked) {
const handle = window.ComposerCanvas.getResizeHandle(
this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H
);
if (handle) {
const el = this.selectedElement;
this._resize = {
active: true, elemId: el.id, handle,
startMX: event.clientX, startMY: event.clientY,
startW: el.width, startH: el.height,
startX: el.x, startY: el.y,
};
return;
}
}
// Priority 2: element hit test (reverse = top layer first)
const hit = [...this.elements].reverse().find(
el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H)
);
if (hit) {
this.selectedId = hit.id;
if (!hit.locked) {
const stored = this._getStoredPos(hit);
this._drag = {
active: true, elemId: hit.id,
startMX: event.clientX, startMY: event.clientY,
startEX: stored.x, startEY: stored.y,
};
}
} else {
this.selectedId = null;
}
this.render();
},
onCanvasMouseMove(event) {
const { lx, ly } = this._canvasToLed(event);
this.hoverInfo = `${lx}, ${ly}`;
// Resize
if (this._resize.active) {
const dx = Math.round((event.clientX - this._resize.startMX) / this.SCALE);
const dy = Math.round((event.clientY - this._resize.startMY) / this.SCALE);
const el = this.elements.find(e => e.id === this._resize.elemId);
if (!el) return;
const h = this._resize.handle;
if (h.includes('e')) el.width = Math.max(1, this._resize.startW + dx);
if (h.includes('s')) el.height = Math.max(1, this._resize.startH + dy);
if (h.includes('w')) {
const nw = Math.max(1, this._resize.startW - dx);
el.x = this._resize.startX + (this._resize.startW - nw);
el.width = nw;
}
if (h.includes('n')) {
const nh = Math.max(1, this._resize.startH - dy);
el.y = this._resize.startY + (this._resize.startH - nh);
el.height = nh;
}
this.isDirty = true;
this.render({ showTooltip: true });
// Cursor
const canvas = document.getElementById('led-canvas');
if (canvas) canvas.style.cursor = window.ComposerCanvas.getCursorForHandle(h);
return;
}
// Move drag
if (this._drag.active) {
const dx = Math.round((event.clientX - this._drag.startMX) / this.SCALE);
const dy = Math.round((event.clientY - this._drag.startMY) / this.SCALE);
const el = this.elements.find(e => e.id === this._drag.elemId);
if (!el) return;
if (el.type === 'line') {
const newX = Math.max(0, Math.min(this.MATRIX_W - 1, this._drag.startEX + dx));
const newY = Math.max(0, Math.min(this.MATRIX_H - 1, this._drag.startEY + dy));
const offX = newX - el.x0, offY = newY - el.y0;
el.x0 = newX; el.y0 = newY;
el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + offX));
el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + offY));
this._drag.startEX = el.x0; this._drag.startEY = el.y0;
this._drag.startMX = event.clientX; this._drag.startMY = event.clientY;
} else {
// Store anchor-relative offset
const newStored = {
x: this._drag.startEX + dx,
y: this._drag.startEY + dy,
};
this._setStoredPos(el, newStored);
}
this.isDirty = true;
this.render({ showTooltip: true });
return;
}
// Cursor hints when hovering
const canvas = document.getElementById('led-canvas');
if (canvas) {
let cursor = 'crosshair';
if (window.ComposerCanvas.RESIZABLE_TYPES.includes(this.selectedElement?.type)) {
const handle = window.ComposerCanvas.getResizeHandle(
this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H
);
if (handle) cursor = window.ComposerCanvas.getCursorForHandle(handle);
}
if (cursor === 'crosshair') {
const hit = [...this.elements].reverse().find(
el => window.ComposerCanvas.hitTest(el, lx, ly, this.MATRIX_W, this.MATRIX_H)
);
if (hit) cursor = 'move';
}
canvas.style.cursor = cursor;
}
},
onCanvasMouseUp() {
if (this._resize.active || this._drag.active) {
this._resize = { active: false };
this._drag = { active: false };
this._snapshot();
}
const canvas = document.getElementById('led-canvas');
if (canvas) canvas.style.cursor = 'crosshair';
},
onCanvasMouseLeave() {
this.hoverInfo = '';
this.onCanvasMouseUp();
},
_canvasToLed(event) {
const rect = event.target.getBoundingClientRect();
const lx = Math.max(0, Math.min(this.MATRIX_W - 1, Math.floor((event.clientX - rect.left) / this.SCALE)));
const ly = Math.max(0, Math.min(this.MATRIX_H - 1, Math.floor((event.clientY - rect.top) / this.SCALE)));
return { lx: this._snap(lx), ly: this._snap(ly) };
},
_snap(v) {
if (!this.snapToGrid || this.snapSize < 2) return v;
return Math.round(v / this.snapSize) * this.snapSize;
},
// Anchor-aware position storage: x/y stored as offset from anchor
_getStoredPos(el) {
// addElement() assigns `x`/`y` before spreading ELEMENT_DEFAULTS, and the
// line defaults define only x0/y0 -- so a line carries both, with `x` set
// to canvas/4 and x0 to 0. Drag and nudge move x0/y0 only, so preferring
// `x` here handed the drag a base it never updates and the line jumped by
// the difference on the next grab.
if (el.type === 'line') {
return { x: el.x0 ?? 0, y: el.y0 ?? 0 };
}
return { x: el.x ?? el.x0 ?? 0, y: el.y ?? el.y0 ?? 0 };
},
_setStoredPos(el, { x, y }) {
if (this.snapToGrid && this.snapSize > 0) {
x = Math.round(x / this.snapSize) * this.snapSize;
y = Math.round(y / this.snapSize) * this.snapSize;
}
const clampX = v => Math.max(-this.MATRIX_W, Math.min(this.MATRIX_W * 2, v));
const clampY = v => Math.max(-this.MATRIX_H, Math.min(this.MATRIX_H * 2, v));
if (el.type === 'line') {
el.x0 = clampX(x); el.y0 = clampY(y);
} else {
el.x = clampX(x); el.y = clampY(y);
}
},
// ── Alignment ─────────────────────────────────────────────────────
_alignElement(el, axis, mode) {
const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H);
if (!bb) return;
if (axis === 'x') {
const newX = mode === 'start' ? 0
: mode === 'center' ? Math.round((this.MATRIX_W - bb.w) / 2)
: this.MATRIX_W - bb.w;
// Clear x-anchor so stored x IS the absolute position
if ('xAnchor' in el) el.xAnchor = null;
el.x = Math.round(newX);
if (el.type === 'line') el.x0 = Math.round(newX);
} else {
const newY = mode === 'start' ? 0
: mode === 'center' ? Math.round((this.MATRIX_H - bb.h) / 2)
: this.MATRIX_H - bb.h;
if ('yAnchor' in el) el.yAnchor = null;
el.y = Math.round(newY);
if (el.type === 'line') el.y0 = Math.round(newY);
}
this._snapshot();
this.isDirty = true;
this.render();
},
alignLeft() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'start'); },
alignHCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'center'); },
alignRight() { if (this.selectedElement) this._alignElement(this.selectedElement, 'x', 'end'); },
alignTop() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'start'); },
alignVCenter() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'center'); },
alignBottom() { if (this.selectedElement) this._alignElement(this.selectedElement, 'y', 'end'); },
// ── Keyboard shortcuts ────────────────────────────────────────────
_onKeyDown(e) {
const tag = document.activeElement?.tagName;
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|| document.activeElement?.isContentEditable === true;
// While a field has focus the browser's own editing keys win. This
// guard used to sit below, so Ctrl+C copied the selected *element*
// instead of the selected text (preventDefault stopped the real copy),
// Ctrl+V pasted an element, Ctrl+A could not select the field's
// contents, and Tab always moved the element selection rather than
// focus -- leaving no way to reach the next input from the keyboard.
if (inInput) return;
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); return; }
if ((e.key === 'y') || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); return; }
if (e.key === 'd' && this.selectedId) { e.preventDefault(); this.duplicateElement(this.selectedId); return; }
if (e.key === 'c' && this.selectedId && !e.shiftKey) { e.preventDefault(); this.copyElement(); return; }
if (e.key === 'c' && this.selectedId && e.shiftKey) { e.preventDefault(); this.copyStyle(); return; }
if (e.key === 'v' && this._clipboard && !e.shiftKey) { e.preventDefault(); this.pasteElement(); return; }
if (e.key === 'v' && this._styleClipboard && e.shiftKey) { e.preventDefault(); this.pasteStyle(); return; }
if (e.key === 'a') { e.preventDefault(); if (this.elements.length) { this.selectedId = this.elements[0].id; this.render(); } return; }
}
// Tab cycles through elements (canvas focus only -- see the guard above)
if (e.key === 'Tab' && this.elements.length) {
e.preventDefault();
const idx = this.elements.findIndex(el => el.id === this.selectedId);
const next = e.shiftKey
? (idx <= 0 ? this.elements.length - 1 : idx - 1)
: (idx >= this.elements.length - 1 ? 0 : idx + 1);
this.selectedId = this.elements[next].id;
this.render();
return;
}
const dist = e.shiftKey ? 5 : (this.snapToGrid && this.snapSize >= 2 ? this.snapSize : 1);
if (e.key === 'ArrowLeft') { e.preventDefault(); this.nudge(-dist, 0); }
if (e.key === 'ArrowRight') { e.preventDefault(); this.nudge(dist, 0); }
if (e.key === 'ArrowUp') { e.preventDefault(); this.nudge(0, -dist); }
if (e.key === 'ArrowDown') { e.preventDefault(); this.nudge(0, dist); }
if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedId) {
e.preventDefault();
this.removeElement(this.selectedId);
}
if (e.key === 'Escape') {
if (this.showHelpModal) { this.showHelpModal = false; return; }
this.selectedId = null; this.render();
}
if (e.key === '?') { this.showHelpModal = !this.showHelpModal; }
if (e.key === 'g' || e.key === 'G') { this.snapToGrid = !this.snapToGrid; }
if (e.key === ']' && this.selectedId) { this.bringToFront(this.selectedId); }
if (e.key === '[' && this.selectedId) { this.sendToBack(this.selectedId); }
},
nudge(dx, dy) {
const el = this.selectedElement;
if (!el) return;
if (el.locked) { this._setStatus('Unlock element to move it', 'info'); return; }
if (el.type === 'line') {
el.x0 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x0 + dx));
el.y0 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y0 + dy));
el.x1 = Math.max(0, Math.min(this.MATRIX_W - 1, el.x1 + dx));
el.y1 = Math.max(0, Math.min(this.MATRIX_H - 1, el.y1 + dy));
} else if (el.type === 'divider') {
if (el.orientation === 'horizontal') el.y = Math.max(0, Math.min(this.MATRIX_H - 1, (el.y ?? 0) + dy));
else el.x = Math.max(0, Math.min(this.MATRIX_W - 1, (el.x ?? 0) + dx));
} else {
el.x = (el.x ?? 0) + dx;
el.y = (el.y ?? 0) + dy;
}
this.isDirty = true;
this._snapshot();
this.render();
},
// ── Element management ────────────────────────────────────────────
addElement(type) {
const defaults = window.ComposerCanvas.ELEMENT_DEFAULTS[type];
if (!defaults) return;
const el = {
id: this._nextId++,
type,
label: '',
conditions: [],
xAnchor: null, yAnchor: null, minWidth: 0,
x: Math.floor(this.MATRIX_W / 4),
y: Math.floor(this.MATRIX_H / 2) - 4,
...structuredClone(defaults),
};
if (type === 'line') {
const cx = Math.floor(this.MATRIX_W / 4), cy = Math.floor(this.MATRIX_H / 2);
el.x0 = cx; el.y0 = cy;
el.x1 = Math.min(this.MATRIX_W - 1, cx + 30); el.y1 = cy;
el.x = cx; el.y = cy;
}
this.elements.push(el);
this.selectedId = el.id;
this.isDirty = true;
this._snapshot();
this.render();
},
duplicateElement(id) {
const el = this.elements.find(e => e.id === id);
if (!el) return;
const copy = {
...structuredClone(el),
id: this._nextId++,
label: el.label ? el.label + ' copy' : '',
};
// Offset slightly so it's visually distinct
if (copy.type === 'line') { copy.x0 += 4; copy.y0 += 4; copy.x1 += 4; copy.y1 += 4; }
else { copy.x = (copy.x ?? 0) + 4; copy.y = (copy.y ?? 0) + 4; }
const idx = this.elements.findIndex(e => e.id === id);
this.elements.splice(idx + 1, 0, copy);
this.selectedId = copy.id;
this.isDirty = true;
this._snapshot();
this.render();
},
copyElement() {
if (!this.selectedElement) return;
this._clipboard = structuredClone(this.selectedElement);
this._setStatus(`Copied: ${this.selectedElement.label || this.selectedElement.type}`, 'info');
},
copyStyle() {
const el = this.selectedElement;
if (!el) return;
this._styleClipboard = { r: el.r, g: el.g, b: el.b, font: el.font };
this._setStatus('Style copied — select another element and paste style', 'info');
},
pasteStyle() {
const el = this.selectedElement;
if (!el || !this._styleClipboard) return;
const { r, g, b, font } = this._styleClipboard;
if (r !== undefined) { el.r = r; el.g = g; el.b = b; }
if (font !== undefined && el.font !== undefined) el.font = font;
this.isDirty = true;
this._snapshot();
this.render();
this._setStatus('Style pasted', 'success');
},
pasteElement() {
if (!this._clipboard) return;
const copy = structuredClone(this._clipboard);
copy.id = this._nextId++;
if (copy.type === 'line') { copy.x0 += 6; copy.y0 += 6; copy.x1 += 6; copy.y1 += 6; }
else { copy.x = (copy.x ?? 0) + 6; copy.y = (copy.y ?? 0) + 6; }
this.elements.push(copy);
this.selectedId = copy.id;
this.isDirty = true;
this._snapshot();
this.render();
this._setStatus('Pasted element', 'success');
},
removeElement(id) {
this.elements = this.elements.filter(e => e.id !== id);
if (this.selectedId === id) this.selectedId = null;
this.isDirty = true;
this._snapshot();
this.render();
},
moveElementUp(id) {
const idx = this.elements.findIndex(e => e.id === id);
if (idx < this.elements.length - 1) {
[this.elements[idx], this.elements[idx + 1]] = [this.elements[idx + 1], this.elements[idx]];
this.isDirty = true;
this._snapshot();
this.render();
}
},
moveElementDown(id) {
const idx = this.elements.findIndex(e => e.id === id);
if (idx > 0) {
[this.elements[idx], this.elements[idx - 1]] = [this.elements[idx - 1], this.elements[idx]];
this.isDirty = true;
this._snapshot();
this.render();
}
},
bringToFront(id) {
const idx = this.elements.findIndex(e => e.id === id);
if (idx < this.elements.length - 1) {
this.elements.push(this.elements.splice(idx, 1)[0]);
this.isDirty = true;
this._snapshot();
this.render();
}
},
sendToBack(id) {
const idx = this.elements.findIndex(e => e.id === id);
if (idx > 0) {
this.elements.unshift(this.elements.splice(idx, 1)[0]);
this.isDirty = true;
this._snapshot();
this.render();
}
},
clearAll() {
if (this.elements.length === 0) return;
if (!confirm('Remove all elements from the canvas?')) return;
this.elements = [];
this.selectedId = null;
this.isDirty = true;
this._snapshot();
this.render();
},
// ── Alignment ─────────────────────────────────────────────────────
// ── Templates ─────────────────────────────────────────────────────
loadTemplate(tmpl) {
if (this.isDirty && this.elements.length > 0) {
if (!confirm('Load template? Your current canvas will be replaced.')) return;
}
this.elements = tmpl.elements.map((el, i) => ({
xAnchor: null, yAnchor: null, minWidth: 0, conditions: [],
...structuredClone(el),
id: i + 1,
}));
this._nextId = this.elements.length + 1;
this.dataModel = structuredClone(tmpl.dataModel);
if (tmpl.preset) this.changePreset(tmpl.preset, { silent: true });
this.selectedId = null;
this.showTemplateModal = false;
this.isDirty = true;
this._snapshot();
this.render();
this._setStatus(`Loaded template: ${tmpl.label}`, 'success');
},
// ── Metadata helpers ──────────────────────────────────────────────
onNameInput() {
if (this._idAutoGenerated) {
this.metadata.id = this.metadata.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
},
onIdInput() { this._idAutoGenerated = false; },
isIdValid() { return /^[a-z][a-z0-9-]{0,62}$/.test(this.metadata.id); },
// ── Config variables ──────────────────────────────────────────────
addConfigVar() {
const key = this.newConfigVar.key.trim();
if (!key) { this._setStatus('Key is required', 'error'); return; }
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
this._setStatus('Key must be a valid Python identifier', 'error'); return;
}
if (this.dataModel.configVars.find(v => v.key === key)) {
this._setStatus(`"${key}" already exists`, 'error'); return;
}
this.dataModel.configVars.push({ ...this.newConfigVar, key });
this.newConfigVar = { key:'', label:'', type:'string', default:'', description:'' };
this.showConfigVarModal = false;
this.isDirty = true;
this._snapshot();
},
removeConfigVar(key) {
const bound = this.elements.filter(
e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key
);
if (bound.length && !confirm(`"${key}" is used by ${bound.length} element(s). Remove anyway?`)) return;
this.dataModel.configVars = this.dataModel.configVars.filter(v => v.key !== key);
this.isDirty = true;
this._snapshot();
},
_isBound(key) {
return this.elements.some(
e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key
);
},
addPreMadeVar(varDef) {
if (this.dataModel.configVars.find(v => v.key === varDef.key)) {
this._setStatus(`"${varDef.key}" already added`, 'warning'); return;
}
this.dataModel.configVars.push({ ...varDef });
this.isDirty = true;
this._snapshot();
this._setStatus(`Added: ${varDef.key}`, 'success');
},
// ── Open existing plugin ──────────────────────────────────────────
async openPluginModal() {
this.showOpenModal = true;
this.installedPlugins = [];
this.loadPluginsStatus = 'loading';
try {
const resp = await fetch('/composer/api/plugins');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
this.installedPlugins = await resp.json();
this.loadPluginsStatus = 'done';
} catch (e) {
this.loadPluginsStatus = 'error';
this._setStatus('Could not load plugin list: ' + e.message, 'error');
}
},
async loadPlugin(pluginId) {
this.showOpenModal = false;
try {
// encodeURIComponent: this id comes back from /composer/api/plugins,
// and any character outside the expected set would otherwise change
// which path is requested rather than being part of the id.
const resp = await fetch(`/composer/api/load/${encodeURIComponent(pluginId)}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || `HTTP ${resp.status}`);
}
const data = await resp.json();
const state = data.state ?? data;
this._applyState(state);
this._idAutoGenerated = false;
this.isDirty = false;
this._snapshot();
this._setStatus(`Loaded: ${pluginId}` + (data.source === 'schema_import' ? ' (config vars only)' : ''), 'success');
this.render();
} catch (e) {
this._setStatus('Failed to load plugin: ' + e.message, 'error');
}
},
// ── Code preview ──────────────────────────────────────────────────
async showPreview() {
if (!this._validateBeforeExport()) return;
this.showCodeModal = true;
this.loadingCode = true;
this.codeFiles = {};
try {
const resp = await fetch('/composer/api/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this._buildPayload()),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`);
this.codeFiles = data.files;
this.codeTab = 'manager.py';
} catch (e) {
this._setStatus('Preview failed: ' + e.message, 'error');
this.showCodeModal = false;
} finally {
this.loadingCode = false;
}
},
// ── Color helpers ─────────────────────────────────────────────────
rgbToHex(r, g, b) {
return '#' + [r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
},
onColorChange(el, hexField, hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; }
else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; }
else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; }
else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; }
else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; }
else { el.r = r; el.g = g; el.b = b; }
this._trackColor(r, g, b);
// Matches applyPaletteColor below. Without these, a colour set through
// the picker never marked the design dirty and never took a snapshot --
// _debouncedAutosave only runs from _snapshot -- so the change was lost
// on reload and could not be undone.
this.isDirty = true;
this._snapshot();
this.render();
},
applyPaletteColor(el, hexField, swatch) {
const { r, g, b } = swatch;
if (hexField === 'fill') { el.fillR = r; el.fillG = g; el.fillB = b; }
else if (hexField === 'out') { el.outR = r; el.outG = g; el.outB = b; }
else if (hexField === 'bg') { el.bgR = r; el.bgG = g; el.bgB = b; }
else if (hexField === 'track') { el.trackR = r; el.trackG = g; el.trackB = b; }
else if (hexField === 'empty') { el.emptyR = r; el.emptyG = g; el.emptyB = b; }
else { el.r = r; el.g = g; el.b = b; }
this._trackColor(r, g, b);
this.isDirty = true;
this._snapshot();
this.render();
},
// ── Export & Install ──────────────────────────────────────────────
_buildPayload() {
return {
composer_version: '1.0',
metadata: this.metadata,
elements: this.elements,
dataModel: this.dataModel,
preset: this.currentPreset,
};
},
_validateBeforeExport() {
if (!this.metadata.name.trim()) { this._setStatus('Plugin name is required', 'error'); return false; }
if (!this.isIdValid()) { this._setStatus('Plugin ID is invalid', 'error'); return false; }
if (!this.metadata.author.trim()) { this._setStatus('Author is required', 'error'); return false; }
if (this.elements.length === 0) { this._setStatus('Add at least one element', 'error'); return false; }
const unbound = this.elements.filter(
e => (e.type === 'dynamic_text' || e.type === 'progress_bar') && !e.binding?.key
);
if (unbound.length) { this._setStatus(`${unbound.length} element(s) have no variable bound`, 'error'); return false; }
return true;
},
async generateZip() {
if (!this._validateBeforeExport()) return;
this.generateStatus = 'working';
try {
const resp = await fetch('/composer/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this._buildPayload()),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ message: 'Server error' }));
throw new Error(err.message || `HTTP ${resp.status}`);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${this.metadata.id}.zip`; a.click();
URL.revokeObjectURL(url);
this.generateStatus = 'done';
this._setStatus('Plugin ZIP downloaded', 'success');
setTimeout(() => { if (this.generateStatus === 'done') this.generateStatus = 'idle'; }, 4000);
} catch (err) {
this.generateStatus = 'error';
this._setStatus(err.message, 'error');
setTimeout(() => { if (this.generateStatus === 'error') this.generateStatus = 'idle'; }, 6000);
}
},
async installLocally(force = false) {
if (!this._validateBeforeExport()) return;
this.installStatus = 'working';
try {
const resp = await fetch('/composer/api/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...this._buildPayload(), _force: force }),
});
const data = await resp.json().catch(() => ({ message: 'Server error' }));
if (resp.status === 409 && !force) {
this.installStatus = 'idle';
if (confirm(`Plugin "${this.metadata.id}" is already installed.\n\nOverwrite it with the current design?`)) {
return this.installLocally(true);
}
return;
}
if (!resp.ok) throw new Error(data.message || `HTTP ${resp.status}`);
this.installStatus = 'done';
this.isDirty = false;
localStorage.removeItem(LS_KEY);
this._setStatus(`"${this.metadata.id}" installed — enable it in the Plugin Manager`, 'success');
setTimeout(() => { if (this.installStatus === 'done') this.installStatus = 'idle'; }, 6000);
} catch (err) {
this.installStatus = 'error';
this._setStatus(err.message, 'error');
setTimeout(() => { if (this.installStatus === 'error') this.installStatus = 'idle'; }, 6000);
}
},
// ── Color themes ──────────────────────────────────────────────────
applyColorTheme(theme) {
// Distribute theme colors round-robin across text-type elements
const textTypes = ['text', 'dynamic_text', 'clock', 'countdown', 'marquee', 'gauge'];
let idx = 0;
for (const el of this.elements) {
if (textTypes.includes(el.type)) {
const c = theme.colors[idx % theme.colors.length];
el.r = c.r; el.g = c.g; el.b = c.b;
idx++;
}
}
this.isDirty = true;
this._snapshot();
this.render();
this._setStatus(`Applied theme: ${theme.label}`, 'success');
},
// ── Status ────────────────────────────────────────────────────────
_setStatus(msg, type = 'info') {
this.statusMsg = msg;
this.statusType = type;
},
statusClass() {
return {
info: 'text-gray-500',
success: 'text-green-600',
error: 'text-red-600',
warning: 'text-yellow-600',
}[this.statusType] || 'text-gray-500';
},
};
}