feat(ui): make inputselect also handle value+label options
This commit is contained in:
@@ -152,6 +152,7 @@ export class GraphManager extends EventEmitter<{
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
inputs: group.inputs,
|
||||
outputs: group.outputs,
|
||||
nodes: groupNodes,
|
||||
@@ -527,16 +528,25 @@ export class GraphManager extends EventEmitter<{
|
||||
return;
|
||||
}
|
||||
|
||||
const inputs = {
|
||||
const defaultInputs = {
|
||||
...(node.state.type?.inputs || {}),
|
||||
...groupDefinition?.inputs,
|
||||
...groupDefinition?.inputs
|
||||
};
|
||||
|
||||
// This is to make sure the the groupId is always first
|
||||
delete defaultInputs['groupId'];
|
||||
const inputs = {
|
||||
'groupId': {
|
||||
type: 'select',
|
||||
label: '',
|
||||
value: node.props?.groupId,
|
||||
internal: true,
|
||||
options: this.graph.groups.map(g => g.id)
|
||||
}
|
||||
options: this.graph.groups.map((g, i) => ({
|
||||
value: g.id,
|
||||
label: g.name || `Group ${i + 1}`
|
||||
}))
|
||||
},
|
||||
...defaultInputs
|
||||
};
|
||||
|
||||
const groupType = {
|
||||
@@ -658,6 +668,13 @@ export class GraphManager extends EventEmitter<{
|
||||
return this.graph.groups.find(g => g.id === id);
|
||||
}
|
||||
|
||||
renameGroup(groupId: number, name: string) {
|
||||
const group = this.getGroup(groupId);
|
||||
if (!group) return;
|
||||
group.name = name;
|
||||
this.save();
|
||||
}
|
||||
|
||||
isInsideGroup = $state(false);
|
||||
|
||||
enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean {
|
||||
@@ -845,35 +862,53 @@ export class GraphManager extends EventEmitter<{
|
||||
groupPosition[0] /= nodes.length;
|
||||
groupPosition[1] /= nodes.length;
|
||||
|
||||
// Map from deduped edge source key → group input index, used for both
|
||||
// internal edge wiring and external edge socket naming.
|
||||
const inputIndexByEdgeKey = new Map<string, number>();
|
||||
[...groupInputs.keys()].forEach((key, i) => inputIndexByEdgeKey.set(key, i));
|
||||
|
||||
// Allocate all needed IDs up front so sequential calls never collide.
|
||||
const usedIds = new Set<number>([
|
||||
...this.nodes.keys(),
|
||||
...this.graph.groups.map(g => g.id),
|
||||
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
|
||||
]);
|
||||
const nextId = () => {
|
||||
let id = 0;
|
||||
while (usedIds.has(id)) id++;
|
||||
usedIds.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const groupInputNode: NodeInstance = {
|
||||
id: this.createNodeId(),
|
||||
id: nextId(),
|
||||
type: '__internal/group/input',
|
||||
position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2],
|
||||
state: {}
|
||||
};
|
||||
|
||||
const groupOutputNode: NodeInstance = {
|
||||
id: this.createNodeId(),
|
||||
id: nextId(),
|
||||
type: '__internal/group/output',
|
||||
position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2],
|
||||
state: {}
|
||||
};
|
||||
|
||||
// Edges that are inside the group
|
||||
// Edges that are inside the group, routed through boundary nodes at
|
||||
// the correct input/output index for each unique external connection.
|
||||
const internalEdges = this.edges.filter((edge) => {
|
||||
return ids.has(edge[0].id) || ids.has(edge[2].id);
|
||||
}).map((edge) => {
|
||||
// Going in from the group
|
||||
if (!ids.has(edge[0].id)) {
|
||||
return [groupInputNode.id, 0, edge[2].id, edge[3]];
|
||||
// Going out to the group
|
||||
const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0;
|
||||
return [groupInputNode.id, idx, edge[2].id, edge[3]];
|
||||
} else if (!ids.has(edge[2].id)) {
|
||||
return [edge[0].id, edge[1], groupOutputNode.id, 'out_0'];
|
||||
}
|
||||
return [edge[0].id, edge[1], edge[2].id, edge[3]];
|
||||
}) as [number, number, number, string][];
|
||||
|
||||
const groupId = this.createNodeId();
|
||||
const groupId = nextId();
|
||||
const groupDefinition: GroupDefinition = {
|
||||
id: groupId,
|
||||
inputs: inputs,
|
||||
@@ -882,6 +917,9 @@ export class GraphManager extends EventEmitter<{
|
||||
nodes: [groupInputNode, ...nodes, groupOutputNode]
|
||||
};
|
||||
|
||||
// Push before createNode so createNodeId() inside sees the allocated IDs.
|
||||
this.graph.groups.push(groupDefinition);
|
||||
|
||||
const groupNode = this.createNode({
|
||||
type: '__internal/group/instance',
|
||||
position: [groupPosition[0], groupPosition[1]],
|
||||
@@ -892,20 +930,17 @@ export class GraphManager extends EventEmitter<{
|
||||
|
||||
if (!groupNode) throw new Error('Failed to create group node');
|
||||
|
||||
// Update the edges that are now inside
|
||||
// the group to be connected to that group node
|
||||
// Rewire external edges to/from the group node using the correct input socket.
|
||||
const externalEdges = this.edges.map((edge) => {
|
||||
if (ids.has(edge[2].id)) {
|
||||
// Edge going into the group
|
||||
return [edge[0], edge[1], groupNode, 'input_0'] as Edge;
|
||||
const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0;
|
||||
return [edge[0], edge[1], groupNode, `input_${idx}`] as Edge;
|
||||
} else if (ids.has(edge[0].id)) {
|
||||
// Edge going out of the group
|
||||
return [groupNode, 0, edge[2], edge[3]] as Edge;
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
|
||||
this.graph.groups.push(groupDefinition);
|
||||
this.nodes.set(groupNode.id, groupNode);
|
||||
this.edges = externalEdges;
|
||||
|
||||
|
||||
@@ -178,7 +178,8 @@ describe('exitGroupNode', () => {
|
||||
|
||||
state.exitGroupNode();
|
||||
|
||||
expect(state.activeNodeId).toBe(-1);
|
||||
// Group instance node is re-selected on exit; internal selection is cleared
|
||||
expect(state.activeNodeId).toBe(groupNode!.id);
|
||||
expect(state.selectedNodes.size).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -376,7 +376,7 @@ export class GraphState {
|
||||
const result = this.graph.exitGroup();
|
||||
if (!result) return;
|
||||
this.cameraPosition = result.camera;
|
||||
this.activeNodeId = -1;
|
||||
this.activeNodeId = result.nodeId;
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
};
|
||||
|
||||
let { manager, node = $bindable() }: Props = $props();
|
||||
|
||||
const isGroupInstance = $derived(node?.type === '__internal/group/instance');
|
||||
const activeGroup = $derived(
|
||||
isGroupInstance ? manager.getGroup(node!.props?.groupId as number) : undefined
|
||||
);
|
||||
|
||||
let groupName = $state('');
|
||||
$effect(() => {
|
||||
groupName = activeGroup?.name ?? '';
|
||||
});
|
||||
|
||||
function handleRename(e: Event) {
|
||||
const name = (e.target as HTMLInputElement).value;
|
||||
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
||||
@@ -17,7 +32,18 @@
|
||||
|
||||
{#if node}
|
||||
{#key node.id}
|
||||
{#if node}
|
||||
{#if isGroupInstance && activeGroup}
|
||||
<div class="group-settings">
|
||||
<label for="group-name">Group name</label>
|
||||
<input
|
||||
id="group-name"
|
||||
type="text"
|
||||
placeholder="Group {activeGroup.id}"
|
||||
value={groupName}
|
||||
oninput={handleRename}
|
||||
/>
|
||||
</div>
|
||||
{:else if node}
|
||||
<ActiveNodeSelected {manager} bind:node />
|
||||
{/if}
|
||||
{/key}
|
||||
@@ -26,7 +52,59 @@
|
||||
{/if}
|
||||
|
||||
{#if manager?.graph.groups.length}
|
||||
<button onclick={() => manager.removeUnusedGroups()}>
|
||||
remove unused groups
|
||||
</button>
|
||||
<div class="group-actions">
|
||||
<button onclick={() => manager.removeUnusedGroups()}>
|
||||
Remove unused groups
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.group-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.group-settings label {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.group-settings input {
|
||||
background: var(--color-layer-1);
|
||||
border: 1px solid var(--color-outline);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
font-size: 0.9em;
|
||||
padding: 0.4em 0.6em;
|
||||
}
|
||||
|
||||
.group-settings input:focus {
|
||||
outline: 1px solid var(--color-active);
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
padding: 0.75em 1em;
|
||||
border-top: 1px solid var(--color-outline);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.group-actions button {
|
||||
width: 100%;
|
||||
padding: 0.4em 0.8em;
|
||||
background: var(--color-layer-1);
|
||||
border: 1px solid var(--color-outline);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-actions button:hover {
|
||||
border-color: var(--color-active);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user