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);
|
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 ── */
|
/* ── Selection rectangle ── */
|
||||||
|
|
||||||
.graph-selection-rect {
|
.graph-selection-rect {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ function buildGraph(e) {
|
|||||||
|
|
||||||
// 6. Sync clocks
|
// 6. Sync clocks
|
||||||
for (const c of e.syncClocks || []) {
|
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
|
// 7. Picture sources
|
||||||
|
|||||||
@@ -172,14 +172,47 @@ function renderNode(node, callbacks) {
|
|||||||
return g;
|
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) {
|
function _createOverlay(node, nodeWidth, callbacks) {
|
||||||
const overlay = svgEl('g', { class: 'graph-node-overlay' });
|
const overlay = svgEl('g', { class: 'graph-node-overlay' });
|
||||||
const btnSize = 24;
|
const btnSize = 24;
|
||||||
const btnGap = 2;
|
const btnGap = 2;
|
||||||
const btns = [
|
|
||||||
{ icon: '\u270E', action: 'edit', cls: '' }, // ✎
|
// Build button list dynamically based on node kind/subtype
|
||||||
{ icon: '\u2716', action: 'delete', cls: 'danger' }, // ✖
|
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 totalW = btns.length * (btnSize + btnGap) - btnGap + 8;
|
||||||
const ox = nodeWidth - totalW - 4;
|
const ox = nodeWidth - totalW - 4;
|
||||||
const oy = -btnSize - 6;
|
const oy = -btnSize - 6;
|
||||||
@@ -211,6 +244,9 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (btn.action === 'edit' && callbacks.onEditNode) callbacks.onEditNode(node);
|
if (btn.action === 'edit' && callbacks.onEditNode) callbacks.onEditNode(node);
|
||||||
if (btn.action === 'delete' && callbacks.onDeleteNode) callbacks.onDeleteNode(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);
|
overlay.appendChild(bg);
|
||||||
});
|
});
|
||||||
@@ -218,6 +254,48 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
|||||||
return overlay;
|
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).
|
* Highlight a single node (add class, scroll to).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { GraphCanvas } from '../core/graph-canvas.js';
|
import { GraphCanvas } from '../core/graph-canvas.js';
|
||||||
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.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 { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
|
||||||
import {
|
import {
|
||||||
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
|
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
|
||||||
automationsCacheObj,
|
automationsCacheObj,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
|
import { fetchWithAuth } from '../core/api.js';
|
||||||
|
import { showToast } from '../core/ui.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
|
|
||||||
let _canvas = null;
|
let _canvas = null;
|
||||||
@@ -147,17 +149,31 @@ async function _fetchAllEntities() {
|
|||||||
devices, captureTemplates, ppTemplates, pictureSources,
|
devices, captureTemplates, ppTemplates, pictureSources,
|
||||||
audioSources, audioTemplates, valueSources, colorStripSources,
|
audioSources, audioTemplates, valueSources, colorStripSources,
|
||||||
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
|
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
|
||||||
|
batchStatesResp,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
|
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
|
||||||
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
|
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
|
||||||
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
|
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
|
||||||
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
|
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
|
||||||
automationsCacheObj.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 {
|
return {
|
||||||
devices, captureTemplates, ppTemplates, pictureSources,
|
devices, captureTemplates, ppTemplates, pictureSources,
|
||||||
audioSources, audioTemplates, valueSources, colorStripSources,
|
audioSources, audioTemplates, valueSources, colorStripSources,
|
||||||
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
|
syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +197,9 @@ function _renderGraph(container) {
|
|||||||
onNodeDblClick: _onNodeDblClick,
|
onNodeDblClick: _onNodeDblClick,
|
||||||
onEditNode: _onEditNode,
|
onEditNode: _onEditNode,
|
||||||
onDeleteNode: _onDeleteNode,
|
onDeleteNode: _onDeleteNode,
|
||||||
|
onStartStopNode: _onStartStopNode,
|
||||||
|
onTestNode: _onTestNode,
|
||||||
|
onNotificationTest: _onNotificationTest,
|
||||||
});
|
});
|
||||||
markOrphans(nodeGroup, _nodeMap, _edges);
|
markOrphans(nodeGroup, _nodeMap, _edges);
|
||||||
|
|
||||||
@@ -666,6 +685,75 @@ function _onDeleteNode(node) {
|
|||||||
fnMap[node.kind]?.();
|
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 ── */
|
/* ── Keyboard ── */
|
||||||
|
|
||||||
function _onKeydown(e) {
|
function _onKeydown(e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user