From c4c203968df05af1b11105bbcb6f9d42cd037e77 Mon Sep 17 00:00:00 2001 From: Max Richter Date: Wed, 13 Mar 2024 16:18:48 +0100 Subject: [PATCH] feat: add initial undo/redo system --- frontend/package.json | 1 + frontend/src/lib/components/Node.svelte | 14 +- .../src/lib/components/graph/Graph.svelte | 144 ++++++++++++------ .../src/lib/components/graph/GraphView.svelte | 16 +- frontend/src/lib/graph-manager.ts | 57 ++++--- frontend/src/lib/history-manager.ts | 95 ++++++++++++ frontend/src/lib/types/index.ts | 4 +- pnpm-lock.yaml | 21 +++ 8 files changed, 278 insertions(+), 74 deletions(-) create mode 100644 frontend/src/lib/history-manager.ts 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: