Add graph icon grid, search-to-graph nav, overlay on CSS cards, fix clipboard copy
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -141,7 +141,7 @@
|
||||
.css-test-fire-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
top: 24px;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||
: '';
|
||||
const overlayBtn = isPictureKind
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); toggleCSSOverlay('${source.id}')" title="${t('overlay.toggle')}">${ICON_OVERLAY}</button>`
|
||||
: '';
|
||||
const testNotifyBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
|
||||
: '';
|
||||
@@ -1372,7 +1375,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||
${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`, {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
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 = `<span class="graph-add-entity-dot" style="background:${color}"></span><span>${label}</span>`;
|
||||
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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Скачать резервную копию",
|
||||
|
||||
@@ -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": "下载备份",
|
||||
|
||||
Reference in New Issue
Block a user