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'>();
loaded = false;
graph: Graph = { id: 0, nodes: [], edges: [], groups: [] };
graph: Graph = $state({ id: 0, nodes: [], edges: [], groups: [] });
id = $state(0);
nodes = new SvelteMap<number, NodeInstance>();
@@ -495,9 +495,24 @@ export class GraphManager extends EventEmitter<{
const groupId = this.graphStack.at(-1)?.groupId;
const group = groupId !== undefined ? this.getGroup(groupId) : undefined;
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 {
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
} as NodeDefinition;
}
@@ -514,6 +529,9 @@ export class GraphManager extends EventEmitter<{
i
) => [`out_${i}`, { type: o.type, label: o.label, external: true }])
),
meta: {
title: 'Group Output'
},
outputs: [],
execute: (x: Int32Array) => x
} as NodeDefinition;
@@ -1007,7 +1025,9 @@ export class GraphManager extends EventEmitter<{
const toType = this.getNodeType(to);
// 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];
if (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) {
const inputs = this.getNodeType(node)?.inputs;
@@ -384,6 +384,13 @@ export class GraphState {
node: NodeInstance,
index: string | 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') {
return [
(node?.state?.x ?? node.position[0]) + 20,
@@ -100,8 +100,22 @@
if (typeof index === 'string') {
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';
}
function getGroupName() {
const groupId = graph.graphStack.at(-1)?.groupId;
if (groupId !== undefined) {
const group = graph.getGroup(groupId);
return group?.name || `Group#${groupId}`;
}
}
</script>
<svelte:window
@@ -114,6 +128,7 @@
bind:this={graphState.wrapper}
class="graph-wrapper"
style="height: 100%"
class:is-inside-group={graph.isInsideGroup}
class:is-panning={graphState.isPanning}
class:is-hovering={graphState.hoveredNodeId !== -1}
aria-label="Graph"
@@ -121,11 +136,13 @@
tabindex="0"
bind:clientWidth={graphState.width}
bind:clientHeight={graphState.height}
style:--padding-right="{safePadding?.right || 0}px"
onkeydown={(ev) => keymap.handleKeyboardEvent(ev)}
onmousedown={(ev) => mouseEvents.handleMouseDown(ev)}
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
{...fileDropEvents.getEventListenerProps()}
>
<div class="shadow"></div>
<input
type="file"
accept="application/wasm,application/json"
@@ -140,6 +157,9 @@
<button class="exit-group" onclick={() => graphState.exitGroupNode()}>
↑ Exit Group
</button>
<p class="group-name absolute">
Group <b>{getGroupName()}</b>
</p>
{/if}
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
@@ -250,6 +270,15 @@
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 {
position: absolute;
top: 12px;
@@ -280,6 +309,22 @@
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 {
cursor: grab;
}
@@ -1,6 +1,10 @@
import type { NodeDefinition } from '@nodarium/types';
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
if (node.id === '__internal/group/input') {
return 50;
}
const input = node.inputs?.[inputKey];
if (!input) {
return 0;
@@ -23,7 +23,10 @@
const cornerTop = 10;
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 path = $derived(
@@ -73,13 +76,15 @@
{/if}
{nodeType?.meta?.title || node.type?.split('/').pop()}
</div>
<div
class="target"
role="button"
tabindex="0"
onmousedown={handleMouseDown}
>
</div>
{#if rightBump}
<div
class="target"
role="button"
tabindex="0"
onmousedown={handleMouseDown}
>
</div>
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
@@ -29,14 +29,27 @@
function handleMouseDown(ev: MouseEvent) {
ev.preventDefault();
ev.stopPropagation();
graphState.setDownSocket({
node,
index: id,
position: graphState.getSocketPosition(node, id)
});
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({
node,
index: 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 aspectRatio = 0.5;
@@ -46,6 +59,7 @@
height: 2000 / height,
y: 50.5,
cornerBottom,
rightBump,
leftBump,
aspectRatio
})
@@ -55,6 +69,7 @@
depth: 7,
height: 2200 / height,
y: 50.5,
rightBump,
cornerBottom,
leftBump,
aspectRatio
@@ -76,6 +91,7 @@
<div
class="wrapper"
data-node-type={node.type}
class:is-group-input={node.type === '__internal/group/input'}
data-node-input={id}
style:height="{height}px"
style:--socket-color={hoverColor}
@@ -130,6 +146,11 @@
transform: translateY(-50%) translateX(-50%);
}
.is-group-input .target {
right: 0px;
transform: translateY(-50%) translateX(50%);
}
.possible-socket .target::before {
content: "";
position: absolute;