mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Deletions, each re-verified with a fresh repo-wide grep (core, web, scripts, docs, plugin monorepo) immediately before removal: Modules with zero live importers: - src/background_cache_mixin.py + src/generic_cache_mixin.py (134+150 LOC — referenced only by each other) - src/font_test_manager.py (134 LOC) - src/image_utils.py (22 LOC, self-documented deprecated) - src/layout_manager.py (408 LOC — only its own test imported it) + test/test_layout_manager.py - src/common/basketball_plugin_example.py (328 LOC sample) requirements.txt entries with zero importers in core (pre-plugin-era manager deps): icalevents, geopy, timezonefinder, unidecode. Plus the google-auth trio (google-auth-oauthlib, google-auth-httplib2, google-api-python-client): their only importer is the calendar PLUGIN, which declares all three in its own requirements.txt (verified in the monorepo and on an installed copy) — the plugin dependency installer owns them. Existing venvs are unaffected (removal doesn't uninstall); fresh installs get them when calendar is installed. Two stale references cleaned (a comment in test_pillow_compat.py, a directory listing in HOW_TO_RUN_TESTS.md). Full suite green except the two documented pre-existing failures (circuit_breaker mock drift, fixed in #400; clock-simple 64x32 overflow, pre-dates this series); all core entry modules verified importing cleanly under the emulator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
1338 lines
59 KiB
JavaScript
1338 lines
59 KiB
JavaScript
/**
|
||
* 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;
|
||
if (state.currentPreset && state.currentPreset !== this.currentPreset) {
|
||
this.changePreset(state.currentPreset, { silent: true });
|
||
}
|
||
this._nextId = Math.max(...this.elements.map(e => e.id + 1), 1);
|
||
this.selectedId = null;
|
||
this.isDirty = true;
|
||
},
|
||
|
||
// ── Display preset ────────────────────────────────────────────────
|
||
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.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;
|
||
this._applyState({ metadata: data.metadata, elements: data.elements, dataModel: data.dataModel });
|
||
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();
|
||
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),
|
||
};
|
||
this.render();
|
||
},
|
||
|
||
// ── Canvas events ─────────────────────────────────────────────────
|
||
onCanvasMouseDown(event) {
|
||
const { lx, ly } = this._canvasToLed(event);
|
||
|
||
// Priority 1: resize handle on selected rectangle (skip if locked)
|
||
if (this.selectedElement?.type === 'rectangle' && !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 (this.selectedElement?.type === 'rectangle') {
|
||
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) {
|
||
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';
|
||
|
||
// Ctrl/Cmd combos work everywhere
|
||
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 regardless of input focus
|
||
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;
|
||
}
|
||
|
||
if (inInput) 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 {
|
||
const resp = await fetch(`/composer/api/load/${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);
|
||
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';
|
||
},
|
||
};
|
||
}
|