feat: ungroup nodes
📊 Benchmark the Runtime / benchmark (pull_request) Successful in 1m45s
🚀 Lint & Test & Deploy / quality (pull_request) Failing after 1m7s
🚀 Lint & Test & Deploy / test-unit (pull_request) Successful in 35s
🚀 Lint & Test & Deploy / test-e2e (pull_request) Successful in 2m4s
🚀 Lint & Test & Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
2026-05-05 21:51:17 +02:00
parent f0cb12a088
commit 3e32ca419a
5 changed files with 60 additions and 9 deletions
@@ -474,8 +474,37 @@ export class GraphManager extends EventEmitter<{
// Construct the group inputs on the fly // Construct the group inputs on the fly
if (node.type === '__internal/group/instance') { 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) { if (!groupDefinition) {
log.error(`Group not found: ${node.props?.groupId}`); log.error(`Group not found: ${node.props?.groupId}`);
return; return;
@@ -834,8 +863,9 @@ export class GraphManager extends EventEmitter<{
const inputs: Record<string, NodeInput> = {}; const inputs: Record<string, NodeInput> = {};
[...groupInputs.values()].forEach((edge, i) => { [...groupInputs.values()].forEach((edge, i) => {
const internalInputDef = edge[2].state.type?.inputs?.[edge[3]];
const input = { const input = {
label: `Input ${i}`, label: internalInputDef?.label ?? edge[3],
type: edge[0].state.type?.outputs?.[edge[1]] || '*' type: edge[0].state.type?.outputs?.[edge[1]] || '*'
}; };
inputs[`input_${i}`] = input as NodeInput; inputs[`input_${i}`] = input as NodeInput;
@@ -844,8 +874,9 @@ export class GraphManager extends EventEmitter<{
const outputs = []; const outputs = [];
if (groupOutputs.size) { if (groupOutputs.size) {
const edge = groupOutputs.values().next().value!; const edge = groupOutputs.values().next().value!;
const outputType = edge[0].state.type?.outputs?.[edge[1]] || '*';
outputs.push({ outputs.push({
label: `Output`, label: outputType === '*' ? 'Output' : outputType.charAt(0).toUpperCase() + outputType.slice(1),
type: edge[2].state.type?.inputs?.[edge[3]].type || '*' type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
}); });
} }
@@ -963,6 +994,8 @@ export class GraphManager extends EventEmitter<{
const group = this.getGroup(groupId); const group = this.getGroup(groupId);
if (!group) return false; if (!group) return false;
log.log('ungrouping node', { groupId, group });
this.startUndoGroup(); this.startUndoGroup();
const edgesToGroup = this.edges.filter(e => e[2].id === nodeId); const edgesToGroup = this.edges.filter(e => e[2].id === nodeId);
@@ -980,8 +1013,12 @@ export class GraphManager extends EventEmitter<{
centerX += n.position[0]; centerX += n.position[0];
centerY += n.position[1]; centerY += n.position[1];
} }
const offsetX = internalNodes.length ? groupNode.position[0] - centerX / internalNodes.length : 0; const offsetX = internalNodes.length
const offsetY = internalNodes.length ? groupNode.position[1] - centerY / internalNodes.length : 0; ? 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 // Allocate new IDs that don't collide with anything in the current graph
const usedIds = new SvelteSet<number>([ const usedIds = new SvelteSet<number>([
@@ -997,7 +1034,7 @@ export class GraphManager extends EventEmitter<{
}; };
// Map old internal IDs (including boundary nodes) to fresh IDs // Map old internal IDs (including boundary nodes) to fresh IDs
const idMap = new Map<number, number>(); const idMap = new SvelteMap<number, number>();
for (const n of group.nodes) { for (const n of group.nodes) {
idMap.set(n.id, nextId()); 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 // input_X socket on the group node → the external source that was feeding it
const inputIdxToExternal = new Map<number, { node: NodeInstance; socket: number }>(); const inputIdxToExternal = new SvelteMap<number, { node: NodeInstance; socket: number }>();
for (const edge of edgesToGroup) { for (const edge of edgesToGroup) {
const match = (edge[3] as string).match(/^input_(\d+)$/); const match = (edge[3] as string).match(/^input_(\d+)$/);
if (match) inputIdxToExternal.set(parseInt(match[1]), { node: edge[0], socket: edge[1] }); if (match) inputIdxToExternal.set(parseInt(match[1]), { node: edge[0], socket: edge[1] });
@@ -213,6 +213,10 @@ export class GraphState {
}; };
} }
unGroupSelectedNodes() {
return this.graph.ungroupNode(this.activeNodeId);
}
groupSelectedNodes() { groupSelectedNodes() {
return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]); return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]);
} }
+8
View File
@@ -66,6 +66,14 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
callback: () => graphState.groupSelectedNodes() callback: () => graphState.groupSelectedNodes()
}); });
keymap.addShortcut({
key: 'g',
alt: true,
preventDefault: true,
description: 'Ungroup selected nodes',
callback: () => graphState.unGroupSelectedNodes()
});
keymap.addShortcut({ keymap.addShortcut({
key: 'Tab', key: 'Tab',
preventDefault: true, preventDefault: true,
+2 -1
View File
@@ -2,7 +2,8 @@ export const groupNode = {
id: '__internal/group/instance', id: '__internal/group/instance',
meta: { title: 'Group' }, meta: { title: 'Group' },
inputs: { inputs: {
input: { groupId: {
label: '',
type: 'select', type: 'select',
values: [] values: []
} }
+2 -1
View File
@@ -5,6 +5,7 @@
import { debounceAsyncFunction } from '$lib/helpers'; import { debounceAsyncFunction } from '$lib/helpers';
import { createKeyMap } from '$lib/helpers/createKeyMap'; import { createKeyMap } from '$lib/helpers/createKeyMap';
import { debugNode } from '$lib/node-registry/debugNode'; import { debugNode } from '$lib/node-registry/debugNode';
import { groupNode } from '$lib/node-registry/groupNode.js';
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index'; import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
import NodeStore from '$lib/node-store/NodeStore.svelte'; import NodeStore from '$lib/node-store/NodeStore.svelte';
import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte'; import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte';
@@ -38,7 +39,7 @@
const registryCache = new IndexDBCache('node-registry'); const registryCache = new IndexDBCache('node-registry');
const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode]); const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode, groupNode]);
const workerRuntime = new WorkerRuntimeExecutor(); const workerRuntime = new WorkerRuntimeExecutor();
const runtimeCache = new MemoryRuntimeCache(); const runtimeCache = new MemoryRuntimeCache();
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache); const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);