From 3292e0daaf1b72c6b96b58b58185a8ac1ef40572 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 15 Mar 2026 11:32:55 +0300 Subject: [PATCH] Add graph icon grid, search-to-graph nav, overlay on CSS cards, fix clipboard copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert graph editor add-entity menu to showTypePicker icon grid with SVG icons - Add CSPT to graph add-entity picker and ALL_CACHES watcher - Add graphNavigateToNode() — command palette navigates to graph node when graph tab active - Add CSPT entities to global search palette results - Add overlay toggle button on picture-based CSS cards (toggleCSSOverlay) - Fix clipboard copy on non-HTTPS (LAN) with execCommand fallback for all copy functions - Fix notification bell button vertical centering in test preview strip canvas - Add overlay.toggle, search.group.cspt i18n keys (en/ru/zh) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/wled_controller/static/css/modal.css | 2 +- server/src/wled_controller/static/js/app.js | 2 + .../static/js/core/command-palette.js | 19 ++- .../static/js/features/automations.js | 12 +- .../static/js/features/color-strips.js | 32 +++++- .../static/js/features/devices.js | 7 +- .../static/js/features/graph-editor.js | 108 +++++++----------- .../wled_controller/static/locales/en.json | 2 + .../wled_controller/static/locales/ru.json | 2 + .../wled_controller/static/locales/zh.json | 2 + 10 files changed, 114 insertions(+), 74 deletions(-) diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 6505a37..8feff33 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -141,7 +141,7 @@ .css-test-fire-btn { position: absolute; right: 6px; - top: 50%; + top: 24px; transform: translateY(-50%); width: 32px; height: 32px; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 8167590..a158faf 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -124,6 +124,7 @@ import { onAudioVizChange, applyGradientPreset, cloneColorStrip, + toggleCSSOverlay, copyEndpointUrl, onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, @@ -423,6 +424,7 @@ Object.assign(window, { onAudioVizChange, applyGradientPreset, cloneColorStrip, + toggleCSSOverlay, copyEndpointUrl, onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, diff --git a/server/src/wled_controller/static/js/core/command-palette.js b/server/src/wled_controller/static/js/core/command-palette.js index 21544a2..e2f633a 100644 --- a/server/src/wled_controller/static/js/core/command-palette.js +++ b/server/src/wled_controller/static/js/core/command-palette.js @@ -8,9 +8,10 @@ import { navigateToCard } from './navigation.js'; import { getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon, ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE, - ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, + ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, } from './icons.js'; import { getCardColor } from './card-colors.js'; +import { graphNavigateToNode } from '../features/graph-editor.js'; let _isOpen = false; let _items = []; @@ -32,7 +33,7 @@ function _mapEntities(data, mapFn) { } function _buildItems(results, states = {}) { - const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets] = results; + const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates] = results; const items = []; _mapEntities(devices, d => items.push({ @@ -106,6 +107,11 @@ function _buildItems(results, states = {}) { nav: ['automations', null, 'scenes', 'data-scene-id', sp.id], })); + _mapEntities(csptTemplates, ct => items.push({ + name: ct.name, detail: '', group: 'cspt', icon: ICON_CSPT, + nav: ['streams', 'css_processing', 'cspt-templates', 'data-cspt-id', ct.id], + })); + return items; } @@ -122,6 +128,7 @@ const _responseKeys = [ ['/value-sources', 'sources'], ['/picture-sources', 'streams'], ['/scene-presets', 'presets'], + ['/color-strip-processing-templates', 'templates'], ]; async function _fetchAllEntities() { @@ -142,7 +149,7 @@ async function _fetchAllEntities() { // ─── Group ordering ─── const _groupOrder = [ - 'devices', 'targets', 'kc_targets', 'css', 'automations', + 'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations', 'streams', 'capture_templates', 'pp_templates', 'pattern_templates', 'audio', 'value', 'scenes', ]; @@ -306,6 +313,12 @@ function _selectCurrent() { if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return; const item = _filtered[_selectedIdx]; closeCommandPalette(); + // If graph tab is active, navigate to graph node instead of card + const graphTabActive = document.querySelector('.tab-btn[data-tab="graph"].active'); + if (graphTabActive) { + const entityId = item.nav[4]; // last element is always entity ID + if (graphNavigateToNode(entityId)) return; + } navigateToCard(...item.nav); } diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 28c27ef..216b482 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -729,11 +729,19 @@ export async function toggleAutomationEnabled(automationId, enable) { export function copyWebhookUrl(btn) { const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url'); - navigator.clipboard.writeText(input.value).then(() => { + if (!input || !input.value) return; + const onCopied = () => { const orig = btn.textContent; btn.textContent = t('automations.condition.webhook.copied'); setTimeout(() => { btn.textContent = orig; }, 1500); - }); + }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(input.value).then(onCopied); + } else { + input.select(); + document.execCommand('copy'); + onCopied(); + } } export async function cloneAutomation(automationId) { diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index fa720c5..de71062 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -9,7 +9,7 @@ import { showToast, showConfirm, desktopFocus } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon, getValueSourceIcon, - ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, + ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_OVERLAY, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_EYE, @@ -1347,6 +1347,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { const calibrationBtn = isPictureKind ? `` : ''; + const overlayBtn = isPictureKind + ? `` + : ''; const testNotifyBtn = isNotification ? `` : ''; @@ -1372,7 +1375,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { actions: ` - ${calibrationBtn}${testNotifyBtn}${testPreviewBtn}`, + ${calibrationBtn}${overlayBtn}${testNotifyBtn}${testPreviewBtn}`, }); } @@ -1894,10 +1897,17 @@ function _showApiInputEndpoints(cssId) { export function copyEndpointUrl(btn) { const input = btn.parentElement.querySelector('input'); - if (input && input.value) { + if (!input || !input.value) return; + // navigator.clipboard requires secure context (HTTPS or localhost) + if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(input.value).then(() => { showToast(t('settings.copied') || 'Copied!', 'success'); }); + } else { + // Fallback for non-secure contexts (HTTP on LAN) + input.select(); + document.execCommand('copy'); + showToast(t('settings.copied') || 'Copied!', 'success'); } } @@ -1962,6 +1972,22 @@ export async function startCSSOverlay(cssId) { } } +export async function toggleCSSOverlay(cssId) { + try { + const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); + if (!resp.ok) return; + const { active } = await resp.json(); + if (active) { + await stopCSSOverlay(cssId); + } else { + await startCSSOverlay(cssId); + } + } catch (err) { + if (err.isAuth) return; + console.error('Failed to toggle CSS overlay:', err); + } +} + export async function stopCSSOverlay(cssId) { try { const response = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/stop`, { diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index fbbcabb..739cf90 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -635,10 +635,15 @@ async function _populateSettingsSerialPorts(currentUrl) { export function copyWsUrl() { const input = document.getElementById('settings-ws-url'); - if (input && input.value) { + if (!input || !input.value) return; + if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(input.value).then(() => { showToast(t('settings.copied') || 'Copied!', 'success'); }); + } else { + input.select(); + document.execCommand('copy'); + showToast(t('settings.copied') || 'Copied!', 'success'); } } diff --git a/server/src/wled_controller/static/js/features/graph-editor.js b/server/src/wled_controller/static/js/features/graph-editor.js index 1050ead..5630c15 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -17,6 +17,8 @@ import { fetchWithAuth } from '../core/api.js'; import { showToast, showConfirm } from '../core/ui.js'; import { t } from '../core/i18n.js'; import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js'; +import { showTypePicker } from '../core/icon-select.js'; +import * as P from '../core/icon-paths.js'; let _canvas = null; let _nodeMap = null; @@ -377,18 +379,20 @@ export async function graphRelayout() { await loadGraphEditor(); } -// Entity kind → window function to open add/create modal +// Entity kind → window function to open add/create modal + icon path +const _ico = (d) => `${d}`; const ADD_ENTITY_MAP = [ - { kind: 'device', fn: () => window.showAddDevice?.() }, - { kind: 'capture_template', fn: () => window.showAddTemplateModal?.() }, - { kind: 'pp_template', fn: () => window.showAddPPTemplateModal?.() }, - { kind: 'audio_template', fn: () => window.showAddAudioTemplateModal?.() }, - { kind: 'picture_source', fn: () => window.showAddStreamModal?.() }, - { kind: 'audio_source', fn: () => window.showAudioSourceModal?.() }, - { kind: 'value_source', fn: () => window.showValueSourceModal?.() }, - { kind: 'color_strip_source', fn: () => window.showCSSEditor?.() }, - { kind: 'output_target', fn: () => window.showTargetEditor?.() }, - { kind: 'automation', fn: () => window.openAutomationEditor?.() }, + { kind: 'device', fn: () => window.showAddDevice?.(), icon: _ico(P.monitor) }, + { kind: 'capture_template', fn: () => window.showAddTemplateModal?.(), icon: _ico(P.camera) }, + { kind: 'pp_template', fn: () => window.showAddPPTemplateModal?.(), icon: _ico(P.wrench) }, + { kind: 'cspt', fn: () => window.showAddCSPTModal?.(), icon: _ico(P.wrench) }, + { kind: 'audio_template', fn: () => window.showAddAudioTemplateModal?.(),icon: _ico(P.music) }, + { kind: 'picture_source', fn: () => window.showAddStreamModal?.(), icon: _ico(P.tv) }, + { kind: 'audio_source', fn: () => window.showAudioSourceModal?.(), icon: _ico(P.music) }, + { kind: 'value_source', fn: () => window.showValueSourceModal?.(), icon: _ico(P.hash) }, + { kind: 'color_strip_source', fn: () => window.showCSSEditor?.(), icon: _ico(P.film) }, + { kind: 'output_target', fn: () => window.showTargetEditor?.(), icon: _ico(P.zap) }, + { kind: 'automation', fn: () => window.openAutomationEditor?.(), icon: _ico(P.clipboardList) }, ]; // All caches to watch for new entity creation @@ -397,62 +401,26 @@ const ALL_CACHES = [ streamsCache, audioSourcesCache, audioTemplatesCache, valueSourcesCache, colorStripSourcesCache, syncClocksCache, outputTargetsCache, patternTemplatesCache, scenePresetsCache, - automationsCacheObj, + automationsCacheObj, csptCache, ]; -let _addEntityMenu = null; - export function graphAddEntity() { - if (_addEntityMenu) { _dismissAddEntityMenu(); return; } - - const container = document.querySelector('#graph-editor-content .graph-container'); - if (!container) return; - - const toolbar = container.querySelector('.graph-toolbar'); - const menu = document.createElement('div'); - menu.className = 'graph-add-entity-menu'; - - // Position below toolbar - if (toolbar) { - menu.style.left = toolbar.offsetLeft + 'px'; - menu.style.top = (toolbar.offsetTop + toolbar.offsetHeight + 6) + 'px'; - } - - for (const item of ADD_ENTITY_MAP) { - const btn = document.createElement('button'); - btn.className = 'graph-add-entity-item'; - const color = ENTITY_COLORS[item.kind] || '#666'; - const label = ENTITY_LABELS[item.kind] || item.kind.replace(/_/g, ' '); - btn.innerHTML = `${label}`; - btn.addEventListener('click', () => { - _dismissAddEntityMenu(); - _watchForNewEntity(); - item.fn(); - }); - menu.appendChild(btn); - } - - container.appendChild(menu); - _addEntityMenu = menu; - - // Close on click outside - setTimeout(() => { - document.addEventListener('click', _onAddEntityClickAway, true); - }, 0); -} - -function _onAddEntityClickAway(e) { - if (_addEntityMenu && !_addEntityMenu.contains(e.target)) { - _dismissAddEntityMenu(); - } -} - -function _dismissAddEntityMenu() { - if (_addEntityMenu) { - _addEntityMenu.remove(); - _addEntityMenu = null; - } - document.removeEventListener('click', _onAddEntityClickAway, true); + const items = ADD_ENTITY_MAP.map(item => ({ + value: item.kind, + icon: item.icon, + label: ENTITY_LABELS[item.kind] || item.kind.replace(/_/g, ' '), + })); + showTypePicker({ + title: t('graph.add_entity') || 'Add Entity', + items, + onPick: (kind) => { + const entry = ADD_ENTITY_MAP.find(e => e.kind === kind); + if (entry) { + _watchForNewEntity(); + entry.fn(); + } + }, + }); } // Watch for new entity creation after add-entity menu action @@ -1121,6 +1089,18 @@ function _onNodeDblClick(node) { } } +/** Navigate graph to a node by entity ID — zoom + highlight. */ +export function graphNavigateToNode(entityId) { + const node = _nodeMap?.get(entityId); + if (!node || !_canvas) return false; + _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2); + const nodeGroup = document.querySelector('.graph-nodes'); + if (nodeGroup) { highlightNode(nodeGroup, entityId); setTimeout(() => highlightNode(nodeGroup, null), 3000); } + const edgeGroup = document.querySelector('.graph-edges'); + if (edgeGroup && _edges) { highlightChain(edgeGroup, entityId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); } + return true; +} + function _onEditNode(node) { const fnMap = { device: () => window.showSettings?.(node.id), diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 48c9766..b834bef 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -677,6 +677,7 @@ "pattern.description.hint": "Optional notes about where or how this pattern is used", "pattern.visual_editor.hint": "Click + buttons to add rectangles. Drag edges to resize, drag inside to move.", "pattern.rectangles.hint": "Fine-tune rectangle positions and sizes with exact coordinates (0.0 to 1.0)", + "overlay.toggle": "Toggle screen overlay", "overlay.button.show": "Show overlay visualization", "overlay.button.hide": "Hide overlay visualization", "overlay.started": "Overlay visualization started", @@ -1344,6 +1345,7 @@ "search.group.audio": "Audio Sources", "search.group.value": "Value Sources", "search.group.scenes": "Scene Presets", + "search.group.cspt": "Strip Processing Templates", "settings.backup.label": "Backup Configuration", "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.", "settings.backup.button": "Download Backup", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index a807f2e..442a860 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -626,6 +626,7 @@ "pattern.description.hint": "Необязательные заметки о назначении этого паттерна", "pattern.visual_editor.hint": "Нажмите кнопки + чтобы добавить прямоугольники. Тяните края для изменения размера, тяните внутри для перемещения.", "pattern.rectangles.hint": "Точная настройка позиций и размеров прямоугольников в координатах (0.0 до 1.0)", + "overlay.toggle": "Переключить наложение на экран", "overlay.button.show": "Показать визуализацию наложения", "overlay.button.hide": "Скрыть визуализацию наложения", "overlay.started": "Визуализация наложения запущена", @@ -1293,6 +1294,7 @@ "search.group.audio": "Аудиоисточники", "search.group.value": "Источники значений", "search.group.scenes": "Пресеты сцен", + "search.group.cspt": "Шаблоны обработки полос", "settings.backup.label": "Резервное копирование", "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.", "settings.backup.button": "Скачать резервную копию", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index f868702..db27e0b 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -626,6 +626,7 @@ "pattern.description.hint": "关于此图案使用位置或方式的可选说明", "pattern.visual_editor.hint": "点击 + 按钮添加矩形。拖动边缘调整大小,拖动内部移动位置。", "pattern.rectangles.hint": "用精确坐标(0.0 到 1.0)微调矩形位置和大小", + "overlay.toggle": "切换屏幕叠加层", "overlay.button.show": "显示叠加层可视化", "overlay.button.hide": "隐藏叠加层可视化", "overlay.started": "叠加层可视化已启动", @@ -1293,6 +1294,7 @@ "search.group.audio": "音频源", "search.group.value": "值源", "search.group.scenes": "场景预设", + "search.group.cspt": "色带处理模板", "settings.backup.label": "备份配置", "settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。", "settings.backup.button": "下载备份",