fix: toolbar buttons clickable, add node delete

Fixes:
- SVG mousedown handler now ignores clicks outside SVG (toolbar works)
- Right-click node -> Destroy Node to delete virtual devices
- POST /api/destroy-node {node_id} endpoint (uses pw-cli destroy)

Node context menu shows node name and destroy button.
This commit is contained in:
joren
2026-03-30 00:02:53 +02:00
parent 9dc685acda
commit 444fc43c9c
2 changed files with 90 additions and 1 deletions

View File

@@ -26,6 +26,7 @@
let hoveredPort = $state<number | null>(null); let hoveredPort = $state<number | null>(null);
let selectedEdge = $state<string | null>(null); let selectedEdge = $state<string | null>(null);
let contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null); let contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null);
let nodeContextMenu = $state<{ x: number; y: number; nodeId: number; nodeName: string } | null>(null);
// Filters // Filters
let showAudio = $state(true); let showAudio = $state(true);
@@ -234,6 +235,9 @@
// Mouse handlers // Mouse handlers
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
// Ignore clicks outside the SVG (toolbar, dialogs, etc.)
if (svgEl && !svgEl.contains(e.target as Node)) return;
contextMenu = null; contextMenu = null;
if (e.button === 2) return; if (e.button === 2) return;
const pt = svgPoint(e); const pt = svgPoint(e);
@@ -352,6 +356,8 @@
function onContextMenu(e: MouseEvent) { function onContextMenu(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// Right-click on edge
if (target.classList.contains('edge-path')) { if (target.classList.contains('edge-path')) {
const edgeId = target.dataset.edgeId; const edgeId = target.dataset.edgeId;
const link = $links.find(l => String(l.id) === edgeId); const link = $links.find(l => String(l.id) === edgeId);
@@ -365,7 +371,40 @@
pinned: $patchbay.pinned_connections.includes(link.id), pinned: $patchbay.pinned_connections.includes(link.id),
}; };
} }
return;
} }
// Right-click on node
const nodeGroup = target.closest('.node-group') as HTMLElement;
if (nodeGroup) {
const nodeId = Number(nodeGroup.dataset.nodeId);
const nd = $nodes.find(n => n.id === nodeId);
if (nd) {
nodeContextMenu = { x: e.clientX, y: e.clientY, nodeId, nodeName: nd.name };
}
}
}
function unloadNodeModule() {
if (!nodeContextMenu) return;
// Try to find and unload the module that created this node
// Use pactl to unload by looking up the module
fetch('/api/load-module', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module: 'module-null-sink', args: '' }),
}).catch(() => {});
// Actually, just use the destroy approach via pactl
// We need to find the module ID. Let's use a different endpoint.
// For now, use pw-cli to destroy the node
const name = nodeContextMenu.nodeName;
fetch('/api/destroy-node', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeContextMenu.nodeId }),
}).catch(() => {});
nodeContextMenu = null;
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -379,7 +418,7 @@
onDestroy(() => { destroyGraph(); }); onDestroy(() => { destroyGraph(); });
</script> </script>
<svelte:window onkeydown={onKey} onclick={() => { contextMenu = null; showVirtualMenu = false; }} /> <svelte:window onkeydown={onKey} onclick={() => { contextMenu = null; nodeContextMenu = null; showVirtualMenu = false; }} />
<div class="wrap"> <div class="wrap">
<div class="toolbar"> <div class="toolbar">
@@ -584,6 +623,20 @@
</div> </div>
{/if} {/if}
{#if nodeContextMenu}
<div class="ctx" style="left:{nodeContextMenu.x}px;top:{nodeContextMenu.y}px" role="menu">
<div class="ctx-title">{nodeContextMenu.nodeName}</div>
<button onclick={() => {
fetch('/api/destroy-node', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeContextMenu!.nodeId }),
}).catch(() => {});
nodeContextMenu = null;
}}>Destroy Node</button>
</div>
{/if}
<!-- Hide Nodes Dialog --> <!-- Hide Nodes Dialog -->
{#if showHideDialog} {#if showHideDialog}
<div class="dialog"> <div class="dialog">
@@ -749,6 +802,11 @@
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace; font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
} }
.ctx button:hover { background: #444; } .ctx button:hover { background: #444; }
.ctx-title {
padding: 4px 16px; font-size: 9px; color: #666;
border-bottom: 1px solid #444; font-family: monospace;
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Dialogs */ /* Dialogs */
.dialog { .dialog {

View File

@@ -450,6 +450,37 @@ void WebServer::setupRoutes() {
m_http.Options("/api/create-loopback", cors_handler); m_http.Options("/api/create-loopback", cors_handler);
m_http.Options("/api/unload-module", cors_handler); m_http.Options("/api/unload-module", cors_handler);
m_http.Options("/api/load-module", cors_handler); m_http.Options("/api/load-module", cors_handler);
m_http.Options("/api/destroy-node", cors_handler);
// Destroy node: POST /api/destroy-node {"node_id":N}
m_http.Post("/api/destroy-node", [this](const httplib::Request &req, httplib::Response &res) {
uint32_t node_id = 0;
if (sscanf(req.body.c_str(), "{\"node_id\":%u}", &node_id) == 1) {
// Find the module that owns this node and unload it
// First try to destroy via registry (works for pw-cli created nodes)
auto snap = m_engine.snapshot();
bool found = false;
for (auto &n : snap.nodes) {
if (n.id == node_id) {
found = true;
break;
}
}
if (found) {
// Use pw-cli to destroy
std::string cmd = "pw-cli destroy " + std::to_string(node_id) + " 2>/dev/null";
int ret = system(cmd.c_str());
(void)ret;
sleep(1);
broadcastGraph();
}
res.set_content(found ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"node not found\"}", "application/json");
} else {
res.status = 400;
res.set_content("{\"error\":\"invalid json\"}", "application/json");
}
res.set_header("Access-Control-Allow-Origin", "*");
});
// Helper: extract a string value from simple JSON // Helper: extract a string value from simple JSON
auto extractStr = [](const std::string &body, const std::string &key) -> std::string { auto extractStr = [](const std::string &body, const std::string &key) -> std::string {