Add command palette entity selector for all editor dropdowns

Replace plain <select> dropdowns with a searchable command palette modal
for 16 entity selectors across 6 editors (targets, streams, CSS sources,
value sources, audio sources, pattern templates). Unified EntityPalette
singleton + EntitySelect wrapper in core/entity-palette.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 00:17:44 +03:00
parent b4d89e271d
commit 6fc0e20e1d
12 changed files with 657 additions and 7 deletions

View File

@@ -583,3 +583,145 @@ textarea:focus-visible {
.icon-select-cell { padding: 8px 4px; } .icon-select-cell { padding: 8px 4px; }
.icon-select-grid { gap: 4px; padding: 4px; } .icon-select-grid { gap: 4px; padding: 4px; }
} }
/* ── Entity Palette (command-palette style selector) ─────── */
.entity-palette-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: flex-start;
padding-top: min(20vh, 120px);
}
.entity-palette-overlay.open {
display: flex;
}
.entity-palette {
width: min(500px, 90vw);
max-height: 60vh;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
}
.entity-palette-search-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
}
.entity-palette-search-row .icon {
width: 18px;
height: 18px;
opacity: 0.5;
flex-shrink: 0;
}
.entity-palette-input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 1rem;
padding: 0;
}
.entity-palette-list {
overflow-y: auto;
padding: 4px 0;
}
.entity-palette-empty {
padding: 16px;
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
.entity-palette-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.1s;
}
.entity-palette-item:hover,
.entity-palette-item.ep-highlight {
background: var(--bg-secondary);
}
.entity-palette-item.ep-current {
border-left: 3px solid var(--primary-color);
padding-left: 11px;
}
.ep-item-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.ep-item-icon .icon {
width: 18px;
height: 18px;
}
.ep-item-label {
flex: 1;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ep-item-desc {
font-size: 0.8rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
/* Entity Select trigger (replaces <select>) */
.entity-select-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
background: var(--bg-color);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px;
font-size: 1rem;
cursor: pointer;
text-align: left;
transition: border-color 0.2s;
}
.entity-select-trigger:hover {
border-color: var(--primary-color);
}
.es-trigger-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.es-trigger-icon .icon {
width: 16px;
height: 16px;
}
.es-trigger-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.es-trigger-none {
color: var(--text-secondary);
}
.es-trigger-arrow {
flex-shrink: 0;
opacity: 0.5;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,267 @@
/**
* Command-palette style entity selector.
*
* Usage:
* import { EntityPalette, EntitySelect } from '../core/entity-palette.js';
*
* // Direct use (promise-based):
* const value = await EntityPalette.pick({
* items: [{ value: 'abc', label: 'My Device', icon: '<svg>…</svg>', desc: '192.168.1.1' }],
* current: 'abc',
* placeholder: 'Search devices...',
* });
* // value = 'abc' (selected) or undefined (cancelled)
*
* // Wrapper (replaces a <select> visually):
* const sel = new EntitySelect({
* target: document.getElementById('my-select'),
* getItems: () => devices.map(d => ({ value: d.id, label: d.name, icon: '…' })),
* placeholder: 'Search devices...',
* onChange: (value) => { … },
* });
*
* The original <select> is hidden but stays in the DOM (value kept in sync).
* Call sel.refresh() after repopulating the <select>, sel.destroy() to remove.
*/
import { ICON_SEARCH } from './icons.js';
// ── EntityPalette (singleton modal) ─────────────────────────
let _instance = null;
export class EntityPalette {
/**
* Open the palette and return a promise.
* Resolves to selected value (string) or undefined if cancelled.
*/
static pick(opts) {
if (!_instance) _instance = new EntityPalette();
return _instance._pick(opts);
}
constructor() {
this._overlay = document.createElement('div');
this._overlay.className = 'entity-palette-overlay';
this._overlay.innerHTML = `
<div class="entity-palette">
<div class="entity-palette-search-row">
${ICON_SEARCH}
<input type="text" class="entity-palette-input" autocomplete="off" spellcheck="false">
</div>
<div class="entity-palette-list"></div>
</div>
`;
document.body.appendChild(this._overlay);
this._input = this._overlay.querySelector('.entity-palette-input');
this._list = this._overlay.querySelector('.entity-palette-list');
this._resolve = null;
this._items = [];
this._filtered = [];
this._highlightIdx = 0;
this._overlay.addEventListener('click', (e) => {
if (e.target === this._overlay) this._cancel();
});
this._input.addEventListener('input', () => this._filter());
this._input.addEventListener('keydown', (e) => this._onKeyDown(e));
}
_pick({ items, current, placeholder, allowNone, noneLabel }) {
return new Promise(resolve => {
this._resolve = resolve;
this._items = items || [];
this._currentValue = current;
this._allowNone = allowNone;
this._noneLabel = noneLabel;
this._input.placeholder = placeholder || '';
this._input.value = '';
this._filter();
this._overlay.classList.add('open');
// Focus after paint so the overlay is visible
requestAnimationFrame(() => this._input.focus());
});
}
_buildFullList() {
const all = [];
if (this._allowNone) {
all.push({ value: '', label: this._noneLabel || '—', icon: '', desc: '', _isNone: true });
}
all.push(...this._items);
return all;
}
_filter() {
const query = this._input.value.toLowerCase().trim();
const all = this._buildFullList();
this._filtered = query
? all.filter(i => i.label.toLowerCase().includes(query) || (i.desc && i.desc.toLowerCase().includes(query)))
: all;
// Highlight current value, or first item
this._highlightIdx = this._filtered.findIndex(i => i.value === this._currentValue);
if (this._highlightIdx === -1) this._highlightIdx = 0;
this._render();
}
_render() {
if (this._filtered.length === 0) {
this._list.innerHTML = '<div class="entity-palette-empty">—</div>';
return;
}
this._list.innerHTML = this._filtered.map((item, i) => {
const cls = [
'entity-palette-item',
i === this._highlightIdx ? 'ep-highlight' : '',
item.value === this._currentValue ? 'ep-current' : '',
].filter(Boolean).join(' ');
return `<div class="${cls}" data-idx="${i}">
${item.icon ? `<span class="ep-item-icon">${item.icon}</span>` : ''}
<span class="ep-item-label">${item.label}</span>
${item.desc ? `<span class="ep-item-desc">${item.desc}</span>` : ''}
</div>`;
}).join('');
// Click handlers
this._list.querySelectorAll('.entity-palette-item').forEach(el => {
el.addEventListener('click', () => {
this._select(this._filtered[parseInt(el.dataset.idx)]);
});
});
// Scroll highlighted into view
const hl = this._list.querySelector('.ep-highlight');
if (hl) hl.scrollIntoView({ block: 'nearest' });
}
_onKeyDown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this._highlightIdx = Math.min(this._highlightIdx + 1, this._filtered.length - 1);
this._render();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this._highlightIdx = Math.max(this._highlightIdx - 1, 0);
this._render();
} else if (e.key === 'Enter') {
e.preventDefault();
if (this._filtered[this._highlightIdx]) {
this._select(this._filtered[this._highlightIdx]);
}
} else if (e.key === 'Escape') {
this._cancel();
} else if (e.key === 'Tab') {
e.preventDefault();
}
}
_select(item) {
this._overlay.classList.remove('open');
if (this._resolve) this._resolve(item.value);
this._resolve = null;
}
_cancel() {
this._overlay.classList.remove('open');
if (this._resolve) this._resolve(undefined);
this._resolve = null;
}
}
// ── EntitySelect (wrapper around a <select>) ────────────────
export class EntitySelect {
/**
* @param {Object} opts
* @param {HTMLSelectElement} opts.target - the <select> to enhance
* @param {Function} opts.getItems - () => Array<{value, label, icon?, desc?}>
* @param {string} [opts.placeholder] - palette search placeholder
* @param {Function} [opts.onChange] - called with (value) after selection
* @param {boolean} [opts.allowNone] - show a "None" entry at the top
* @param {string} [opts.noneLabel] - label for the None entry
*/
constructor({ target, getItems, placeholder, onChange, allowNone, noneLabel }) {
this._select = target;
this._getItems = getItems;
this._placeholder = placeholder || '';
this._onChange = onChange;
this._allowNone = allowNone || false;
this._noneLabel = noneLabel || '—';
this._items = getItems();
// Hide native select
this._select.style.display = 'none';
// Build trigger button
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'entity-select-trigger';
this._trigger.addEventListener('click', () => this._open());
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
this._syncTrigger();
}
async _open() {
this._items = this._getItems();
const value = await EntityPalette.pick({
items: this._items,
current: this._select.value,
placeholder: this._placeholder,
allowNone: this._allowNone,
noneLabel: this._noneLabel,
});
if (value !== undefined) {
this._select.value = value;
this._syncTrigger();
this._select.dispatchEvent(new Event('change', { bubbles: true }));
if (this._onChange) this._onChange(value);
}
}
_syncTrigger() {
const val = this._select.value;
const item = this._items.find(i => i.value === val);
if (item) {
this._trigger.innerHTML =
`${item.icon ? `<span class="es-trigger-icon">${item.icon}</span>` : ''}` +
`<span class="es-trigger-label">${item.label}</span>` +
`<span class="es-trigger-arrow">&#x25BE;</span>`;
} else if (this._allowNone && !val) {
this._trigger.innerHTML =
`<span class="es-trigger-label es-trigger-none">${this._noneLabel}</span>` +
`<span class="es-trigger-arrow">&#x25BE;</span>`;
} else {
// Fallback: read from selected option text
const opt = this._select.selectedOptions[0];
const text = opt ? opt.textContent : val || '—';
this._trigger.innerHTML =
`<span class="es-trigger-label">${text}</span>` +
`<span class="es-trigger-arrow">&#x25BE;</span>`;
}
}
/** Update the value programmatically (no change event). */
setValue(value) {
this._select.value = value;
this._syncTrigger();
}
/** Refresh items and trigger display (call after repopulating the <select>). */
refresh() {
this._items = this._getItems();
this._syncTrigger();
}
/** Remove enhancement, restore native <select>. */
destroy() {
this._trigger.remove();
this._select.style.display = '';
}
}

