feat: add initial undo/redo system

This commit is contained in:
max_richter 2024-03-13 16:18:48 +01:00
parent 305341fdf0
commit c4c203968d
8 changed files with 278 additions and 74 deletions

View File

@ -20,6 +20,7 @@
"@threlte/extras": "^8.7.5", "@threlte/extras": "^8.7.5",
"@threlte/flex": "^1.0.1", "@threlte/flex": "^1.0.1",
"@types/three": "^0.159.0", "@types/three": "^0.159.0",
"jsondiffpatch": "^0.6.0",
"three": "^0.159.0" "three": "^0.159.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Node } from "$lib/types"; import type { Node } from "$lib/types";
import { getContext, onMount } from "svelte";
import NodeHeader from "./NodeHeader.svelte"; import NodeHeader from "./NodeHeader.svelte";
import NodeParameter from "./NodeParameter.svelte"; import NodeParameter from "./NodeParameter.svelte";
import { activeNodeId, selectedNodes } from "./graph/stores"; import { activeNodeId, selectedNodes } from "./graph/stores";
@ -7,9 +8,20 @@
export let node: Node; export let node: Node;
export let inView = true; export let inView = true;
const updateNodePosition =
getContext<(n: Node) => void>("updateNodePosition");
const type = node?.tmp?.type; const type = node?.tmp?.type;
const parameters = Object.entries(type?.inputs || {}); const parameters = Object.entries(type?.inputs || {});
let ref: HTMLDivElement;
$: if (node && ref) {
node.tmp = node.tmp || {};
node.tmp.ref = ref;
updateNodePosition(node);
}
</script> </script>
<div <div
@ -18,7 +30,7 @@
class:selected={!!$selectedNodes?.has(node.id)} class:selected={!!$selectedNodes?.has(node.id)}
class:in-view={inView} class:in-view={inView}
data-node-id={node.id} data-node-id={node.id}
bind:this={node.tmp.ref} bind:this={ref}
> >
<NodeHeader {node} /> <NodeHeader {node} />

View File

