Implemented admin pane + user permission
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user