diff --git a/app/package.json b/app/package.json index 4675c39..bb2f303 100644 --- a/app/package.json +++ b/app/package.json @@ -1,5 +1,5 @@ { - "name": "@nodes/app", + "name": "@nodarium/app", "private": true, "version": "0.0.0", "type": "module", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "@nodes/registry": "link:../packages/registry", - "@nodes/ui": "link:../packages/ui", - "@nodes/utils": "link:../packages/utils", + "@nodarium/registry": "link:../packages/registry", + "@nodarium/ui": "link:../packages/ui", + "@nodarium/utils": "link:../packages/utils", "@sveltejs/kit": "^2.49.0", "@threlte/core": "8.3.0", "@threlte/extras": "9.7.0", @@ -26,7 +26,7 @@ }, "devDependencies": { "@iconify-json/tabler": "^1.2.23", - "@nodes/types": "link:../packages/types", + "@nodarium/types": "link:../packages/types", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tsconfig/svelte": "^5.0.6", diff --git a/app/src/lib/graph-interface/AddMenu.svelte b/app/src/lib/graph-interface/components/AddMenu.svelte similarity index 89% rename from app/src/lib/graph-interface/AddMenu.svelte rename to app/src/lib/graph-interface/components/AddMenu.svelte index 3bd6773..835a6d5 100644 --- a/app/src/lib/graph-interface/AddMenu.svelte +++ b/app/src/lib/graph-interface/components/AddMenu.svelte @@ -1,19 +1,12 @@ - +
- import type { NodeDefinition, NodeRegistry } from "@nodes/types"; - import { onDestroy, onMount } from "svelte"; + import type { NodeDefinition, NodeRegistry } from "@nodarium/types"; + import { onMount } from "svelte"; let mx = $state(0); let my = $state(0); diff --git a/app/src/lib/graph-interface/edges/Edge.svelte b/app/src/lib/graph-interface/edges/Edge.svelte index d58898d..529ca53 100644 --- a/app/src/lib/graph-interface/edges/Edge.svelte +++ b/app/src/lib/graph-interface/edges/Edge.svelte @@ -12,7 +12,7 @@ }); }); - const lineCache = new Map(); + // const lineCache = new Map(); const curve = new CubicBezierCurve( new Vector2(0, 0), @@ -25,7 +25,7 @@ - -{#each edgePositions as edge (`${edge.join("-")}`)} - {@const [x1, y1, x2, y2] = edge} - -{/each} - - -
- {#each nodes.values() as node (node.id)} - - {/each} -
- - - diff --git a/app/src/lib/graph-interface/graph/Wrapper.svelte b/app/src/lib/graph-interface/graph/Wrapper.svelte index 7cd070e..923e142 100644 --- a/app/src/lib/graph-interface/graph/Wrapper.svelte +++ b/app/src/lib/graph-interface/graph/Wrapper.svelte @@ -1,14 +1,10 @@ - + diff --git a/app/src/lib/graph-interface/graph/context.ts b/app/src/lib/graph-interface/graph/context.ts deleted file mode 100644 index f9a28bc..0000000 --- a/app/src/lib/graph-interface/graph/context.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { GraphManager } from "../graph-manager.svelte"; -import { getContext } from "svelte"; - -export function getGraphManager(): GraphManager { - return getContext("graphManager"); -} diff --git a/app/src/lib/graph-interface/graph/state.svelte.ts b/app/src/lib/graph-interface/graph/state.svelte.ts index 7e6323d..04212c4 100644 --- a/app/src/lib/graph-interface/graph/state.svelte.ts +++ b/app/src/lib/graph-interface/graph/state.svelte.ts @@ -1,12 +1,59 @@ -import type { Socket } from "@nodes/types"; -import { getContext } from "svelte"; +import type { Node, Socket } from "@nodarium/types"; +import { getContext, setContext } from "svelte"; import { SvelteSet } from "svelte/reactivity"; +import type { GraphManager } from "../graph-manager.svelte"; +import type { OrthographicCamera } from "three"; + +const graphStateKey = Symbol("graph-state"); export function getGraphState() { - return getContext("graphState"); + return getContext(graphStateKey); +} +export function setGraphState(graphState: GraphState) { + return setContext(graphStateKey, graphState) +} + +const graphManagerKey = Symbol("graph-manager"); +export function getGraphManager() { + return getContext(graphManagerKey) +} + +export function setGraphManager(manager: GraphManager) { + return setContext(graphManagerKey, manager); } export class GraphState { + + constructor(private graph: GraphManager) { } + + cameraPosition: [number, number, number] = $state([0, 0, 4]); + wrapper = $state(null!); + + rect: DOMRect = $derived( + this.wrapper ? this.wrapper.getBoundingClientRect() : new DOMRect(0, 0, 0, 0), + ); + width = $derived(this.rect?.width ?? 100); + height = $derived(this.rect?.height ?? 100); + camera = $state(null!); + + clipboard: null | { + nodes: Node[]; + edges: [number, number, number, string][]; + } = null; + + cameraBounds = $derived([ + this.cameraPosition[0] - this.width / this.cameraPosition[2] / 2, + this.cameraPosition[0] + this.width / this.cameraPosition[2] / 2, + this.cameraPosition[1] - this.height / this.cameraPosition[2] / 2, + this.cameraPosition[1] + this.height / this.cameraPosition[2] / 2, + ]); + + boxSelection = $state(false); + edgeEndPosition = $state<[number, number] | null>(); + addMenuPosition = $state<[number, number] | null>(null); + + mousePosition = $state([0, 0]); + mouseDown = $state<[number, number] | null>(null); activeNodeId = $state(-1); selectedNodes = new SvelteSet(); activeSocket = $state(null); @@ -15,7 +62,269 @@ export class GraphState { possibleSocketIds = $derived( new Set(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`)), ); + clearSelection() { this.selectedNodes.clear(); } + + isBodyFocused = () => document?.activeElement?.nodeName !== "INPUT"; + + setCameraTransform( + x = this.cameraPosition[0], + y = this.cameraPosition[1], + z = this.cameraPosition[2], + ) { + if (this.camera) { + this.camera.position.x = x; + this.camera.position.z = y; + this.camera.zoom = z; + } + this.cameraPosition = [x, y, z]; + localStorage.setItem("cameraPosition", JSON.stringify(this.cameraPosition)); + } + + + updateNodePosition(node: Node) { + if (node?.tmp?.ref && node?.tmp?.mesh) { + 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`); + node.tmp.mesh.position.x = node.tmp.x + 10; + node.tmp.mesh.position.z = node.tmp.y + this.getNodeHeight(node.type) / 2; + if ( + node.tmp.x === node.position[0] && + node.tmp.y === node.position[1] + ) { + delete node.tmp.x; + delete node.tmp.y; + } + this.graph.edges = [...this.graph.edges]; + } else { + node.tmp.ref.style.setProperty("--nx", `${node.position[0] * 10}px`); + node.tmp.ref.style.setProperty("--ny", `${node.position[1] * 10}px`); + node.tmp.mesh.position.x = node.position[0] + 10; + node.tmp.mesh.position.z = + node.position[1] + this.getNodeHeight(node.type) / 2; + } + } + } + + getSnapLevel() { + const z = this.cameraPosition[2]; + if (z > 66) { + return 8; + } else if (z > 55) { + return 4; + } else if (z > 11) { + return 2; + } else { + } + return 1; + } + + getSocketPosition( + node: Node, + index: string | number, + ): [number, number] { + if (typeof index === "number") { + return [ + (node?.tmp?.x ?? node.position[0]) + 20, + (node?.tmp?.y ?? node.position[1]) + 2.5 + 10 * index, + ]; + } else { + const _index = Object.keys(node.tmp?.type?.inputs || {}).indexOf(index); + return [ + node?.tmp?.x ?? node.position[0], + (node?.tmp?.y ?? node.position[1]) + 10 + 10 * _index, + ]; + } + } + + private nodeHeightCache: Record = {}; + getNodeHeight(nodeTypeId: string) { + if (nodeTypeId in this.nodeHeightCache) { + return this.nodeHeightCache[nodeTypeId]; + } + const node = this.graph.getNodeType(nodeTypeId); + if (!node?.inputs) { + return 5; + } + const height = + 5 + + 10 * + Object.keys(node.inputs).filter( + (p) => + p !== "seed" && + node?.inputs && + !("setting" in node?.inputs?.[p]) && + node.inputs[p].hidden !== true, + ).length; + this.nodeHeightCache[nodeTypeId] = height; + return height; + } + + setNodePosition(node: Node) { + if (node?.tmp?.ref && node?.tmp?.mesh) { + 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`); + node.tmp.mesh.position.x = node.tmp.x + 10; + node.tmp.mesh.position.z = node.tmp.y + this.getNodeHeight(node.type) / 2; + if ( + node.tmp.x === node.position[0] && + node.tmp.y === node.position[1] + ) { + delete node.tmp.x; + delete node.tmp.y; + } + this.graph.edges = [...this.graph.edges]; + } else { + node.tmp.ref.style.setProperty("--nx", `${node.position[0] * 10}px`); + node.tmp.ref.style.setProperty("--ny", `${node.position[1] * 10}px`); + node.tmp.mesh.position.x = node.position[0] + 10; + node.tmp.mesh.position.z = + node.position[1] + this.getNodeHeight(node.type) / 2; + } + } + } + + + copyNodes() { + if (this.activeNodeId === -1 && !this.selectedNodes?.size) + return; + let _nodes = [ + this.activeNodeId, + ...(this.selectedNodes?.values() || []), + ] + .map((id) => this.graph.getNode(id)) + .filter(Boolean) as Node[]; + + const _edges = this.graph.getEdgesBetweenNodes(_nodes); + _nodes = $state.snapshot( + _nodes.map((_node) => ({ + ..._node, + tmp: { + downX: this.mousePosition[0] - _node.position[0], + downY: this.mousePosition[1] - _node.position[1], + }, + })), + ); + + this.clipboard = { + nodes: _nodes, + edges: _edges, + }; + } + + pasteNodes() { + if (!this.clipboard) return; + + const _nodes = this.clipboard.nodes + .map((node) => { + node.tmp = node.tmp || {}; + node.position[0] = this.mousePosition[0] - (node?.tmp?.downX || 0); + node.position[1] = this.mousePosition[1] - (node?.tmp?.downY || 0); + return node; + }) + .filter(Boolean) as Node[]; + + const newNodes = this.graph.createGraph(_nodes, this.clipboard.edges); + this.selectedNodes.clear(); + for (const node of newNodes) { + this.selectedNodes.add(node.id); + } + } + + + setDownSocket(socket: Socket) { + this.activeSocket = socket; + + let { node, index, position } = socket; + + // remove existing edge + if (typeof index === "string") { + const edges = this.graph.getEdgesToNode(node); + for (const edge of edges) { + if (edge[3] === index) { + node = edge[0]; + index = edge[1]; + position = this.getSocketPosition(node, index); + this.graph.removeEdge(edge); + break; + } + } + } + + this.mouseDown = position; + this.activeSocket = { + node, + index, + position, + }; + + this.possibleSockets = this.graph + .getPossibleSockets(this.activeSocket) + .map(([node, index]) => { + return { + node, + index, + position: this.getSocketPosition(node, index), + }; + }); + }; + + + projectScreenToWorld(x: number, y: number): [number, number] { + return [ + this.cameraPosition[0] + + (x - this.width / 2) / this.cameraPosition[2], + this.cameraPosition[1] + + (y - this.height / 2) / this.cameraPosition[2], + ]; + } + + getNodeIdFromEvent(event: MouseEvent) { + let clickedNodeId = -1; + + let mx = event.clientX - this.rect.x; + let my = event.clientY - this.rect.y; + + if (event.button === 0) { + // check if the clicked element is a node + if (event.target instanceof HTMLElement) { + const nodeElement = event.target.closest(".node"); + const nodeId = nodeElement?.getAttribute?.("data-node-id"); + if (nodeId) { + clickedNodeId = parseInt(nodeId, 10); + } + } + + // if we do not have an active node, + // we are going to check if we clicked on a node by coordinates + if (clickedNodeId === -1) { + const [downX, downY] = this.projectScreenToWorld(mx, my); + for (const node of this.graph.nodes.values()) { + const x = node.position[0]; + const y = node.position[1]; + const height = this.getNodeHeight(node.type); + if (downX > x && downX < x + 20 && downY > y && downY < y + height) { + clickedNodeId = node.id; + break; + } + } + } + } + return clickedNodeId; + } + + isNodeInView(node: Node) { + const height = this.getNodeHeight(node.type); + const width = 20; + return ( + node.position[0] > this.cameraBounds[0] - width && + node.position[0] < this.cameraBounds[1] && + node.position[1] > this.cameraBounds[2] - height && + node.position[1] < this.cameraBounds[3] + ); + }; } diff --git a/app/src/lib/graph-interface/history-manager.ts b/app/src/lib/graph-interface/history-manager.ts index 17179ef..e86441e 100644 --- a/app/src/lib/graph-interface/history-manager.ts +++ b/app/src/lib/graph-interface/history-manager.ts @@ -1,10 +1,10 @@ import { create, type Delta } from "jsondiffpatch"; -import type { Graph } from "@nodes/types"; +import type { Graph } from "@nodarium/types"; import { clone } from "./helpers/index.js"; -import { createLogger } from "@nodes/utils"; +import { createLogger } from "@nodarium/utils"; const diff = create({ - objectHash: function(obj, index) { + objectHash: function (obj, index) { if (obj === null) return obj; if ("id" in obj) return obj.id as string; if ("_id" in obj) return obj._id as string; diff --git a/app/src/lib/graph-interface/keymaps.ts b/app/src/lib/graph-interface/keymaps.ts new file mode 100644 index 0000000..d363a4b --- /dev/null +++ b/app/src/lib/graph-interface/keymaps.ts @@ -0,0 +1,192 @@ +import { animate, lerp } from "$lib/helpers"; +import type { createKeyMap } from "$lib/helpers/createKeyMap"; +import FileSaver from "file-saver"; +import type { GraphManager } from "./graph-manager.svelte"; +import type { GraphState } from "./graph/state.svelte"; + +type Keymap = ReturnType; +export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: GraphState) { + + + keymap.addShortcut({ + key: "l", + description: "Select linked nodes", + callback: () => { + const activeNode = graph.getNode(graphState.activeNodeId); + if (activeNode) { + const nodes = graph.getLinkedNodes(activeNode); + graphState.selectedNodes.clear(); + for (const node of nodes) { + graphState.selectedNodes.add(node.id); + } + } + }, + }); + + + keymap.addShortcut({ + key: "?", + description: "Toggle Help", + callback: () => { + // TODO: fix this + // showHelp = !showHelp; + }, + }); + + keymap.addShortcut({ + key: "c", + ctrl: true, + description: "Copy active nodes", + callback: graphState.copyNodes, + }); + + keymap.addShortcut({ + key: "v", + ctrl: true, + description: "Paste nodes", + callback: graphState.pasteNodes, + }); + + keymap.addShortcut({ + key: "Escape", + description: "Deselect nodes", + callback: () => { + graphState.activeNodeId = -1; + graphState.clearSelection(); + graphState.edgeEndPosition = null; + (document.activeElement as HTMLElement)?.blur(); + }, + }); + + keymap.addShortcut({ + key: "A", + shift: true, + description: "Add new Node", + callback: () => { + graphState.addMenuPosition = [graphState.mousePosition[0], graphState.mousePosition[1]]; + }, + }); + + keymap.addShortcut({ + key: ".", + description: "Center camera", + callback: () => { + if (!graphState.isBodyFocused()) return; + + const average = [0, 0]; + for (const node of graph.nodes.values()) { + average[0] += node.position[0]; + average[1] += node.position[1]; + } + average[0] = average[0] ? average[0] / graph.nodes.size : 0; + average[1] = average[1] ? average[1] / graph.nodes.size : 0; + + const camX = graphState.cameraPosition[0]; + const camY = graphState.cameraPosition[1]; + const camZ = graphState.cameraPosition[2]; + + const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t); + + animate(500, (a: number) => { + graphState.setCameraTransform( + lerp(camX, average[0], ease(a)), + lerp(camY, average[1], ease(a)), + lerp(camZ, 2, ease(a)), + ); + if (graphState.mouseDown) return false; + }); + }, + }); + + keymap.addShortcut({ + key: "a", + ctrl: true, + preventDefault: true, + description: "Select all nodes", + callback: () => { + if (!graphState.isBodyFocused()) return; + for (const node of graph.nodes.keys()) { + graphState.selectedNodes.add(node); + } + }, + }); + + keymap.addShortcut({ + key: "z", + ctrl: true, + description: "Undo", + callback: () => { + if (!graphState.isBodyFocused()) return; + graph.undo(); + for (const node of graph.nodes.values()) { + graphState.updateNodePosition(node); + } + }, + }); + + keymap.addShortcut({ + key: "y", + ctrl: true, + description: "Redo", + callback: () => { + graph.redo(); + for (const node of graph.nodes.values()) { + graphState.updateNodePosition(node); + } + }, + }); + + keymap.addShortcut({ + key: "s", + ctrl: true, + description: "Save", + preventDefault: true, + callback: () => { + const state = graph.serialize(); + const blob = new Blob([JSON.stringify(state)], { + type: "application/json;charset=utf-8", + }); + FileSaver.saveAs(blob, "nodarium-graph.json"); + }, + }); + + keymap.addShortcut({ + key: ["Delete", "Backspace", "x"], + description: "Delete selected nodes", + callback: (event) => { + if (!graphState.isBodyFocused()) return; + graph.startUndoGroup(); + if (graphState.activeNodeId !== -1) { + const node = graph.getNode(graphState.activeNodeId); + if (node) { + graph.removeNode(node, { restoreEdges: event.ctrlKey }); + graphState.activeNodeId = -1; + } + } + if (graphState.selectedNodes) { + for (const nodeId of graphState.selectedNodes) { + const node = graph.getNode(nodeId); + if (node) { + graph.removeNode(node, { restoreEdges: event.ctrlKey }); + } + } + graphState.clearSelection(); + } + graph.saveUndoGroup(); + }, + }); + + keymap.addShortcut({ + key: "f", + description: "Smart Connect Nodes", + callback: () => { + const nodes = [...graphState.selectedNodes.values()] + .map((g) => graph.getNode(g)) + .filter((n) => !!n); + const edge = graph.smartConnect(nodes[0], nodes[1]); + if (!edge) graph.smartConnect(nodes[1], nodes[0]); + }, + }); + + +} diff --git a/app/src/lib/graph-interface/node/Node.svelte b/app/src/lib/graph-interface/node/Node.svelte index b111102..f52777f 100644 --- a/app/src/lib/graph-interface/node/Node.svelte +++ b/app/src/lib/graph-interface/node/Node.svelte @@ -1,7 +1,7 @@ @@ -78,4 +74,4 @@ /> - + diff --git a/app/src/lib/graph-interface/node/NodeHTML.svelte b/app/src/lib/graph-interface/node/NodeHTML.svelte index f8f7bb2..7aa30b1 100644 --- a/app/src/lib/graph-interface/node/NodeHTML.svelte +++ b/app/src/lib/graph-interface/node/NodeHTML.svelte @@ -1,11 +1,14 @@ diff --git a/app/src/lib/graph-interface/node/NodeHeader.svelte b/app/src/lib/graph-interface/node/NodeHeader.svelte index ecfe242..9c8d6d1 100644 --- a/app/src/lib/graph-interface/node/NodeHeader.svelte +++ b/app/src/lib/graph-interface/node/NodeHeader.svelte @@ -1,23 +1,19 @@ -