diff --git a/web_interface/static/v3/js/composer/composer-app.js b/web_interface/static/v3/js/composer/composer-app.js index f0124371..92b9c114 100644 --- a/web_interface/static/v3/js/composer/composer-app.js +++ b/web_interface/static/v3/js/composer/composer-app.js @@ -446,8 +446,13 @@ function composerApp() { 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 }); + // _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; @@ -455,6 +460,29 @@ function composerApp() { }, // ── 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.render(); + }, + changePreset(presetLabel, opts = {}) { const preset = window.ComposerCanvas.DISPLAY_PRESETS.find(p => p.label === presetLabel); if (!preset) return; @@ -545,7 +573,13 @@ function composerApp() { 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 }); + // 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(); @@ -619,7 +653,10 @@ function composerApp() { const { lx, ly } = this._canvasToLed(event); // Priority 1: resize handle on selected rectangle (skip if locked) - if (this.selectedElement?.type === 'rectangle' && !this.selectedElement.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 ); @@ -721,7 +758,7 @@ function composerApp() { const canvas = document.getElementById('led-canvas'); if (canvas) { let cursor = 'crosshair'; - if (this.selectedElement?.type === 'rectangle') { + if (window.ComposerCanvas.RESIZABLE_TYPES.includes(this.selectedElement?.type)) { const handle = window.ComposerCanvas.getResizeHandle( this.selectedElement, lx, ly, this.MATRIX_W, this.MATRIX_H ); @@ -766,6 +803,14 @@ function composerApp() { // 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 }; }, @@ -817,9 +862,17 @@ function composerApp() { // ── Keyboard shortcuts ──────────────────────────────────────────── _onKeyDown(e) { const tag = document.activeElement?.tagName; - const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + 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; - // 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; } @@ -831,7 +884,7 @@ function composerApp() { 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 + // 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); @@ -843,8 +896,6 @@ function composerApp() { 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); } @@ -1142,7 +1193,10 @@ function composerApp() { async loadPlugin(pluginId) { this.showOpenModal = false; try { - const resp = await fetch(`/composer/api/load/${pluginId}`); + // 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}`); @@ -1200,6 +1254,12 @@ function composerApp() { 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(); }, diff --git a/web_interface/static/v3/js/composer/composer-canvas.js b/web_interface/static/v3/js/composer/composer-canvas.js index adc3791d..5019b81b 100644 --- a/web_interface/static/v3/js/composer/composer-canvas.js +++ b/web_interface/static/v3/js/composer/composer-canvas.js @@ -22,6 +22,13 @@ window.ComposerCanvas = (() => { let _ctx = null; let _showGrid = true; + //: Element types the canvas draws resize handles for. Exported because the + //: editor has to gate its resize and hover behaviour on exactly this list -- + //: the two had drifted, so handles appeared on five shapes that could not + //: actually be resized. + const RESIZABLE_TYPES = ['rectangle', 'rounded_rectangle', 'ellipse', 'arc', + 'gauge', 'sparkline']; + const DISPLAY_PRESETS = [ { label: '64×32', w: 64, h: 32 }, { label: '128×32', w: 128, h: 32 }, @@ -225,8 +232,17 @@ window.ComposerCanvas = (() => { const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2; return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps }; } - case 'section': - return { x: ax, y: ay, w: 0, h: 0 }; + case 'section': { + // Was 0x0, so the element was unselectable except through the 3px + // hit-test padding and drew nothing at all -- a user adding one from + // the palette saw an empty canvas. + // Same font resolution as the draw case below, or the box will not + // match the glyphs: getBoundingBox's shared `finfo` falls back to + // press_start, and a section has no font of its own. + const sinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six; + const label = el.label || 'Section'; + return { x: ax, y: ay, w: label.length * sinfo.charW, h: sinfo.sizePx }; + } default: return { x: ax, y: ay, w: 4, h: 4 }; } @@ -252,7 +268,7 @@ window.ComposerCanvas = (() => { // Returns the handle direction under LED-space point (lx, ly), or null function getResizeHandle(el, lx, ly, matrixW, matrixH) { - if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null; + if (!RESIZABLE_TYPES.includes(el.type)) return null; const handles = _getRectHandles(el, matrixW, matrixH); const PAD = 4; for (const [dir, pt] of Object.entries(handles)) { @@ -307,6 +323,18 @@ window.ComposerCanvas = (() => { try { switch (el.type) { + case 'section': { + // A design-time label: it marks a region for the author and is not + // emitted into the generated plugin. There was no case here at all, + // so adding "Section Label" from the palette drew nothing and left + // the user with an apparently broken control. + const sfinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six; + ctx.font = `${sfinfo.sizePx * s}px ${sfinfo.family}`; + ctx.fillStyle = `rgba(${el.r ?? 120},${el.g ?? 120},${el.b ?? 120},0.85)`; + ctx.textBaseline = 'top'; + ctx.fillText(el.label || 'Section', ax * s, ay * s); + break; + } case 'text': case 'dynamic_text': case 'clock': { @@ -496,6 +524,12 @@ window.ComposerCanvas = (() => { const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s; const rx = (gw / 2) * s, ry = (gh / 2) * s; const lw = Math.max(1, (el.lineWidth ?? 3)); + // rx/ry are canvas pixels ((gw/2)*s) but lw is LED pixels, so + // insetting by lw/2 under-corrected by the scale factor while the + // stroke was drawn at lw*s -- the arc spilled outside the element's + // reported bounding box at any SCALE > 1, and the preview stopped + // matching the generated PIL output. + const lwPx = lw * s; const startDeg = el.startAngle ?? 135; const endDeg = el.endAngle ?? 45; // Arc sweep: from startDeg clockwise to endDeg (PIL convention) @@ -510,17 +544,17 @@ window.ComposerCanvas = (() => { // Track arc if (el.hasTrack !== false) { ctx.beginPath(); - ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false); + ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false); ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`; - ctx.lineWidth = lw * s; + ctx.lineWidth = lwPx; ctx.stroke(); } // Fill arc if (pct > 0) { ctx.beginPath(); - ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false); + ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false); ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`; - ctx.lineWidth = lw * s; + ctx.lineWidth = lwPx; ctx.stroke(); } // Centre label @@ -624,7 +658,7 @@ window.ComposerCanvas = (() => { } // Resize handles: on rect, rounded rect, ellipse - if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) { + if (RESIZABLE_TYPES.includes(el.type)) { const handles = _getRectHandles(el, matrixW, matrixH); const HS = 5; ctx.fillStyle = 'white'; @@ -752,6 +786,6 @@ window.ComposerCanvas = (() => { init, render, setGrid, updateCanvasSize, hitTest, getBoundingBox, computeActualPos, resolveAnchor, getResizeHandle, getCursorForHandle, - ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, + ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, RESIZABLE_TYPES, }; })();