@ -56,10 +56,20 @@
function updateNodePosition(node: NodeType) { function updateNodePosition(node: NodeType) {
node.tmp = node.tmp || {}; node.tmp = node.tmp || {};
if (node?.tmp?.ref) { if (node?.tmp?.ref) {
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("--nx", `${node.position.x * 10}px`);
node.tmp.ref.style.setProperty("--ny", `${node.position.y * 10}px`); node.tmp.ref.style.setProperty("--ny", `${node.position.y * 10}px`);
} }
} }
}
setContext("updateNodePosition", updateNodePosition);
const nodeHeightCache: Record<string, number> = {}; const nodeHeightCache: Record<string, number> = {};
function getNodeHeight(nodeTypeId: string) { function getNodeHeight(nodeTypeId: string) {
@ -100,7 +110,7 @@
if (edge[3] === index) { if (edge[3] === index) {
node = edge[0]; node = edge[0];
index = edge[1]; index = edge[1];
position = getSocketPosition({ node, index }); position = getSocketPosition(node, index);
graph.removeEdge(edge); graph.removeEdge(edge);
break; break;
} }
@ -120,7 +130,7 @@
return { return {
node, node,
index, index,
position: getSocketPosition({ node, index }), position: getSocketPosition(node, index),
}; };
}); });
$possibleSocketIds = new Set( $possibleSocketIds = new Set(
@ -142,23 +152,23 @@
} }
function getSocketPosition( function getSocketPosition(
socket: Omit<Socket, "position">, node: NodeType,
index: string | number,
): [number, number] { ): [number, number] {
if (typeof socket.index === "number") { if (typeof index === "number") {
return [ return [
socket.node.position.x + 5, (node?.tmp?.x ?? node.position.x) + 5,
socket.node.position.y + 0.625 + 2.5 * socket.index, (node?.tmp?.y ?? node.position.y) + 0.625 + 2.5 * index,
]; ];
} else { } else {
const _index = Object.keys(socket.node.tmp?.type?.inputs || {}).indexOf( const _index = Object.keys(node.tmp?.type?.inputs || {}).indexOf(index);
socket.index,
);
return [ return [
socket.node.position.x, node?.tmp?.x ?? node.position.x,
socket.node.position.y + 2.5 + 2.5 * _index, (node?.tmp?.y ?? node.position.y) + 2.5 + 2.5 * _index,
]; ];
} }
} }
setContext("getSocketPosition", getSocketPosition);
function setMouseFromEvent(event: MouseEvent) { function setMouseFromEvent(event: MouseEvent) {
const x = event.clientX; const x = event.clientX;
@ -230,16 +240,15 @@
if ($selectedNodes?.size) { if ($selectedNodes?.size) {
for (const nodeId of $selectedNodes) { for (const nodeId of $selectedNodes) {
const n = graph.getNode(nodeId); const n = graph.getNode(nodeId);
if (!n) continue; if (!n?.tmp) continue;
n.position.x = (n?.tmp?.downX || 0) - vecX; n.tmp.x = (n?.tmp?.downX || 0) - vecX;
n.position.y = (n?.tmp?.downY || 0) - vecY; n.tmp.y = (n?.tmp?.downY || 0) - vecY;
updateNodePosition(n); updateNodePosition(n);
} }
} }
node.position.x = newX; node.tmp.x = newX;
node.position.y = newY; node.tmp.y = newY;
node.position = node.position;
updateNodePosition(node); updateNodePosition(node);
@ -306,7 +315,41 @@
} }
function handleKeyDown(event: KeyboardEvent) { 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) { if ($activeNodeId !== -1) {
const node = graph.getNode($activeNodeId); const node = graph.getNode($activeNodeId);
if (node) { if (node) {
@ -349,38 +392,52 @@
activeNode.tmp = activeNode.tmp || {}; activeNode.tmp = activeNode.tmp || {};
activeNode.tmp.isMoving = false; activeNode.tmp.isMoving = false;
const snapLevel = getSnapLevel(); const snapLevel = getSnapLevel();
const fx = snapToGrid(activeNode.position.x, 5 / snapLevel); activeNode.position.x = snapToGrid(
const fy = snapToGrid(activeNode.position.y, 5 / snapLevel); activeNode?.tmp?.x ?? activeNode.position.x,
if ($selectedNodes) { 5 / snapLevel,
for (const nodeId of $selectedNodes) { );
const node = graph.getNode(nodeId); 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; if (!node) continue;
node.tmp = node.tmp || {}; node.tmp = node.tmp || {};
node.tmp.snapX = node.position.x - (activeNode.position.x - fx); const { x, y } = node.tmp;
node.tmp.snapY = node.position.y - (activeNode.position.y - fy); if (x !== undefined && y !== undefined) {
node.position.x = x + vec[0];
node.position.y = y + vec[1];
} }
} }
nodes.push(activeNode);
animate(500, (a: number) => { animate(500, (a: number) => {
activeNode.position.x = lerp(activeNode.position.x, fx, a); for (const node of nodes) {
activeNode.position.y = lerp(activeNode.position.y, fy, a); if (
updateNodePosition(activeNode); node?.tmp &&
node.tmp["x"] !== undefined &&
if ($selectedNodes) { node.tmp["y"] !== undefined
for (const nodeId of $selectedNodes) { ) {
const node = graph.getNode(nodeId); node.tmp.x = lerp(node.tmp.x, node.position.x, a);
if (!node) continue; node.tmp.y = lerp(node.tmp.y, node.position.y, a);
node.position.x = lerp(node.position.x, node?.tmp?.snapX || 0, a);
node.position.y = lerp(node.position.y, node?.tmp?.snapY || 0, a);
updateNodePosition(node); updateNodePosition(node);
} if (node?.tmp?.isMoving) {
}
if (activeNode?.tmp?.isMoving) {
return false; return false;
} }
}
}
$edges = $edges; $edges = $edges;
}); });
graph.history.save();
} else if ($hoveredSocket && $activeSocket) { } else if ($hoveredSocket && $activeSocket) {
if ( if (
typeof $hoveredSocket.index === "number" && typeof $hoveredSocket.index === "number" &&
@ -403,6 +460,7 @@
$hoveredSocket.index, $hoveredSocket.index,
); );
} }
graph.history.save();
} }
mouseDown = null; mouseDown = null;

View File

