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

@@ -15,7 +15,8 @@ import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.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';
class AudioSourceModal extends Modal {
@@ -36,6 +37,11 @@ class AudioSourceModal extends Modal {
const audioSourceModal = new AudioSourceModal();
// ── EntitySelect instances for audio source editor ──
let _asTemplateEntitySelect = null;
let _asDeviceEntitySelect = null;
let _asParentEntitySelect = null;
// ── Modal ─────────────────────────────────────────────────────
export async function showAudioSourceModal(sourceType, editData) {
@@ -242,6 +248,20 @@ function _filterDevicesBySelectedTemplate() {
const match = Array.from(select.options).find(o => o.textContent === prevName);
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) {
@@ -259,6 +279,19 @@ function _loadMultichannelSources(selectedId) {
select.innerHTML = multichannel.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).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) {
@@ -268,6 +301,20 @@ function _loadAudioTemplates(selectedId) {
select.innerHTML = templates.map(t =>
`<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>`
).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) ────────────────────

View File

@@ -8,7 +8,7 @@ import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import {
getColorStripIcon, getPictureSourceIcon,
getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
@@ -17,6 +17,7 @@ import {
import { wrapCard } from '../core/card-colors.js';
import { attachProcessPicker } from '../core/process-picker.js';
import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
class CSSEditorModal extends Modal {
constructor() {
@@ -71,6 +72,11 @@ class CSSEditorModal extends Modal {
const cssEditorModal = new CSSEditorModal();
// ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect = null;
let _cssAudioSourceEntitySelect = null;
let _cssClockEntitySelect = null;
/* ── Icon-grid type selector ──────────────────────────────────── */
const CSS_TYPE_KEYS = [
@@ -181,6 +187,21 @@ function _populateClockDropdown(selectedId) {
sel.innerHTML = `<option value="">${t('common.none')}</option>` +
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
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() {
@@ -581,6 +602,20 @@ async function _loadAudioSources() {
if (sources.length === 0) {
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 {
select.innerHTML = '';
}
@@ -927,6 +962,18 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
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
const _populateFromCSS = async (css) => {
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 { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { EntitySelect } from '../core/entity-palette.js';
let _patternBgEntitySelect = null;
class PatternTemplateModal extends Modal {
constructor() {
@@ -88,6 +91,20 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
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);
setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(null);

View File

@@ -49,6 +49,7 @@ import {
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
// ── Card section instances ──
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. */
let _streamModalDisplaysEngine = null;
// ── EntitySelect instances for stream modal ──
let _captureTemplateEntitySelect = null;
let _sourceEntitySelect = null;
let _ppTemplateEntitySelect = null;
async function populateStreamModalDropdowns() {
const [captureTemplates, streams, ppTemplates] = await Promise.all([
captureTemplatesCache.fetch().catch(() => []),
@@ -1672,6 +1678,44 @@ async function populateStreamModalDropdowns() {
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();
}

View File

@@ -20,11 +20,12 @@ import { _splitOpenrgbZone } from './device-discovery.js';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import { createColorStripCard } from './color-strips.js';
import {
getValueSourceIcon, getTargetTypeIcon,
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
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,
} from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js';
import { wrapCard } from '../core/card-colors.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js';
@@ -224,6 +225,11 @@ function _updateBrightnessThresholdVisibility() {
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 = '') {
const select = document.getElementById('target-editor-css-source');
select.innerHTML = _editorCssSources.map(s =>
@@ -235,12 +241,54 @@ function _populateBrightnessVsDropdown(selectedId = '') {
const select = document.getElementById('target-editor-brightness-vs');
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
_cachedValueSources.forEach(vs => {
const icon = getValueSourceIcon(vs.source_type);
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${icon} ${escapeHtml(vs.name)}</option>`;
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
});
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) {
try {
// Load devices, CSS sources, and value sources for dropdowns
@@ -334,6 +382,9 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
_populateBrightnessVsDropdown('');
}
// Entity palette selectors
_ensureTargetEntitySelects();
// Auto-name generation
_targetNameManuallyEdited = !!(targetId || cloneData);
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 { Modal } from '../core/modal.js';
import {
getValueSourceIcon,
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
import { loadPictureSources } from './streams.js';
export { getValueSourceIcon };
// ── EntitySelect instances for value source editor ──
let _vsAudioSourceEntitySelect = null;
let _vsPictureSourceEntitySelect = null;
class ValueSourceModal extends Modal {
constructor() { super('value-source-modal'); }
@@ -585,6 +590,20 @@ function _populateAudioSourceDropdown(selectedId) {
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
}).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 ──────────────────────────────────────────
@@ -595,6 +614,19 @@ function _populatePictureSourceDropdown(selectedId) {
select.innerHTML = _cachedStreams.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).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) {