View File

@@ -15,7 +15,8 @@ import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js'; import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { ICON_MUSIC } from '../core/icons.js'; import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js';
import { loadPictureSources } from './streams.js'; import { loadPictureSources } from './streams.js';
class AudioSourceModal extends Modal { class AudioSourceModal extends Modal {
@@ -36,6 +37,11 @@ class AudioSourceModal extends Modal {
const audioSourceModal = new AudioSourceModal(); const audioSourceModal = new AudioSourceModal();
// ── EntitySelect instances for audio source editor ──
let _asTemplateEntitySelect = null;
let _asDeviceEntitySelect = null;
let _asParentEntitySelect = null;
// ── Modal ───────────────────────────────────────────────────── // ── Modal ─────────────────────────────────────────────────────
export async function showAudioSourceModal(sourceType, editData) { export async function showAudioSourceModal(sourceType, editData) {
@@ -242,6 +248,20 @@ function _filterDevicesBySelectedTemplate() {
const match = Array.from(select.options).find(o => o.textContent === prevName); const match = Array.from(select.options).find(o => o.textContent === prevName);
if (match) select.value = match.value; if (match) select.value = match.value;
} }
if (_asDeviceEntitySelect) _asDeviceEntitySelect.destroy();
if (devices.length > 0) {
_asDeviceEntitySelect = new EntitySelect({
target: select,
getItems: () => devices.map(d => ({
value: `${d.index}:${d.is_loopback ? '1' : '0'}`,
label: d.name,
icon: d.is_loopback ? ICON_AUDIO_LOOPBACK : ICON_AUDIO_INPUT,
desc: d.is_loopback ? 'Loopback' : 'Input',
})),
placeholder: t('palette.search'),
});
}
} }
function _selectAudioDevice(deviceIndex, isLoopback) { function _selectAudioDevice(deviceIndex, isLoopback) {
@@ -259,6 +279,19 @@ function _loadMultichannelSources(selectedId) {
select.innerHTML = multichannel.map(s => select.innerHTML = multichannel.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>` `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join(''); ).join('');
if (_asParentEntitySelect) _asParentEntitySelect.destroy();
if (multichannel.length > 0) {
_asParentEntitySelect = new EntitySelect({
target: select,
getItems: () => multichannel.map(s => ({
value: s.id,
label: s.name,
icon: getAudioSourceIcon('multichannel'),
})),
placeholder: t('palette.search'),
});
}
} }
function _loadAudioTemplates(selectedId) { function _loadAudioTemplates(selectedId) {
@@ -268,6 +301,20 @@ function _loadAudioTemplates(selectedId) {
select.innerHTML = templates.map(t => select.innerHTML = templates.map(t =>
`<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>` `<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>`
).join(''); ).join('');
if (_asTemplateEntitySelect) _asTemplateEntitySelect.destroy();
if (templates.length > 0) {
_asTemplateEntitySelect = new EntitySelect({
target: select,
getItems: () => templates.map(tmpl => ({
value: tmpl.id,
label: tmpl.name,
icon: ICON_AUDIO_TEMPLATE,
desc: tmpl.engine_type.toUpperCase(),
})),
placeholder: t('palette.search'),
});
}
} }
// ── Audio Source Test (real-time spectrum) ──────────────────── // ── Audio Source Test (real-time spectrum) ────────────────────

View File

@@ -8,7 +8,7 @@ import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { import {
getColorStripIcon, getPictureSourceIcon, getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
@@ -17,6 +17,7 @@ import {
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { attachProcessPicker } from '../core/process-picker.js'; import { attachProcessPicker } from '../core/process-picker.js';
import { IconSelect } from '../core/icon-select.js'; import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
class CSSEditorModal extends Modal { class CSSEditorModal extends Modal {
constructor() { constructor() {
@@ -71,6 +72,11 @@ class CSSEditorModal extends Modal {
const cssEditorModal = new CSSEditorModal(); const cssEditorModal = new CSSEditorModal();
// ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect = null;
let _cssAudioSourceEntitySelect = null;
let _cssClockEntitySelect = null;
/* ── Icon-grid type selector ──────────────────────────────────── */ /* ── Icon-grid type selector ──────────────────────────────────── */
const CSS_TYPE_KEYS = [ const CSS_TYPE_KEYS = [
@@ -181,6 +187,21 @@ function _populateClockDropdown(selectedId) {
sel.innerHTML = `<option value="">${t('common.none')}</option>` + sel.innerHTML = `<option value="">${t('common.none')}</option>` +
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join(''); _cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
sel.value = prev || ''; sel.value = prev || '';
// Entity palette for clock
if (_cssClockEntitySelect) _cssClockEntitySelect.destroy();
_cssClockEntitySelect = new EntitySelect({
target: sel,
getItems: () => _cachedSyncClocks.map(c => ({
value: c.id,
label: c.name,
icon: ICON_CLOCK,
desc: `${c.speed}x`,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('common.none'),
});
} }
export function onCSSClockChange() { export function onCSSClockChange() {
@@ -581,6 +602,20 @@ async function _loadAudioSources() {
if (sources.length === 0) { if (sources.length === 0) {
select.innerHTML = ''; select.innerHTML = '';
} }
// Entity palette for audio source
if (_cssAudioSourceEntitySelect) _cssAudioSourceEntitySelect.destroy();
if (sources.length > 0) {
_cssAudioSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => sources.map(s => ({
value: s.id,
label: s.name,
icon: getAudioSourceIcon(s.source_type),
desc: s.source_type,
})),
placeholder: t('palette.search'),
});
}
} catch { } catch {
select.innerHTML = ''; select.innerHTML = '';
} }
@@ -927,6 +962,18 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
sourceSelect.appendChild(opt); sourceSelect.appendChild(opt);
}); });
// Entity palette for picture source
if (_cssPictureSourceEntitySelect) _cssPictureSourceEntitySelect.destroy();
_cssPictureSourceEntitySelect = new EntitySelect({
target: sourceSelect,
getItems: () => sources.map(s => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
})),
placeholder: t('palette.search'),
});
// Helper: populate editor fields from a CSS source object // Helper: populate editor fields from a CSS source object
const _populateFromCSS = async (css) => { const _populateFromCSS = async (css) => {
const sourceType = css.source_type || 'picture'; const sourceType = css.source_type || 'picture';

View File

@@ -21,6 +21,9 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js'; import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { EntitySelect } from '../core/entity-palette.js';
let _patternBgEntitySelect = null;
class PatternTemplateModal extends Modal { class PatternTemplateModal extends Modal {
constructor() { constructor() {
@@ -88,6 +91,20 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
bgSelect.appendChild(opt); bgSelect.appendChild(opt);
}); });
// Entity palette for background source
if (_patternBgEntitySelect) _patternBgEntitySelect.destroy();
if (sources.length > 0) {
_patternBgEntitySelect = new EntitySelect({
target: bgSelect,
getItems: () => sources.map(s => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
})),
placeholder: t('palette.search'),
});
}
setPatternEditorBgImage(null); setPatternEditorBgImage(null);
setPatternEditorSelectedIdx(-1); setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(null); setPatternCanvasDragMode(null);

View File

@@ -49,6 +49,7 @@ import {
} from '../core/icons.js'; } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { IconSelect } from '../core/icon-select.js'; import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
// ── Card section instances ── // ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' }); const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
@@ -1617,6 +1618,11 @@ export async function editStream(streamId) {
/** Track which engine type the stream-modal displays were loaded for. */ /** Track which engine type the stream-modal displays were loaded for. */
let _streamModalDisplaysEngine = null; let _streamModalDisplaysEngine = null;
// ── EntitySelect instances for stream modal ──
let _captureTemplateEntitySelect = null;
let _sourceEntitySelect = null;
let _ppTemplateEntitySelect = null;
async function populateStreamModalDropdowns() { async function populateStreamModalDropdowns() {
const [captureTemplates, streams, ppTemplates] = await Promise.all([ const [captureTemplates, streams, ppTemplates] = await Promise.all([
captureTemplatesCache.fetch().catch(() => []), captureTemplatesCache.fetch().catch(() => []),
@@ -1672,6 +1678,44 @@ async function populateStreamModalDropdowns() {
ppSelect.appendChild(opt); ppSelect.appendChild(opt);
}); });
// Entity palette selectors
if (_captureTemplateEntitySelect) _captureTemplateEntitySelect.destroy();
_captureTemplateEntitySelect = new EntitySelect({
target: templateSelect,
getItems: () => captureTemplates.map(tmpl => ({
value: tmpl.id,
label: tmpl.name,
icon: getEngineIcon(tmpl.engine_type),
desc: tmpl.engine_type,
})),
placeholder: t('palette.search'),
});
if (_sourceEntitySelect) _sourceEntitySelect.destroy();
_sourceEntitySelect = new EntitySelect({
target: sourceSelect,
getItems: () => {
const editingId = document.getElementById('stream-id').value;
return streams.filter(s => s.id !== editingId).map(s => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
}));
},
placeholder: t('palette.search'),
});
if (_ppTemplateEntitySelect) _ppTemplateEntitySelect.destroy();
_ppTemplateEntitySelect = new EntitySelect({
target: ppSelect,
getItems: () => ppTemplates.map(tmpl => ({
value: tmpl.id,
label: tmpl.name,
icon: ICON_PP_TEMPLATE,
})),
placeholder: t('palette.search'),
});
_autoGenerateStreamName(); _autoGenerateStreamName();
} }

View File

@@ -20,11 +20,12 @@ import { _splitOpenrgbZone } from './device-discovery.js';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import { createColorStripCard } from './color-strips.js'; import { createColorStripCard } from './color-strips.js';
import { import {
getValueSourceIcon, getTargetTypeIcon, getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
} from '../core/icons.js'; } from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js'; import { updateSubTabHash, updateTabBadge } from './tabs.js';
@@ -224,6 +225,11 @@ function _updateBrightnessThresholdVisibility() {
document.getElementById('target-editor-brightness-threshold-group').style.display = ''; document.getElementById('target-editor-brightness-threshold-group').style.display = '';
} }
// ── EntitySelect instances for target editor ──
let _deviceEntitySelect = null;
let _cssEntitySelect = null;
let _brightnessVsEntitySelect = null;
function _populateCssDropdown(selectedId = '') { function _populateCssDropdown(selectedId = '') {
const select = document.getElementById('target-editor-css-source'); const select = document.getElementById('target-editor-css-source');
select.innerHTML = _editorCssSources.map(s => select.innerHTML = _editorCssSources.map(s =>
@@ -235,12 +241,54 @@ function _populateBrightnessVsDropdown(selectedId = '') {
const select = document.getElementById('target-editor-brightness-vs'); const select = document.getElementById('target-editor-brightness-vs');
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`; let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
_cachedValueSources.forEach(vs => { _cachedValueSources.forEach(vs => {
const icon = getValueSourceIcon(vs.source_type); html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${icon} ${escapeHtml(vs.name)}</option>`;
}); });
select.innerHTML = html; select.innerHTML = html;
} }
function _ensureTargetEntitySelects() {
// Device
if (_deviceEntitySelect) _deviceEntitySelect.destroy();
_deviceEntitySelect = new EntitySelect({
target: document.getElementById('target-editor-device'),
getItems: () => _targetEditorDevices.map(d => ({
value: d.id,
label: d.name,
icon: getDeviceTypeIcon(d.device_type),
desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''),
})),
placeholder: t('palette.search'),
});
// CSS source
if (_cssEntitySelect) _cssEntitySelect.destroy();
_cssEntitySelect = new EntitySelect({
target: document.getElementById('target-editor-css-source'),
getItems: () => _editorCssSources.map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon(s.source_type),
desc: s.source_type,
})),
placeholder: t('palette.search'),
});
// Brightness value source
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
_brightnessVsEntitySelect = new EntitySelect({
target: document.getElementById('target-editor-brightness-vs'),
getItems: () => _cachedValueSources.map(vs => ({
value: vs.id,
label: vs.name,
icon: getValueSourceIcon(vs.source_type),
desc: vs.source_type,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('targets.brightness_vs.none'),
});
}
export async function showTargetEditor(targetId = null, cloneData = null) { export async function showTargetEditor(targetId = null, cloneData = null) {
try { try {
// Load devices, CSS sources, and value sources for dropdowns // Load devices, CSS sources, and value sources for dropdowns
@@ -334,6 +382,9 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
_populateBrightnessVsDropdown(''); _populateBrightnessVsDropdown('');
} }
// Entity palette selectors
_ensureTargetEntitySelects();
// Auto-name generation // Auto-name generation
_targetNameManuallyEdited = !!(targetId || cloneData); _targetNameManuallyEdited = !!(targetId || cloneData);
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };

View File

@@ -16,17 +16,22 @@ import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { import {
getValueSourceIcon, getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
} from '../core/icons.js'; } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { IconSelect } from '../core/icon-select.js'; import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
import { loadPictureSources } from './streams.js'; import { loadPictureSources } from './streams.js';
export { getValueSourceIcon }; export { getValueSourceIcon };
// ── EntitySelect instances for value source editor ──
let _vsAudioSourceEntitySelect = null;
let _vsPictureSourceEntitySelect = null;
class ValueSourceModal extends Modal { class ValueSourceModal extends Modal {
constructor() { super('value-source-modal'); } constructor() { super('value-source-modal'); }
@@ -585,6 +590,20 @@ function _populateAudioSourceDropdown(selectedId) {
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]'; const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`; return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
}).join(''); }).join('');
if (_vsAudioSourceEntitySelect) _vsAudioSourceEntitySelect.destroy();
if (_cachedAudioSources.length > 0) {
_vsAudioSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedAudioSources.map(s => ({
value: s.id,
label: s.name,
icon: getAudioSourceIcon(s.source_type),
desc: s.source_type,
})),
placeholder: t('palette.search'),
});
}
} }
// ── Adaptive helpers ────────────────────────────────────────── // ── Adaptive helpers ──────────────────────────────────────────
@@ -595,6 +614,19 @@ function _populatePictureSourceDropdown(selectedId) {
select.innerHTML = _cachedStreams.map(s => select.innerHTML = _cachedStreams.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>` `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join(''); ).join('');
if (_vsPictureSourceEntitySelect) _vsPictureSourceEntitySelect.destroy();
if (_cachedStreams.length > 0) {
_vsPictureSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedStreams.map(s => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
})),
placeholder: t('palette.search'),
});
}
} }
export function addSchedulePoint(time = '', value = 1.0) { export function addSchedulePoint(time = '', value = 1.0) {

View File

@@ -323,6 +323,7 @@
"common.edit": "Edit", "common.edit": "Edit",
"common.clone": "Clone", "common.clone": "Clone",
"common.none": "None", "common.none": "None",
"palette.search": "Search…",
"section.filter.placeholder": "Filter...", "section.filter.placeholder": "Filter...",
"section.filter.reset": "Clear filter", "section.filter.reset": "Clear filter",
"section.expand_all": "Expand all sections", "section.expand_all": "Expand all sections",

View File

@@ -323,6 +323,7 @@
"common.edit": "Редактировать", "common.edit": "Редактировать",
"common.clone": "Клонировать", "common.clone": "Клонировать",
"common.none": "Нет", "common.none": "Нет",
"palette.search": "Поиск…",
"section.filter.placeholder": "Фильтр...", "section.filter.placeholder": "Фильтр...",
"section.filter.reset": "Очистить фильтр", "section.filter.reset": "Очистить фильтр",
"section.expand_all": "Развернуть все секции", "section.expand_all": "Развернуть все секции",

View File

@@ -323,6 +323,7 @@
"common.edit": "编辑", "common.edit": "编辑",
"common.clone": "克隆", "common.clone": "克隆",
"common.none": "无", "common.none": "无",
"palette.search": "搜索…",
"section.filter.placeholder": "筛选...", "section.filter.placeholder": "筛选...",
"section.filter.reset": "清除筛选", "section.filter.reset": "清除筛选",
"section.expand_all": "全部展开", "section.expand_all": "全部展开",

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback * - Navigation: network-first with offline fallback
*/ */
const CACHE_NAME = 'ledgrab-v16'; const CACHE_NAME = 'ledgrab-v17';
// Only pre-cache static assets (no auth required). // Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page. // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.