From 3e32ca419a1b914cbe64d98d887a89f4f5abb0e2 Mon Sep 17 00:00:00 2001 From: Max Richter Date: Tue, 5 May 2026 21:51:17 +0200 Subject: [PATCH] feat: ungroup nodes --- .../graph-interface/graph-manager.svelte.ts | 51 ++++++++++++++++--- .../lib/graph-interface/graph-state.svelte.ts | 4 ++ app/src/lib/graph-interface/keymaps.ts | 8 +++ app/src/lib/node-registry/groupNode.ts | 3 +- app/src/routes/+page.svelte | 3 +- 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/app/src/lib/graph-interface/graph-manager.svelte.ts b/app/src/lib/graph-interface/graph-manager.svelte.ts index a0873d4..9aa67d1 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.ts @@ -474,8 +474,37 @@ export class GraphManager extends EventEmitter<{ // Construct the group inputs on the fly if (node.type === '__internal/group/instance') { - const groupDefinition = this.getGroup(node.props?.groupId as number); + const groupId = node.props?.groupId as number; + if (!groupId) { + return { + ...node.state.type, + meta: { + title: 'Group', + ...node?.state?.type?.meta || {} + }, + inputs: { + 'groupId': { + type: 'select', + label: '', + value: this.groups[0].id, + internal: true, + options: this.groups.map((g) => ({ + value: g.id, + label: g.name || `Group#${g.id}` + })).filter((g) => { + const activeIds = new SvelteSet([ + ...this.parentStack.filter(e => e.id !== this.id).map(e => e.id), + ...(this.currentGroupId !== null ? [this.currentGroupId] : []) + ]); + return !activeIds.has(g.value); + }) + } + }, + outputs: [] + } as NodeDefinition; + } + const groupDefinition = this.getGroup(node.props?.groupId as number); if (!groupDefinition) { log.error(`Group not found: ${node.props?.groupId}`); return; @@ -834,8 +863,9 @@ export class GraphManager extends EventEmitter<{ const inputs: Record = {}; [...groupInputs.values()].forEach((edge, i) => { + const internalInputDef = edge[2].state.type?.inputs?.[edge[3]]; const input = { - label: `Input ${i}`, + label: internalInputDef?.label ?? edge[3], type: edge[0].state.type?.outputs?.[edge[1]] || '*' }; inputs[`input_${i}`] = input as NodeInput; @@ -844,8 +874,9 @@ export class GraphManager extends EventEmitter<{ const outputs = []; if (groupOutputs.size) { const edge = groupOutputs.values().next().value!; + const outputType = edge[0].state.type?.outputs?.[edge[1]] || '*'; outputs.push({ - label: `Output`, + label: outputType === '*' ? 'Output' : outputType.charAt(0).toUpperCase() + outputType.slice(1), type: edge[2].state.type?.inputs?.[edge[3]].type || '*' }); } @@ -963,6 +994,8 @@ export class GraphManager extends EventEmitter<{ const group = this.getGroup(groupId); if (!group) return false; + log.log('ungrouping node', { groupId, group }); + this.startUndoGroup(); const edgesToGroup = this.edges.filter(e => e[2].id === nodeId); @@ -980,8 +1013,12 @@ export class GraphManager extends EventEmitter<{ centerX += n.position[0]; centerY += n.position[1]; } - const offsetX = internalNodes.length ? groupNode.position[0] - centerX / internalNodes.length : 0; - const offsetY = internalNodes.length ? groupNode.position[1] - centerY / internalNodes.length : 0; + const offsetX = internalNodes.length + ? groupNode.position[0] - centerX / internalNodes.length + : 0; + const offsetY = internalNodes.length + ? groupNode.position[1] - centerY / internalNodes.length + : 0; // Allocate new IDs that don't collide with anything in the current graph const usedIds = new SvelteSet([ @@ -997,7 +1034,7 @@ export class GraphManager extends EventEmitter<{ }; // Map old internal IDs (including boundary nodes) to fresh IDs - const idMap = new Map(); + const idMap = new SvelteMap(); for (const n of group.nodes) { idMap.set(n.id, nextId()); } @@ -1020,7 +1057,7 @@ export class GraphManager extends EventEmitter<{ } // input_X socket on the group node → the external source that was feeding it - const inputIdxToExternal = new Map(); + const inputIdxToExternal = new SvelteMap(); for (const edge of edgesToGroup) { const match = (edge[3] as string).match(/^input_(\d+)$/); if (match) inputIdxToExternal.set(parseInt(match[1]), { node: edge[0], socket: edge[1] }); diff --git a/app/src/lib/graph-interface/graph-state.svelte.ts b/app/src/lib/graph-interface/graph-state.svelte.ts index 74092ef..db7bcfc 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.ts @@ -213,6 +213,10 @@ export class GraphState { }; } + unGroupSelectedNodes() { + return this.graph.ungroupNode(this.activeNodeId); + } + groupSelectedNodes() { return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]); } diff --git a/app/src/lib/graph-interface/keymaps.ts b/app/src/lib/graph-interface/keymaps.ts index e96f6e6..c7f42c3 100644 --- a/app/src/lib/graph-interface/keymaps.ts +++ b/app/src/lib/graph-interface/keymaps.ts @@ -66,6 +66,14 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr callback: () => graphState.groupSelectedNodes() }); + keymap.addShortcut({ + key: 'g', + alt: true, + preventDefault: true, + description: 'Ungroup selected nodes', + callback: () => graphState.unGroupSelectedNodes() + }); + keymap.addShortcut({ key: 'Tab', preventDefault: true, diff --git a/app/src/lib/node-registry/groupNode.ts b/app/src/lib/node-registry/groupNode.ts index 9d22684..03ff95d 100644 --- a/app/src/lib/node-registry/groupNode.ts +++ b/app/src/lib/node-registry/groupNode.ts @@ -2,7 +2,8 @@ export const groupNode = { id: '__internal/group/instance', meta: { title: 'Group' }, inputs: { - input: { + groupId: { + label: '', type: 'select', values: [] } diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index f6ee00b..edc30a5 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -5,6 +5,7 @@ import { debounceAsyncFunction } from '$lib/helpers'; import { createKeyMap } from '$lib/helpers/createKeyMap'; import { debugNode } from '$lib/node-registry/debugNode'; + import { groupNode } from '$lib/node-registry/groupNode.js'; import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index'; import NodeStore from '$lib/node-store/NodeStore.svelte'; import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte'; @@ -38,7 +39,7 @@ const registryCache = new IndexDBCache('node-registry'); - const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode]); + const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode, groupNode]); const workerRuntime = new WorkerRuntimeExecutor(); const runtimeCache = new MemoryRuntimeCache(); const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);