feat: make group input/output node work
📊 Benchmark the Runtime / benchmark (pull_request) Successful in 1m11s
🚀 Lint & Test & Deploy / quality (pull_request) Successful in 2m7s
🚀 Lint & Test & Deploy / test-unit (pull_request) Successful in 32s
🚀 Lint & Test & Deploy / test-e2e (pull_request) Successful in 1m50s
🚀 Lint & Test & Deploy / deploy (pull_request) Successful in 1m56s

This commit is contained in:
2026-05-04 19:11:52 +02:00
parent 1a56ba986d
commit 106797de32
12 changed files with 255 additions and 113 deletions
@@ -69,7 +69,7 @@ export class GraphManager extends EventEmitter<{
status = $state<'loading' | 'idle' | 'error'>(); status = $state<'loading' | 'idle' | 'error'>();
loaded = false; loaded = false;
graph: Graph = { id: 0, nodes: [], edges: [], groups: [] }; graph: Graph = $state({ id: 0, nodes: [], edges: [], groups: [] });
id = $state(0); id = $state(0);
nodes = new SvelteMap<number, NodeInstance>(); nodes = new SvelteMap<number, NodeInstance>();
@@ -495,9 +495,24 @@ export class GraphManager extends EventEmitter<{
const groupId = this.graphStack.at(-1)?.groupId; const groupId = this.graphStack.at(-1)?.groupId;
const group = groupId !== undefined ? this.getGroup(groupId) : undefined; const group = groupId !== undefined ? this.getGroup(groupId) : undefined;
if (!group) return node.state.type; if (!group) return node.state.type;
const groupInputs: NodeDefinition['inputs'] = Object.fromEntries(
Object.values(group?.inputs || {}).map((o, i) => {
return [
`in_${i}`,
{
...o,
external: true
}
];
}) || []
);
return { return {
id: '__internal/group/input' as NodeId, id: '__internal/group/input' as NodeId,
outputs: Object.values(group.inputs ?? {}).map(i => i.type), meta: {
title: 'Group Input'
},
inputs: groupInputs,
execute: (x: Int32Array) => x execute: (x: Int32Array) => x
} as NodeDefinition; } as NodeDefinition;
} }
@@ -514,6 +529,9 @@ export class GraphManager extends EventEmitter<{
i i
) => [`out_${i}`, { type: o.type, label: o.label, external: true }]) ) => [`out_${i}`, { type: o.type, label: o.label, external: true }])
), ),
meta: {
title: 'Group Output'
},
outputs: [], outputs: [],
execute: (x: Int32Array) => x execute: (x: Int32Array) => x
} as NodeDefinition; } as NodeDefinition;
@@ -1007,7 +1025,9 @@ export class GraphManager extends EventEmitter<{
const toType = this.getNodeType(to); const toType = this.getNodeType(to);
// check if socket types match // check if socket types match
const fromSocketType = fromType?.outputs?.[fromSocket]; const fromSocketType = from.type === '__internal/group/input'
? fromType?.inputs?.[Object.keys(fromType?.inputs || {})[fromSocket]].type
: fromType?.outputs?.[fromSocket];
const toSocketType = [toType?.inputs?.[toSocket]?.type]; const toSocketType = [toType?.inputs?.[toSocket]?.type];
if (toType?.inputs?.[toSocket]?.accepts) { if (toType?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(toType?.inputs?.[toSocket]?.accepts || [])); toSocketType.push(...(toType?.inputs?.[toSocket]?.accepts || []));
@@ -1179,7 +1199,9 @@ export class GraphManager extends EventEmitter<{
} }
}); });
const ownType = nodeType.outputs?.[index]; const ownType = node.type === '__internal/group/input'
? nodeType.inputs?.[Object.keys(nodeType?.inputs || {})[index]].type
: nodeType.outputs?.[index];
for (const node of nodes) { for (const node of nodes) {
const inputs = this.getNodeType(node)?.inputs; const inputs = this.getNodeType(node)?.inputs;
@@ -384,6 +384,13 @@ export class GraphState {
node: NodeInstance, node: NodeInstance,
index: string | number index: string | number
): [number, number] { ): [number, number] {
if (node.type === '__internal/group/input' && typeof index === 'number') {
return [
(node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + 2.5 + 5 * index + 5
];
}
if (typeof index === 'number') { if (typeof index === 'number') {
return [ return [
(node?.state?.x ?? node.position[0]) + 20, (node?.state?.x ?? node.position[0]) + 20,
@@ -100,8 +100,22 @@
if (typeof index === 'string') { if (typeof index === 'string') {
return nodeType?.inputs?.[index].type || 'unknown'; return nodeType?.inputs?.[index].type || 'unknown';
} }
if (node.type === '__internal/group/input') {
const key = Object.keys(nodeType?.inputs || {})[index];
return nodeType?.inputs?.[key].type || 'unknown';
}
return nodeType?.outputs?.[index] || 'unknown'; return nodeType?.outputs?.[index] || 'unknown';
} }
function getGroupName() {
const groupId = graph.graphStack.at(-1)?.groupId;
if (groupId !== undefined) {
const group = graph.getGroup(groupId);
return group?.name || `Group#${groupId}`;
}
}
</script> </script>
<svelte:window <svelte:window
@@ -114,6 +128,7 @@
bind:this={graphState.wrapper} bind:this={graphState.wrapper}
class="graph-wrapper" class="graph-wrapper"
style="height: 100%" style="height: 100%"
class:is-inside-group={graph.isInsideGroup}
class:is-panning={graphState.isPanning} class:is-panning={graphState.isPanning}
class:is-hovering={graphState.hoveredNodeId !== -1} class:is-hovering={graphState.hoveredNodeId !== -1}
aria-label="Graph" aria-label="Graph"
@@ -121,11 +136,13 @@
tabindex="0" tabindex="0"
bind:clientWidth={graphState.width} bind:clientWidth={graphState.width}
bind:clientHeight={graphState.height} bind:clientHeight={graphState.height}
style:--padding-right="{safePadding?.right || 0}px"
onkeydown={(ev) => keymap.handleKeyboardEvent(ev)} onkeydown={(ev) => keymap.handleKeyboardEvent(ev)}
onmousedown={(ev) => mouseEvents.handleMouseDown(ev)} onmousedown={(ev) => mouseEvents.handleMouseDown(ev)}
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)} oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
{...fileDropEvents.getEventListenerProps()} {...fileDropEvents.getEventListenerProps()}
> >
<div class="shadow"></div>
<input <input
type="file" type="file"
accept="application/wasm,application/json" accept="application/wasm,application/json"
@@ -140,6 +157,9 @@
<button class="exit-group" onclick={() => graphState.exitGroupNode()}> <button class="exit-group" onclick={() => graphState.exitGroupNode()}>
↑ Exit Group ↑ Exit Group
</button> </button>
<p class="group-name absolute">
Group <b>{getGroupName()}</b>
</p>
{/if} {/if}
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}> <Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
@@ -250,6 +270,15 @@
height: 100%; height: 100%;
} }
.group-name {
position: absolute;
left: calc(50% - var(--padding-right) / 2);
transition: left 0.3s ease;
top: 12px;
transform: translateX(-50%);
z-index: 1000;
}
.exit-group { .exit-group {
position: absolute; position: absolute;
top: 12px; top: 12px;
@@ -280,6 +309,22 @@
cursor: pointer; cursor: pointer;
} }
.shadow {
position: absolute;
top: -5px;
left: -5px;
right: calc(var(--padding-right) - 5px);
bottom: -5px;
z-index: 1;
transition: box-shadow 0.3s ease;
box-shadow: 0 0 0px 0px var(--color-layer-2) inset;
pointer-events: none;
}
.is-inside-group .shadow {
box-shadow: 0 0 0px 8px var(--color-layer-2) inset;
}
.is-panning { .is-panning {
cursor: grab; cursor: grab;
} }
@@ -1,6 +1,10 @@
import type { NodeDefinition } from '@nodarium/types'; import type { NodeDefinition } from '@nodarium/types';
export function getParameterHeight(node: NodeDefinition, inputKey: string) { export function getParameterHeight(node: NodeDefinition, inputKey: string) {
if (node.id === '__internal/group/input') {
return 50;
}
const input = node.inputs?.[inputKey]; const input = node.inputs?.[inputKey];
if (!input) { if (!input) {
return 0; return 0;
@@ -23,7 +23,10 @@
const cornerTop = 10; const cornerTop = 10;
const nodeType = $derived(graph.getNodeType(node)); const nodeType = $derived(graph.getNodeType(node));
const rightBump = $derived(!!nodeType?.outputs?.length); const rightBump = $derived(
!!nodeType?.outputs?.length && node.type !== '__internal/group/input'
);
const aspectRatio = 0.25; const aspectRatio = 0.25;
const path = $derived( const path = $derived(
@@ -73,6 +76,7 @@
{/if} {/if}
{nodeType?.meta?.title || node.type?.split('/').pop()} {nodeType?.meta?.title || node.type?.split('/').pop()}
</div> </div>
{#if rightBump}
<div <div
class="target" class="target"
role="button" role="button"
@@ -80,6 +84,7 @@
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
> >
</div> </div>
{/if}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100" viewBox="0 0 100 100"
@@ -29,14 +29,27 @@
function handleMouseDown(ev: MouseEvent) { function handleMouseDown(ev: MouseEvent) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
if (node.type === '__internal/group/input') {
const outputIndex = Object.entries(nodeType?.inputs ?? {}).findIndex(([key]) => key === id);
graphState.setDownSocket({
node,
index: outputIndex,
position: graphState.getSocketPosition(node, outputIndex)
});
} else {
graphState.setDownSocket({ graphState.setDownSocket({
node, node,
index: id, index: id,
position: graphState.getSocketPosition(node, id) position: graphState.getSocketPosition(node, id)
}); });
} }
}
const leftBump = $derived(nodeType.inputs?.[id].internal !== true); const leftBump = $derived(
nodeType.inputs?.[id].internal !== true && node.type !== '__internal/group/input'
);
const rightBump = $derived(node.type === '__internal/group/input');
const cornerBottom = $derived(isLast ? 5 : 0); const cornerBottom = $derived(isLast ? 5 : 0);
const aspectRatio = 0.5; const aspectRatio = 0.5;
@@ -46,6 +59,7 @@
height: 2000 / height, height: 2000 / height,
y: 50.5, y: 50.5,
cornerBottom, cornerBottom,
rightBump,
leftBump, leftBump,
aspectRatio aspectRatio
}) })
@@ -55,6 +69,7 @@
depth: 7, depth: 7,
height: 2200 / height, height: 2200 / height,
y: 50.5, y: 50.5,
rightBump,
cornerBottom, cornerBottom,
leftBump, leftBump,
aspectRatio aspectRatio
@@ -76,6 +91,7 @@
<div <div
class="wrapper" class="wrapper"
data-node-type={node.type} data-node-type={node.type}
class:is-group-input={node.type === '__internal/group/input'}
data-node-input={id} data-node-input={id}
style:height="{height}px" style:height="{height}px"
style:--socket-color={hoverColor} style:--socket-color={hoverColor}
@@ -130,6 +146,11 @@
transform: translateY(-50%) translateX(-50%); transform: translateY(-50%) translateX(-50%);
} }
.is-group-input .target {
right: 0px;
transform: translateY(-50%) translateX(50%);
}
.possible-socket .target::before { .possible-socket .target::before {
content: ""; content: "";
position: absolute; position: absolute;
+3
View File
@@ -1,5 +1,8 @@
export const debugNode = { export const debugNode = {
id: '__internal/debug/instance', id: '__internal/debug/instance',
meta: {
title: 'Debug'
},
inputs: { inputs: {
input: { input: {
type: '*' type: '*'
@@ -96,6 +96,4 @@
bind:value={store} bind:value={store}
type={nodeDefinition} type={nodeDefinition}
/> />
{:else}
<p class="mx-4 mt-4">Node has no settings</p>
{/if} {/if}
@@ -11,97 +11,11 @@
let { manager, node = $bindable() }: Props = $props(); let { manager, node = $bindable() }: Props = $props();
const isGroupInstance = $derived(node?.type === '__internal/group/instance'); const isGroupInstance = $derived(node?.type === '__internal/group/instance');
const activeGroup = $derived(
isGroupInstance ? manager.getGroup(node!.props?.groupId as number) : undefined
);
const groupName = $derived(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'> {#if node && !isGroupInstance}
<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'>
<h3>Node Settings</h3> <h3>Node Settings</h3>
</div>
{#if node}
{#key node.id}
{#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> </div>
{:else if node}
<ActiveNodeSelected {manager} bind:node /> <ActiveNodeSelected {manager} bind:node />
{/if}
{/key}
{:else}
<p class="mx-4 mt-4">No node selected</p>
{/if} {/if}
{#if manager?.graph.groups.length}
<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>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
import type { NodeInstance } from '@nodarium/types';
type Props = {
manager: GraphManager;
node?: NodeInstance;
};
const { manager, node = $bindable() }: Props = $props();
const activeGroup = $derived.by(() => {
console.log('isInsideGroup', manager?.isInsideGroup);
if (manager?.isInsideGroup) {
const activeGroupId = manager.graphStack?.at(-1)?.groupId;
console.log('activeGroupId', activeGroupId);
if (activeGroupId !== undefined) {
return manager.getGroup(activeGroupId);
}
}
if (node?.type === '__internal/group/instance') {
return manager.getGroup(node.props?.groupId as number);
}
});
const groupName = $derived(activeGroup?.name ?? '');
function handleRename(e: Event) {
const name = (e.target as HTMLInputElement).value;
if (activeGroup) manager.renameGroup(activeGroup.id, name);
}
const hasUnusedGroups = $derived.by(() => {
if (!manager) return false;
if (manager.isInsideGroup) return false;
if (manager.graph.groups.length === 0) return false;
return manager.graph.groups.filter(g => {
return !manager.graph.nodes.find(n => n.props?.groupId === g.id);
}).length;
});
</script>
{#if activeGroup || hasUnusedGroups}
<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'>
<h3>Group Settings</h3>
</div>
{/if}
{#if activeGroup}
{#key activeGroup.id}
<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>
{/key}
{/if}
{#if hasUnusedGroups}
<div class="group-actions">
<button onclick={() => manager.removeUnusedGroups()}>
Remove ({hasUnusedGroups}) orphaned 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-bottom: 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 -1
View File
@@ -21,6 +21,7 @@
import Changelog from '$lib/sidebar/panels/Changelog.svelte'; import Changelog from '$lib/sidebar/panels/Changelog.svelte';
import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte'; import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte';
import GraphSource from '$lib/sidebar/panels/GraphSource.svelte'; import GraphSource from '$lib/sidebar/panels/GraphSource.svelte';
import GroupSettings from '$lib/sidebar/panels/GroupSettings.svelte';
import Keymap from '$lib/sidebar/panels/Keymap.svelte'; import Keymap from '$lib/sidebar/panels/Keymap.svelte';
import { panelState } from '$lib/sidebar/PanelState.svelte'; import { panelState } from '$lib/sidebar/PanelState.svelte';
import Sidebar from '$lib/sidebar/Sidebar.svelte'; import Sidebar from '$lib/sidebar/Sidebar.svelte';
@@ -258,7 +259,7 @@
graph={pm.graph} graph={pm.graph}
bind:this={graphInterface} bind:this={graphInterface}
registry={nodeRegistry} registry={nodeRegistry}
safePadding={{ right: sidebarOpen ? 330 : undefined }} safePadding={{ right: sidebarOpen ? 320 : undefined }}
backgroundType={appSettings.value.nodeInterface.backgroundType} backgroundType={appSettings.value.nodeInterface.backgroundType}
snapToGrid={appSettings.value.nodeInterface.snapToGrid} snapToGrid={appSettings.value.nodeInterface.snapToGrid}
bind:activeNode bind:activeNode
@@ -336,12 +337,14 @@
title="Graph Settings" title="Graph Settings"
icon="i-[custom--graph] bg-blue-400" icon="i-[custom--graph] bg-blue-400"
> >
<span class="block h-[1px]"></span>
<NestedSettings <NestedSettings
id="graph-settings" id="graph-settings"
type={graphSettingTypes} type={graphSettingTypes}
bind:value={graphSettings} bind:value={graphSettings}
/> />
<ActiveNodeSettings {manager} bind:node={activeNode} /> <ActiveNodeSettings {manager} bind:node={activeNode} />
<GroupSettings {manager} bind:node={activeNode} />
</Panel> </Panel>
<Panel <Panel
id="changelog" id="changelog"
+2 -2
View File
@@ -1,6 +1,6 @@
<script module lang="ts"> <script module lang="ts">
import { SvelteMap } from 'svelte/reactivity'; // eslint-disable-next-line svelte/prefer-svelte-reactivity
const cache = new SvelteMap<string, Record<string, boolean>>(); const cache = new Map<string, Record<string, boolean>>();
function getStore(root: string): Record<string, boolean> { function getStore(root: string): Record<string, boolean> {
if (!cache.has(root)) { if (!cache.has(root)) {