@ -14,14 +14,16 @@
const isNodeInView = getContext<(n: NodeType) => boolean>("isNodeInView"); const isNodeInView = getContext<(n: NodeType) => boolean>("isNodeInView");
const getSocketPosition =
getContext<(node: NodeType, index: string | number) => [number, number]>(
"getSocketPosition",
);
function getEdgePosition(edge: EdgeType) { function getEdgePosition(edge: EdgeType) {
const index = Object.keys(edge[2].tmp?.type?.inputs || {}).indexOf(edge[3]); const pos1 = getSocketPosition(edge[0], edge[1]);
return [ const pos2 = getSocketPosition(edge[2], edge[3]);
edge[0].position.x + 5,
edge[0].position.y + 0.625 + edge[1] * 2.5, return [pos1[0], pos1[1], pos2[0], pos2[1]];
edge[2].position.x,
edge[2].position.y + 2.5 + index * 2.5,
];
} }
onMount(() => { onMount(() => {

View File

@ -1,5 +1,6 @@
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import type { Graph, NodeRegistry as INodeRegistry, NodeType, Node, Edge, Socket } from "./types"; import type { Graph, NodeRegistry as INodeRegistry, NodeType, Node, Edge, Socket } from "./types";
import { HistoryManager } from "./history-manager";
const nodeTypes: NodeType[] = [ const nodeTypes: NodeType[] = [
{ {
@ -43,6 +44,8 @@ export class GraphManager {
private _edges: Edge[] = []; private _edges: Edge[] = [];
edges: Writable<Edge[]> = writable([]); edges: Writable<Edge[]> = writable([]);
history: HistoryManager = new HistoryManager(this);
private constructor(private graph: Graph, private nodeRegistry: NodeRegistry = new NodeRegistry()) { private constructor(private graph: Graph, private nodeRegistry: NodeRegistry = new NodeRegistry()) {
this.nodes.subscribe((nodes) => { this.nodes.subscribe((nodes) => {
this._nodes = nodes; this._nodes = nodes;
@ -50,38 +53,30 @@ export class GraphManager {
this.edges.subscribe((edges) => { this.edges.subscribe((edges) => {
this._edges = edges; this._edges = edges;
}); });
globalThis["serialize"] = () => this.serialize();
} }
serialize() { serialize(): Graph {
const nodes = Array.from(this._nodes.values()).map(node => ({ const nodes = Array.from(this._nodes.values()).map(node => ({
id: node.id, id: node.id,
position: node.position, position: { x: node.position.x, y: node.position.y },
type: node.type, type: node.type,
props: node.props, 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 }; return { nodes, edges };
} }
async load() { private _init(graph: Graph) {
const nodes = new Map(graph.nodes.map(node => {
const nodes = new Map(this.graph.nodes.map(node => [node.id, node]));
for (const node of nodes.values()) {
const nodeType = this.nodeRegistry.getNode(node.type); const nodeType = this.nodeRegistry.getNode(node.type);
if (!nodeType) { if (nodeType) {
console.error(`Node type not found: ${node.type}`);
this.status.set("error");
return;
}
node.tmp = node.tmp || {}; node.tmp = node.tmp || {};
node.tmp.type = nodeType; node.tmp.type = nodeType;
} }
return [node.id, node]
}));
this.edges.set(graph.edges.map((edge) => {
this.edges.set(this.graph.edges.map((edge) => {
const from = nodes.get(edge[0]); const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]); const to = nodes.get(edge[2]);
if (!from || !to) { if (!from || !to) {
@ -101,7 +96,25 @@ export class GraphManager {
this.nodes.set(nodes); 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.status.set("idle");
this.history.save();
} }
@ -155,6 +168,7 @@ export class GraphManager {
nodes.delete(node.id); nodes.delete(node.id);
return nodes; return nodes;
}); });
this.history.save();
} }
createEdge(from: Node, fromSocket: number, to: Node, toSocket: string) { createEdge(from: Node, fromSocket: number, to: Node, toSocket: string) {
@ -181,6 +195,8 @@ export class GraphManager {
this.edges.update((edges) => { this.edges.update((edges) => {
return [...edges.filter(e => e[2].id !== to.id || e[3] !== toSocket), [from, fromSocket, to, toSocket]]; return [...edges.filter(e => e[2].id !== to.id || e[3] !== toSocket), [from, fromSocket, to, toSocket]];
}); });
this.history.save();
} }
getParentsOfNode(node: Node) { getParentsOfNode(node: Node) {
@ -257,6 +273,7 @@ export class GraphManager {
this.edges.update((edges) => { this.edges.update((edges) => {
return edges.filter((e) => e[0].id !== id0 || e[1] !== sid0 || e[2].id !== id2 || e[3] !== sid2); return edges.filter((e) => e[0].id !== id0 || e[1] !== sid0 || e[2].id !== id2 || e[3] !== sid2);
}); });
this.history.save();
} }
getEdgesToNode(node: Node) { getEdgesToNode(node: Node) {
@ -328,7 +345,5 @@ export class GraphManager {
return new GraphManager(graph); return new GraphManager(graph);
} }
} }

View File

@ -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")
}
}
}

View File

@ -11,8 +11,8 @@ export type Node = {
type?: NodeType; type?: NodeType;
downX?: number; downX?: number;
downY?: number; downY?: number;
snapX?: number; x?: number;
snapY?: number; y?: number;
ref?: HTMLElement; ref?: HTMLElement;
visible?: boolean; visible?: boolean;
isMoving?: boolean; isMoving?: boolean;

View File

@ -35,6 +35,9 @@ importers:
'@types/three': '@types/three':
specifier: ^0.159.0 specifier: ^0.159.0
version: 0.159.0 version: 0.159.0
jsondiffpatch:
specifier: ^0.6.0
version: 0.6.0
three: three:
specifier: ^0.159.0 specifier: ^0.159.0
version: 0.159.0 version: 0.159.0
@ -1146,6 +1149,10 @@ packages:
/@types/cookie@0.6.0: /@types/cookie@0.6.0:
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 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: /@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@ -1803,6 +1810,10 @@ packages:
resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==} resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==}
dev: true dev: true
/diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
dev: false
/dir-glob@3.0.1: /dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2638,6 +2649,16 @@ packages:
/jsonc-parser@3.2.1: /jsonc-parser@3.2.1:
resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} 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: /jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
dependencies: dependencies: