/** * 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' }; /** * Where an element's offsets live in the layout block, or null. * * A hand-written schema does not line up: football styles 'score_text' * but positions it under 'score'. Core resolves that through the same * alias map the renderer reads offsets with and records the answer as * x-layout-key, so the rules exist once, in element_style.py. Without * the annotation both blocks share one key, as the compact declaration * does. */ function layoutKeyFor(schema, key) { var props = (schema && schema.properties) || {}; var layoutProps = ownObj(props, 'layout').properties || {}; var declared = ownObj(props, key)['x-layout-key']; // Only an object-shaped entry holds offsets. A leaf straight under // layout (a show_logo toggle) is a control of its own, so it is never // claimed here and always gets a position row. if (typeof declared === 'string' && ownObj(layoutProps, declared).properties) { return declared; } return ownObj(layoutProps, key).properties ? key : null; } /** One row per styled element, paired with its offsets if it has any. */ function styleRows(schema) { return elementKeys(schema).map(function (key) { return { key: key, layoutKey: layoutKeyFor(schema, key) }; }); } /** * One row per positioned thing that has no style block of its own. * * Football positions both logos, timeouts, possession, down-and-distance, * the date, the time and the records, none of which has a font or a * colour. The editor takes the whole layout section over, so anything * not drawn here has no control at all. */ function positionRows(schema) { var layout = ownObj((schema && schema.properties) || {}, 'layout'); var layoutProps = layout.properties || {}; var claimed = styleRows(schema).map(function (r) { return r.layoutKey; }); var order = layout['x-propertyOrder'] || Object.keys(layoutProps); return order.filter(function (lk) { var entry = own(layoutProps, lk); // Object-shaped entries carry offsets; a leaf is itself the // control. Both need a row here, because the editor removes the // fallback's whole layout section. return entry && typeof entry === 'object' && claimed.indexOf(lk) === -1; }).map(function (lk) { return { key: null, layoutKey: lk }; }); } function layoutTitleOf(schema, layoutKey) { var layoutProps = ownObj((schema && schema.properties) || {}, 'layout').properties || {}; return ownObj(layoutProps, layoutKey).title || layoutKey.replace(/_/g, ' '); } /** * The columns a table needs, derived from the schema rather than fixed. * * Three sources: the sub-fields a row's style block declares (font, * font_size, text_color, visible, align), the sub-fields of its layout * entry (x_offset, y_offset, scale), and a layout entry whose own value * *is* the field -- a show_logo toggle straight under layout has no x/y * object underneath, so it gets a column keyed to itself. * * `rows` defaults to every row the schema produces, style and position. */ function columnsFor(schema, rows) { var props = schema.properties || {}; var layoutProps = ownObj(props, 'layout').properties || {}; rows = rows || styleRows(schema).concat(positionRows(schema)); // 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(); rows.forEach(function (row) { if (row.key) { Object.keys(ownObj(props, row.key).properties || {}).forEach( function (f) { seen.set(f, 'element'); }); } if (!row.layoutKey) { return; } var entry = own(layoutProps, row.layoutKey); if (entry && typeof entry === 'object' && entry.properties) { Object.keys(entry.properties).forEach( function (f) { seen.set(f, 'layout'); }); } else if (entry && typeof entry === 'object') { // A leaf. Namespaced so an unrelated column sharing its name // -- another entry's "scale" axis, say -- can never shadow it // and leave this row's only control blank. seen.set('layout-leaf:' + row.layoutKey, '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) { var isLeaf = f.indexOf('layout-leaf:') === 0; var fieldKey = isLeaf ? f.slice('layout-leaf:'.length) : f; return { key: fieldKey, where: seen.get(f), label: own(COLUMN_LABELS, fieldKey) || fieldKey.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.row.key; // null for a position-only row var layoutKey = opts.row.layoutKey; // null for a style-only row var value = opts.value; var optional = opts.optional; var props = key ? (ownObj(schema.properties || {}, key).properties || {}) : {}; var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {}; var axes = layoutKey ? (ownObj(layoutProps, layoutKey).properties || {}) : {}; var row = el('div', { class: 'style-editor-row grid items-center gap-2 py-1', 'data-element': key || ('layout.' + layoutKey) }); row.appendChild(el('div', { class: 'text-sm text-gray-700 style-editor-label', text: key ? titleOf(schema, key) : layoutTitleOf(schema, layoutKey) })); 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 belongs to the one position row named after it; // every other row leaves that cell blank. var prop = isLeaf ? (col.key === layoutKey ? own(layoutProps, layoutKey) : 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', layoutKey] : inLayout ? ['layout', layoutKey, col.key] : [key, col.key]; // Posted under the key the schema declares, never the style key: // a plugin's own offset reader looks up layout.score, so a value // saved as layout.score_text would be kept and never drawn. A // leaf is the field itself, so its name has no sub-field suffix -- // the same name the generic renderer would have posted. var base = (isLeaf || inLayout) ? opts.layoutPrefix + '.' + layoutKey : 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, firstLabel) { var row = el('div', { class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300' }); [firstLabel || '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' }); // Columns per table, so the positions table does not inherit a Font // column and the style table does not grow a "home x offset" one. var columns = columnsFor(opts.schema, opts.rows); // 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, opts.firstLabel)); opts.rows.forEach(function (row) { wrap.appendChild(elementRow({ schema: opts.schema, row: row, columns: columns, prefix: opts.prefix, layoutPrefix: opts.prefix + '.layout', value: opts.value, fonts: opts.fonts, optional: opts.optional })); }); return wrap; } /** One panel's tables: the styled elements, then anything only positioned. */ function panelBody(opts) { var body = el('div'); var shared = { schema: opts.schema, prefix: opts.prefix, value: opts.value, fonts: opts.fonts, optional: opts.optional }; var styled = styleRows(opts.schema); if (styled.length) { body.appendChild(table(Object.assign({ rows: styled }, shared))); } var positioned = positionRows(opts.schema); if (positioned.length) { body.appendChild(el('div', { class: 'text-xs font-semibold text-gray-500 uppercase mt-4 mb-1 style-editor-subhead', text: 'Other positions' })); body.appendChild(table(Object.assign( { rows: positioned, firstLabel: 'Item' }, shared))); } return body; } // ---- 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__', panelBody({ 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(panelBody({ 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; } }); })();