diff --git a/frontend/src/lib/components/graph/Graph.svelte b/frontend/src/lib/components/graph/Graph.svelte index 98a2a7b..c3a3e07 100644 --- a/frontend/src/lib/components/graph/Graph.svelte +++ b/frontend/src/lib/components/graph/Graph.svelte @@ -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,22 +442,59 @@ } } + 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); + const activeNode = graph.getNode($activeNodeId); + if (activeNode) { + const nodes = graph.getLinkedNodes(activeNode); + $selectedNodes = new Set(nodes.map((n) => n.id)); } + console.log(activeNode); } if (event.key === "Escape") { @@ -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); } diff --git a/frontend/src/lib/graph-manager.ts b/frontend/src/lib/graph-manager.ts index a2b9d6c..1e6a3ee 100644 --- a/frontend/src/lib/graph-manager.ts +++ b/frontend/src/lib/graph-manager.ts @@ -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> = 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(); + + 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) { diff --git a/frontend/src/lib/helpers/EventEmitter.ts b/frontend/src/lib/helpers/EventEmitter.ts index b37eeb5..122df55 100644 --- a/frontend/src/lib/helpers/EventEmitter.ts +++ b/frontend/src/lib/helpers/EventEmitter.ts @@ -1,7 +1,6 @@ import throttle from './throttle'; -const debug = { amountEmitters: 0, amountCallbacks: 0, emitters: [] }; type EventMap = Record; @@ -13,8 +12,6 @@ export default class EventEmitter unknown)[] } = {}; @@ -42,24 +39,9 @@ export default class EventEmitter [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 { - 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]; } } diff --git a/frontend/src/lib/helpers/index.ts b/frontend/src/lib/helpers/index.ts index 47e4d3e..57b9882 100644 --- a/frontend/src/lib/helpers/index.ts +++ b/frontend/src/lib/helpers/index.ts @@ -70,3 +70,27 @@ export const debounce = (fn: Function, ms = 300) => { timeoutId = setTimeout(() => fn.apply(this, args), ms); }; }; + +export const clone: (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; + } + + } + } +})(); + diff --git a/frontend/src/lib/history-manager.ts b/frontend/src/lib/history-manager.ts index c453dc6..1546b42 100644 --- a/frontend/src/lib/history-manager.ts +++ b/frontend/src/lib/history-manager.ts @@ -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,82 +14,88 @@ 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); - 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(); - } + 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 + this.history.splice(this.index + 1); } - this.prevState = newState; - }, this.opts.debounce) as unknown as number; + + this.history.push(delta); + this.index++; + + // Limit the size of the history + if (this.history.length > this.opts.maxHistory) { + this.history.shift(); + } + this.state = newState; + } else { + log.log("no changes") + } } } + 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") } } } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 712ac72..abe0a47 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -37,7 +37,7 @@