/** * Style Editor Widget * * One compact row per display element -- font, size, colour, alignment, * visibility, X/Y nudge, scale -- instead of the nested accordions the * generic object renderer produces. A realistic scoreboard declares seven * elements with layout offsets and three modes, which comes to 65 nested * sections and five levels of clicking to reach one per-mode font size. * * Three things about this widget are load-bearing: * * 1. It emits ordinary inputs with the same dotted names the generic * renderer would produce (`customization.score_text.font`, * `customization.score_text.text_color.0`, `customization.layout. * score_text.x_offset`, `customization.modes.live....`). The whole * save/validate/merge pipeline is therefore untouched: no hidden JSON * blob, no new server-side parsing. * * 2. Its columns come from the schema, not from a list in here. A column * appears when any element declares that field, so a plugin adding a * field to its schema gets a control without this file changing, and a * logo that declares only offsets and a scale gets no empty font cell. * * 3. Mode tabs edit `customization.modes.`, whose fields mean * "inherit" when blank. Blank must post as empty (-> null), never as 0, * or every mode would pin itself to the base the first time it was saved. * * @module StyleEditorWidget */ (function () { 'use strict'; if (typeof window.LEDMatrixWidgets === 'undefined') { console.error('[StyleEditor] LEDMatrixWidgets registry not found. Load registry.js first.'); return; } var FONT_CACHE = null; var FONT_INFLIGHT = null; /** * Read one own property, by a key that came from data. * * Every lookup in here is keyed by something out of a schema or a saved * config -- an element name, a mode name, a field name. A key of * `__proto__` or `constructor` would otherwise walk up the prototype * chain and hand back a function instead of a schema, so reads go * through here and misses come back undefined. */ function own(obj, key) { if (!obj || typeof obj !== 'object') { return undefined; } // Via the descriptor rather than obj[key]: this is the one read that // cannot avoid a data-supplied key, and going through the descriptor // means there is no computed member access here at all. var descriptor = Object.getOwnPropertyDescriptor(obj, key); return descriptor ? descriptor.value : undefined; } /** `own`, but always an object -- for `(x || {}).properties` chains. */ function ownObj(obj, key) { var found = own(obj, key); return (found && typeof found === 'object') ? found : {}; } /** The font catalog, fetched once per page. */ function loadFonts() { if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); } if (FONT_INFLIGHT) { return FONT_INFLIGHT; } FONT_INFLIGHT = fetch('/api/v3/fonts/catalog') .then(function (r) { if (!r.ok) { throw new Error('font catalog fetch failed: ' + r.status); } return r.json(); }) .then(function (payload) { var catalog = (payload && payload.data && payload.data.catalog) || {}; FONT_CACHE = Object.keys(catalog).map(function (key) { var entry = ownObj(catalog, key); return { filename: entry.filename, label: entry.display_name || entry.filename, scalable: entry.scalable !== false, nativeSize: entry.native_size || null }; }).sort(function (a, b) { return a.label.localeCompare(b.label); }); return FONT_CACHE; }) .catch(function (e) { console.warn('[StyleEditor] could not load the font catalog', e); // Leave FONT_CACHE unset and clear FONT_INFLIGHT so the next // call retries instead of being stuck on an empty result. FONT_INFLIGHT = null; return []; }); return FONT_INFLIGHT; } function el(tag, attrs, children) { var node = document.createElement(tag); Object.keys(attrs || {}).forEach(function (k) { var v = own(attrs, k); if (k === 'class') { node.className = v; } else if (k === 'text') { node.textContent = v; } else if (v !== null && v !== undefined) { node.setAttribute(k, v); } }); (children || []).forEach(function (c) { node.appendChild(c); }); return node; } /** Walk a nested value object by path segments. */ function at(value, path) { var cur = value; // Consumed rather than indexed, so no step reads path[i]. var remaining = (path || []).slice(); while (remaining.length) { if (cur === null || typeof cur !== 'object') { return undefined; } cur = own(cur, remaining.shift()); } return cur; } /** * What a control should show: the configured value if there is one, * else the schema default. * * The base tab must fall back to the default rather than leaving the * control empty. An empty has no empty // state of its own. clearBtn = el('button', { type: 'button', class: 'text-xs text-gray-500 hover:text-gray-800 style-editor-clear', title: 'Inherit the colour above', text: '×' }); if (!has) { clearBtn.classList.add('hidden'); } clearBtn.addEventListener('click', function () { channels.forEach(function (c) { c.value = ''; }); setSubmitted(false); swatch.value = '#ffffff'; clearBtn.classList.add('hidden'); }); wrap.appendChild(clearBtn); } return wrap; } // ---- columns --------------------------------------------------------- var COLUMN_ORDER = ['font', 'font_size', 'text_color', 'align', 'visible', 'x_offset', 'y_offset', 'scale']; var COLUMN_LABELS = { font: 'Font', font_size: 'Size', text_color: 'Colour', align: 'Align', visible: 'Show', x_offset: 'X', y_offset: 'Y', scale: 'Scale' }; var COLUMN_WIDTHS = { font: 'minmax(8rem, 2fr)', text_color: '4.5rem', align: '6rem', visible: '3.5rem' }; /** * The columns a table needs, derived from the schema rather than fixed. * * Three sources: the sub-fields elements declare (font, font_size, * text_color, visible, align), the sub-fields their layout blocks * declare (x_offset, y_offset, scale), and a layout-only key whose own * value *is* the field to set -- a plain "show_logo" toggle has no x/y * object underneath it, so it gets a column keyed to itself rather than * to a shared sub-field name. */ function columnsFor(schema) { var props = schema.properties || {}; var layoutProps = ownObj(props, 'layout').properties || {}; // A Map, not an object: the keys are field names out of a schema, so // a field literally named "constructor" is a column like any other // and never touches a prototype. var seen = new Map(); elementKeys(schema).forEach(function (key) { Object.keys(ownObj(props, key).properties || {}).forEach( function (f) { seen.set(f, 'element'); }); var layoutEntry = own(layoutProps, key); if (layoutEntry && typeof layoutEntry === 'object' && layoutEntry.properties) { Object.keys(layoutEntry.properties).forEach( function (f) { seen.set(f, 'layout'); }); } else if (layoutEntry && !seen.has(key)) { // layout-only and a leaf: nothing else will share this // column, but leaving it out drops the field's only control // the moment the wholesale `layout` claim removes its // fallback (#569 review). seen.set(key, 'layout-leaf'); } }); var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); }); // Anything the schema declares that this file has never heard of // still gets a column, rather than silently vanishing. var extra = Array.from(seen.keys()).filter(function (f) { return COLUMN_ORDER.indexOf(f) === -1; }).sort(); return known.concat(extra).map(function (f) { return { key: f, where: seen.get(f), label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ') }; }); } /** Build the control for one cell from its schema property. */ function control(opts) { var prop = opts.prop; var declared = prop.type; var types = Array.isArray(declared) ? declared : [declared]; var xOptions = prop['x-options'] || prop['x_options'] || {}; var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null; if (opts.key === 'font' || prop['x-widget'] === 'font-selector') { return fontControl(opts.name, opts.current, usableFonts(opts.fonts, cap, opts.current), opts.optional, opts.onFontChange); } if (types.indexOf('boolean') !== -1) { return booleanControl(opts.name, opts.current, opts.optional); } if (types.indexOf('array') !== -1) { return colourControl(opts.name, opts.current, opts.optional); } if (Array.isArray(prop.enum)) { return enumControl(opts.name, opts.current, prop.enum, opts.optional); } return numberControl(opts.name, opts.current, prop, opts.optional ? 'inherit' : ''); } function elementRow(opts) { var schema = opts.schema; var key = opts.key; var value = opts.value; var optional = opts.optional; var props = ownObj(schema.properties || {}, key).properties || {}; var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {}; var axes = ownObj(layoutProps, key).properties || {}; var row = el('div', { class: 'style-editor-row grid items-center gap-2 py-1', 'data-element': key }); row.appendChild(el('div', { class: 'text-sm text-gray-700 style-editor-label', text: titleOf(schema, key) })); var sizeInput = null; var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' }); var fontSelect = null; function syncSize(select) { // A BDF font has exactly one usable pixel size; offering a free // number there offers something that cannot take effect. if (!sizeInput || !select) { return; } var opt = select.options[select.selectedIndex]; var scalable = !opt || opt.dataset.scalable !== '0'; if (scalable) { sizeInput.disabled = false; sizeInput.title = ''; sizeNote.textContent = ''; } else { sizeInput.disabled = true; sizeInput.value = opt.dataset.nativeSize || ''; sizeInput.title = 'This is a bitmap font; it renders at one fixed size.'; sizeNote.textContent = 'fixed'; } } opts.columns.forEach(function (col) { var isLeaf = col.where === 'layout-leaf'; var inLayout = col.where === 'layout'; // A leaf column only applies to the one row named after it -- // every other row leaves it blank, same as an element that // doesn't declare a shared sub-field. var prop = isLeaf ? (col.key === key ? own(layoutProps, key) : null) : inLayout ? own(axes, col.key) : own(props, col.key); var cell; if (!prop) { // This element does not declare that field; keep the grid // aligned with an empty cell. row.appendChild(el('span')); return; } var path = isLeaf ? ['layout', key] : inLayout ? ['layout', key, col.key] : [key, col.key]; var base = (isLeaf || inLayout) ? opts.layoutPrefix + '.' + key : opts.prefix + '.' + key; var node = control({ key: col.key, prop: prop, name: isLeaf ? base : base + '.' + col.key, current: effective(value, path, prop, optional), optional: optional, fonts: opts.fonts, // The element's declared size ceiling is what a fixed-size // font has to fit under. maxFixedSize: (props.font_size || {}).maximum || null, onFontChange: function () { syncSize(fontSelect); } }); if (col.key === 'font') { fontSelect = node; } if (col.key === 'font_size') { sizeInput = node; cell = el('div', { class: 'flex items-center' }); cell.appendChild(node); cell.appendChild(sizeNote); node = cell; } row.appendChild(node); }); if (fontSelect) { syncSize(fontSelect); } return row; } function header(columns) { var row = el('div', { class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300' }); ['Element'].concat(columns.map(function (c) { return c.label; })) .forEach(function (label) { row.appendChild(el('div', { class: 'text-xs font-semibold text-gray-500 uppercase', text: label })); }); return row; } function table(opts) { var wrap = el('div', { class: 'style-editor-table' }); var columns = columnsFor(opts.schema); // Sized here rather than in CSS: the column count depends on what // the plugin declared. wrap.style.gridTemplateColumns = ''; wrap.style.setProperty('--style-editor-columns', 'minmax(7rem, 1.4fr) ' + columns.map(function (c) { return COLUMN_WIDTHS[c.key] || '5rem'; }).join(' ')); wrap.appendChild(header(columns)); elementKeys(opts.schema).forEach(function (key) { wrap.appendChild(elementRow({ schema: opts.schema, key: key, columns: columns, prefix: opts.prefix, layoutPrefix: opts.prefix + '.layout', value: opts.value, fonts: opts.fonts, optional: opts.optional })); }); return wrap; } // ---- widget ---------------------------------------------------------- window.LEDMatrixWidgets.register('style-editor', { name: 'Style Editor', version: '1.1.0', render: function (container, config, value, options) { var schema = (config && config.schema) || {}; var base = (options && options.name) || 'customization'; var current = value || {}; container.innerHTML = ''; var root = el('div', { class: 'style-editor' }); container.appendChild(root); // Published synchronously, because the host reads it the moment // this returns while the panels below wait on the font catalog. // layout and modes count as ours: their fields appear as columns // in these rows, so leaving them to the generic renderer would // post every offset twice from two different controls. var owned = elementKeys(schema); if (ownObj(schema.properties || {}, 'layout').properties) { owned = owned.concat(['layout']); } if (ownObj(schema.properties || {}, 'modes').properties) { owned = owned.concat(['modes']); } container.dataset.ownedKeys = owned.join(','); loadFonts().then(function (fonts) { var modeProps = ownObj(schema.properties || {}, 'modes').properties || {}; var modes = Object.keys(modeProps); // A list, not a keyed object: the ids are mode names out of // the schema, and nothing here needs a lookup by key. var panels = []; var tabs = null; if (modes.length) { tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' }); root.appendChild(tabs); } function panel(id, node) { panels.push({ id: id, node: node }); node.classList.add('style-editor-panel'); root.appendChild(node); } function show(id) { panels.forEach(function (p) { p.node.hidden = (p.id !== id); }); if (!tabs) { return; } Array.prototype.forEach.call(tabs.children, function (b) { b.classList.toggle('is-active', b.dataset.panel === id); }); } function tab(id, label) { if (!tabs) { return; } var b = el('button', { type: 'button', class: 'style-editor-tab text-sm px-3 py-1 rounded', text: label }); b.dataset.panel = id; b.addEventListener('click', function () { show(id); }); tabs.appendChild(b); } panel('__base__', table({ schema: schema, prefix: base, value: current, fonts: fonts, optional: false })); tab('__base__', 'All modes'); modes.forEach(function (mode) { var modeSchema = ownObj(modeProps, mode); var modeValue = at(current, ['modes', mode]) || {}; var node = el('div'); node.appendChild(el('p', { class: 'text-xs text-gray-500 mb-2', text: 'Anything left blank follows the "All modes" tab.' })); node.appendChild(table({ schema: modeSchema, prefix: base + '.modes.' + mode, value: modeValue, fonts: fonts, optional: true })); panel(mode, node); tab(mode, modeSchema.title || mode); }); show('__base__'); }); }, getValue: function () { // The inputs are ordinary named form fields; the form itself is // the source of truth, so there is no separate value to hand back. return null; } }); console.log('[StyleEditor] widget registered'); })();