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 Camera from "../Camera.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 type { Socket } from "$lib/types";
import {
@ -38,6 +38,10 @@
const cameraDown = [0, 0];
let cameraPosition: [number, number, number] = [0, 0, 4];
let addMenuPosition: [number, number] | null = null;
let clipboard: null | {
nodes: Node[];
edges: [number, number, number, string][];
} = null;
$: if (cameraPosition && loaded) {
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) {
const bodyIsFocused =
document.activeElement === document.body ||
document?.activeElement?.id === "graph";
if (event.key === "l") {
if (event.ctrlKey) {
const activeNode = graph.getNode($activeNodeId);
if (activeNode) {
const nodes = graph.getLinkedNodes(activeNode);
$selectedNodes = new Set(nodes.map((n) => n.id));
}
} else {
const activeNode = graph.getNode($activeNodeId);
console.log(activeNode);
}
}
if (event.key === "Escape") {
$activeNodeId = -1;
@ -497,20 +538,22 @@
}
if (event.key === "c" && event.ctrlKey) {
copyNodes();
}
if (event.key === "v" && event.ctrlKey) {
pasteNodes();
}
if (event.key === "z" && event.ctrlKey) {
graph.history.undo();
graph.undo();
for (const node of $nodes.values()) {
updateNodePosition(node);
}
}
if (event.key === "y" && event.ctrlKey) {
graph.history.redo();
graph.redo();
for (const node of $nodes.values()) {
updateNodePosition(node);
}

View File

@ -4,6 +4,9 @@ import { HistoryManager } from "./history-manager";
import * as templates from "./graphs";
import EventEmitter from "./helpers/EventEmitter";
import throttle from "./helpers/throttle";
import { createLogger } from "./helpers";
const logger = createLogger("graph-manager");
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());
history: HistoryManager = new HistoryManager(this);
history: HistoryManager = new HistoryManager();
constructor(private nodeRegistry: NodeRegistry, private runtime: RuntimeExecutor) {
super();
@ -41,12 +44,13 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
}
serialize(): Graph {
logger.log("serializing graph")
const nodes = Array.from(this._nodes.values()).map(node => ({
id: node.id,
position: node.position,
position: [...node.position],
type: node.type,
props: node.props,
}));
})) as Node[];
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 };
}
@ -57,7 +61,7 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
const start = performance.now();
const result = this.runtime.execute(this.serialize());
const end = performance.now();
console.log(`Execution took ${end - start}ms -> ${result}`);
logger.log(`Execution took ${end - start}ms -> ${result}`);
}
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) {
const nodes = new Map(graph.nodes.map(node => {
const nodeType = this.nodeRegistry.getNode(node.type);
@ -116,36 +139,28 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
async load(graph: Graph) {
this.loaded = false;
this.graph = graph;
const a = performance.now();
this.status.set("loading");
this.id.set(graph.id);
const b = performance.now();
for (const node of this.graph.nodes) {
const nodeType = this.nodeRegistry.getNode(node.type);
if (!nodeType) {
console.error(`Node type not found: ${node.type}`);
logger.error(`Node type not found: ${node.type}`);
this.status.set("error");
return;
}
node.tmp = node.tmp || {};
node.tmp.type = nodeType;
}
const c = performance.now();
this.history.reset();
this._init(this.graph);
const d = performance.now();
this.save();
const e = performance.now();
this.status.set("idle");
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();
}
private createNodeId() {
return Math.max(...this.getAllNodes().map(n => n.id), 0) + 1;
createNodeId() {
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);
if (!nodeType) {
@ -236,7 +300,7 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
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) => {
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() {
this.currentUndoGroup = 1;
}
@ -305,8 +387,10 @@ export class GraphManager extends EventEmitter<{ "save": Graph }> {
save() {
if (this.currentUndoGroup) return;
this.emit("save", this.serialize());
this.history.save();
const state = this.serialize();
this.history.save(state);
this.emit("save", state);
logger.log("saving graph");
}
getParentsOfNode(node: Node) {

View File

@ -1,7 +1,6 @@
import throttle from './throttle';
const debug = { amountEmitters: 0, amountCallbacks: 0, emitters: [] };
type EventMap = Record<string, unknown>;
@ -13,8 +12,6 @@ export default class EventEmitter<T extends EventMap = { [key: string]: unknown
index = 0;
public eventMap: T = {} as T;
constructor() {
this.index = debug.amountEmitters;
debug.amountEmitters++;
}
private cbs: { [key: string]: ((data?: unknown) => unknown)[] } = {};
@ -42,24 +39,9 @@ export default class EventEmitter<T extends EventMap = { [key: string]: unknown
});
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);
return () => {
debug.amountCallbacks--;
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() {
debug.amountEmitters--;
Object.keys(this.cbs).forEach((key) => {
debug.amountCallbacks -= this.cbs[key].length;
delete this.cbs[key];
});
Object.keys(this.cbsOnce).forEach((key) => delete this.cbsOnce[key]);
this.cbs = {};
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);
};
};
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 type { Graph } from "./types";
import { createLogger, clone } from "./helpers";
const diff = create({
@ -14,40 +14,38 @@ const diff = create({
}
})
const log = createLogger("history")
export class HistoryManager {
index: number = -1;
history: Delta[] = [];
private initialState: Graph | undefined;
private prevState: Graph | undefined;
private timeout: number | undefined;
private state: Graph | undefined;
private opts = {
debounce: 400,
maxHistory: 100,
}
constructor(private manager: GraphManager, { maxHistory = 100, debounce = 100 } = {}) {
constructor({ maxHistory = 100, debounce = 100 } = {}) {
this.history = [];
this.index = -1;
this.opts.debounce = debounce;
this.opts.maxHistory = maxHistory;
globalThis["_history"] = this;
}
save() {
if (!this.prevState) {
this.prevState = this.manager.serialize();
this.initialState = globalThis.structuredClone(this.prevState);
save(state: Graph) {
if (!this.state) {
this.state = clone(state);
this.initialState = this.state;
log.log("initial state saved")
} else {
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
const newState = this.manager.serialize();
const delta = diff.diff(this.prevState, newState);
const newState = state;
const delta = diff.diff(this.state, newState);
if (delta) {
log.log("saving state")
// Add the delta to history
if (this.index < this.history.length - 1) {
// 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) {
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() {
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 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)
const prevState = diff.unpatch(this.state, delta) as Graph;
this.state = prevState;
this.index = Math.max(-1, this.index - 1);
return clone(prevState);
}
}
redo() {
if (this.index < this.history.length - 1) {
const nextIndex = this.index + 1;
if (this.index <= this.history.length - 1) {
const nextIndex = Math.min(this.history.length - 1, this.index + 1);
const delta = this.history[nextIndex];
const nextState = diff.patch(this.prevState, delta) as Graph;
this.manager._init(nextState);
const nextState = diff.patch(this.state, delta) as Graph;
this.index = nextIndex;
this.prevState = nextState;
this.state = nextState;
return clone(nextState);
} else {
console.log("Reached end")
log.log("reached end")
}
}
}

View File

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