diff --git a/frontend/package.json b/frontend/package.json
index 09cb505..e319639 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -20,6 +20,7 @@
"@threlte/extras": "^8.7.5",
"@threlte/flex": "^1.0.1",
"@types/three": "^0.159.0",
+ "jsondiffpatch": "^0.6.0",
"three": "^0.159.0"
},
"devDependencies": {
diff --git a/frontend/src/lib/components/Node.svelte b/frontend/src/lib/components/Node.svelte
index 78ea74b..d14544f 100644
--- a/frontend/src/lib/components/Node.svelte
+++ b/frontend/src/lib/components/Node.svelte
@@ -1,5 +1,6 @@
diff --git a/frontend/src/lib/components/graph/Graph.svelte b/frontend/src/lib/components/graph/Graph.svelte
index 13ab29b..40f2d40 100644
--- a/frontend/src/lib/components/graph/Graph.svelte
+++ b/frontend/src/lib/components/graph/Graph.svelte
@@ -56,10 +56,20 @@
function updateNodePosition(node: NodeType) {
node.tmp = node.tmp || {};
if (node?.tmp?.ref) {
- node.tmp.ref.style.setProperty("--nx", `${node.position.x * 10}px`);
- node.tmp.ref.style.setProperty("--ny", `${node.position.y * 10}px`);
+ if (node.tmp["x"] !== undefined && node.tmp["y"] !== undefined) {
+ node.tmp.ref.style.setProperty("--nx", `${node.tmp.x * 10}px`);
+ node.tmp.ref.style.setProperty("--ny", `${node.tmp.y * 10}px`);
+ if (node.tmp.x === node.position.x && node.tmp.y === node.position.y) {
+ delete node.tmp.x;
+ delete node.tmp.y;
+ }
+ } else {
+ node.tmp.ref.style.setProperty("--nx", `${node.position.x * 10}px`);
+ node.tmp.ref.style.setProperty("--ny", `${node.position.y * 10}px`);
+ }
}
}
+ setContext("updateNodePosition", updateNodePosition);
const nodeHeightCache: Record = {};
function getNodeHeight(nodeTypeId: string) {
@@ -100,7 +110,7 @@
if (edge[3] === index) {
node = edge[0];
index = edge[1];
- position = getSocketPosition({ node, index });
+ position = getSocketPosition(node, index);
graph.removeEdge(edge);
break;
}
@@ -120,7 +130,7 @@
return {
node,
index,
- position: getSocketPosition({ node, index }),
+ position: getSocketPosition(node, index),
};
});
$possibleSocketIds = new Set(
@@ -142,23 +152,23 @@
}
function getSocketPosition(
- socket: Omit,
+ node: NodeType,
+ index: string | number,
): [number, number] {
- if (typeof socket.index === "number") {
+ if (typeof index === "number") {
return [
- socket.node.position.x + 5,
- socket.node.position.y + 0.625 + 2.5 * socket.index,
+ (node?.tmp?.x ?? node.position.x) + 5,
+ (node?.tmp?.y ?? node.position.y) + 0.625 + 2.5 * index,
];
} else {
- const _index = Object.keys(socket.node.tmp?.type?.inputs || {}).indexOf(
- socket.index,
- );
+ const _index = Object.keys(node.tmp?.type?.inputs || {}).indexOf(index);
return [
- socket.node.position.x,
- socket.node.position.y + 2.5 + 2.5 * _index,
+ node?.tmp?.x ?? node.position.x,
+ (node?.tmp?.y ?? node.position.y) + 2.5 + 2.5 * _index,
];
}
}
+ setContext("getSocketPosition", getSocketPosition);
function setMouseFromEvent(event: MouseEvent) {
const x = event.clientX;
@@ -230,16 +240,15 @@
if ($selectedNodes?.size) {
for (const nodeId of $selectedNodes) {
const n = graph.getNode(nodeId);
- if (!n) continue;
- n.position.x = (n?.tmp?.downX || 0) - vecX;
- n.position.y = (n?.tmp?.downY || 0) - vecY;
+ if (!n?.tmp) continue;
+ n.tmp.x = (n?.tmp?.downX || 0) - vecX;
+ n.tmp.y = (n?.tmp?.downY || 0) - vecY;
updateNodePosition(n);
}
}
- node.position.x = newX;
- node.position.y = newY;
- node.position = node.position;
+ node.tmp.x = newX;
+ node.tmp.y = newY;
updateNodePosition(node);
@@ -306,7 +315,41 @@
}
function handleKeyDown(event: KeyboardEvent) {
- if (event.key === "Delete") {
+ if (event.key === "Escape") {
+ $activeNodeId = -1;
+ $selectedNodes?.clear();
+ $selectedNodes = $selectedNodes;
+ }
+
+ if (event.key === "a" && event.ctrlKey) {
+ $selectedNodes = new Set($nodes.keys());
+ }
+
+ if (event.key === "c" && event.ctrlKey) {
+ }
+
+ if (event.key === "v" && event.ctrlKey) {
+ }
+
+ if (event.key === "z" && event.ctrlKey) {
+ graph.history.undo();
+ for (const node of $nodes.values()) {
+ updateNodePosition(node);
+ }
+ }
+
+ if (event.key === "y" && event.ctrlKey) {
+ graph.history.redo();
+ for (const node of $nodes.values()) {
+ updateNodePosition(node);
+ }
+ }
+
+ if (
+ event.key === "Delete" ||
+ event.key === "Backspace" ||
+ event.key === "x"
+ ) {
if ($activeNodeId !== -1) {
const node = graph.getNode($activeNodeId);
if (node) {
@@ -349,38 +392,52 @@
activeNode.tmp = activeNode.tmp || {};
activeNode.tmp.isMoving = false;
const snapLevel = getSnapLevel();
- const fx = snapToGrid(activeNode.position.x, 5 / snapLevel);
- const fy = snapToGrid(activeNode.position.y, 5 / snapLevel);
- if ($selectedNodes) {
- for (const nodeId of $selectedNodes) {
- const node = graph.getNode(nodeId);
- if (!node) continue;
- node.tmp = node.tmp || {};
- node.tmp.snapX = node.position.x - (activeNode.position.x - fx);
- node.tmp.snapY = node.position.y - (activeNode.position.y - fy);
+ activeNode.position.x = snapToGrid(
+ activeNode?.tmp?.x ?? activeNode.position.x,
+ 5 / snapLevel,
+ );
+ activeNode.position.y = snapToGrid(
+ activeNode?.tmp?.y ?? activeNode.position.y,
+ 5 / snapLevel,
+ );
+ const nodes = [
+ ...[...($selectedNodes?.values() || [])].map((id) => graph.getNode(id)),
+ ] as NodeType[];
+
+ const vec = [
+ activeNode.position.x - (activeNode?.tmp.x || 0),
+ activeNode.position.y - (activeNode?.tmp.y || 0),
+ ];
+
+ for (const node of nodes) {
+ if (!node) continue;
+ node.tmp = node.tmp || {};
+ const { x, y } = node.tmp;
+ if (x !== undefined && y !== undefined) {
+ node.position.x = x + vec[0];
+ node.position.y = y + vec[1];
}
}
+ nodes.push(activeNode);
animate(500, (a: number) => {
- activeNode.position.x = lerp(activeNode.position.x, fx, a);
- activeNode.position.y = lerp(activeNode.position.y, fy, a);
- updateNodePosition(activeNode);
-
- if ($selectedNodes) {
- for (const nodeId of $selectedNodes) {
- const node = graph.getNode(nodeId);
- if (!node) continue;
- node.position.x = lerp(node.position.x, node?.tmp?.snapX || 0, a);
- node.position.y = lerp(node.position.y, node?.tmp?.snapY || 0, a);
+ for (const node of nodes) {
+ if (
+ node?.tmp &&
+ node.tmp["x"] !== undefined &&
+ node.tmp["y"] !== undefined
+ ) {
+ node.tmp.x = lerp(node.tmp.x, node.position.x, a);
+ node.tmp.y = lerp(node.tmp.y, node.position.y, a);
updateNodePosition(node);
+ if (node?.tmp?.isMoving) {
+ return false;
+ }
}
}
- if (activeNode?.tmp?.isMoving) {
- return false;
- }
-
$edges = $edges;
});
+ graph.history.save();
} else if ($hoveredSocket && $activeSocket) {
if (
typeof $hoveredSocket.index === "number" &&
@@ -403,6 +460,7 @@
$hoveredSocket.index,
);
}
+ graph.history.save();
}
mouseDown = null;
diff --git a/frontend/src/lib/components/graph/GraphView.svelte b/frontend/src/lib/components/graph/GraphView.svelte
index 4b02c40..5261864 100644
--- a/frontend/src/lib/components/graph/GraphView.svelte
+++ b/frontend/src/lib/components/graph/GraphView.svelte
@@ -14,14 +14,16 @@
const isNodeInView = getContext<(n: NodeType) => boolean>("isNodeInView");
+ const getSocketPosition =
+ getContext<(node: NodeType, index: string | number) => [number, number]>(
+ "getSocketPosition",
+ );
+
function getEdgePosition(edge: EdgeType) {
- const index = Object.keys(edge[2].tmp?.type?.inputs || {}).indexOf(edge[3]);
- return [
- edge[0].position.x + 5,
- edge[0].position.y + 0.625 + edge[1] * 2.5,
- edge[2].position.x,
- edge[2].position.y + 2.5 + index * 2.5,
- ];
+ const pos1 = getSocketPosition(edge[0], edge[1]);
+ const pos2 = getSocketPosition(edge[2], edge[3]);
+
+ return [pos1[0], pos1[1], pos2[0], pos2[1]];
}
onMount(() => {
diff --git a/frontend/src/lib/graph-manager.ts b/frontend/src/lib/graph-manager.ts
index 53240a9..3483c52 100644
--- a/frontend/src/lib/graph-manager.ts
+++ b/frontend/src/lib/graph-manager.ts
@@ -1,5 +1,6 @@
import { writable, type Writable } from "svelte/store";
import type { Graph, NodeRegistry as INodeRegistry, NodeType, Node, Edge, Socket } from "./types";
+import { HistoryManager } from "./history-manager";
const nodeTypes: NodeType[] = [
{
@@ -43,6 +44,8 @@ export class GraphManager {
private _edges: Edge[] = [];
edges: Writable = writable([]);
+ history: HistoryManager = new HistoryManager(this);
+
private constructor(private graph: Graph, private nodeRegistry: NodeRegistry = new NodeRegistry()) {
this.nodes.subscribe((nodes) => {
this._nodes = nodes;
@@ -50,38 +53,30 @@ export class GraphManager {
this.edges.subscribe((edges) => {
this._edges = edges;
});
-
- globalThis["serialize"] = () => this.serialize();
}
- serialize() {
+ serialize(): Graph {
const nodes = Array.from(this._nodes.values()).map(node => ({
id: node.id,
- position: node.position,
+ position: { x: node.position.x, y: node.position.y },
type: node.type,
props: node.props,
}));
- const edges = this._edges.map(edge => [edge[0].id, edge[1], edge[2].id, edge[3]]);
+ const edges = this._edges.map(edge => [edge[0].id, edge[1], edge[2].id, edge[3]]) as Graph["edges"];
return { nodes, edges };
}
- async load() {
-
- const nodes = new Map(this.graph.nodes.map(node => [node.id, node]));
-
- for (const node of nodes.values()) {
+ private _init(graph: Graph) {
+ const nodes = new Map(graph.nodes.map(node => {
const nodeType = this.nodeRegistry.getNode(node.type);
- if (!nodeType) {
- console.error(`Node type not found: ${node.type}`);
- this.status.set("error");
- return;
+ if (nodeType) {
+ node.tmp = node.tmp || {};
+ node.tmp.type = nodeType;
}
- node.tmp = node.tmp || {};
- node.tmp.type = nodeType;
- }
+ return [node.id, node]
+ }));
-
- this.edges.set(this.graph.edges.map((edge) => {
+ this.edges.set(graph.edges.map((edge) => {
const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]);
if (!from || !to) {
@@ -101,7 +96,25 @@ export class GraphManager {
this.nodes.set(nodes);
+ }
+
+ async load() {
+
+ for (const node of this.graph.nodes) {
+ const nodeType = this.nodeRegistry.getNode(node.type);
+ if (!nodeType) {
+ console.error(`Node type not found: ${node.type}`);
+ this.status.set("error");
+ return;
+ }
+ node.tmp = node.tmp || {};
+ node.tmp.type = nodeType;
+ }
+
+ this._init(this.graph);
+
this.status.set("idle");
+ this.history.save();
}
@@ -155,6 +168,7 @@ export class GraphManager {
nodes.delete(node.id);
return nodes;
});
+ this.history.save();
}
createEdge(from: Node, fromSocket: number, to: Node, toSocket: string) {
@@ -181,6 +195,8 @@ export class GraphManager {
this.edges.update((edges) => {
return [...edges.filter(e => e[2].id !== to.id || e[3] !== toSocket), [from, fromSocket, to, toSocket]];
});
+
+ this.history.save();
}
getParentsOfNode(node: Node) {
@@ -257,6 +273,7 @@ export class GraphManager {
this.edges.update((edges) => {
return edges.filter((e) => e[0].id !== id0 || e[1] !== sid0 || e[2].id !== id2 || e[3] !== sid2);
});
+ this.history.save();
}
getEdgesToNode(node: Node) {
@@ -328,7 +345,5 @@ export class GraphManager {
return new GraphManager(graph);
}
-
}
-
diff --git a/frontend/src/lib/history-manager.ts b/frontend/src/lib/history-manager.ts
new file mode 100644
index 0000000..c453dc6
--- /dev/null
+++ b/frontend/src/lib/history-manager.ts
@@ -0,0 +1,95 @@
+import type { GraphManager } from "./graph-manager";
+import { create, type Delta } from "jsondiffpatch";
+import type { Graph } from "./types";
+
+
+const diff = create({
+ objectHash: function (obj, index) {
+ if (obj === null) return obj;
+ if ("id" in obj) return obj.id;
+ if (Array.isArray(obj)) {
+ return obj.join("-")
+ }
+ return obj?.id || obj._id || '$$index:' + index;
+ }
+})
+
+export class HistoryManager {
+
+ index: number = -1;
+ history: Delta[] = [];
+ private initialState: Graph | undefined;
+ private prevState: Graph | undefined;
+ private timeout: number | undefined;
+
+ private opts = {
+ debounce: 400,
+ maxHistory: 100,
+ }
+
+
+ constructor(private manager: GraphManager, { maxHistory = 100, debounce = 100 } = {}) {
+ this.history = [];
+ this.index = -1;
+ this.opts.debounce = debounce;
+ this.opts.maxHistory = maxHistory;
+ }
+
+ save() {
+ if (!this.prevState) {
+ this.prevState = this.manager.serialize();
+ this.initialState = globalThis.structuredClone(this.prevState);
+ } else {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+
+ this.timeout = setTimeout(() => {
+ const newState = this.manager.serialize();
+ const delta = diff.diff(this.prevState, newState);
+ if (delta) {
+ // Add the delta to history
+ if (this.index < this.history.length - 1) {
+ // Clear the history after the current index if new changes are made
+ this.history.splice(this.index + 1);
+ }
+
+ this.history.push(delta);
+ this.index++;
+
+ // Limit the size of the history
+ if (this.history.length > this.opts.maxHistory) {
+ this.history.shift();
+ }
+ }
+ this.prevState = newState;
+ }, this.opts.debounce) as unknown as number;
+ }
+ }
+
+ undo() {
+ if (this.index > 0) {
+ const delta = this.history[this.index];
+ const prevState = diff.unpatch(this.prevState, delta) as Graph;
+ this.manager._init(prevState);
+ this.index--;
+ this.prevState = prevState;
+ } else if (this.index === 0 && this.initialState) {
+ this.manager._init(globalThis.structuredClone(this.initialState));
+ console.log("Reached start", this.index, this.history.length)
+ }
+ }
+
+ redo() {
+ if (this.index < this.history.length - 1) {
+ const nextIndex = this.index + 1;
+ const delta = this.history[nextIndex];
+ const nextState = diff.patch(this.prevState, delta) as Graph;
+ this.manager._init(nextState);
+ this.index = nextIndex;
+ this.prevState = nextState;
+ } else {
+ console.log("Reached end")
+ }
+ }
+}
diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts
index 10afc26..f8e2780 100644
--- a/frontend/src/lib/types/index.ts
+++ b/frontend/src/lib/types/index.ts
@@ -11,8 +11,8 @@ export type Node = {
type?: NodeType;
downX?: number;
downY?: number;
- snapX?: number;
- snapY?: number;
+ x?: number;
+ y?: number;
ref?: HTMLElement;
visible?: boolean;
isMoving?: boolean;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9322d9c..84f8a0d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
'@types/three':
specifier: ^0.159.0
version: 0.159.0
+ jsondiffpatch:
+ specifier: ^0.6.0
+ version: 0.6.0
three:
specifier: ^0.159.0
version: 0.159.0
@@ -1146,6 +1149,10 @@ packages:
/@types/cookie@0.6.0:
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
+ /@types/diff-match-patch@1.0.36:
+ resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
+ dev: false
+
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -1803,6 +1810,10 @@ packages:
resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==}
dev: true
+ /diff-match-patch@1.0.5:
+ resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
+ dev: false
+
/dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -2638,6 +2649,16 @@ packages:
/jsonc-parser@3.2.1:
resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==}
+ /jsondiffpatch@0.6.0:
+ resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ dependencies:
+ '@types/diff-match-patch': 1.0.36
+ chalk: 5.3.0
+ diff-match-patch: 1.0.5
+ dev: false
+
/jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
dependencies: