From d178f812fb0d5435c618958f3d274fe1bb1f9454 Mon Sep 17 00:00:00 2001 From: Max Richter Date: Tue, 2 Dec 2025 16:58:31 +0100 Subject: [PATCH] refactor: move event handlers to own classes --- .../lib/graph-interface/graph/Graph.svelte | 519 +----------------- .../lib/graph-interface/graph/Wrapper.svelte | 8 +- .../lib/graph-interface/graph/constants.ts | 3 + app/src/lib/graph-interface/graph/events.ts | 500 +++++++++++++++++ .../lib/graph-interface/graph/state.svelte.ts | 10 + 5 files changed, 540 insertions(+), 500 deletions(-) create mode 100644 app/src/lib/graph-interface/graph/constants.ts create mode 100644 app/src/lib/graph-interface/graph/events.ts diff --git a/app/src/lib/graph-interface/graph/Graph.svelte b/app/src/lib/graph-interface/graph/Graph.svelte index 893e663..743d183 100644 --- a/app/src/lib/graph-interface/graph/Graph.svelte +++ b/app/src/lib/graph-interface/graph/Graph.svelte @@ -1,6 +1,5 @@ - + 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);