import { Node, ServoNode, VariableNode, CurveNode, MathNode, MapNode, NoiseNode } from './Node.js'; import { TextInputControl } from './Control.js'; import { encodeNodeGraph, loadFromBinary } from './NodeSerializer.js'; // === Grid Settings === const GRID_SPACING = 20; // Distance between dots in pixels const CANVAS_COLOR = "#ffffffff"; const GRID_COLOR = "#ff0000ff"; // Dot color const GRID_RADIUS = 1; // Dot radius in pixels export class NodeEditor { constructor(canvas) { this.canvas = canvas; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; this.ctx = canvas.getContext('2d'); this.nodes = [ //new Node(100, 100), //new Node(300, 200), // new CurveNode(600, 400, 22), // new ServoNode(900, 400, 22), // new VariableNode(300, 800), // new MathNode(600, 800), // new MapNode(900, 800), // new NoiseNode(900, 1200), ]; this.connections = [] this.draggingConnection = null; // { fromNode: Node, x: number, y: number } this.hoveredConnection = null; //this.connections.push({ from: this.nodes[0], to: this.nodes[1] }); //this.connections.push({ from: this.nodes[2], to: this.nodes[3] }); this.draggingNode = null; this.offsetX = 0; // pan X this.offsetY = 0; // pan Y this.zoom = 1; // zoom scale this.isPanning = false; this.panStart = { x: 0, y: 0 }; this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this)); this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this)); this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this)); this.canvas.addEventListener("dblclick", this.onDoubleClick.bind(this)); this.canvas.addEventListener('wheel', this.onWheel.bind(this), { passive: false }); this.canvas.addEventListener("contextmenu", this.onContextMenu.bind(this)); this.modifiers = { ctrl: false, shift: false, alt: false }; window.addEventListener("keydown", (e) => { if (e.key === "Control") this.modifiers.ctrl = true; if (e.key === "Shift") this.modifiers.shift = true; if (e.key === "Alt") this.modifiers.alt = true; for (const node of this.nodes) { for (const control of node.controls) { if (control.isFocused) { let clipboardHandled = false; if (control.onClipboard) { control.onClipboard(e); clipboardHandled = e.defaultPrevented; } if (!clipboardHandled && control.onKeyDown) { control.onKeyDown(e.key); } e.preventDefault(); return; } } } }); window.addEventListener("keyup", (e) => { if (e.key === "Control") this.modifiers.ctrl = false; if (e.key === "Shift") this.modifiers.shift = false; if (e.key === "Alt") this.modifiers.alt = false; }); // let encoded = encodeNodeGraph(this.nodes, this.connections); // console.log(encoded); //loadFromBinary(this, encoded); //this.createGridCanvas(); } start() { requestAnimationFrame(this.draw.bind(this)); } clear() { this.nodes = []; this.connections = []; } addNode(node) { this.nodes.push(node); this._redraw?.(); // or trigger draw manually } generateDefaultNodes(curveSets, motorIDs) { //console.log("Generating Default Nodes"); //console.log(curveSets, motorIDs); for (var i = 0; i < motorIDs.length; i++) { let inputNode = new CurveNode(200, 20 + i * 140, motorIDs[i]); let outputNode = new ServoNode(500, 40 + i * 140, motorIDs[i]); this.nodes.push(inputNode); this.nodes.push(outputNode); this.connections.push({ from: inputNode, to: outputNode }); //console.log(inputNode); } this.draw(); //this.addVariableNode(50, 120, "Var"); } encodeNodeGraph() { return encodeNodeGraph(this.nodes, this.connections); } loadFromBinary(data) { console.log(data); this.clear(); loadFromBinary(this, data); } draw() { this.ctx.fillStyle = CANVAS_COLOR; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); //this.ctx.drawImage(this.gridCanvas, 0, 0); this.drawGrid(); this.ctx.save(); this.ctx.translate(this.offsetX, this.offsetY); this.ctx.scale(this.zoom, this.zoom); this.drawConnections(); for (const node of this.nodes) { node.draw(this.ctx); } this.ctx.restore(); requestAnimationFrame(this.draw.bind(this)); } drawConnections() { this.ctx.strokeStyle = "#444"; this.ctx.lineWidth = 2; for (const conn of this.connections) { const x1 = conn.from.output.x; const y1 = conn.from.output.y; const x2 = conn.to.input.x; const y2 = conn.to.input.y; const dx = Math.abs(x2 - x1) * 0.5; this.ctx.beginPath(); this.ctx.moveTo(x1, y1); this.ctx.bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); if (conn === this.hoveredConnection) { this.ctx.strokeStyle = "#666"; // lighter on hover this.ctx.lineWidth = 6.5; } else { this.ctx.strokeStyle = "#444"; this.ctx.lineWidth = 4; } this.ctx.stroke(); } // Live dragging connection if (this.draggingConnection) { const x1 = this.draggingConnection.fromNode.output.x; const y1 = this.draggingConnection.fromNode.output.y; const x2 = this.draggingConnection.x; const y2 = this.draggingConnection.y; const dx = Math.abs(x2 - x1) * 0.5; this.ctx.beginPath(); this.ctx.moveTo(x1, y1); this.ctx.bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); this.ctx.stroke(); } } createGridCanvas() { const gridCanvas = document.createElement('canvas'); gridCanvas.width = this.canvas.width; gridCanvas.height = this.canvas.height; const ctx = gridCanvas.getContext('2d'); ctx.fillStyle = GRID_COLOR; for (let x = 0; x < gridCanvas.width; x += GRID_SPACING) { for (let y = 0; y < gridCanvas.height; y += GRID_SPACING) { ctx.fillRect(x, y, 1, 1); } } this.gridCanvas = gridCanvas; } drawGrid() { const { width, height } = this.canvas; const spacing = GRID_SPACING * this.zoom; const radius = GRID_RADIUS * this.zoom; // Skip grid rendering if spacing is too small if (spacing < 2) return; // Dynamically skip grid points when zoomed out const skip = spacing < 8 ? Math.ceil(8 / spacing) : 1; this.ctx.fillStyle = GRID_COLOR; this.ctx.beginPath(); const startX = (this.offsetX % spacing + spacing) % spacing; const startY = (this.offsetY % spacing + spacing) % spacing; let i = 0; for (let x = startX; x < width; x += spacing, i++) { if (i % skip !== 0) continue; let j = 0; for (let y = startY; y < height; y += spacing, j++) { if (j % skip !== 0) continue; this.ctx.fillRect(x - radius / 2, y - radius / 2, radius, radius); } } this.ctx.fill(); } snapToGrid(x, y) { const spacing = GRID_SPACING; const snappedX = Math.round(x / spacing) * spacing; const snappedY = Math.round(y / spacing) * spacing; return { x: snappedX, y: snappedY }; } screenToWorld(mx, my) { const rect = this.canvas.getBoundingClientRect(); const x = (mx - rect.left - this.offsetX) / this.zoom; const y = (my - rect.top - this.offsetY) / this.zoom; return { x, y }; } onMouseDown(e) { if (e.button === 1) { // middle mouse this.isPanning = true; this.panStart.x = e.clientX; this.panStart.y = e.clientY; e.preventDefault(); return; } const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY); for (const node of this.nodes) { if (node.hasOutput) { const { x, y } = node.output; const radius = 8; const dx = wx - x; const dy = wy - y; if (dx * dx + dy * dy < radius * radius) { this.draggingConnection = { fromNode: node, x: wx, y: wy }; return; } } for (const control of node.controls) { control._ctx = this.ctx; // inject context if needed const handled = control.onEvent?.("mouseDown", { mx: wx, my: wy, parentX: node.x, parentY: node.y }); if (handled) return; } } for (let i = this.nodes.length - 1; i >= 0; i--) { const node = this.nodes[i]; if (node.contains(wx, wy)) { node.onSelect(true); node.onDrag(true); node.offsetX = wx - node.x; node.offsetY = wy - node.y; this.draggingNode = node; this.nodes.splice(i, 1); this.nodes.push(node); break; } } } onMouseMove(e) { this.hoveredConnection = null; if (this.isPanning) { const dx = e.clientX - this.panStart.x; const dy = e.clientY - this.panStart.y; this.offsetX += dx; this.offsetY += dy; this.panStart.x = e.clientX; this.panStart.y = e.clientY; return; } const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY); if (this.draggingConnection) { this.draggingConnection.x = wx; this.draggingConnection.y = wy; return; } for (const conn of this.connections) { const x1 = conn.from.output.x; const y1 = conn.from.output.y; const x2 = conn.to.input.x; const y2 = conn.to.input.y; const dx = Math.abs(x2 - x1) * 0.5; for (let t = 0; t <= 1; t += 0.01) { const px = this.bezierPoint(x1, x1 + dx, x2 - dx, x2, t); const py = this.bezierPoint(y1, y1, y2, y2, t); const dist = Math.hypot(wx - px, wy - py); if (dist < 8) { this.hoveredConnection = conn; break; } } if (this.hoveredConnection) break; } // First, check if any control is under the cursor let controlHovered = false; for (let i = this.nodes.length - 1; i >= 0; i--) { const node = this.nodes[i]; for (const control of node.controls) { control._ctx = this.ctx; // inject context if needed if (control.contains?.(wx, wy, node.x, node.y)) { controlHovered = true; } control.onEvent?.("mouseMove", { mx: wx, my: wy, parentX: node.x, parentY: node.y, modifiers: { ...this.modifiers, zoom: this.zoom } }); } if (!this.draggingNode && !controlHovered) { for (const node of this.nodes) { node.onMouseMove(wx, wy); } } } // If dragging, update position and skip hover logic if (this.draggingNode) { const rawX = wx - this.draggingNode.offsetX; const rawY = wy - this.draggingNode.offsetY; const { x, y } = this.snapToGrid(rawX, rawY); this.draggingNode.x = x; this.draggingNode.y = y; this.draggingNode.onHover(true); return; } // If a control is hovered, skip node hover if (controlHovered) { for (const node of this.nodes) { node.onHover(false); } return; } // Otherwise, handle node hover normally let hoveringHandled = false; for (let i = this.nodes.length - 1; i >= 0; i--) { const node = this.nodes[i]; if (!hoveringHandled && node.contains(wx, wy)) { node.onHover(true); hoveringHandled = true; } else { node.onHover(false); } } } onMouseUp(e) { const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY); if (this.isPanning) { this.isPanning = false; return; } if (this.draggingConnection) { for (const node of this.nodes) { if (node.hasInput) { const { x, y } = node.input; const radius = 8; const dx = wx - x; const dy = wy - y; if (dx * dx + dy * dy < radius * radius) { // Remove any existing connection to this node's input for (let i = this.connections.length - 1; i >= 0; i--) { if (this.connections[i].to === node) { this.connections.splice(i, 1); } } // Add the new connection this.connections.push({ from: this.draggingConnection.fromNode, to: node }); break; } } } this.draggingConnection = null; } for (const node of this.nodes) { for (const control of node.controls) { control._ctx = this.ctx; // inject context if needed const handled = control.onEvent?.("mouseUp", { mx: wx, my: wy, parentX: node.x, parentY: node.y }); if (handled) return; } } if (this.draggingNode) { this.draggingNode.onDrag(false); this.draggingNode = null; } for (const node of this.nodes) { node.onSelect(false); } } onDoubleClick(e) { const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY); // First: check if a node was double-clicked for (let i = this.nodes.length - 1; i >= 0; i--) { const node = this.nodes[i]; if (!node.canDelete){ continue; } const nodeBounds = { x: node.x, y: node.y, width: node.width ?? 120, // fallback width height: node.height ?? 60, // fallback height }; if ( wx >= nodeBounds.x && wx <= nodeBounds.x + nodeBounds.width && wy >= nodeBounds.y && wy <= nodeBounds.y + nodeBounds.height ) { console.log("Double-clicked node:", node); this.nodes.splice(i, 1); // Remove any connections involving this node this.connections = this.connections.filter( conn => conn.from !== node && conn.to !== node ); return; } } // Second: check if a connection was double-clicked for (let i = this.connections.length - 1; i >= 0; i--) { const conn = this.connections[i]; const x1 = conn.from.output.x; const y1 = conn.from.output.y; const x2 = conn.to.input.x; const y2 = conn.to.input.y; const dx = Math.abs(x2 - x1) * 0.5; for (let t = 0; t <= 1; t += 0.05) { const px = this.bezierPoint(x1, x1 + dx, x2 - dx, x2, t); const py = this.bezierPoint(y1, y1, y2, y2, t); const dist = Math.hypot(wx - px, wy - py); if (dist < 8) { console.log("Double-clicked connection"); this.connections.splice(i, 1); return; } } } } onWheel(e) { const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY); // Check if any control is under the cursor for (let i = this.nodes.length - 1; i >= 0; i--) { const node = this.nodes[i]; for (const control of node.controls) { if (control.contains?.(wx, wy, node.x, node.y)) { const direction = e.deltaY < 0 ? 1 : -1; control.onScroll?.(direction); e.preventDefault(); // prevent zoom return; } } } // No control captured scroll — apply zoom e.preventDefault(); const zoomFactor = 1.1; const mouseX = e.clientX; const mouseY = e.clientY; const canvasRect = this.canvas.getBoundingClientRect(); const x = (mouseX - canvasRect.left - this.offsetX) / this.zoom; const y = (mouseY - canvasRect.top - this.offsetY) / this.zoom; const prevZoom = this.zoom; if (e.deltaY < 0) { this.zoom *= zoomFactor; } else { this.zoom /= zoomFactor; } // Adjust offset to keep world-space point under cursor fixed this.offsetX -= (wx * this.zoom - wx * prevZoom); this.offsetY -= (wy * this.zoom - wy * prevZoom); } onContextMenu(e) { e.preventDefault(); const { x: wx, y: wy } = this.screenToWorld(e.clientX, e.clientY); const menu = document.createElement("div"); menu.className = "context-menu"; menu.style.position = "fixed"; menu.style.left = `${e.clientX}px`; menu.style.top = `${e.clientY}px`; // drops down from cursor menu.style.background = "#222"; menu.style.color = "#fff"; menu.style.padding = "4px 0"; menu.style.borderRadius = "6px"; menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.4)"; menu.style.zIndex = 1000; menu.style.fontFamily = "sans-serif"; menu.style.minWidth = "160px"; const options = [ //{ label: "Add Servo", action: () => this.addNode(new ServoNode(wx, wy, 0)) }, //{ label: "Add Curve", action: () => this.addNode(new CurveNode(wx, wy, 0)) }, { label: "Add Noise", action: () => this.addNode(new NoiseNode(wx, wy)) }, { label: "Add Variable", action: () => this.addNode(new VariableNode(wx, wy)) }, { label: "Add Math", action: () => this.addNode(new MathNode(wx, wy)) }, { label: "Add Map", action: () => this.addNode(new MapNode(wx, wy)) }, ]; options.forEach(opt => { const item = document.createElement("div"); item.textContent = opt.label; item.style.padding = "6px 12px"; item.style.cursor = "pointer"; item.style.transition = "background 0.2s ease"; item.addEventListener("mouseenter", () => { item.style.background = "#444"; }); item.addEventListener("mouseleave", () => { item.style.background = "transparent"; }); item.addEventListener("click", () => { opt.action(); document.body.removeChild(menu); }); menu.appendChild(item); }); document.body.appendChild(menu); const removeMenu = () => { if (document.body.contains(menu)) { document.body.removeChild(menu); } }; setTimeout(() => { document.addEventListener("click", removeMenu, { once: true }); }, 0); } bezierPoint(p0, p1, p2, p3, t) { const c0 = (1 - t) ** 3; const c1 = 3 * (1 - t) ** 2 * t; const c2 = 3 * (1 - t) * t ** 2; const c3 = t ** 3; return c0 * p0 + c1 * p1 + c2 * p2 + c3 * p3; } }