Add start/stop, test, and notification buttons to graph editor node overlays

- Output targets and sync clocks get start/stop (▶/■) with optimistic UI update
- Test/preview button for templates, sources, and KC targets
- Notification test button (🔔) for notification color strip sources
- Fetch batch states to show correct running status for output targets
- Sync clocks show running state from API (is_running)
- Surgical DOM patching (patchNodeRunning) preserves hover state on toggle
- Success button hover style (green) for start action

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 15:56:19 +03:00
parent a54e2ab8b0
commit 7902d2e1f9
4 changed files with 181 additions and 7 deletions

View File

@@ -479,6 +479,14 @@
fill: var(--primary-contrast);
}
.graph-node-overlay-btn.success:hover rect {
fill: var(--success-color);
}
.graph-node-overlay-btn.success:hover text {
fill: var(--primary-contrast);
}
/* ── Selection rectangle ── */
.graph-selection-rect {

View File

@@ -198,7 +198,7 @@ function buildGraph(e) {
// 6. Sync clocks
for (const c of e.syncClocks || []) {
addNode(c.id, 'sync_clock', c.name, '');
addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false });
}
// 7. Picture sources

View File

@@ -172,14 +172,47 @@ function renderNode(node, callbacks) {
return g;
}
// Entity kinds that support start/stop
const START_STOP_KINDS = new Set(['output_target', 'sync_clock']);
// Entity kinds that support test/preview
const TEST_KINDS = new Set([
'capture_template', 'pp_template', 'audio_template',
'picture_source', 'audio_source', 'value_source',
'color_strip_source',
]);
function _createOverlay(node, nodeWidth, callbacks) {
const overlay = svgEl('g', { class: 'graph-node-overlay' });
const btnSize = 24;
const btnGap = 2;
const btns = [
{ icon: '\u270E', action: 'edit', cls: '' }, // ✎
{ icon: '\u2716', action: 'delete', cls: 'danger' }, // ✖
];
// Build button list dynamically based on node kind/subtype
const btns = [];
// Start/stop button for applicable kinds
if (START_STOP_KINDS.has(node.kind)) {
btns.push({
icon: node.running ? '\u25A0' : '\u25B6', // ■ stop / ▶ play
action: 'startstop',
cls: node.running ? 'danger' : 'success',
});
}
// Test button for applicable kinds
if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) {
btns.push({ icon: '\u25B7', action: 'test', cls: '' }); // ▷ test
}
// Notification test for notification color strip sources
if (node.kind === 'color_strip_source' && node.subtype === 'notification') {
btns.push({ icon: '\uD83D\uDD14', action: 'notify', cls: '' }); // 🔔
}
// Always: edit and delete
btns.push({ icon: '\u270E', action: 'edit', cls: '' }); // ✎
btns.push({ icon: '\u2716', action: 'delete', cls: 'danger' }); // ✖
const totalW = btns.length * (btnSize + btnGap) - btnGap + 8;
const ox = nodeWidth - totalW - 4;
const oy = -btnSize - 6;
@@ -211,6 +244,9 @@ function _createOverlay(node, nodeWidth, callbacks) {
e.stopPropagation();
if (btn.action === 'edit' && callbacks.onEditNode) callbacks.onEditNode(node);
if (btn.action === 'delete' && callbacks.onDeleteNode) callbacks.onDeleteNode(node);
if (btn.action === 'startstop' && callbacks.onStartStopNode) callbacks.onStartStopNode(node);
if (btn.action === 'test' && callbacks.onTestNode) callbacks.onTestNode(node);
if (btn.action === 'notify' && callbacks.onNotificationTest) callbacks.onNotificationTest(node);
});
overlay.appendChild(bg);
});
@@ -218,6 +254,48 @@ function _createOverlay(node, nodeWidth, callbacks) {
return overlay;
}
/**
* Patch a node's running state in-place without replacing the element.
* Updates the start/stop button icon/class, running dot, and node CSS class.
*/
export function patchNodeRunning(group, node) {
const el = group.querySelector(`.graph-node[data-id="${node.id}"]`);
if (!el) return;
// Toggle running class
el.classList.toggle('running', !!node.running);
// Update or add/remove running dot
let dot = el.querySelector('.graph-node-running-dot');
if (node.running && !dot) {
dot = svgEl('circle', {
class: 'graph-node-running-dot',
cx: node.width - 14, cy: 14, r: 4,
});
// Insert before the overlay group
const overlay = el.querySelector('.graph-node-overlay');
if (overlay) el.insertBefore(dot, overlay);
else el.appendChild(dot);
} else if (!node.running && dot) {
dot.remove();
}
// Update start/stop button in overlay
const overlayBtns = el.querySelectorAll('.graph-node-overlay-btn');
for (const btn of overlayBtns) {
const txt = btn.querySelector('text');
if (!txt) continue;
const content = txt.textContent;
// Match play ▶ or stop ■ icons
if (content === '\u25B6' || content === '\u25A0') {
txt.textContent = node.running ? '\u25A0' : '\u25B6';
btn.classList.toggle('success', !node.running);
btn.classList.toggle('danger', !!node.running);
break;
}
}
}
/**
* Highlight a single node (add class, scroll to).
*/

