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/flex": "^1.0.1",
"@types/three": "^0.159.0",
"jsondiffpatch": "^0.6.0",
"three": "^0.159.0"
},
"devDependencies": {

View File

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

View File

@ -56,10 +56,20 @@
function updateNodePosition(node: NodeType) {
node.tmp = node.tmp || {};
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("--ny", `${node.position.y * 10}px`);
}
}
}
setContext("updateNodePosition", updateNodePosition);
const nodeHeightCache: Record<string, number> = {};
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<Socket, "position">,
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);
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 || {};
node.tmp.snapX = node.position.x - (activeNode.position.x - fx);
node.tmp.snapY = node.position.y - (activeNode.position.y - fy);
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 (activeNode?.tmp?.isMoving) {
if (node?.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;

View File

@ -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(() => {

View File

@ -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<Edge[]> = 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;
}
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);
}
}

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;
downX?: number;
downY?: number;
snapX?: number;
snapY?: number;
x?: number;
y?: number;
ref?: HTMLElement;
visible?: boolean;
isMoving?: boolean;

View File

@ -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: