fix: make history work as expected

This commit is contained in:
max_richter 2024-03-21 15:00:41 +01:00
parent 2629008447
commit 2df3035855
6 changed files with 236 additions and 100 deletions

View File

@ -6,7 +6,7 @@
import { onMount, setContext } from "svelte"; import { onMount, setContext } from "svelte";
import Camera from "../Camera.svelte"; import Camera from "../Camera.svelte";
import GraphView from "./GraphView.svelte"; import GraphView from "./GraphView.svelte";
import type { Node as NodeType } from "$lib/types"; import type { Node, Node as NodeType } from "$lib/types";
import FloatingEdge from "../edges/FloatingEdge.svelte"; import FloatingEdge from "../edges/FloatingEdge.svelte";
import type { Socket } from "$lib/types"; import type { Socket } from "$lib/types";
import { import {
@ -38,6 +38,10 @@
const cameraDown = [0, 0]; const cameraDown = [0, 0];
let cameraPosition: [number, number, number] = [0, 0, 4]; let cameraPosition: [number, number, number] = [0, 0, 4];
let addMenuPosition: [number, number] | null = null; let addMenuPosition: [number, number] | null = null;
let clipboard: null | {
nodes: Node[];
edges: [number, number, number, string][];
} = null;
$: if (cameraPosition && loaded) { $: if (cameraPosition && loaded) {
localStorage.setItem("cameraPosition", JSON.stringify(cameraPosition)); localStorage.setItem("cameraPosition", JSON.stringify(cameraPosition));
@ -438,23 +442,60 @@
} }
} }
function copyNodes() {
if ($activeNodeId === -1 && !$selectedNodes?.size) return;
let _nodes = [$activeNodeId, ...($selectedNodes?.values() || [])]
.map((id) => graph.getNode(id))
.filter(Boolean) as Node[];
const _edges = graph.getEdgesBetweenNodes(_nodes);
_nodes = _nodes.map((_node) => {
const node = globalThis.structuredClone({
..._node,
tmp: {
downX: mousePosition[0] - _node.position[0],
downY: mousePosition[1] - _node.position[1],
},
});
return node;
});
clipboard = {
nodes: _nodes,
edges: _edges,
};
}
function pasteNodes() {
if (!clipboard) return;
const _nodes = clipboard.nodes
.map((node) => {
node.tmp = node.tmp || {};
node.position[0] = mousePosition[0] - (node?.tmp?.downX || 0);
node.position[1] = mousePosition[1] - (node?.tmp?.downY || 0);
return node;
})
.filter(Boolean) as Node[];
const newNodes = graph.createGraph(_nodes, clipboard.edges);
$selectedNodes = new Set(newNodes.map((n) => n.id));
}
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
const bodyIsFocused = const bodyIsFocused =
document.activeElement === document.body || document.activeElement === document.body ||
document?.activeElement?.id === "graph"; document?.activeElement?.id === "graph";
if (event.key === "l") { if (event.key === "l") {
if (event.ctrlKey) {
const activeNode = graph.getNode($activeNodeId); const activeNode = graph.getNode($activeNodeId);
if (activeNode) { if (activeNode) {
const nodes = graph.getLinkedNodes(activeNode); const nodes = graph.getLinkedNodes(activeNode);
$selectedNodes = new Set(nodes.map((n) => n.id)); $selectedNodes = new Set(nodes.map((n) => n.id));
} }
} else {
const activeNode = graph.getNode($activeNodeId);
console.log(activeNode); console.log(activeNode);
} }
}
if (event.key === "Escape") { if (event.key === "Escape") {
$activeNodeId = -1; $activeNodeId = -1;
@ -497,20 +538,22 @@
} }
if (event.key === "c" && event.ctrlKey) { if (event.key === "c" && event.ctrlKey) {
copyNodes();
} }
if (event.key === "v" && event.ctrlKey) { if (event.key === "v" && event.ctrlKey) {
pasteNodes();
} }
if (event.key === "z" && event.ctrlKey) { if (event.key === "z" && event.ctrlKey) {
graph.history.undo(); graph.undo();
for (const node of $nodes.values()) { for (const node of $nodes.values()) {
updateNodePosition(node); updateNodePosition(node);
} }
} }
if (event.key === "y" && event.ctrlKey) { if (event.key === "y" && event.ctrlKey) {
graph.history.redo(); graph.redo();
for (const node of $nodes.values()) { for (const node of $nodes.values()) {
updateNodePosition(node); updateNodePosition(node);
} }

View File

@ -4,6 +4,9 @@ import { HistoryManager } from "./history-manager";
import * as templates from "./graphs"; import * as templates from "./graphs";
import EventEmitter from "./helpers/EventEmitter"; import EventEmitter from "./helpers/EventEmitter";
import throttle from "./helpers/throttle"; import throttle from "./helpers/throttle";
import { createLogger } from "./helpers";
const logger = createLogger("graph-manager");
export class GraphManager extends EventEmitter<{ "save": Graph }> { export class GraphManager extends EventEmitter<{ "save": Graph }> {
@ -22,7 +25,7 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
inputSockets: Writable<Set<string>> = writable(new Set()); inputSockets: Writable<Set<string>> = writable(new Set());
history: HistoryManager = new HistoryManager(this); history: HistoryManager = new HistoryManager();
constructor(private nodeRegistry: NodeRegistry, private runtime: RuntimeExecutor) { constructor(private nodeRegistry: NodeRegistry, private runtime: RuntimeExecutor) {
super(); super();
@ -41,12 +44,13 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
} }
serialize(): Graph { serialize(): Graph {
logger.log("serializing 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: [...node.position],
type: node.type, type: node.type,
props: node.props, props: node.props,
})); })) as Node[];
const edges = this._edges.map(edge => [edge[0].id, edge[1], edge[2].id, edge[3]]) as Graph["edges"]; const edges = this._edges.map(edge => [edge[0].id, edge[1], edge[2].id, edge[3]]) as Graph["edges"];
return { id: this.graph.id, nodes, edges }; return { id: this.graph.id, nodes, edges };
} }
@ -57,7 +61,7 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
const start = performance.now(); const start = performance.now();
const result = this.runtime.execute(this.serialize()); const result = this.runtime.execute(this.serialize());
const end = performance.now(); const end = performance.now();
console.log(`Execution took ${end - start}ms -> ${result}`); logger.log(`Execution took ${end - start}ms -> ${result}`);
} }
getNodeTypes() { getNodeTypes() {
@ -80,6 +84,25 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
} }
getEdgesBetweenNodes(nodes: Node[]): [number, number, number, string][] {
const edges = [];
for (const node of nodes) {
const children = node.tmp?.children || [];
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);
if (edge) {
edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [number, number, number, string]);
}
}
}
}
return edges;
}
private _init(graph: Graph) { private _init(graph: Graph) {
const nodes = new Map(graph.nodes.map(node => { const nodes = new Map(graph.nodes.map(node => {
const nodeType = this.nodeRegistry.getNode(node.type); const nodeType = this.nodeRegistry.getNode(node.type);
@ -116,36 +139,28 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
async load(graph: Graph) { async load(graph: Graph) {
this.loaded = false; this.loaded = false;
this.graph = graph; this.graph = graph;
const a = performance.now();
this.status.set("loading"); this.status.set("loading");
this.id.set(graph.id); this.id.set(graph.id);
const b = performance.now();
for (const node of this.graph.nodes) { for (const node of this.graph.nodes) {
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}`); logger.error(`Node type not found: ${node.type}`);
this.status.set("error"); this.status.set("error");
return; return;
} }
node.tmp = node.tmp || {}; node.tmp = node.tmp || {};
node.tmp.type = nodeType; node.tmp.type = nodeType;
} }
const c = performance.now();
this.history.reset();
this._init(this.graph); this._init(this.graph);
const d = performance.now();
this.save(); this.save();
const e = performance.now();
this.status.set("idle"); this.status.set("idle");
this.loaded = true; this.loaded = true;
const f = performance.now();
console.log(`Loading took ${f - a}ms; a-b: ${b - a}ms; b-c: ${c - b}ms; c-d: ${d - c}ms; d-e: ${e - d}ms; e-f: ${f - e}ms`);
} }
@ -224,11 +239,60 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
this.save(); this.save();
} }
private createNodeId() { createNodeId() {
return Math.max(...this.getAllNodes().map(n => n.id), 0) + 1; const max = Math.max(...this._nodes.keys());
return max + 1;
} }
createNode({ type, position }: { type: string, position: [number, number] }) { createGraph(nodes: Node[], edges: [number, number, number, string][]) {
// map old ids to new ids
const idMap = new Map<number, number>();
const startId = this.createNodeId();
nodes = nodes.map((node, i) => {
const id = startId + i;
idMap.set(node.id, id);
const type = this.nodeRegistry.getNode(node.type);
if (!type) {
throw new Error(`Node type not found: ${node.type}`);
}
return { ...node, id, tmp: { type } };
});
const _edges = edges.map(edge => {
const from = nodes.find(n => n.id === idMap.get(edge[0]));
const to = nodes.find(n => n.id === idMap.get(edge[2]));
if (!from || !to) {
throw new Error("Edge references non-existing node");
}
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
return [from, edge[1], to, edge[3]] as Edge;
});
for (const node of nodes) {
this._nodes.set(node.id, node);
}
this._edges.push(..._edges);
this.nodes.set(this._nodes);
this.edges.set(this._edges);
this.save();
return nodes;
}
createNode({ type, position, props = {} }: { type: Node["type"], position: Node["position"], props: Node["props"] }) {
const nodeType = this.nodeRegistry.getNode(type); const nodeType = this.nodeRegistry.getNode(type);
if (!nodeType) { if (!nodeType) {
@ -236,7 +300,7 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
return; return;
} }
const node: Node = { id: this.createNodeId(), type, position, tmp: { type: nodeType } }; const node: Node = { id: this.createNodeId(), type, position, tmp: { type: nodeType }, props };
this.nodes.update((nodes) => { this.nodes.update((nodes) => {
nodes.set(node.id, node); nodes.set(node.id, node);
@ -294,6 +358,24 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
} }
} }
undo() {
const nextState = this.history.undo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
}
}
redo() {
const nextState = this.history.redo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
}
}
startUndoGroup() { startUndoGroup() {
this.currentUndoGroup = 1; this.currentUndoGroup = 1;
} }
@ -305,8 +387,10 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
save() { save() {
if (this.currentUndoGroup) return; if (this.currentUndoGroup) return;
this.emit("save", this.serialize()); const state = this.serialize();
this.history.save(); this.history.save(state);
this.emit("save", state);
logger.log("saving graph");
} }
getParentsOfNode(node: Node) { getParentsOfNode(node: Node) {

View File

@ -1,7 +1,6 @@
import throttle from './throttle'; import throttle from './throttle';
const debug = { amountEmitters: 0, amountCallbacks: 0, emitters: [] };
type EventMap = Record<string, unknown>; type EventMap = Record<string, unknown>;
@ -13,8 +12,6 @@ export default class EventEmitter<T extends EventMap = { [key: string]: unknown
index = 0; index = 0;
public eventMap: T = {} as T; public eventMap: T = {} as T;
constructor() { constructor() {
this.index = debug.amountEmitters;
debug.amountEmitters++;
} }
private cbs: { [key: string]: ((data?: unknown) => unknown)[] } = {}; private cbs: { [key: string]: ((data?: unknown) => unknown)[] } = {};
@ -42,24 +39,9 @@ export default class EventEmitter<T extends EventMap = { [key: string]: unknown
}); });
this.cbs = cbs; this.cbs = cbs;
debug.emitters[this.index] = {
name: this.constructor.name,
cbs: Object.fromEntries(
Object.keys(this.cbs).map((key) => [key, this.cbs[key].length]),
),
};
debug.amountCallbacks++;
// console.log('New EventEmitter ', this.constructor.name); // console.log('New EventEmitter ', this.constructor.name);
return () => { return () => {
debug.amountCallbacks--;
cbs[event]?.splice(cbs[event].indexOf(cb), 1); cbs[event]?.splice(cbs[event].indexOf(cb), 1);
debug.emitters[this.index] = {
name: this.constructor.name,
cbs: Object.fromEntries(
Object.keys(this.cbs).map((key) => [key, this.cbs[key].length]),
),
};
}; };
} }
@ -77,14 +59,11 @@ export default class EventEmitter<T extends EventMap = { [key: string]: unknown
} }
public destroyEventEmitter() { public destroyEventEmitter() {
debug.amountEmitters--;
Object.keys(this.cbs).forEach((key) => { Object.keys(this.cbs).forEach((key) => {
debug.amountCallbacks -= this.cbs[key].length;
delete this.cbs[key]; delete this.cbs[key];
}); });
Object.keys(this.cbsOnce).forEach((key) => delete this.cbsOnce[key]); Object.keys(this.cbsOnce).forEach((key) => delete this.cbsOnce[key]);
this.cbs = {}; this.cbs = {};
this.cbsOnce = {}; this.cbsOnce = {};
delete debug.emitters[this.index];
} }
} }

View File

@ -70,3 +70,27 @@ export const debounce = (fn: Function, ms = 300) => {
timeoutId = setTimeout(() => fn.apply(this, args), ms); timeoutId = setTimeout(() => fn.apply(this, args), ms);
}; };
}; };
export const clone: <T>(v: T) => T = "structedClone" in globalThis ? globalThis.structuredClone : (obj) => JSON.parse(JSON.stringify(obj));
export const createLogger = (() => {
let maxLength = 5;
return (scope: string) => {
maxLength = Math.max(maxLength, scope.length);
let muted = false;
return {
log: (...args: any[]) => !muted && console.log(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
info: (...args: any[]) => !muted && console.info(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
warn: (...args: any[]) => !muted && console.warn(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
error: (...args: any[]) => console.error(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #f88", ...args),
mute() {
muted = true;
},
unmute() {
muted = false;
}
}
}
})();

View File

@ -1,6 +1,6 @@
import type { GraphManager } from "./graph-manager";
import { create, type Delta } from "jsondiffpatch"; import { create, type Delta } from "jsondiffpatch";
import type { Graph } from "./types"; import type { Graph } from "./types";
import { createLogger, clone } from "./helpers";
const diff = create({ const diff = create({
@ -14,40 +14,38 @@ const diff = create({
} }
}) })
const log = createLogger("history")
export class HistoryManager { export class HistoryManager {
index: number = -1; index: number = -1;
history: Delta[] = []; history: Delta[] = [];
private initialState: Graph | undefined; private initialState: Graph | undefined;
private prevState: Graph | undefined; private state: Graph | undefined;
private timeout: number | undefined;
private opts = { private opts = {
debounce: 400, debounce: 400,
maxHistory: 100, maxHistory: 100,
} }
constructor({ maxHistory = 100, debounce = 100 } = {}) {
constructor(private manager: GraphManager, { maxHistory = 100, debounce = 100 } = {}) {
this.history = []; this.history = [];
this.index = -1; this.index = -1;
this.opts.debounce = debounce; this.opts.debounce = debounce;
this.opts.maxHistory = maxHistory; this.opts.maxHistory = maxHistory;
globalThis["_history"] = this;
} }
save() { save(state: Graph) {
if (!this.prevState) { if (!this.state) {
this.prevState = this.manager.serialize(); this.state = clone(state);
this.initialState = globalThis.structuredClone(this.prevState); this.initialState = this.state;
log.log("initial state saved")
} else { } else {
if (this.timeout) { const newState = state;
clearTimeout(this.timeout); const delta = diff.diff(this.state, newState);
}
this.timeout = setTimeout(() => {
const newState = this.manager.serialize();
const delta = diff.diff(this.prevState, newState);
if (delta) { if (delta) {
log.log("saving state")
// Add the delta to history // Add the delta to history
if (this.index < this.history.length - 1) { if (this.index < this.history.length - 1) {
// Clear the history after the current index if new changes are made // Clear the history after the current index if new changes are made
@ -61,35 +59,43 @@ export class HistoryManager {
if (this.history.length > this.opts.maxHistory) { if (this.history.length > this.opts.maxHistory) {
this.history.shift(); this.history.shift();
} }
this.state = newState;
} else {
log.log("no changes")
} }
this.prevState = newState;
}, this.opts.debounce) as unknown as number;
} }
} }
reset() {
this.history = [];
this.index = -1;
this.state = undefined;
this.initialState = undefined;
}
undo() { undo() {
if (this.index > 0) { if (this.index === -1 && this.initialState) {
log.log("reached start, loading initial state")
return clone(this.initialState);
} else {
const delta = this.history[this.index]; const delta = this.history[this.index];
const prevState = diff.unpatch(this.prevState, delta) as Graph; const prevState = diff.unpatch(this.state, delta) as Graph;
this.manager._init(prevState); this.state = prevState;
this.index--; this.index = Math.max(-1, this.index - 1);
this.prevState = prevState; return clone(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() { redo() {
if (this.index < this.history.length - 1) { if (this.index <= this.history.length - 1) {
const nextIndex = this.index + 1; const nextIndex = Math.min(this.history.length - 1, this.index + 1);
const delta = this.history[nextIndex]; const delta = this.history[nextIndex];
const nextState = diff.patch(this.prevState, delta) as Graph; const nextState = diff.patch(this.state, delta) as Graph;
this.manager._init(nextState);
this.index = nextIndex; this.index = nextIndex;
this.prevState = nextState; this.state = nextState;
return clone(nextState);
} else { } else {
console.log("Reached end") log.log("reached end")
} }
} }
} }

View File

@ -37,7 +37,7 @@
<br /> <br />
<button <button
on:click={() => on:click={() =>
graphManager.load(graphManager.createTemplate("grid", 10, 10))} graphManager.load(graphManager.createTemplate("grid", 5, 5))}
>load grid</button >load grid</button
> >
<br /> <br />