feat: drop node on edge

Closes #13
This commit is contained in:
2026-01-20 17:46:09 +01:00
parent f98b90dcd3
commit a3d10d6094
13 changed files with 710 additions and 439 deletions

View File

@@ -1,31 +1,30 @@
import throttle from '$lib/helpers/throttle';
import type {
Edge,
Graph,
NodeInstance,
NodeDefinition,
NodeInput,
NodeRegistry,
NodeId,
Socket,
} from "@nodarium/types";
import { fastHashString } from "@nodarium/utils";
import { SvelteMap } from "svelte/reactivity";
import EventEmitter from "./helpers/EventEmitter";
import { createLogger } from "@nodarium/utils";
import throttle from "$lib/helpers/throttle";
import { HistoryManager } from "./history-manager";
NodeInput,
NodeInstance,
NodeRegistry,
Socket
} from '@nodarium/types';
import { fastHashString } from '@nodarium/utils';
import { createLogger } from '@nodarium/utils';
import { SvelteMap } from 'svelte/reactivity';
import EventEmitter from './helpers/EventEmitter';
import { HistoryManager } from './history-manager';
const logger = createLogger("graph-manager");
const logger = createLogger('graph-manager');
logger.mute();
const clone =
"structuredClone" in self
? self.structuredClone
: (args: any) => JSON.parse(JSON.stringify(args));
const clone = 'structuredClone' in self
? self.structuredClone
: (args: any) => JSON.parse(JSON.stringify(args));
function areSocketsCompatible(
output: string | undefined,
inputs: string | (string | undefined)[] | undefined,
inputs: string | (string | undefined)[] | undefined
) {
if (Array.isArray(inputs) && output) {
return inputs.includes(output);
@@ -34,24 +33,23 @@ function areSocketsCompatible(
}
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
if (firstEdge[0].id !== secondEdge[0].id) {
return false;
}
if (firstEdge[1] !== secondEdge[1]) {
return false
return false;
}
if (firstEdge[2].id !== secondEdge[2].id) {
return false
return false;
}
if (firstEdge[3] !== secondEdge[3]) {
return false
return false;
}
return true
return true;
}
export class GraphManager extends EventEmitter<{
@@ -62,7 +60,7 @@ export class GraphManager extends EventEmitter<{
values: Record<string, unknown>;
};
}> {
status = $state<"loading" | "idle" | "error">();
status = $state<'loading' | 'idle' | 'error'>();
loaded = false;
graph: Graph = { id: 0, nodes: [], edges: [] };
@@ -88,7 +86,7 @@ export class GraphManager extends EventEmitter<{
history: HistoryManager = new HistoryManager();
execute = throttle(() => {
if (this.loaded === false) return;
this.emit("result", this.serialize());
this.emit('result', this.serialize());
}, 10);
constructor(public registry: NodeRegistry) {
@@ -100,21 +98,21 @@ export class GraphManager extends EventEmitter<{
id: node.id,
position: [...node.position],
type: node.type,
props: node.props,
props: node.props
})) as NodeInstance[];
const edges = this.edges.map((edge) => [
edge[0].id,
edge[1],
edge[2].id,
edge[3],
]) as Graph["edges"];
edge[3]
]) as Graph['edges'];
const serialized = {
id: this.graph.id,
settings: $state.snapshot(this.settings),
nodes,
edges,
edges
};
logger.log("serializing graph", serialized);
logger.log('serializing graph', serialized);
return clone($state.snapshot(serialized));
}
@@ -148,6 +146,95 @@ export class GraphManager extends EventEmitter<{
return [...nodes.values()];
}
getEdgeId(e: Edge) {
return `${e[0].id}-${e[1]}-${e[2].id}-${e[3]}`;
}
getEdgeById(id: string): Edge | undefined {
return this.edges.find((e) => this.getEdgeId(e) === id);
}
dropNodeOnEdge(nodeId: number, edge: Edge) {
const draggedNode = this.getNode(nodeId);
if (!draggedNode || !draggedNode.state?.type) return;
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
const draggedInputs = Object.entries(draggedNode.state.type.inputs ?? {});
const draggedOutputs = draggedNode.state.type.outputs ?? [];
const edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const bestInputEntry = draggedInputs.find(([_, input]) => {
const accepted = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, accepted);
});
const bestOutputIdx = draggedOutputs.findIndex(outputType => areSocketsCompatible(outputType, targetAcceptedTypes));
if (!bestInputEntry || bestOutputIdx === -1) {
logger.error('Could not find compatible sockets for drop');
return;
}
this.startUndoGroup();
this.removeEdge(edge, { applyDeletion: false });
this.createEdge(fromNode, fromSocketIdx, draggedNode, bestInputEntry[0], {
applyUpdate: false
});
this.createEdge(draggedNode, bestOutputIdx, toNode, toSocketKey, {
applyUpdate: false
});
this.saveUndoGroup();
this.execute();
}
getPossibleDropOnEdges(nodeId: number): Edge[] {
const draggedNode = this.getNode(nodeId);
if (!draggedNode || !draggedNode.state?.type) return [];
const draggedInputs = Object.values(draggedNode.state.type.inputs ?? {});
const draggedOutputs = draggedNode.state.type.outputs ?? [];
// Optimization: Pre-calculate parents to avoid cycles
const parentIds = new Set(this.getParentsOfNode(draggedNode).map(n => n.id));
return this.edges.filter((edge) => {
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
// 1. Prevent cycles: If the target node is already a parent, we can't drop here
if (parentIds.has(toNode.id)) return false;
// 2. Prevent self-dropping: Don't drop on edges already connected to this node
if (fromNode.id === nodeId || toNode.id === nodeId) return false;
// 3. Check if edge.source can plug into ANY draggedNode.input
const edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
const canPlugIntoDragged = draggedInputs.some(input => {
const acceptedTypes = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, acceptedTypes);
});
if (!canPlugIntoDragged) return false;
// 4. Check if ANY draggedNode.output can plug into edge.target
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const draggedCanPlugIntoTarget = draggedOutputs.some(outputType =>
areSocketsCompatible(outputType, targetAcceptedTypes)
);
return draggedCanPlugIntoTarget;
});
}
getEdgesBetweenNodes(nodes: NodeInstance[]): [number, number, number, string][] {
const edges = [];
for (const node of nodes) {
@@ -155,14 +242,14 @@ export class GraphManager extends EventEmitter<{
for (const child of children) {
if (nodes.includes(child)) {
const edge = this.edges.find(
(e) => e[0].id === node.id && e[2].id === child.id,
(e) => e[0].id === node.id && e[2].id === child.id
);
if (edge) {
edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [
number,
number,
number,
string,
string
]);
}
}
@@ -179,18 +266,18 @@ export class GraphManager extends EventEmitter<{
const n = node as NodeInstance;
if (nodeType) {
n.state = {
type: nodeType,
type: nodeType
};
}
return [node.id, n];
}),
})
);
const edges = graph.edges.map((edge) => {
const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]);
if (!from || !to) {
throw new Error("Edge references non-existing node");
throw new Error('Edge references non-existing node');
}
from.state.children = from.state.children || [];
from.state.children.push(to);
@@ -214,21 +301,21 @@ export class GraphManager extends EventEmitter<{
this.loaded = false;
this.graph = graph;
this.status = "loading";
this.status = 'loading';
this.id = graph.id;
logger.info("loading graph", $state.snapshot(graph));
logger.info('loading graph', $state.snapshot(graph));
const nodeIds = Array.from(new Set([...graph.nodes.map((n) => n.type)]));
await this.registry.load(nodeIds);
logger.info("loaded node types", this.registry.getAllNodes());
logger.info('loaded node types', this.registry.getAllNodes());
for (const node of this.graph.nodes) {
const nodeType = this.registry.getNode(node.type);
if (!nodeType) {
logger.error(`Node type not found: ${node.type}`);
this.status = "error";
this.status = 'error';
return;
}
// Turn into runtime node
@@ -253,11 +340,11 @@ export class GraphManager extends EventEmitter<{
settingTypes[settingId] = {
__node_type: type.id,
__node_input: key,
...type.inputs[key],
...type.inputs[key]
};
if (
settingValues[settingId] === undefined &&
"value" in type.inputs[key]
settingValues[settingId] === undefined
&& 'value' in type.inputs[key]
) {
settingValues[settingId] = type.inputs[key].value;
}
@@ -267,14 +354,14 @@ export class GraphManager extends EventEmitter<{
}
this.settings = settingValues;
this.emit("settings", { types: settingTypes, values: settingValues });
this.emit('settings', { types: settingTypes, values: settingValues });
this.history.reset();
this._init(this.graph);
this.save();
this.status = "idle";
this.status = 'idle';
this.loaded = true;
logger.log(`Graph loaded in ${performance.now() - a}ms`);
@@ -307,9 +394,9 @@ export class GraphManager extends EventEmitter<{
if (settingId) {
settingTypes[settingId] = nodeType.inputs[key];
if (
settingValues &&
settingValues?.[settingId] === undefined &&
"value" in nodeType.inputs[key]
settingValues
&& settingValues?.[settingId] === undefined
&& 'value' in nodeType.inputs[key]
) {
settingValues[settingId] = nodeType.inputs[key].value;
}
@@ -319,7 +406,7 @@ export class GraphManager extends EventEmitter<{
this.settings = settingValues;
this.settingTypes = settingTypes;
this.emit("settings", { types: settingTypes, values: settingValues });
this.emit('settings', { types: settingTypes, values: settingValues });
}
getChildren(node: NodeInstance) {
@@ -368,7 +455,7 @@ export class GraphManager extends EventEmitter<{
const inputType = to?.state?.type?.inputs?.[toSocket]?.type;
if (outputType === inputType) {
this.createEdge(from, fromSocket, to, toSocket, {
applyUpdate: false,
applyUpdate: false
});
continue;
}
@@ -403,7 +490,7 @@ export class GraphManager extends EventEmitter<{
// map old ids to new ids
const idMap = new Map<number, number>();
let startId = this.createNodeId()
let startId = this.createNodeId();
nodes = nodes.map((node) => {
const id = startId++;
@@ -420,7 +507,7 @@ export class GraphManager extends EventEmitter<{
const to = nodes.find((n) => n.id === idMap.get(edge[2]));
if (!from || !to) {
throw new Error("Edge references non-existing node");
throw new Error('Edge references non-existing node');
}
to.state.parents = to.state.parents || [];
@@ -445,11 +532,11 @@ export class GraphManager extends EventEmitter<{
createNode({
type,
position,
props = {},
props = {}
}: {
type: NodeInstance["type"];
position: NodeInstance["position"];
props: NodeInstance["props"];
type: NodeInstance['type'];
position: NodeInstance['position'];
props: NodeInstance['props'];
}) {
const nodeType = this.registry.getNode(type);
if (!nodeType) {
@@ -462,14 +549,14 @@ export class GraphManager extends EventEmitter<{
type,
position,
state: { type: nodeType },
props,
props
});
this.nodes.set(node.id, node);
this.save();
return node
return node;
}
createEdge(
@@ -477,17 +564,16 @@ export class GraphManager extends EventEmitter<{
fromSocket: number,
to: NodeInstance,
toSocket: string,
{ applyUpdate = true } = {},
{ applyUpdate = true } = {}
): Edge | undefined {
const existingEdges = this.getEdgesToNode(to);
// check if this exact edge already exists
const existingEdge = existingEdges.find(
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket,
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket
);
if (existingEdge) {
logger.error("Edge already exists", existingEdge);
logger.error('Edge already exists', existingEdge);
return;
}
@@ -500,13 +586,13 @@ export class GraphManager extends EventEmitter<{
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
logger.error(
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`,
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`
);
return;
}
const edgeToBeReplaced = this.edges.find(
(e) => e[2].id === to.id && e[3] === toSocket,
(e) => e[2].id === to.id && e[3] === toSocket
);
if (edgeToBeReplaced) {
this.removeEdge(edgeToBeReplaced, { applyDeletion: false });
@@ -533,7 +619,7 @@ export class GraphManager extends EventEmitter<{
const nextState = this.history.undo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
this.emit('save', this.serialize());
}
}
@@ -541,7 +627,7 @@ export class GraphManager extends EventEmitter<{
const nextState = this.history.redo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
this.emit('save', this.serialize());
}
}
@@ -558,8 +644,8 @@ export class GraphManager extends EventEmitter<{
if (this.currentUndoGroup) return;
const state = this.serialize();
this.history.save(state);
this.emit("save", state);
logger.log("saving graphs", state);
this.emit('save', state);
logger.log('saving graphs', state);
}
getParentsOfNode(node: NodeInstance) {
@@ -567,7 +653,7 @@ export class GraphManager extends EventEmitter<{
const stack = node.state?.parents?.slice(0);
while (stack?.length) {
if (parents.length > 1000000) {
logger.warn("Infinite loop detected");
logger.warn('Infinite loop detected');
break;
}
const parent = stack.pop();
@@ -586,26 +672,28 @@ export class GraphManager extends EventEmitter<{
return [];
}
const definitions = typeof socket.index === "string"
const definitions = typeof socket.index === 'string'
? allDefinitions.filter(s => {
return s.outputs?.find(_s => Object
.values(nodeType?.inputs || {})
.map(s => s.type)
.includes(_s as NodeInput["type"])
)
return s.outputs?.find(_s =>
Object
.values(nodeType?.inputs || {})
.map(s => s.type)
.includes(_s as NodeInput['type'])
);
})
: allDefinitions.filter(s => Object
.values(s.inputs ?? {})
.find(s => {
if (s.hidden) return false;
if (nodeType.outputs?.includes(s.type)) {
return true
}
return s.accepts?.find(a => nodeType.outputs?.includes(a))
}))
return definitions
: allDefinitions.filter(s =>
Object
.values(s.inputs ?? {})
.find(s => {
if (s.hidden) return false;
if (nodeType.outputs?.includes(s.type)) {
return true;
}
return s.accepts?.find(a => nodeType.outputs?.includes(a));
})
);
return definitions;
}
getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] {
@@ -615,11 +703,11 @@ export class GraphManager extends EventEmitter<{
const sockets: [NodeInstance, string | number][] = [];
// if index is a string, we are an input looking for outputs
if (typeof index === "string") {
if (typeof index === 'string') {
// filter out self and child nodes
const children = new Set(this.getChildren(node).map((n) => n.id));
const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !children.has(n.id),
(n) => n.id !== node.id && !children.has(n.id)
);
const ownType = nodeType?.inputs?.[index].type;
@@ -634,20 +722,20 @@ export class GraphManager extends EventEmitter<{
}
}
}
} else if (typeof index === "number") {
} else if (typeof index === 'number') {
// if index is a number, we are an output looking for inputs
// filter out self and parent nodes
const parents = new Set(this.getParentsOfNode(node).map((n) => n.id));
const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !parents.has(n.id),
(n) => n.id !== node.id && !parents.has(n.id)
);
// get edges from this socket
const edges = new Map(
this.getEdgesFromNode(node)
.filter((e) => e[1] === index)
.map((e) => [e[2].id, e[3]]),
.map((e) => [e[2].id, e[3]])
);
const ownType = nodeType.outputs?.[index];
@@ -660,8 +748,8 @@ export class GraphManager extends EventEmitter<{
otherType.push(...(inputs[key].accepts || []));
if (
areSocketsCompatible(ownType, otherType) &&
edges.get(node.id) !== key
areSocketsCompatible(ownType, otherType)
&& edges.get(node.id) !== key
) {
sockets.push([node, key]);
}
@@ -674,7 +762,7 @@ export class GraphManager extends EventEmitter<{
removeEdge(
edge: Edge,
{ applyDeletion = true }: { applyDeletion?: boolean } = {},
{ applyDeletion = true }: { applyDeletion?: boolean } = {}
) {
const id0 = edge[0].id;
const sid0 = edge[1];
@@ -682,21 +770,20 @@ export class GraphManager extends EventEmitter<{
const sid2 = edge[3];
const _edge = this.edges.find(
(e) =>
e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2,
(e) => e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2
);
if (!_edge) return;
if (edge[0].state.children) {
edge[0].state.children = edge[0].state.children.filter(
(n: NodeInstance) => n.id !== id2,
(n: NodeInstance) => n.id !== id2
);
}
if (edge[2].state.parents) {
edge[2].state.parents = edge[2].state.parents.filter(
(n: NodeInstance) => n.id !== id0,
(n: NodeInstance) => n.id !== id0
);
}
@@ -705,7 +792,6 @@ export class GraphManager extends EventEmitter<{
this.execute();
this.save();
}
}
getEdgesToNode(node: NodeInstance) {