Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+260 -22
View File
@@ -5,6 +5,9 @@ import LuaEditor from './LuaEditor';
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
import { inferNodeTypes, portAccept, typesCompatible, SynthType } from './lib/synthTypes';
import { VersionTree, DiffViewer } from './VersionHistory';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
interface DataSource { name: string; }
interface SignalInfo { name: string; type?: string; }
@@ -91,7 +94,7 @@ interface GNode {
}
// A wire terminates on a specific input port (index) of the target node.
interface GWire { from: string; to: string; toPort: number; }
interface Graph { nodes: GNode[]; wires: GWire[]; }
interface Graph { nodes: GNode[]; wires: GWire[]; groups?: NodeGroup[]; }
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
const REM = (() => {
@@ -99,6 +102,9 @@ const REM = (() => {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 10 * REM;
// Logical canvas size (matches .flow-canvas-inner in styles.css); zoom scales it.
const CANVAS_W = 125 * REM;
const CANVAS_H = 87.5 * REM;
const PORT_TOP = 1.7 * REM; // y of the first input/output port row
const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports
const PORT_R = 0.375 * REM;
@@ -108,6 +114,11 @@ const PORT_R = 0.375 * REM;
// offsets or they land at the top edge of the port circle instead of its center.
const BORDER = 1;
const BORDER_TOP = 3;
// ── Group geometry (node grouping / collapsing) ─────────────────────────────
const GROUP_PAD = 0.65 * REM; // padding around member nodes
const GROUP_HEADER = 1.65 * REM; // height of the group title bar
const GROUP_BOX_W = NODE_W; // collapsed-box width
const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM; // collapsed-box height
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
@@ -163,7 +174,8 @@ function buildInitial(def: SignalDef): Graph {
for (const n of def.graph.nodes) {
(n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
}
return { nodes, wires };
const groups = def.graph.groups;
return { nodes, wires, ...(groups && groups.length ? { groups: groups.map(g => ({ ...g, members: [...g.members] })) } : {}) };
}
// Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
@@ -212,7 +224,8 @@ function compile(g: Graph): SynthGraph {
if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
});
return { nodes, output: output?.id ?? '' };
const groups = pruneGroups(g.groups, new Set(g.nodes.map(n => n.id)));
return { nodes, output: output?.id ?? '', ...(groups.length ? { groups } : {}) };
}
// Detect a cycle in the graph (Kahn count over input edges).
@@ -308,6 +321,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
// Multi-selection (Shift+click) and the selected group, for node grouping.
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
@@ -333,7 +349,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const zoomRef = useRef(1);
const dragNode = useRef<{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
@@ -342,6 +359,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
graphRef.current = graph;
const undoStack = useRef<Graph[]>([]);
const redoStack = useRef<Graph[]>([]);
const clipboard = useRef<{ nodes: GNode[]; wires: GWire[] } | null>(null);
const [debug, setDebug] = useState(false);
const [, setTick] = useState(0);
const bump = () => setTick(t => t + 1);
const canUndo = undoStack.current.length > 0;
@@ -427,16 +446,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
redoStack.current = [];
}
// Commit a new graph. When `next.groups` is left undefined the current groups
// are carried over, so ordinary node/wire edits never lose grouping; group
// mutators pass an explicit (possibly empty) list to change it.
function commit(next: Graph, record = true) {
if (record) pushUndo();
setGraph(next);
const groups = next.groups !== undefined ? next.groups : graphRef.current.groups;
setGraph({ nodes: next.nodes, wires: next.wires, ...(groups && groups.length ? { groups } : {}) });
}
function undo() {
if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1];
redoStack.current = [graphRef.current, ...redoStack.current];
undoStack.current = undoStack.current.slice(0, -1);
setSelected(null); setSelectedWire(null);
setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null);
setGraph(prev); bump();
}
function redo() {
@@ -444,7 +467,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const next = redoStack.current[0];
undoStack.current = [...undoStack.current, graphRef.current];
redoStack.current = redoStack.current.slice(1);
setSelected(null); setSelectedWire(null);
setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null);
setGraph(next); bump();
}
@@ -527,15 +550,79 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
commit({ nodes, wires });
}
function moveNode(id: string, x: number, y: number, record: boolean) {
// Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
function moveNodes(pos: Map<string, { x: number; y: number }>, record: boolean) {
const g = graphRef.current;
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
commit({ nodes: g.nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), wires: g.wires }, record);
}
function deleteNode(id: string) {
const n = graph.nodes.find(x => x.id === id);
if (!n || n.kind === 'output') return; // the output node is permanent
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
const nextNodes = graph.nodes.filter(x => x.id !== id);
const nextGroups = pruneGroups(graph.groups, new Set(nextNodes.map(nn => nn.id)));
commit({ nodes: nextNodes, wires: graph.wires.filter(w => w.from !== id && w.to !== id), groups: nextGroups });
if (selected === id) setSelected(null);
if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
}
// ── Clipboard ────────────────────────────────────────────────────────────
// Copy the current selection (multi-select aware) plus any wires internal to
// it, with params deep-cloned. The permanent output node is never copied.
function copySelection() {
const cur = graphRef.current;
const ids = selSet.size > 0 ? selSet : (selected ? new Set([selected]) : new Set<string>());
const picked = cur.nodes.filter(n => ids.has(n.id) && n.kind !== 'output');
if (picked.length === 0) return;
const pickedIds = new Set(picked.map(n => n.id));
const innerWires = cur.wires.filter(w => pickedIds.has(w.from) && pickedIds.has(w.to));
clipboard.current = {
nodes: picked.map(n => ({ ...n, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined })),
wires: innerWires.map(w => ({ ...w })),
};
}
function pasteClipboard() {
const clip = clipboard.current;
if (!clip || clip.nodes.length === 0) return;
const idMap = new Map<string, string>();
const cur = graphRef.current;
const newNodes = clip.nodes.map(n => {
const id = genId();
idMap.set(n.id, id);
return { ...n, id, x: n.x + 1.5 * REM, y: n.y + 1.5 * REM, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined };
});
const newWires = clip.wires
.filter(w => idMap.has(w.from) && idMap.has(w.to))
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
for (const n of newNodes) if (n.kind === 'source' && n.ds) loadSignals(n.ds);
commit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
setSelSet(new Set(newNodes.map(n => n.id)));
setSelected(newNodes.length === 1 ? newNodes[0].id : null);
setSelectedWire(null);
setSelectedGroup(null);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function setGroups(next: NodeGroup[], record = true) {
const g = graphRef.current;
commit({ nodes: g.nodes, wires: g.wires, groups: next }, record);
}
function groupSelection() {
if (selSet.size < 1) return;
const members = graph.nodes.filter(n => selSet.has(n.id)).map(n => n.id);
if (members.length < 1) return;
const grp: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
setGroups([...(graph.groups ?? []), grp]);
setSelectedGroup(grp.id); setSelected(null); setSelectedWire(null);
}
function ungroup(gid: string) {
setGroups((graph.groups ?? []).filter(g => g.id !== gid));
if (selectedGroup === gid) setSelectedGroup(null);
}
function toggleCollapse(gid: string) {
setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
}
function renameGroup(gid: string, label: string) {
setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, label } : g)), false);
}
function addWire(from: string, to: string, toPort: number) {
if (from === to) return;
@@ -572,13 +659,35 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
function toCanvas(e: MouseEvent) {
const el = canvasRef.current!;
const rect = el.getBoundingClientRect();
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
const z = zoomRef.current;
return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z };
}
// Begin dragging `ids` (one node, a multi-selection, or a whole group). All
// members move together, keeping their relative offsets.
function beginDrag(e: MouseEvent, ids: string[]) {
const p = toCanvas(e);
const starts = new Map<string, { x: number; y: number }>();
for (const id of ids) {
const n = graphRef.current.nodes.find(x => x.id === id);
if (n) starts.set(id, { x: n.x, y: n.y });
}
dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
}
function startNodeDrag(e: MouseEvent, node: GNode) {
e.stopPropagation();
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
setSelected(node.id); setSelectedWire(null);
// Shift+click toggles the node in the multi-selection without dragging.
if (e.shiftKey) {
const s = new Set(selSet);
if (s.has(node.id)) s.delete(node.id); else s.add(node.id);
setSelSet(s); setSelected(node.id); setSelectedWire(null); setSelectedGroup(null);
return;
}
setSelectedWire(null); setSelectedGroup(null);
// Drag the whole multi-selection if this node is part of it, else just it.
const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id];
if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id]));
setSelected(node.id);
beginDrag(e, ids);
}
function startWire(e: MouseEvent, node: GNode) {
e.stopPropagation();
@@ -604,7 +713,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const p = toCanvas(e);
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
const dx = p.x - d.ox, dy = p.y - d.oy;
const pos = new Map<string, { x: number; y: number }>();
for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
moveNodes(pos, record);
return;
}
const cur = pendingRef.current;
@@ -651,22 +763,86 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
if (mod) return;
if (e.key === 'Escape') { if (hud) closeHud(); return; }
if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; }
if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; }
// 'g' groups the current multi-selection.
if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
if (selectedGroup !== null) ungroup(selectedGroup);
else if (selectedWire !== null) deleteWire(selectedWire);
else if (selected) deleteNode(selected);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selected, selectedWire, graph, hud]);
}, [selected, selectedWire, selectedGroup, selSet, graph, hud]);
const byId = new Map(graph.nodes.map(n => [n.id, n]));
const groups = graph.groups ?? [];
const sel = graph.nodes.find(n => n.id === selected) ?? null;
const { errors: nodeErrors, first: validationError } = validate(graph);
// ── Group geometry (for the current render) ─────────────────────────────────
const rectOf = (id: string): Rect | null => {
const n = byId.get(id);
return n ? { x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) } : null;
};
const groupGeom = groups.map(g => {
const bounds = groupBounds(g.members, rectOf, GROUP_PAD, GROUP_HEADER);
const box: Rect | null = bounds ? { x: bounds.x, y: bounds.y, w: GROUP_BOX_W, h: GROUP_BOX_H } : null;
return { g, bounds, box };
});
const hiddenNodes = new Set<string>();
const boxByGroup = new Map<string, Rect>();
for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
boxByGroup.set(gg.g.id, gg.box);
for (const m of gg.g.members) hiddenNodes.add(m);
}
const collapsedBoxOf = (id: string): Rect | null => {
const g = groupContaining(groups, id);
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
};
const resolveOut = (n: GNode) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n);
};
const resolveIn = (n: GNode, port: number) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n, port);
};
// A wire is hidden when both ends collapse into the same group.
const wireHidden = (from: string, to: string) => {
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
return !!gf && gf === gt && gf.collapsed;
};
// ── Pan / zoom ───────────────────────────────────────────────────────────────
const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) }));
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } =
useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
// ── Live / debug mode ────────────────────────────────────────────────────────
// Polls the server with the current (unsaved) graph; each node lights up with
// its computed value. Stateful DSP nodes are flagged "approx" (single-shot eval).
const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
const res = await fetch('/api/v1/synthetic/trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: compile(graphRef.current) }),
});
if (!res.ok) return null;
const j = await res.json();
const snap: DebugSnapshot = new Map();
for (const [id, n] of Object.entries(j.nodes ?? {})) {
const node = n as { value: any; type?: 'scalar' | 'array'; approx?: boolean };
snap.set(id, { value: node.value, type: node.type, approx: node.approx, active: node.value != null });
}
return snap;
});
// Static type propagation: infer each node's output type (scalar/array) to
// colour wires and surface type-incompatible wirings as node errors. Mirrors
// the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
@@ -802,6 +978,16 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title="Live debug — evaluate the graph and show each node's value"></button>
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Inputs</div>
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
@@ -820,15 +1006,59 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
onMouseDown={() => { setSelected(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{/* Group frames (behind nodes). Collapsed groups render a compact box. */}
{groupGeom.map(({ g, bounds, box }) => {
if (!bounds) return null;
const gsel = selectedGroup === g.id;
if (g.collapsed && box) {
return (
<div key={g.id}
class={`flow-group-box${gsel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}>▸</button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${gsel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}>▾</button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}>✕</button>
</div>
</div>
);
})}
<svg class="flow-wires">
{graph.wires.map((w, idx) => {
const a = byId.get(w.from); const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
if (wireHidden(w.from, w.to)) return null;
const p1 = resolveOut(a); const p2 = resolveIn(b, w.toPort);
const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar';
return (
<path key={idx}
@@ -845,15 +1075,22 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
})()}
</svg>
{graph.nodes.map(node => {
{graph.nodes.filter(n => !hiddenNodes.has(n.id)).map(node => {
const nIn = inPortCount(node, graph.wires);
const dbg = debug ? debugSnap.get(node.id) : undefined;
const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
return (
<div key={node.id}
class={nodeClass(node)}
class={`${nodeClass(node)}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${grouped}`}
title={nodeErrors.get(node.id) || undefined}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
{dbg && (
<span class={`flow-node-badge${dbg.approx ? ' approx' : ''}`} title={badgeTitle(dbg)}>
{(dbg.approx ? '~' : '') + formatBadge(dbg)}
</span>
)}
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
@@ -887,6 +1124,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
</div>
);
})}
</div>
</div>
</div>