mouseEvents.handleMouseMove(ev)}
+ onmouseup={(ev) => mouseEvents.handleMouseUp(ev)}
+/>
mouseEvents.handleMouseScroll(ev)}
bind:this={graphState.wrapper}
class="graph-wrapper"
- class:is-panning={isPanning}
- class:is-hovering={hoveredNodeId !== -1}
+ class:is-panning={graphState.isPanning}
+ class:is-hovering={graphState.hoveredNodeId !== -1}
aria-label="Graph"
role="button"
tabindex="0"
bind:clientWidth={graphState.width}
bind:clientHeight={graphState.height}
- ondragenter={handleDragEnter}
- ondragover={handlerDragOver}
- ondragexit={handleDragEnd}
- ondrop={handleDrop}
- onmouseleave={handleMouseLeave}
- onkeydown={keymap.handleKeyboardEvent}
- onmousedown={handleMouseDown}
+ onkeydown={(ev) => keymap.handleKeyboardEvent(ev)}
+ onmousedown={(ev) => mouseEvents.handleMouseDown(ev)}
+ {...fileDropEvents.getEventListenerProps()}
>
fileDropEvents.handleDragEnd(ev)}
+ ondragleave={(ev) => fileDropEvents.handleDragEnd(ev)}
/>
@@ -570,7 +91,7 @@
position={graphState.cameraPosition}
/>
- {#if showGrid !== false}
+ {#if graphState.showGrid !== false}
-{#if showHelp}
+{#if graphState.showHelp}
{/if}
diff --git a/app/src/lib/graph-interface/graph/Wrapper.svelte b/app/src/lib/graph-interface/graph/Wrapper.svelte
index 923e142..dfb9d43 100644
--- a/app/src/lib/graph-interface/graph/Wrapper.svelte
+++ b/app/src/lib/graph-interface/graph/Wrapper.svelte
@@ -41,6 +41,12 @@
setGraphManager(manager);
const graphState = new GraphState(manager);
+ $effect(() => {
+ graphState.showGrid = showGrid;
+ graphState.snapToGrid = snapToGrid;
+ graphState.showHelp = showHelp;
+ });
+
setGraphState(graphState);
setupKeymaps(keymap, manager, graphState);
@@ -78,4 +84,4 @@
manager.load(graph);
-
+
diff --git a/app/src/lib/graph-interface/graph/constants.ts b/app/src/lib/graph-interface/graph/constants.ts
new file mode 100644
index 0000000..e1dbf4c
--- /dev/null
+++ b/app/src/lib/graph-interface/graph/constants.ts
@@ -0,0 +1,3 @@
+export const minZoom = 1;
+export const maxZoom = 40;
+export const zoomSpeed = 2;
diff --git a/app/src/lib/graph-interface/graph/events.ts b/app/src/lib/graph-interface/graph/events.ts
new file mode 100644
index 0000000..a3e2cf3
--- /dev/null
+++ b/app/src/lib/graph-interface/graph/events.ts
@@ -0,0 +1,500 @@
+import { GraphSchema, type NodeType, type Node } from "@nodarium/types";
+import type { GraphManager } from "../graph-manager.svelte";
+import type { GraphState } from "./state.svelte";
+import { animate, lerp } from "$lib/helpers";
+import { snapToGrid as snapPointToGrid } from "../helpers";
+import { maxZoom, minZoom, zoomSpeed } from "./constants";
+
+
+export class FileDropEventManager {
+
+ constructor(
+ private graph: GraphManager,
+ private state: GraphState
+ ) { }
+
+ handleFileDrop(event: DragEvent) {
+ event.preventDefault();
+ this.state.isDragging = false;
+ if (!event.dataTransfer) return;
+ const nodeId = event.dataTransfer.getData("data/node-id") as NodeType;
+ let mx = event.clientX - this.state.rect.x;
+ let my = event.clientY - this.state.rect.y;
+
+ if (nodeId) {
+ let nodeOffsetX = event.dataTransfer.getData("data/node-offset-x");
+ let nodeOffsetY = event.dataTransfer.getData("data/node-offset-y");
+ if (nodeOffsetX && nodeOffsetY) {
+ mx += parseInt(nodeOffsetX);
+ my += parseInt(nodeOffsetY);
+ }
+
+ let props = {};
+ let rawNodeProps = event.dataTransfer.getData("data/node-props");
+ if (rawNodeProps) {
+ try {
+ props = JSON.parse(rawNodeProps);
+ } catch (e) { }
+ }
+
+ const pos = this.state.projectScreenToWorld(mx, my);
+ this.graph.registry.load([nodeId]).then(() => {
+ this.graph.createNode({
+ type: nodeId,
+ props,
+ position: pos,
+ });
+ });
+ } else if (event.dataTransfer.files.length) {
+ const file = event.dataTransfer.files[0];
+
+ if (file.type === "application/wasm") {
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const buffer = e.target?.result;
+ if (buffer?.constructor === ArrayBuffer) {
+ const nodeType = await this.graph.registry.register(buffer);
+
+ this.graph.createNode({
+ type: nodeType.id,
+ props: {},
+ position: this.state.projectScreenToWorld(mx, my),
+ });
+ }
+ };
+ reader.readAsArrayBuffer(file);
+ } else if (file.type === "application/json") {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const buffer = e.target?.result as ArrayBuffer;
+ if (buffer) {
+ const state = GraphSchema.parse(JSON.parse(buffer.toString()));
+ this.graph.load(state);
+ }
+ };
+ reader.readAsText(file);
+ }
+ }
+ }
+
+ handleMouseLeave() {
+ this.state.isDragging = false;
+ this.state.isPanning = false;
+ }
+
+ handleDragEnter(e: DragEvent) {
+ e.preventDefault();
+ this.state.isDragging = true;
+ this.state.isPanning = false;
+ }
+
+ handleDragOver(e: DragEvent) {
+ e.preventDefault();
+ this.state.isDragging = true;
+ this.state.isPanning = false;
+ }
+
+ handleDragEnd(e: DragEvent) {
+ e.preventDefault();
+ this.state.isDragging = true;
+ this.state.isPanning = false;
+ }
+
+ getEventListenerProps() {
+ return {
+ ondragenter: (ev: DragEvent) => this.handleDragEnter(ev),
+ ondragover: (ev: DragEvent) => this.handleDragOver(ev),
+ ondragexit: (ev: DragEvent) => this.handleDragEnd(ev),
+ ondrop: (ev: DragEvent) => this.handleFileDrop(ev),
+ onmouseleave: () => this.handleMouseLeave(),
+ }
+ }
+}
+
+
+export class MouseEventManager {
+
+
+ constructor(
+ private graph: GraphManager,
+ private state: GraphState
+ ) { }
+
+
+ handleMouseUp(event: MouseEvent) {
+ this.state.isPanning = false;
+ if (!this.state.mouseDown) return;
+
+ const activeNode = this.graph.getNode(this.state.activeNodeId);
+
+ const clickedNodeId = this.state.getNodeIdFromEvent(event);
+
+ if (clickedNodeId !== -1) {
+ if (activeNode) {
+ if (!activeNode?.tmp?.isMoving && !event.ctrlKey && !event.shiftKey) {
+ this.state.activeNodeId = clickedNodeId;
+ this.state.clearSelection();
+ }
+ }
+ }
+
+ if (activeNode?.tmp?.isMoving) {
+ activeNode.tmp = activeNode.tmp || {};
+ activeNode.tmp.isMoving = false;
+ if (this.state.snapToGrid) {
+ const snapLevel = this.state.getSnapLevel();
+ activeNode.position[0] = snapPointToGrid(
+ activeNode?.tmp?.x ?? activeNode.position[0],
+ 5 / snapLevel,
+ );
+ activeNode.position[1] = snapPointToGrid(
+ activeNode?.tmp?.y ?? activeNode.position[1],
+ 5 / snapLevel,
+ );
+ } else {
+ activeNode.position[0] = activeNode?.tmp?.x ?? activeNode.position[0];
+ activeNode.position[1] = activeNode?.tmp?.y ?? activeNode.position[1];
+ }
+ const nodes = [
+ ...[...(this.state.selectedNodes?.values() || [])].map((id) =>
+ this.graph.getNode(id),
+ ),
+ ] as Node[];
+
+ const vec = [
+ activeNode.position[0] - (activeNode?.tmp.x || 0),
+ activeNode.position[1] - (activeNode?.tmp.y || 0),
+ ];
+
+ for (const node of nodes) {
+ if (!node) continue;
+ node.tmp = node.tmp || {};
+ const { x, y } = node.tmp;
+ if (x !== undefined && y !== undefined) {
+ node.position[0] = x + vec[0];
+ node.position[1] = y + vec[1];
+ }
+ }
+ nodes.push(activeNode);
+ animate(500, (a: number) => {
+ 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[0], a);
+ node.tmp.y = lerp(node.tmp.y, node.position[1], a);
+ this.state.updateNodePosition(node);
+ if (node?.tmp?.isMoving) {
+ return false;
+ }
+ }
+ }
+ });
+ this.graph.save();
+ } else if (this.state.hoveredSocket && this.state.activeSocket) {
+ if (
+ typeof this.state.hoveredSocket.index === "number" &&
+ typeof this.state.activeSocket.index === "string"
+ ) {
+ this.graph.createEdge(
+ this.state.hoveredSocket.node,
+ this.state.hoveredSocket.index || 0,
+ this.state.activeSocket.node,
+ this.state.activeSocket.index,
+ );
+ } else if (
+ typeof this.state.activeSocket.index == "number" &&
+ typeof this.state.hoveredSocket.index === "string"
+ ) {
+ this.graph.createEdge(
+ this.state.activeSocket.node,
+ this.state.activeSocket.index || 0,
+ this.state.hoveredSocket.node,
+ this.state.hoveredSocket.index,
+ );
+ }
+ this.graph.save();
+ } else if (this.state.activeSocket && event.ctrlKey) {
+ // Handle automatic adding of nodes on ctrl+mouseUp
+ this.state.edgeEndPosition = [
+ this.state.mousePosition[0],
+ this.state.mousePosition[1],
+ ];
+
+ if (typeof this.state.activeSocket.index === "number") {
+ this.state.addMenuPosition = [
+ this.state.mousePosition[0],
+ this.state.mousePosition[1] - 25 / this.state.cameraPosition[2],
+ ];
+ } else {
+ this.state.addMenuPosition = [
+ this.state.mousePosition[0] - 155 / this.state.cameraPosition[2],
+ this.state.mousePosition[1] - 25 / this.state.cameraPosition[2],
+ ];
+ }
+ return;
+ }
+
+ // check if camera moved
+ if (
+ clickedNodeId === -1 &&
+ !this.state.boxSelection &&
+ this.state.cameraDown[0] === this.state.cameraPosition[0] &&
+ this.state.cameraDown[1] === this.state.cameraPosition[1] &&
+ this.state.isBodyFocused()
+ ) {
+ this.state.activeNodeId = -1;
+ this.state.clearSelection();
+ }
+
+ this.state.mouseDown = null;
+ this.state.boxSelection = false;
+ this.state.activeSocket = null;
+ this.state.possibleSockets = [];
+ this.state.hoveredSocket = null;
+ this.state.addMenuPosition = null;
+ }
+
+
+ handleMouseDown(event: MouseEvent) {
+ if (this.state.mouseDown) return;
+ this.state.edgeEndPosition = null;
+
+ if (event.target instanceof HTMLElement) {
+ if (
+ event.target.nodeName !== "CANVAS" &&
+ !event.target.classList.contains("node") &&
+ !event.target.classList.contains("content")
+ ) {
+ return;
+ }
+ }
+
+ let mx = event.clientX - this.state.rect.x;
+ let my = event.clientY - this.state.rect.y;
+
+ this.state.mouseDown = [mx, my];
+ this.state.cameraDown[0] = this.state.cameraPosition[0];
+ this.state.cameraDown[1] = this.state.cameraPosition[1];
+
+ const clickedNodeId = this.state.getNodeIdFromEvent(event);
+ this.state.mouseDownNodeId = clickedNodeId;
+
+ // if we clicked on a node
+ if (clickedNodeId !== -1) {
+ if (this.state.activeNodeId === -1) {
+ this.state.activeNodeId = clickedNodeId;
+ // if the selected node is the same as the clicked node
+ } else if (this.state.activeNodeId === clickedNodeId) {
+ //$activeNodeId = -1;
+ // if the clicked node is different from the selected node and secondary
+ } else if (event.ctrlKey) {
+ this.state.selectedNodes.add(this.state.activeNodeId);
+ this.state.selectedNodes.delete(clickedNodeId);
+ this.state.activeNodeId = clickedNodeId;
+ // select the node
+ } else if (event.shiftKey) {
+ const activeNode = this.graph.getNode(this.state.activeNodeId);
+ const newNode = this.graph.getNode(clickedNodeId);
+ if (activeNode && newNode) {
+ const edge = this.graph.getNodesBetween(activeNode, newNode);
+ if (edge) {
+ this.state.selectedNodes.clear();
+ for (const node of edge) {
+ this.state.selectedNodes.add(node.id);
+ }
+ this.state.selectedNodes.add(clickedNodeId);
+ }
+ }
+ } else if (!this.state.selectedNodes.has(clickedNodeId)) {
+ this.state.activeNodeId = clickedNodeId;
+ this.state.clearSelection();
+ }
+ } else if (event.ctrlKey) {
+ this.state.boxSelection = true;
+ }
+
+ const node = this.graph.getNode(this.state.activeNodeId);
+ if (!node) return;
+ node.tmp = node.tmp || {};
+ node.tmp.downX = node.position[0];
+ node.tmp.downY = node.position[1];
+
+ if (this.state.selectedNodes) {
+ for (const nodeId of this.state.selectedNodes) {
+ const n = this.graph.getNode(nodeId);
+ if (!n) continue;
+ n.tmp = n.tmp || {};
+ n.tmp.downX = n.position[0];
+ n.tmp.downY = n.position[1];
+ }
+ }
+
+ this.state.edgeEndPosition = null;
+ }
+
+
+ handleMouseMove(event: MouseEvent) {
+ let mx = event.clientX - this.state.rect.x;
+ let my = event.clientY - this.state.rect.y;
+
+ this.state.mousePosition = this.state.projectScreenToWorld(mx, my);
+ this.state.hoveredNodeId = this.state.getNodeIdFromEvent(event);
+
+ if (!this.state.mouseDown) return;
+
+ // we are creating a new edge here
+ if (this.state.activeSocket || this.state.possibleSockets?.length) {
+ let smallestDist = 1000;
+ let _socket;
+ for (const socket of this.state.possibleSockets) {
+ const dist = Math.sqrt(
+ (socket.position[0] - this.state.mousePosition[0]) ** 2 +
+ (socket.position[1] - this.state.mousePosition[1]) ** 2,
+ );
+ if (dist < smallestDist) {
+ smallestDist = dist;
+ _socket = socket;
+ }
+ }
+
+ if (_socket && smallestDist < 0.9) {
+ this.state.mousePosition = _socket.position;
+ this.state.hoveredSocket = _socket;
+ } else {
+ this.state.hoveredSocket = null;
+ }
+ return;
+ }
+
+ // handle box selection
+ if (this.state.boxSelection) {
+ event.preventDefault();
+ event.stopPropagation();
+ const mouseD = this.state.projectScreenToWorld(
+ this.state.mouseDown[0],
+ this.state.mouseDown[1],
+ );
+ const x1 = Math.min(mouseD[0], this.state.mousePosition[0]);
+ const x2 = Math.max(mouseD[0], this.state.mousePosition[0]);
+ const y1 = Math.min(mouseD[1], this.state.mousePosition[1]);
+ const y2 = Math.max(mouseD[1], this.state.mousePosition[1]);
+ for (const node of this.graph.nodes.values()) {
+ if (!node?.tmp) continue;
+ const x = node.position[0];
+ const y = node.position[1];
+ const height = this.state.getNodeHeight(node.type);
+ if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
+ this.state.selectedNodes?.add(node.id);
+ } else {
+ this.state.selectedNodes?.delete(node.id);
+ }
+ }
+ return;
+ }
+
+ // here we are handling dragging of nodes
+ if (this.state.activeNodeId !== -1 && this.state.mouseDownNodeId !== -1) {
+ const node = this.graph.getNode(this.state.activeNodeId);
+ if (!node || event.buttons !== 1) return;
+
+ node.tmp = node.tmp || {};
+
+ const oldX = node.tmp.downX || 0;
+ const oldY = node.tmp.downY || 0;
+
+ let newX =
+ oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
+ let newY =
+ oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
+
+ if (event.ctrlKey) {
+ const snapLevel = this.state.getSnapLevel();
+ if (this.state.snapToGrid) {
+ newX = snapPointToGrid(newX, 5 / snapLevel);
+ newY = snapPointToGrid(newY, 5 / snapLevel);
+ }
+ }
+
+ if (!node.tmp.isMoving) {
+ const dist = Math.sqrt((oldX - newX) ** 2 + (oldY - newY) ** 2);
+ if (dist > 0.2) {
+ node.tmp.isMoving = true;
+ }
+ }
+
+ const vecX = oldX - newX;
+ const vecY = oldY - newY;
+
+ if (this.state.selectedNodes?.size) {
+ for (const nodeId of this.state.selectedNodes) {
+ const n = this.graph.getNode(nodeId);
+ if (!n?.tmp) continue;
+ n.tmp.x = (n?.tmp?.downX || 0) - vecX;
+ n.tmp.y = (n?.tmp?.downY || 0) - vecY;
+ this.state.updateNodePosition(n);
+ }
+ }
+
+ node.tmp.x = newX;
+ node.tmp.y = newY;
+
+ this.state.updateNodePosition(node);
+
+ return;
+ }
+
+ // here we are handling panning of camera
+ this.state.isPanning = true;
+ let newX =
+ this.state.cameraDown[0] -
+ (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
+ let newY =
+ this.state.cameraDown[1] -
+ (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
+
+ this.state.setCameraTransform(newX, newY);
+ }
+
+
+ handleMouseScroll(event: WheelEvent) {
+ const bodyIsFocused =
+ document.activeElement === document.body ||
+ document.activeElement === this.state.wrapper ||
+ document?.activeElement?.id === "graph";
+ if (!bodyIsFocused) return;
+
+ // Define zoom speed and clamp it between -1 and 1
+ const isNegative = event.deltaY < 0;
+ const normalizedDelta = Math.abs(event.deltaY * 0.01);
+ const delta = Math.pow(0.95, zoomSpeed * normalizedDelta);
+
+ // Calculate new zoom level and clamp it between minZoom and maxZoom
+ const newZoom = Math.max(
+ minZoom,
+ Math.min(
+ maxZoom,
+ isNegative
+ ? this.state.cameraPosition[2] / delta
+ : this.state.cameraPosition[2] * delta,
+ ),
+ );
+
+ // Calculate the ratio of the new zoom to the original zoom
+ const zoomRatio = newZoom / this.state.cameraPosition[2];
+
+ // Update camera position and zoom level
+ this.state.setCameraTransform(
+ this.state.mousePosition[0] -
+ (this.state.mousePosition[0] - this.state.cameraPosition[0]) /
+ zoomRatio,
+ this.state.mousePosition[1] -
+ (this.state.mousePosition[1] - this.state.cameraPosition[1]) /
+ zoomRatio,
+ newZoom,
+ );
+ }
+
+}
diff --git a/app/src/lib/graph-interface/graph/state.svelte.ts b/app/src/lib/graph-interface/graph/state.svelte.ts
index 58a58b2..ba7b123 100644
--- a/app/src/lib/graph-interface/graph/state.svelte.ts
+++ b/app/src/lib/graph-interface/graph/state.svelte.ts
@@ -53,6 +53,16 @@ export class GraphState {
edgeEndPosition = $state<[number, number] | null>();
addMenuPosition = $state<[number, number] | null>(null);
+ snapToGrid = $state(false);
+ showGrid = $state(true)
+ showHelp = $state(false)
+
+ cameraDown = [0, 0];
+ mouseDownNodeId = -1;
+
+ isPanning = $state(false);
+ isDragging = $state(false);
+ hoveredNodeId = $state(-1);
mousePosition = $state([0, 0]);
mouseDown = $state<[number, number] | null>(null);
activeNodeId = $state(-1);