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:
2026-03-15 11:32:55 +03:00
parent 294d704eb0
commit 3292e0daaf
10 changed files with 114 additions and 74 deletions

View File

@@ -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) {

View File

@@ -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`, {

View File

@@ -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');
}
}

View File

@@ -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),