View File

@@ -4,7 +4,7 @@
import { GraphCanvas } from '../core/graph-canvas.js';
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
import { renderNodes, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js';
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js';
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
import {
devicesCache, captureTemplatesCache, ppTemplatesCache,
@@ -13,6 +13,8 @@ import {
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
automationsCacheObj,
} from '../core/state.js';
import { fetchWithAuth } from '../core/api.js';
import { showToast } from '../core/ui.js';
import { t } from '../core/i18n.js';
let _canvas = null;
@@ -147,17 +149,31 @@ async function _fetchAllEntities() {
devices, captureTemplates, ppTemplates, pictureSources,
audioSources, audioTemplates, valueSources, colorStripSources,
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
batchStatesResp,
] = await Promise.all([
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
automationsCacheObj.fetch(),
fetchWithAuth('/output-targets/batch/states').catch(() => null),
]);
// Enrich output targets with running state from batch states
let batchStates = {};
if (batchStatesResp && batchStatesResp.ok) {
const data = await batchStatesResp.json().catch(() => ({}));
batchStates = data.states || {};
}
const enrichedTargets = (outputTargets || []).map(t => ({
...t,
running: batchStates[t.id]?.processing || false,
}));
return {
devices, captureTemplates, ppTemplates, pictureSources,
audioSources, audioTemplates, valueSources, colorStripSources,
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations,
};
}
@@ -181,6 +197,9 @@ function _renderGraph(container) {
onNodeDblClick: _onNodeDblClick,
onEditNode: _onEditNode,
onDeleteNode: _onDeleteNode,
onStartStopNode: _onStartStopNode,
onTestNode: _onTestNode,
onNotificationTest: _onNotificationTest,
});
markOrphans(nodeGroup, _nodeMap, _edges);
@@ -666,6 +685,75 @@ function _onDeleteNode(node) {
fnMap[node.kind]?.();
}
function _onStartStopNode(node) {
const newRunning = !node.running;
// Optimistic update — toggle UI immediately
_updateNodeRunning(node.id, newRunning);
if (node.kind === 'output_target') {
const action = newRunning ? 'start' : 'stop';
fetchWithAuth(`/output-targets/${node.id}/${action}`, { method: 'POST' }).then(resp => {
if (resp.ok) {
showToast(t(action === 'start' ? 'device.started' : 'device.stopped'), 'success');
} else {
resp.json().catch(() => ({})).then(err => {
showToast(err.detail || t(`target.error.${action}_failed`), 'error');
});
_updateNodeRunning(node.id, !newRunning); // revert
}
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
} else if (node.kind === 'sync_clock') {
const action = newRunning ? 'resume' : 'pause';
fetchWithAuth(`/sync-clocks/${node.id}/${action}`, { method: 'POST' }).then(resp => {
if (resp.ok) {
showToast(t(action === 'pause' ? 'sync_clock.paused' : 'sync_clock.resumed'), 'success');
} else {
_updateNodeRunning(node.id, !newRunning); // revert
}
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
}
}
/** Update a node's running state in the model and patch it in-place (no re-render). */
function _updateNodeRunning(nodeId, running) {
const node = _nodeMap?.get(nodeId);
if (!node) return;
node.running = running;
const nodeGroup = document.querySelector('.graph-nodes');
const edgeGroup = document.querySelector('.graph-edges');
if (nodeGroup) {
patchNodeRunning(nodeGroup, node);
}
// Update flow dots since running set changed
if (edgeGroup) {
const runningIds = new Set();
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
}
renderFlowDots(edgeGroup, _edges, runningIds);
}
}
function _onTestNode(node) {
const fnMap = {
capture_template: () => window.showTestTemplateModal?.(node.id),
pp_template: () => window.showTestPPTemplateModal?.(node.id),
audio_template: () => window.showTestAudioTemplateModal?.(node.id),
picture_source: () => window.showTestStreamModal?.(node.id),
audio_source: () => window.testAudioSource?.(node.id),
value_source: () => window.testValueSource?.(node.id),
color_strip_source: () => window.testColorStrip?.(node.id),
output_target: () => window.testKCTarget?.(node.id),
};
fnMap[node.kind]?.();
}
function _onNotificationTest(node) {
if (node.kind === 'color_strip_source' && node.subtype === 'notification') {
window.testNotification?.(node.id);
}
}
/* ── Keyboard ── */
function _onKeydown(e) {