Files
LEDMatrix/web_interface/static/v3/js/composer/composer-app.js
T
ChuckBuildsandClaude Opus 5 acc55ef119 fix(composer): scale strokes, anchor lines, snapshot state changes
Three review findings in the composer's JavaScript, all confirmed against the
code.

Stroke widths did not scale with SCALE
--------------------------------------
_drawElement scales all geometry by `s`, but left ctx.lineWidth in canvas
pixels, so at SCALE>1 every outline rendered thinner than one LED pixel and
the preview stopped matching the panel it is previewing. Fixed for rectangle,
ellipse, arc, rounded_rectangle, line, divider and progress_bar. Ellipse and
arc also inset their radii by half the scaled width -- a stroke straddles its
path, so without the inset the outline spills outside the element's bounds.
The gauge branch already did this; the rest now match it.

Selection handles and the grid stay in canvas pixels deliberately: they are
editor chrome, not LED geometry, and live in other functions.

`line` ignored anchors
----------------------
_drawElement resolves ax/ay for every element, but the line branch drew raw
el.x0/el.y0/el.x1/el.y1. Setting xAnchor or yAnchor moved every other element
type and left lines where they were. getBoundingBox had the same omission, so
even once a line moved its hit box would not have. Both now translate by
(ax - el.x0, ay - el.y0); ax resolves from el.x0 for a line, so that is
exactly the anchor offset.

Four state mutations skipped _snapshot
--------------------------------------
_snapshot serialises metadata and currentPreset and is the only caller of
_debouncedAutosave. onBgColorChange, setCustomSize, changePreset and
applyPresetLabel each changed exactly those values without calling it, so the
background colour and the canvas size were lost on reload and could not be
undone. Same defect already fixed in onColorChange.

The review named three; applyPresetLabel has it too -- it is the branch that
handles sizes absent from DISPLAY_PRESETS.

Snapshotting is on the user-driven path only. _applyState and loadTemplate
drive these with {silent: true} while restoring, and snapshotting there would
push restore steps onto the undo stack and re-autosave the state just loaded.

Tests
-----
No JS runner here, so test_composer_js_contracts.py asserts on the parse tree
via tree-sitter: both files parse, no bare `ctx.lineWidth = 1` inside
_drawElement, the line branch and its bounding box carry the anchor offset,
each of the five mutations snapshots, and the two preset paths keep their
!opts.silent guard ahead of the snapshot.

9 of its 11 checks fail against the previous JS. Full suite 3978 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 13:52:48 -04:00

1415 lines
63 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 ─────────────────────────────────────────────────────
alignElement(dir) {
const el = this.selectedElement;
if (!el) return;
const bb = window.ComposerCanvas.getBoundingBox(el, this.MATRIX_W, this.MATRIX_H);
switch (dir) {
case 'left': el.x = 0; break;
case 'center': el.x = Math.round((this.MATRIX_W - bb.w) / 2); break;
case 'right': el.x = this.MATRIX_W - bb.w; break;
case 'top': el.y = 0; break;
case 'middle': el.y = Math.round((this.MATRIX_H - bb.h) / 2); break;
case 'bottom': el.y = this.MATRIX_H - bb.h; break;
}
this.isDirty = true;
this._snapshot();
this.render();
},
// ── 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';
},
};
}