mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 10:58:15 +00:00
fix(composer): eight editor bugs from review
All confirmed by reading the code rather than taken on trust.
Saved designs restored onto the wrong canvas. _buildPayload writes the
size as `preset`; _applyState read `state.currentPreset`, which is never
present, so changePreset(undefined) hit its `if (!preset) return` and did
nothing -- silently. A 256x64 design reopened at 128x32 with every
element misplaced. importDesign passed no size key at all, same result.
Both go through a new applyPresetLabel(), which also handles the custom
labels setCustomSize() writes ("200x50"): those are deliberately absent
from DISPLAY_PRESETS, so changePreset alone could never round-trip them.
Keyboard shortcuts hijacked text fields. The `inInput` guard sat below
the Ctrl/Cmd block, under a comment claiming combos "work everywhere".
In any input, Ctrl+C copied the selected *element* -- preventDefault
stopping the real copy -- Ctrl+V pasted an element, Ctrl+A could not
select the field contents, and Tab always moved the element selection,
so keyboard users could not reach the next input. Guard moved above both
blocks, and it now covers contenteditable too.
Resize handles were advertised on five shapes that ignored them. The
canvas drew handles for six element types; the editor gated resize and
hover on `type === 'rectangle'`. The list was also duplicated inside the
canvas. One exported RESIZABLE_TYPES now feeds all four sites.
Lines jumped on drag. addElement assigns x/y *before* spreading
ELEMENT_DEFAULTS, and the line defaults define only x0/y0 -- so a line
carries both, with x at canvas/4 and x0 at 0. Drag and nudge move x0/y0
only, so _getStoredPos preferring `x` handed the drag a base it never
updates.
Colour-picker edits were lost on reload. onColorChange mutated the
element but never set isDirty or called _snapshot, and _debouncedAutosave
only runs from _snapshot. applyPaletteColor did both; they match now.
Also: section elements drew nothing and reported a 0x0 box, so adding
"Section Label" from the palette looked broken and the element was
selectable only through the 3px hit-test padding -- they now draw their
label, with the bounding box using the same font fallback as the draw
call so the two agree. The gauge inset its arc radius by lw/2 where lw is
LED pixels and the radius is canvas pixels, then stroked at lw*s, so the
arc spilled outside its own bounding box at any scale above 1. And the
plugin id is encodeURIComponent'd before it becomes part of a request
path.
Verified: composer-app.js and composer-canvas.js parse cleanly under
tree-sitter (esprima cannot read this codebase -- it predates ??, and
fails identically on the unmodified files). Every symbol referenced
across module boundaries checked to exist. 156 Python composer tests
pass. The static-audit failure is the same 13 classes as before, all
defined on main and absent only because this branch is behind; nothing
here touches CSS or templates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
co-authored by
Claude Opus 5
parent
986f74e38b
commit
f0bef7784c
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user