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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user