feat(ui): make inputselect also handle value+label options

This commit is contained in:
2026-05-04 14:11:52 +02:00
parent 59a1e63396
commit 3ee074b11c
7 changed files with 174 additions and 31 deletions
@@ -152,6 +152,7 @@ export class GraphManager extends EventEmitter<{
return { return {
id: group.id, id: group.id,
name: group.name,
inputs: group.inputs, inputs: group.inputs,
outputs: group.outputs, outputs: group.outputs,
nodes: groupNodes, nodes: groupNodes,
@@ -527,16 +528,25 @@ export class GraphManager extends EventEmitter<{
return; return;
} }
const inputs = { const defaultInputs = {
...(node.state.type?.inputs || {}), ...(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': { 'groupId': {
type: 'select', type: 'select',
label: '', label: '',
value: node.props?.groupId, value: node.props?.groupId,
internal: true, 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 = { const groupType = {
@@ -658,6 +668,13 @@ export class GraphManager extends EventEmitter<{
return this.graph.groups.find(g => g.id === id); 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); isInsideGroup = $state(false);
enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean { enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean {
@@ -845,35 +862,53 @@ export class GraphManager extends EventEmitter<{
groupPosition[0] /= nodes.length; groupPosition[0] /= nodes.length;
groupPosition[1] /= 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 = { const groupInputNode: NodeInstance = {
id: this.createNodeId(), id: nextId(),
type: '__internal/group/input', type: '__internal/group/input',
position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2], position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2],
state: {} state: {}
}; };
const groupOutputNode: NodeInstance = { const groupOutputNode: NodeInstance = {
id: this.createNodeId(), id: nextId(),
type: '__internal/group/output', type: '__internal/group/output',
position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2], position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2],
state: {} 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) => { const internalEdges = this.edges.filter((edge) => {
return ids.has(edge[0].id) || ids.has(edge[2].id); return ids.has(edge[0].id) || ids.has(edge[2].id);
}).map((edge) => { }).map((edge) => {
// Going in from the group
if (!ids.has(edge[0].id)) { if (!ids.has(edge[0].id)) {
return [groupInputNode.id, 0, edge[2].id, edge[3]]; const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0;
// Going out to the group return [groupInputNode.id, idx, edge[2].id, edge[3]];
} else if (!ids.has(edge[2].id)) { } else if (!ids.has(edge[2].id)) {
return [edge[0].id, edge[1], groupOutputNode.id, 'out_0']; return [edge[0].id, edge[1], groupOutputNode.id, 'out_0'];
} }
return [edge[0].id, edge[1], edge[2].id, edge[3]]; return [edge[0].id, edge[1], edge[2].id, edge[3]];
}) as [number, number, number, string][]; }) as [number, number, number, string][];
const groupId = this.createNodeId(); const groupId = nextId();
const groupDefinition: GroupDefinition = { const groupDefinition: GroupDefinition = {
id: groupId, id: groupId,
inputs: inputs, inputs: inputs,
@@ -882,6 +917,9 @@ export class GraphManager extends EventEmitter<{
nodes: [groupInputNode, ...nodes, groupOutputNode] nodes: [groupInputNode, ...nodes, groupOutputNode]
}; };
// Push before createNode so createNodeId() inside sees the allocated IDs.
this.graph.groups.push(groupDefinition);
const groupNode = this.createNode({ const groupNode = this.createNode({
type: '__internal/group/instance', type: '__internal/group/instance',
position: [groupPosition[0], groupPosition[1]], position: [groupPosition[0], groupPosition[1]],
@@ -892,20 +930,17 @@ export class GraphManager extends EventEmitter<{
if (!groupNode) throw new Error('Failed to create group node'); if (!groupNode) throw new Error('Failed to create group node');
// Update the edges that are now inside // Rewire external edges to/from the group node using the correct input socket.
// the group to be connected to that group node
const externalEdges = this.edges.map((edge) => { const externalEdges = this.edges.map((edge) => {
if (ids.has(edge[2].id)) { if (ids.has(edge[2].id)) {
// Edge going into the group const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0;
return [edge[0], edge[1], groupNode, 'input_0'] as Edge; return [edge[0], edge[1], groupNode, `input_${idx}`] as Edge;
} else if (ids.has(edge[0].id)) { } else if (ids.has(edge[0].id)) {
// Edge going out of the group
return [groupNode, 0, edge[2], edge[3]] as Edge; return [groupNode, 0, edge[2], edge[3]] as Edge;
} }
return edge; return edge;
}); });
this.graph.groups.push(groupDefinition);
this.nodes.set(groupNode.id, groupNode); this.nodes.set(groupNode.id, groupNode);
this.edges = externalEdges; this.edges = externalEdges;
@@ -178,7 +178,8 @@ describe('exitGroupNode', () => {
state.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); expect(state.selectedNodes.size).toBe(0);
}); });
@@ -376,7 +376,7 @@ export class GraphState {
const result = this.graph.exitGroup(); const result = this.graph.exitGroup();
if (!result) return; if (!result) return;
this.cameraPosition = result.camera; this.cameraPosition = result.camera;
this.activeNodeId = -1; this.activeNodeId = result.nodeId;
this.clearSelection(); this.clearSelection();
} }
@@ -9,6 +9,21 @@
}; };
let { manager, node = $bindable() }: Props = $props(); 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> </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'> <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} {#if node}
{#key node.id} {#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 /> <ActiveNodeSelected {manager} bind:node />
{/if} {/if}
{/key} {/key}
@@ -26,7 +52,59 @@
{/if} {/if}
{#if manager?.graph.groups.length} {#if manager?.graph.groups.length}
<div class="group-actions">
<button onclick={() => manager.removeUnusedGroups()}> <button onclick={() => manager.removeUnusedGroups()}>
remove unused groups Remove unused groups
</button> </button>
</div>
{/if} {/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>
+4 -2
View File
@@ -61,8 +61,10 @@ export const NodeInputBooleanSchema = z.object({
export const NodeInputSelectSchema = z.object({ export const NodeInputSelectSchema = z.object({
...DefaultOptionsSchema.shape, ...DefaultOptionsSchema.shape,
type: z.literal('select'), type: z.literal('select'),
options: z.array(z.string()).optional(), options: z.array(
value: z.string().optional() z.union([z.string(), z.object({ value: z.number(), label: z.string() })])
).optional(),
value: z.union([z.string(), z.number()]).optional()
}); });
export const NodeInputSeedSchema = z.object({ export const NodeInputSeedSchema = z.object({
+11 -3
View File
@@ -1,16 +1,24 @@
<script lang="ts"> <script lang="ts">
type SelectOption = string | { value: number; label: string };
interface Props { interface Props {
options?: string[]; options?: SelectOption[];
value?: number; value?: number;
id?: string; id?: string;
} }
let { options = [], value = $bindable(0), id = '' }: Props = $props(); let { options = [], value = $bindable(0), id = '' }: Props = $props();
const normalized = $derived(
options.map((opt, i) =>
typeof opt === 'string' ? { value: i, label: opt } : opt
)
);
</script> </script>
<select {id} bind:value class="bg-layer-2 text-text"> <select {id} bind:value class="bg-layer-2 text-text">
{#each options as label, i (label)} {#each normalized as opt (opt.value)}
<option value={i}>{label}</option> <option value={opt.value}>{opt.label}</option>
{/each} {/each}
</select> </select>
+22 -3
View File
@@ -21,7 +21,7 @@
let vecValue = $state([0.2, 0.3, 0.4]); let vecValue = $state([0.2, 0.3, 0.4]);
const options = ['strawberry', 'raspberry', 'chickpeas']; const options = ['strawberry', 'raspberry', 'chickpeas'];
let selectValue = $state(0); let selectValue = $state(0);
const d = $derived(options[selectValue]); let selectValue2 = $state(0);
let checked = $state(false); let checked = $state(false);
let colorValue = $state<[number, number, number]>([59, 130, 246]); let colorValue = $state<[number, number, number]>([59, 130, 246]);
let mirrorShape = $state(true); let mirrorShape = $state(true);
@@ -82,9 +82,28 @@
<InputVec3 bind:value={vecValue} /> <InputVec3 bind:value={vecValue} />
</Section> </Section>
<Section title="Select" value={d}> <Section title="Select">
<i>Select with simple values</i> <p>
Select with simple values
<br>
<b>value={options[selectValue]}</b>
</p>
<InputSelect bind:value={selectValue} {options} /> <InputSelect bind:value={selectValue} {options} />
<br>
<br>
<p>
Select with <i>&lbrace;option: number, label: string&rbrace;[]</i>
<br>
<b>value={selectValue2}</b>
</p>
<InputSelect
bind:value={selectValue2}
options={[
{ value: 0, label: 'Zero' },
{ value: 1, label: 'One' },
{ value: 2, label: 'Two' }
]}
/>
</Section> </Section>
<Section title="Checkbox" value={checked}> <Section title="Checkbox" value={checked}>