sokol-luajit/editor/www/js/NodeGraph.mjs
2024-12-12 23:26:46 +10:30

615 lines
20 KiB
JavaScript

import * as Node from "/js/Node.mjs";
import Demo from "/js/Demo.mjs";
const nodeDatabase = [];
let cursorWireConnection = null;
let cursorWireDirectionOnNodeSide = null;
let draggingSelection = false;
let selectionWasDragged = false;
let dragInitiationTarget;
let panningSelection = false;
let awaitingGraphViewUpdate = false;
let graphOffsetX = 0;
let graphOffsetY = 0;
let graphScale = 1;
const scaleSpeed = 0.1;
export default async function NodeGraph() {
setupEvents();
const demoHappened = await Demo();
if (!demoHappened) {
const perlin = await addNode("Perlin Noise", 50, 50);
const output = await addNode("Output", 400, 50);
connectWire(perlin, "pattern", output, "diffuse");
}
}
function setupEvents() {
// const nodeGraph = document.querySelector(".node-graph");
const nodeGraph = document.body;
nodeGraph.addEventListener("mousedown", graphMousedownHandler);
nodeGraph.addEventListener("mousemove", graphMousemoveHandler);
nodeGraph.addEventListener("mouseup", graphMouseupHandler);
nodeGraph.addEventListener("click", graphClickHandler);
nodeGraph.addEventListener("wheel", graphWheelHandler);
nodeGraph.addEventListener("keydown", graphKeydownHandler);
}
function graphMousedownHandler(event) {
const target = event.target;
dragInitiationTarget = target;
// Left mouse button
if (event.button === 0) {
// No nodes selected (clear selection)
if (!target.closest("section")) {
deselectAllNodes();
return;
}
// Connector dot selected
if (target.closest("div.connector")) {
const connector = target.closest("div.connector");
const mousePosition = [event.clientX, event.clientY];
const identifier = connector.dataset["identifier"];
const nodeElement = connector.closest("section");
const nodeData = nodeDatabase.find(node => node.element === nodeElement);
const connectorInput = nodeData.inConnections[identifier];
let targetPath;
let targetNode = nodeData;
let targetIdentifier = identifier;
cursorWireDirectionOnNodeSide = connector.dataset["direction"];
// Creating a new connection from output to mouse
if (cursorWireDirectionOnNodeSide === "out") {
targetPath = createWirePath(connector, mousePosition);
}
// Creating a new connection from mouse to input
else if (cursorWireDirectionOnNodeSide === "in" && connectorInput.length == 0) {
targetPath = createWirePath(mousePosition, connector);
}
// Moving an existing connection
else if (cursorWireDirectionOnNodeSide === "in" && connectorInput.length > 0) {
const sourceSide = connectorInput[0];
disconnectWire(sourceSide.node, sourceSide.identifier, nodeData, identifier);
const connectorElement = sourceSide.node.element.querySelector(`.connector[data-identifier="${sourceSide.identifier}"]`);
cursorWireDirectionOnNodeSide = "out";
targetNode = sourceSide.node;
targetIdentifier = sourceSide.identifier;
targetPath = createWirePath(connectorElement, mousePosition);
}
cursorWireConnection = { node: null, identifier: null, wire: null };
cursorWireConnection.node = targetNode;
cursorWireConnection.identifier = targetIdentifier;
cursorWireConnection.wire = targetPath;
return;
}
if (target.closest("select")) {
return;
}
if (target.closest("input")) {
// Prevent input selection until click handler
if (target !== document.activeElement) event.preventDefault();
event.stopPropagation();
return;
}
// Node selected
if (target.closest("section")) {
const nodeElement = target.closest("section");
const nodeData = nodeDatabase.find(node => node.element === nodeElement);
if (nodeData.selected) {
if (event.shiftKey || event.ctrlKey) {
deselectNode(nodeData);
// Prevent shift-selection from highlighting text
event.preventDefault();
}
else {
draggingSelection = true;
}
}
else {
// Add to current selection
if (event.shiftKey || event.ctrlKey) {
selectNode(nodeData, true);
draggingSelection = true;
// Prevent shift-selection from highlighting text
event.preventDefault();
}
// Replace current selection
else {
deselectAllNodes();
selectNode(nodeData, true);
draggingSelection = true;
}
}
return;
}
// Prevent dragging the background from highlighting text
if (dragInitiationTarget === document.body) {
event.preventDefault();
}
}
// Middle mouse button
if (event.button === 1) {
if (!panningSelection) {
panningSelection = true;
return;
}
}
}
function graphMousemoveHandler(event) {
const mousePosition = [event.clientX, event.clientY];
const mouseDelta = [event.movementX / graphScale, event.movementY / graphScale];
if (panningSelection && (event.movementX !== 0 || event.movementY !== 0)) {
graphOffsetX += mouseDelta[0] * graphScale;
graphOffsetY += mouseDelta[1] * graphScale;
updateGraphView();
}
if (draggingSelection) {
moveSelectedNodes(mouseDelta);
// Prevent mouse drag from highlighting text
event.preventDefault();
}
if (cursorWireConnection) {
const attachedConnectorElement = cursorWireConnection.node.element.querySelector(`.connector[data-identifier="${cursorWireConnection.identifier}"]`);
if (cursorWireDirectionOnNodeSide === "out") updateWirePath(cursorWireConnection.wire, attachedConnectorElement, mousePosition);
else if (cursorWireDirectionOnNodeSide === "in") updateWirePath(cursorWireConnection.wire, mousePosition, attachedConnectorElement);
// Prevent mouse drag from highlighting text
event.preventDefault();
}
// Prevent dragging the background from highlighting text
if (dragInitiationTarget === document.body) {
event.preventDefault();
}
}
function graphMouseupHandler(event) {
const target = event.target;
// Left mouse button
if (event.button === 0) {
dragInitiationTarget = undefined;
// Replace current selection if the selected group was not moved when clicking on a selected node
if (target.closest("section") && !target.closest("label")) {
const nodeElement = target.closest("section");
const nodeData = nodeDatabase.find(node => node.element === nodeElement);
if (nodeData.selected && !event.shiftKey && !event.ctrlKey && !selectionWasDragged) {
deselectAllNodesExcept([nodeData]);
selectNode(nodeData, true);
}
}
if (cursorWireConnection) {
const nodeSideData = cursorWireConnection.node;
const nodeSideIdentifier = cursorWireConnection.identifier;
if (target.closest(".connector")) {
const nodeElement = target.closest("section");
const nodeData = nodeDatabase.find(node => node.element === nodeElement);
const nodeIdentifier = target.closest(".connector").dataset["identifier"];
if (cursorWireDirectionOnNodeSide === "out") connectWire(nodeSideData, nodeSideIdentifier, nodeData, nodeIdentifier);
else if (cursorWireDirectionOnNodeSide === "in") connectWire(nodeData, nodeIdentifier, nodeSideData, nodeSideIdentifier);
}
const path = cursorWireConnection.wire;
destroyWirePath(path);
cursorWireConnection = null;
cursorWireDirectionOnNodeSide = null;
}
draggingSelection = false;
selectionWasDragged = false;
}
// Middle mouse button
if (event.button === 1) {
if (panningSelection) {
panningSelection = false;
return;
}
}
}
function graphClickHandler(event) {
const target = event.target;
if (target.closest("input")) {
if (target !== document.activeElement) target.select();
event.stopPropagation();
return;
}
}
function graphWheelHandler(event) {
const minScale = 0.1;
const maxScale = 2.0;
let deltaScale = graphScale * scaleSpeed * (event.deltaY / -100);
const newScale = graphScale + deltaScale;
const newScaleClamped = Math.min(Math.max(newScale, minScale), maxScale);
const clampedExcess = newScale - newScaleClamped;
const deltaScaleClamped = deltaScale - clampedExcess;
graphScale = newScaleClamped;
graphOffsetX -= deltaScaleClamped * event.target.closest("body").clientWidth / 2;
graphOffsetY -= deltaScaleClamped * event.target.closest("body").clientHeight / 2;
updateGraphView();
event.preventDefault();
}
function graphKeydownHandler(event) {
if (event.key.toLowerCase() === "a" && event.ctrlKey) {
if (event.shiftKey) deselectAllNodes();
else selectAllNodes();
event.preventDefault();
return;
}
if (event.key.toLowerCase() === "p") {
addNode("Perlin Noise");
}
if (event.key.toLowerCase() === "b") {
addNode("Blend");
}
if (event.key.toLowerCase() === "c") {
addNode("Color");
}
if (event.key.toLowerCase() === "enter" && document.activeElement.closest("input")) {
document.activeElement.closest("input").blur();
}
if (event.key.toLowerCase() === "backspace" || event.key.toLowerCase() === "delete") {
removeSelectedNodes();
}
}
export function addNode(nodeName, x = 100, y = 100, startSelected = false) {
return Node
.createNode(nodeName, x, y, startSelected)
.then((nodeData) => {
document.querySelector(".node-graph").appendChild(nodeData.element);
nodeDatabase.push(nodeData);
updateNodePosition(nodeData);
return nodeData;
});
}
export function removeNode(nodeData) {
// Disallow removing the single output node
if (nodeData.name === "Output") return;
// Keep a list of every in and out connection to remove at the end
const inConnectionsToRemove = [];
const outConnectionsToRemove = [];
// Find all wires connected as inputs to remove
Object.keys(nodeData.inConnections).forEach((connectorName) => {
const connector = nodeData.inConnections[connectorName];
connector.forEach((connection) => {
const outNodeConnectors = connection.node.outConnections;
Object.keys(outNodeConnectors).forEach((outConnectorName) => {
const outConnector = outNodeConnectors[outConnectorName];
outConnector.forEach((outConnection) => {
if (outConnection.identifier === connectorName && outConnection.node === nodeData) {
inConnectionsToRemove.push([connection.node, outConnectorName, nodeData, connectorName]);
}
});
});
});
});
// Find all wires connected as outputs to remove
Object.keys(nodeData.outConnections).forEach((connectorName) => {
const connector = nodeData.outConnections[connectorName];
connector.forEach((connection) => {
const inNodeConnectors = connection.node.inConnections;
Object.keys(inNodeConnectors).forEach((inConnectorName) => {
const inConnector = inNodeConnectors[inConnectorName];
inConnector.forEach((inConnection) => {
if (inConnection.identifier === connectorName && inConnection.node === nodeData) {
outConnectionsToRemove.push([nodeData, connectorName, connection.node, inConnectorName]);
}
});
});
});
});
// Now remove those found connections once the looping is over
inConnectionsToRemove.forEach((toRemove) => disconnectWire(...toRemove));
outConnectionsToRemove.forEach((toRemove) => disconnectWire(...toRemove));
// Deselect the node to remove the selection outline element
deselectNode(nodeData);
// Remove the node from the DOM
nodeData.element.parentElement.removeChild(nodeData.element);
// Remove the node from the node database
nodeDatabase.splice(nodeDatabase.indexOf(nodeData), 1);
}
function removeSelectedNodes() {
nodeDatabase.filter(node => node.selected).forEach(nodeData => removeNode(nodeData));
}
function moveSelectedNodes(deltaMove) {
nodeDatabase.forEach((nodeData) => {
if (nodeData.selected) {
nodeData.x += deltaMove[0];
nodeData.y += deltaMove[1];
updateNodePosition(nodeData);
}
});
if (deltaMove[0] !== 0 || deltaMove[1] !== 0) selectionWasDragged = true;
}
function selectAllNodes() {
nodeDatabase.forEach((nodeData) => {
selectNode(nodeData, false);
});
}
function deselectAllNodes() {
nodeDatabase.forEach((nodeData) => {
deselectNode(nodeData);
});
}
function deselectAllNodesExcept(nodesArray) {
nodeDatabase.forEach((nodeData => {
if (!nodesArray.includes(nodeData)) deselectNode(nodeData);
}));
}
function selectNode(nodeData, bringToFront) {
if (nodeData.selected) return;
nodeData.element.classList.add("selected");
if (bringToFront) nodeData.element.parentNode.insertAdjacentElement("beforeend", nodeData.element);
nodeData.selected = Node.createNodeOutlineElement(nodeData);
updateSelectionOutline(nodeData);
}
function deselectNode(nodeData) {
if (!nodeData.selected) return;
nodeData.element.classList.remove("selected");
nodeData.selected.parentNode.removeChild(nodeData.selected);
nodeData.selected = null;
}
function updateSelectionOutline(nodeData) {
if (!nodeData.selected) return;
const bounds = nodeData.element.getBoundingClientRect();
const scale = bounds.width / nodeData.element.clientWidth;
nodeData.selected.style.width = `${bounds.width}px`;
nodeData.selected.style.height = `${bounds.height}px`;
nodeData.selected.style.left = `${bounds.left}px`;
nodeData.selected.style.top = `${bounds.top}px`;
nodeData.selected.style.borderRadius = `${10 * scale}px`;
}
function updateGraphView() {
if (!awaitingGraphViewUpdate) {
requestAnimationFrame(() => {
nodeDatabase.forEach((node) => {
updateNodePosition(node);
});
awaitingGraphViewUpdate = false;
});
}
awaitingGraphViewUpdate = true;
}
function updateNodePosition(nodeData) {
nodeData.element.style.transform = `translate(${nodeData.x * graphScale + graphOffsetX}px, ${nodeData.y * graphScale + graphOffsetY}px) scale(${graphScale})`;
updateSelectionOutline(nodeData);
// Update any wires connected to out connections
Object.keys(nodeData.outConnections).forEach((identifier) => {
const connections = nodeData.outConnections[identifier];
connections.forEach((connection) => {
const path = connection.wire;
const outConnectorElement = nodeData.element;
const outConnector = outConnectorElement.querySelector(`.connector[data-identifier="${identifier}"]`);
const inConnectorElement = connection.node.element;
const inConnector = inConnectorElement.querySelector(`.connector[data-identifier="${connection.identifier}"]`);
updateWirePath(path, outConnector, inConnector);
});
});
// Update any wires connected to in connections
Object.keys(nodeData.inConnections).forEach((identifier) => {
const connections = nodeData.inConnections[identifier];
connections.forEach((connection) => {
const path = connection.wire;
const inConnectorElement = nodeData.element;
const inConnector = inConnectorElement.querySelector(`.connector[data-identifier="${identifier}"]`);
const outConnectorElement = connection.node.element;
const outConnector = outConnectorElement.querySelector(`.connector[data-identifier="${connection.identifier}"]`);
updateWirePath(path, outConnector, inConnector);
});
});
}
function updateWirePath(path, outConnector, inConnector) {
let outConnectorX;
let outConnectorY;
if (outConnector instanceof HTMLElement) {
const outConnectorRect = outConnector.getBoundingClientRect();
outConnectorX = outConnectorRect.left + outConnectorRect.width / 2;
outConnectorY = outConnectorRect.top + outConnectorRect.height / 2;
}
else {
outConnectorX = outConnector[0];
outConnectorY = outConnector[1];
}
let inConnectorX;
let inConnectorY;
if (inConnector instanceof HTMLElement) {
const inConnectorRect = inConnector.getBoundingClientRect();
inConnectorX = inConnectorRect.left + inConnectorRect.width / 2;
inConnectorY = inConnectorRect.top + inConnectorRect.height / 2;
}
else {
inConnectorX = inConnector[0];
inConnectorY = inConnector[1];
}
const horizontalGap = Math.abs(outConnectorX - inConnectorX);
const curveLength = 200;
const curveFalloffRate = curveLength * Math.PI * 2;
const curveAmount = (-(2 ** (-10 * horizontalGap / curveFalloffRate)) + 1);
const curve = curveAmount * curveLength;
let pathString = `M${outConnectorX},${outConnectorY} C${outConnectorX + curve},${outConnectorY} ${inConnectorX - curve},${inConnectorY} ${inConnectorX},${inConnectorY}`;
path.setAttribute("d", pathString);
}
function createWirePath(outConnector, inConnector) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
updateWirePath(path, outConnector, inConnector);
document.querySelector("svg.wires").appendChild(path);
return path;
}
function destroyWirePath(path) {
path.parentElement.removeChild(path);
}
export function connectWire(outNodeData, outNodeIdentifier, inNodeData, inNodeIdentifier) {
// Prevent connecting nodes as input->input or output->output
if (!(outNodeIdentifier in outNodeData.outConnections && inNodeIdentifier in inNodeData.inConnections)) return;
// Prevent connecting a node to itself
if (outNodeData === inNodeData) return;
// Prevent adding duplicate identical connections
if (connectionAlreadyExists(outNodeData, outNodeIdentifier, inNodeData, inNodeIdentifier)) return;
// Connect the wire to the output
const outConnection = { node: inNodeData, identifier: inNodeIdentifier, wire: null };
outNodeData.outConnections[outNodeIdentifier].push(outConnection);
// Clear any existing inputs because inputs are exclusive to one wire
inNodeData.inConnections[inNodeIdentifier].forEach((inConnectionSource) => {
const outNodeDataForConnection = inConnectionSource.node;
const outNodeIdentifierForConnection = inConnectionSource.identifier;
disconnectWire(outNodeDataForConnection, outNodeIdentifierForConnection, inNodeData, inNodeIdentifier);
});
// Connect the wire to the input
const inConnection = { node: outNodeData, identifier: outNodeIdentifier, wire: null };
inNodeData.inConnections[inNodeIdentifier].push(inConnection);
// Notify widgets on this node bound to the identifier so they can update
Node.notifyBoundWidgetsOfUpdatedProperty(inNodeData, inNodeIdentifier);
// Recompute everything downstream
Node.recomputeDownstreamNodes(inNodeData);
// Find the connector dot DOM elements and increment the connection degrees
const outConnector = outNodeData.element.querySelector(`.connector[data-identifier="${outNodeIdentifier}"]`);
const inConnector = inNodeData.element.querySelector(`.connector[data-identifier="${inNodeIdentifier}"]`);
outConnector.dataset["outdegree"]++;
inConnector.dataset["indegree"]++;
// Create an SVG wire path and save its reference on both connection sides
const wire = createWirePath(outConnector, inConnector);
outConnection.wire = wire;
inConnection.wire = wire;
// TODO: Implement a cycle detector that doesn't require the connection be already made, which would allow this code to moved to the top, thus preventing this from disconnecting any existing wire
// Prevent connecting nodes in a cycle
// if (Node.findChildNodeDepths(inNodeData) === null) disconnectWire(outNodeData, outNodeIdentifier, inNodeData, inNodeIdentifier);
}
function disconnectWire(outNodeData, outNodeIdentifier, inNodeData, inNodeIdentifier) {
const outConnections = outNodeData.outConnections[outNodeIdentifier];
const inConnections = inNodeData.inConnections[inNodeIdentifier];
// Find and destroy the SVG wire path
const connection = outConnections.find(c => c.node === inNodeData && c.identifier === inNodeIdentifier);
// Filter the out/in connections to not include the in/out identifiers
outConnections.splice(outConnections.indexOf(connection), 1);
inConnections.splice(inConnections.indexOf(connection), 1);
// Find the connector dot DOM elements and decrement the connection degrees
const outConnector = outNodeData.element.querySelector(`.connector[data-identifier="${outNodeIdentifier}"]`);
const inConnector = inNodeData.element.querySelector(`.connector[data-identifier="${inNodeIdentifier}"]`);
outConnector.dataset["outdegree"]--;
inConnector.dataset["indegree"]--;
destroyWirePath(connection.wire);
// Notify widgets on this node bound to the identifier so they can update
Node.notifyBoundWidgetsOfUpdatedProperty(inNodeData, inNodeIdentifier);
// Recompute everything downstream
Node.recomputeDownstreamNodes(inNodeData);
}
function connectionAlreadyExists(outNodeData, outNodeIdentifier, inNodeData, inNodeIdentifier) {
const outConnector = outNodeData.outConnections[outNodeIdentifier];
const outConnection = outConnector.find(c => c.identifier === inNodeIdentifier);
const inConnector = inNodeData.inConnections[inNodeIdentifier];
const inConnection = inConnector.find(c => c.identifier === outNodeIdentifier);
if (!outConnection || !inConnection) return false;
const inNodeDataConnectedToOutConnection = outConnection.node;
const outNodeConnectedToInConnection = inConnection.node;
return inNodeDataConnectedToOutConnection === inNodeData && outNodeConnectedToInConnection === outNodeData;
}