Migrate node graph UI interaction from frontend to backend (#1768)

* Click node using click targets based

* Display graph transform based on state stored in Rust, fix zoom and pan.

* Migrate node selection logic

* Move click targets and transform to NodeNetwork

* Keep click targets in sync with changes to node shape

* Click targets for import/export, add dragging

* Basic wire dragging

* complete wire dragging

* Add node selection box when dragging

* Fix zoom operations and dragging nodes

* Remove click targets from serialized data, fix EnterNestedNetwork

* WIP: Auto connect node when dragged on wire

* Finish auto connect node when dragged on wire

* Add context menus

* Improve layer width calculations and state

* Improve context menu state, various other improvements

* Close menu on escape

* Cleanup Graph.svelte

* Fix lock/hide tool tip shortcuts

* Clean up editor_api.rs, fix lock/hide layers

* Start transferring network and node metadata from NodeNetwork to the editor

* Transfer click targets to NodeGraphMessageHandler

* Fix infinite canvas

* Fix undo/redo, scrollbars, and fix warnings

* Unicode-3.0 license and code cleanup

* License fix

* formatting issue

* Enable DomRect

* Fix layer move crash

* Remove tests

* Ignore test

* formatting

* remove white dot

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
adamgerhant 2024-06-15 08:55:33 -07:00 committed by GitHub
parent cf01f522a8
commit 02360c7bc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2744 additions and 1257 deletions

View file

@ -6,7 +6,7 @@
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
import type { IconName } from "@graphite/utility-functions/icons";
import type { Editor } from "@graphite/wasm-communication/editor";
import type { FrontendNodeWire, FrontendNodeType, FrontendNode, FrontendGraphInput, FrontendGraphOutput, FrontendGraphDataType } from "@graphite/wasm-communication/messages";
import type { FrontendNodeWire, FrontendNodeType, FrontendNode, FrontendGraphInput, FrontendGraphOutput, FrontendGraphDataType, WirePath } from "@graphite/wasm-communication/messages";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
@ -17,7 +17,6 @@
import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
const WHEEL_RATE = (1 / 600) * 3;
const GRID_COLLAPSE_SPACING = 10;
const GRID_SIZE = 24;
const ADD_NODE_MENU_WIDTH = 180;
@ -26,32 +25,16 @@
const editor = getContext<Editor>("editor");
const nodeGraph = getContext<NodeGraphState>("nodeGraph");
type WirePath = { pathString: string; dataType: FrontendGraphDataType; thick: boolean; dashed: boolean };
let graph: HTMLDivElement | undefined;
let nodesContainer: HTMLDivElement | undefined;
let nodeSearchInput: TextInput | undefined;
// TODO: MEMORY LEAK: Items never get removed from this array, so find a way to deal with garbage collection
let layerNameLabelWidths: Record<string, number> = {};
let transform = { scale: 1, x: 1200, y: 0 };
let panning = false;
let draggingNodes: { startX: number; startY: number; roundX: number; roundY: number } | undefined = undefined;
type Box = { startX: number; startY: number; endX: number; endY: number };
let boxSelection: Box | undefined = undefined;
let previousSelection: bigint[] = [];
let selectIfNotDragged: undefined | bigint = undefined;
let wireInProgressFromConnector: SVGSVGElement | undefined = undefined;
let wireInProgressToConnector: SVGSVGElement | DOMRect | undefined = undefined;
// TODO: Using this not-complete code, or another better approach, make it so the dragged in-progress connector correctly handles showing/hiding the SVG shape of the connector caps
// let wireInProgressFromLayerTop: bigint | undefined = undefined;
// let wireInProgressFromLayerBottom: bigint | undefined = undefined;
let disconnecting: { nodeId: bigint; inputIndex: number; wireIndex: number } | undefined = undefined;
let nodeWirePaths: WirePath[] = [];
let searchTerm = "";
let contextMenuOpenCoordinates: { x: number; y: number } | undefined = undefined;
let toggleDisplayAsLayerNodeId: bigint | undefined = undefined;
let toggleDisplayAsLayerCurrentlyIsNode: boolean = false;
let inputs: SVGSVGElement[][] = [];
let outputs: SVGSVGElement[][] = [];
@ -59,26 +42,17 @@
$: watchNodes($nodeGraph.nodes);
$: gridSpacing = calculateGridSpacing(transform.scale);
$: dotRadius = 1 + Math.floor(transform.scale - 0.5 + 0.001) / 2;
$: gridSpacing = calculateGridSpacing($nodeGraph.transform.scale);
$: dotRadius = 1 + Math.floor($nodeGraph.transform.scale - 0.5 + 0.001) / 2;
$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);
$: contextMenuX = ((contextMenuOpenCoordinates?.x || 0) + transform.x) * transform.scale;
$: contextMenuY = ((contextMenuOpenCoordinates?.y || 0) + transform.y) * transform.scale;
let appearAboveMouse = false;
let appearRightOfMouse = false;
$: (() => {
const bounds = graph?.getBoundingClientRect();
if (!bounds) return;
const { width, height } = bounds;
appearRightOfMouse = contextMenuX > width - ADD_NODE_MENU_WIDTH;
appearAboveMouse = contextMenuY > height - ADD_NODE_MENU_HEIGHT;
if ($nodeGraph.contextMenuInformation?.contextMenuData == "CreateNode") {
setTimeout(() => nodeSearchInput?.focus(), 0);
}
})();
$: wirePathInProgress = createWirePathInProgress(wireInProgressFromConnector, wireInProgressToConnector);
$: wirePaths = createWirePaths(wirePathInProgress, nodeWirePaths);
$: wirePaths = createWirePaths($nodeGraph.wirePathInProgress, nodeWirePaths);
function calculateGridSpacing(scale: number): number {
const dense = scale * GRID_SIZE;
@ -129,18 +103,6 @@
return Array.from(categories);
}
function createWirePathInProgress(wireInProgressFromConnector?: SVGSVGElement, wireInProgressToConnector?: SVGSVGElement | DOMRect): WirePath | undefined {
if (wireInProgressFromConnector && wireInProgressToConnector && nodesContainer) {
const from = connectorToNodeIndex(wireInProgressFromConnector);
const to = wireInProgressToConnector instanceof SVGSVGElement ? connectorToNodeIndex(wireInProgressToConnector) : undefined;
const wireStart = $nodeGraph.nodes.find((n) => n.id === from?.nodeId)?.isLayer || false;
const wireEnd = ($nodeGraph.nodes.find((n) => n.id === to?.nodeId)?.isLayer && to?.index == 0) || false;
return createWirePath(wireInProgressFromConnector, wireInProgressToConnector, wireStart, wireEnd, false);
}
return undefined;
}
function createWirePaths(wirePathInProgress: WirePath | undefined, nodeWirePaths: WirePath[]): WirePath[] {
const maybeWirePathInProgress = wirePathInProgress ? [wirePathInProgress] : [];
return [...maybeWirePathInProgress, ...nodeWirePaths];
@ -171,10 +133,9 @@
await tick();
const wires = $nodeGraph.wires;
nodeWirePaths = wires.flatMap((wire, index) => {
nodeWirePaths = wires.flatMap((wire) => {
const { nodeInput, nodeOutput } = resolveWire(wire);
if (!nodeInput || !nodeOutput) return [];
if (disconnecting?.wireIndex === index) return [];
const wireStart = $nodeGraph.nodes.find((n) => n.id === wire.wireStart)?.isLayer || false;
const wireEnd = ($nodeGraph.nodes.find((n) => n.id === wire.wireEnd)?.isLayer && Number(wire.wireEndInputIndex) == 0) || false;
@ -201,13 +162,13 @@
const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1;
const outY = verticalOut ? outputBounds.y + VERTICAL_WIRE_OVERLAP_ON_SHAPED_CAP : outputBounds.y + outputBounds.height / 2;
const outConnectorX = (outX - containerBounds.x) / transform.scale;
const outConnectorY = (outY - containerBounds.y) / transform.scale;
const outConnectorX = (outX - containerBounds.x) / $nodeGraph.transform.scale;
const outConnectorY = (outY - containerBounds.y) / $nodeGraph.transform.scale;
const inX = verticalIn ? inputBounds.x + inputBounds.width / 2 : inputBounds.x + 1;
const inY = verticalIn ? inputBounds.y + inputBounds.height - VERTICAL_WIRE_OVERLAP_ON_SHAPED_CAP : inputBounds.y + inputBounds.height / 2;
const inConnectorX = (inX - containerBounds.x) / transform.scale;
const inConnectorY = (inY - containerBounds.y) / transform.scale;
const inConnectorX = (inX - containerBounds.x) / $nodeGraph.transform.scale;
const inConnectorY = (inY - containerBounds.y) / $nodeGraph.transform.scale;
const horizontalGap = Math.abs(outConnectorX - inConnectorX);
const verticalGap = Math.abs(outConnectorY - inConnectorY);
@ -269,307 +230,10 @@
return { pathString, dataType, thick: verticalIn && verticalOut, dashed };
}
function scroll(e: WheelEvent) {
const [scrollX, scrollY] = [e.deltaX, e.deltaY];
// If zoom with scroll is enabled: horizontal pan with Ctrl, vertical pan with Shift
const zoomWithScroll = $nodeGraph.zoomWithScroll;
const zoom = zoomWithScroll ? !e.ctrlKey && !e.shiftKey : e.ctrlKey;
const horizontalPan = zoomWithScroll ? e.ctrlKey : !e.ctrlKey && e.shiftKey;
// Prevent the web page from being zoomed
if (e.ctrlKey) e.preventDefault();
// Always pan horizontally in response to a horizontal scroll wheel movement
transform.x -= scrollX / transform.scale;
// Zoom
if (zoom) {
let zoomFactor = 1 + Math.abs(scrollY) * WHEEL_RATE;
if (scrollY > 0) zoomFactor = 1 / zoomFactor;
const bounds = graph?.getBoundingClientRect();
if (!bounds) return;
const { x, y, width, height } = bounds;
transform.scale *= zoomFactor;
const newViewportX = width / zoomFactor;
const newViewportY = height / zoomFactor;
const deltaSizeX = width - newViewportX;
const deltaSizeY = height - newViewportY;
const deltaX = deltaSizeX * ((e.x - x) / width);
const deltaY = deltaSizeY * ((e.y - y) / height);
transform.x -= (deltaX / transform.scale) * zoomFactor;
transform.y -= (deltaY / transform.scale) * zoomFactor;
return;
}
// Pan
if (horizontalPan) {
transform.x -= scrollY / transform.scale;
} else {
transform.y -= scrollY / transform.scale;
}
}
function keydown(e: KeyboardEvent) {
if (e.key.toLowerCase() === "escape") {
contextMenuOpenCoordinates = undefined;
document.removeEventListener("keydown", keydown);
wireInProgressFromConnector = undefined;
// wireInProgressFromLayerTop = undefined;
// wireInProgressFromLayerBottom = undefined;
}
}
function loadNodeList(e: PointerEvent, graphBounds: DOMRect) {
contextMenuOpenCoordinates = {
x: (e.clientX - graphBounds.x) / transform.scale - transform.x,
y: (e.clientY - graphBounds.y) / transform.scale - transform.y,
};
// Find actual relevant child and focus it (setTimeout is required to actually focus the input element)
setTimeout(() => nodeSearchInput?.focus(), 0);
document.addEventListener("keydown", keydown);
}
// TODO: Move the event listener from the graph to the window so dragging outside the graph area (or even the whole browser window) works
function pointerDown(e: PointerEvent) {
const [lmb, rmb] = [e.button === 0, e.button === 2];
const nodeError = (e.target as SVGSVGElement).closest("[data-node-error]") as HTMLElement;
if (nodeError && lmb) return;
const port = (e.target as SVGSVGElement).closest("[data-port]") as SVGSVGElement;
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
const nodeIdString = node?.getAttribute("data-node") || undefined;
const nodeId = nodeIdString ? BigInt(nodeIdString) : undefined;
const contextMenu = (e.target as HTMLElement).closest("[data-context-menu]") as HTMLElement | undefined;
// Create the add node popup on right click, then exit
if (rmb) {
toggleDisplayAsLayerNodeId = undefined;
if (node) {
toggleDisplayAsLayerNodeId = nodeId;
toggleDisplayAsLayerCurrentlyIsNode = !($nodeGraph.nodes.find((node) => node.id === nodeId)?.isLayer || false);
}
const graphBounds = graph?.getBoundingClientRect();
if (!graphBounds) return;
loadNodeList(e, graphBounds);
return;
}
// If the user is clicking on the add nodes list or context menu, exit here
if (lmb && contextMenu) return;
// Since the user is clicking elsewhere in the graph, ensure the add nodes list is closed
if (lmb) {
contextMenuOpenCoordinates = undefined;
wireInProgressFromConnector = undefined;
toggleDisplayAsLayerNodeId = undefined;
// wireInProgressFromLayerTop = undefined;
// wireInProgressFromLayerBottom = undefined;
}
// Alt-click sets the clicked node as previewed
if (lmb && e.altKey && nodeId !== undefined) {
editor.handle.togglePreview(nodeId);
}
// Clicked on a port dot
if (lmb && port && node) {
const isOutput = Boolean(port.getAttribute("data-port") === "output");
const frontendNode = (nodeId !== undefined && $nodeGraph.nodes.find((n) => n.id === nodeId)) || undefined;
// Output: Begin dragging out a new wire
if (isOutput) {
// Disallow creating additional vertical output wires from an already-connected layer
if (frontendNode?.isLayer && frontendNode.primaryOutput && frontendNode.primaryOutput.connected.length > 0) return;
wireInProgressFromConnector = port;
// // Since we are just beginning to drag out a wire from the top, we know the in-progress wire exists from this layer's top and has no connection to any other layer bottom yet
// wireInProgressFromLayerTop = nodeId !== undefined && frontendNode?.isLayer ? nodeId : undefined;
// wireInProgressFromLayerBottom = undefined;
}
// Input: Begin moving an existing wire
else {
const inputNodeInPorts = Array.from(node.querySelectorAll(`[data-port="input"]`));
const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(port);
const inputIndex = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined;
if (inputIndex === undefined || nodeId === undefined) return;
// Set the wire to draw from the input that a previous wire was on
const wireIndex = $nodeGraph.wires.filter((wire) => !wire.dashed).findIndex((value) => value.wireEnd === nodeId && value.wireEndInputIndex === BigInt(inputIndex));
if (wireIndex === -1) return;
const nodeOutputConnectors = nodesContainer?.querySelectorAll(`[data-node="${String($nodeGraph.wires[wireIndex].wireStart)}"] [data-port="output"]`) || undefined;
wireInProgressFromConnector = nodeOutputConnectors?.[Number($nodeGraph.wires[wireIndex].wireStartOutputIndex)] as SVGSVGElement | undefined;
const nodeInputConnectors = nodesContainer?.querySelectorAll(`[data-node="${String($nodeGraph.wires[wireIndex].wireEnd)}"] [data-port="input"]`) || undefined;
wireInProgressToConnector = nodeInputConnectors?.[Number($nodeGraph.wires[wireIndex].wireEndInputIndex)] as SVGSVGElement | undefined;
disconnecting = { nodeId: nodeId, inputIndex, wireIndex };
refreshWires();
}
return;
}
// Clicked on a node, so we select it
if (lmb && nodeId !== undefined) {
let updatedSelected = [...$nodeGraph.selected];
let modifiedSelected = false;
// Add to/remove from selection if holding Shift or Ctrl
if (e.shiftKey || e.ctrlKey) {
modifiedSelected = true;
// Remove from selection if already selected
if (!updatedSelected.includes(nodeId)) updatedSelected.push(nodeId);
// Add to selection if not already selected
else updatedSelected.splice(updatedSelected.lastIndexOf(nodeId), 1);
}
// Replace selection with a non-selected node
else if (!updatedSelected.includes(nodeId)) {
modifiedSelected = true;
updatedSelected = [nodeId];
}
// Replace selection (of multiple nodes including this one) with just this one, but only upon pointer up if the user didn't drag the selected nodes
else {
selectIfNotDragged = nodeId;
}
// If this node is selected (whether from before or just now), prepare it for dragging
if (updatedSelected.includes(nodeId)) {
draggingNodes = { startX: e.x, startY: e.y, roundX: 0, roundY: 0 };
}
// Update the selection in the backend if it was modified
if (modifiedSelected) editor.handle.selectNodes(new BigUint64Array(updatedSelected));
return;
}
// Clicked on the graph background so we box select
if (lmb) {
previousSelection = $nodeGraph.selected;
// Clear current selection
if (!e.shiftKey) editor.handle.selectNodes(new BigUint64Array(0));
const graphBounds = graph?.getBoundingClientRect();
boxSelection = { startX: e.x - (graphBounds?.x || 0), startY: e.y - (graphBounds?.y || 0), endX: e.x - (graphBounds?.x || 0), endY: e.y - (graphBounds?.y || 0) };
return;
}
// LMB clicked on the graph background or MMB clicked anywhere
panning = true;
}
function doubleClick(e: MouseEvent) {
if ((e.target as HTMLElement).closest("[data-visibility-button]")) return;
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
const nodeId = node?.getAttribute("data-node") || undefined;
if (nodeId !== undefined && !e.altKey) {
const id = BigInt(nodeId);
editor.handle.enterNestedNetwork(id);
}
}
function pointerMove(e: PointerEvent) {
if (panning) {
transform.x += e.movementX / transform.scale;
transform.y += e.movementY / transform.scale;
} else if (wireInProgressFromConnector && !contextMenuOpenCoordinates) {
const target = e.target as Element | undefined;
const dot = (target?.closest(`[data-port="input"]`) || undefined) as SVGSVGElement | undefined;
if (dot) {
wireInProgressToConnector = dot;
} else {
wireInProgressToConnector = new DOMRect(e.x, e.y);
}
} else if (draggingNodes) {
const deltaX = Math.round((e.x - draggingNodes.startX) / transform.scale / GRID_SIZE);
const deltaY = Math.round((e.y - draggingNodes.startY) / transform.scale / GRID_SIZE);
if (draggingNodes.roundX !== deltaX || draggingNodes.roundY !== deltaY) {
draggingNodes.roundX = deltaX;
draggingNodes.roundY = deltaY;
let stop = false;
const refresh = () => {
if (!stop) refreshWires();
requestAnimationFrame(refresh);
};
refresh();
// const DRAG_SMOOTHING_TIME = 0.1;
const DRAG_SMOOTHING_TIME = 0; // TODO: Reenable this after fixing the bugs with the wires, see the CSS `transition` attribute todo for other info
setTimeout(
() => {
stop = true;
},
DRAG_SMOOTHING_TIME * 1000 + 10,
);
}
} else if (boxSelection) {
// The mouse button was released but we missed the pointer up event
if ((e.buttons & 1) === 0) {
completeBoxSelection();
boxSelection = undefined;
} else if ((e.buttons & 2) !== 0) {
editor.handle.selectNodes(new BigUint64Array(previousSelection));
boxSelection = undefined;
} else {
const graphBounds = graph?.getBoundingClientRect();
boxSelection.endX = e.x - (graphBounds?.x || 0);
boxSelection.endY = e.y - (graphBounds?.y || 0);
}
}
}
function intersetNodeAABB(boxSelection: Box | undefined, nodeIndex: number): boolean {
const bounds = nodeElements[nodeIndex]?.getBoundingClientRect();
const graphBounds = graph?.getBoundingClientRect();
return (
boxSelection !== undefined &&
bounds &&
Math.min(boxSelection.startX, boxSelection.endX) < bounds.right - (graphBounds?.x || 0) &&
Math.max(boxSelection.startX, boxSelection.endX) > bounds.left - (graphBounds?.x || 0) &&
Math.min(boxSelection.startY, boxSelection.endY) < bounds.bottom - (graphBounds?.y || 0) &&
Math.max(boxSelection.startY, boxSelection.endY) > bounds.top - (graphBounds?.y || 0)
);
}
function completeBoxSelection() {
editor.handle.selectNodes(new BigUint64Array($nodeGraph.selected.concat($nodeGraph.nodes.filter((_, nodeIndex) => intersetNodeAABB(boxSelection, nodeIndex)).map((node) => node.id))));
}
function showSelected(selected: bigint[], boxSelect: Box | undefined, node: bigint, nodeIndex: number): boolean {
return selected.includes(node) || intersetNodeAABB(boxSelect, nodeIndex);
}
function toggleNodeVisibilityGraph(id: bigint) {
editor.handle.toggleNodeVisibilityGraph(id);
}
function toggleLayerDisplay(displayAsLayer: boolean) {
let node = $nodeGraph.nodes.find((node) => node.id === toggleDisplayAsLayerNodeId);
function toggleLayerDisplay(displayAsLayer: boolean, toggleId: bigint) {
let node = $nodeGraph.nodes.find((node) => node.id === toggleId);
if (node !== undefined) {
contextMenuOpenCoordinates = undefined;
editor.handle.setToNodeOrLayer(node.id, displayAsLayer);
toggleDisplayAsLayerCurrentlyIsNode = !($nodeGraph.nodes.find((node) => node.id === toggleDisplayAsLayerNodeId)?.isLayer || false);
toggleDisplayAsLayerNodeId = undefined;
}
}
@ -577,144 +241,10 @@
return $nodeGraph.nodes.find((node) => node.id === toggleDisplayAsLayerNodeId)?.canBeLayer || false;
}
function connectorToNodeIndex(svg: SVGSVGElement): { nodeId: bigint; index: number } | undefined {
const node = svg.closest("[data-node]");
if (!node) return undefined;
const nodeIdAttribute = node.getAttribute("data-node");
if (!nodeIdAttribute) return undefined;
const nodeId = BigInt(nodeIdAttribute);
const inputPortElements = Array.from(node.querySelectorAll(`[data-port="input"]`));
const outputPortElements = Array.from(node.querySelectorAll(`[data-port="output"]`));
const inputNodeConnectionIndexSearch = inputPortElements.includes(svg) ? inputPortElements.indexOf(svg) : outputPortElements.indexOf(svg);
const index = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined;
if (nodeId !== undefined && index !== undefined) return { nodeId, index };
else return undefined;
}
// Check if this node should be inserted between two other nodes
function checkInsertBetween() {
if ($nodeGraph.selected.length !== 1) return;
const selectedNodeId = $nodeGraph.selected[0];
const selectedNode = nodesContainer?.querySelector(`[data-node="${String(selectedNodeId)}"]`) || undefined;
// Check that neither the primary input or output of the selected node are already connected.
const notConnected = $nodeGraph.wires.findIndex((wire) => wire.wireStart === selectedNodeId || (wire.wireEnd === selectedNodeId && wire.wireEndInputIndex === BigInt(0))) === -1;
const input = selectedNode?.querySelector(`[data-port="input"]`) || undefined;
const output = selectedNode?.querySelector(`[data-port="output"]`) || undefined;
// TODO: Make sure inputs are correctly typed
if (!selectedNode || !notConnected || !input || !output || !nodesContainer) return;
// Fixes typing for some reason?
const theNodesContainer = nodesContainer;
// Find the wire that the node has been dragged on top of
const wire = $nodeGraph.wires.find((wire) => {
const { nodeInput, nodeOutput } = resolveWire(wire);
if (!nodeInput || !nodeOutput) return false;
const wireCurveLocations = buildWirePathLocations(nodeOutput.getBoundingClientRect(), nodeInput.getBoundingClientRect(), false, false);
const selectedNodeBounds = selectedNode.getBoundingClientRect();
const containerBoundsBounds = theNodesContainer.getBoundingClientRect();
return (
wire.wireEnd != selectedNodeId &&
editor.handle.rectangleIntersects(
new Float64Array(wireCurveLocations.map((loc) => loc.x)),
new Float64Array(wireCurveLocations.map((loc) => loc.y)),
selectedNodeBounds.top - containerBoundsBounds.y,
selectedNodeBounds.left - containerBoundsBounds.x,
selectedNodeBounds.bottom - containerBoundsBounds.y,
selectedNodeBounds.right - containerBoundsBounds.x,
)
);
});
// If the node has been dragged on top of the wire then connect it into the middle.
if (wire) {
const isLayer = $nodeGraph.nodes.find((n) => n.id === selectedNodeId)?.isLayer;
editor.handle.insertNodeBetween(wire.wireEnd, Number(wire.wireEndInputIndex), 0, selectedNodeId, 0, Number(wire.wireStartOutputIndex), wire.wireStart);
if (!isLayer) editor.handle.shiftNode(selectedNodeId);
}
}
function pointerUp(e: PointerEvent) {
panning = false;
const initialDisconnecting = disconnecting;
if (disconnecting) {
editor.handle.disconnectNodes(BigInt(disconnecting.nodeId), disconnecting.inputIndex);
}
disconnecting = undefined;
if (wireInProgressToConnector instanceof SVGSVGElement && wireInProgressFromConnector) {
const from = connectorToNodeIndex(wireInProgressFromConnector);
const to = connectorToNodeIndex(wireInProgressToConnector);
if (from !== undefined && to !== undefined) {
const { nodeId: outputConnectedNodeID, index: outputNodeConnectionIndex } = from;
const { nodeId: inputConnectedNodeID, index: inputNodeConnectionIndex } = to;
editor.handle.connectNodesByWire(outputConnectedNodeID, outputNodeConnectionIndex, inputConnectedNodeID, inputNodeConnectionIndex);
}
} else if (wireInProgressFromConnector && !initialDisconnecting) {
// If the add node menu is already open, we don't want to open it again
if (contextMenuOpenCoordinates) return;
const graphBounds = graph?.getBoundingClientRect();
if (!graphBounds) return;
// Create the node list, which should set nodeListLocation to a valid value
loadNodeList(e, graphBounds);
if (!contextMenuOpenCoordinates) return;
let contextMenuLocation2: { x: number; y: number } = contextMenuOpenCoordinates;
wireInProgressToConnector = new DOMRect((contextMenuLocation2.x + transform.x) * transform.scale + graphBounds.x, (contextMenuLocation2.y + transform.y) * transform.scale + graphBounds.y);
return;
} else if (draggingNodes) {
if (draggingNodes.startX === e.x && draggingNodes.startY === e.y) {
if (selectIfNotDragged !== undefined && ($nodeGraph.selected.length !== 1 || $nodeGraph.selected[0] !== selectIfNotDragged)) {
editor.handle.selectNodes(new BigUint64Array([selectIfNotDragged]));
}
}
if ($nodeGraph.selected.length > 0 && (draggingNodes.roundX !== 0 || draggingNodes.roundY !== 0)) editor.handle.moveSelectedNodes(draggingNodes.roundX, draggingNodes.roundY);
checkInsertBetween();
draggingNodes = undefined;
selectIfNotDragged = undefined;
} else if (boxSelection) {
completeBoxSelection();
boxSelection = undefined;
}
wireInProgressFromConnector = undefined;
wireInProgressToConnector = undefined;
}
function createNode(nodeType: string) {
if (!contextMenuOpenCoordinates) return;
if ($nodeGraph.contextMenuInformation === undefined) return;
const inputNodeConnectionIndex = 0;
const x = Math.round(contextMenuOpenCoordinates.x / GRID_SIZE);
const y = Math.round(contextMenuOpenCoordinates.y / GRID_SIZE) - 1;
const inputConnectedNodeID = editor.handle.createNode(nodeType, x, y);
contextMenuOpenCoordinates = undefined;
if (!wireInProgressFromConnector) return;
const from = connectorToNodeIndex(wireInProgressFromConnector);
if (from !== undefined) {
const { nodeId: outputConnectedNodeID, index: outputNodeConnectionIndex } = from;
editor.handle.connectNodesByWire(outputConnectedNodeID, outputNodeConnectionIndex, inputConnectedNodeID, inputNodeConnectionIndex);
}
wireInProgressFromConnector = undefined;
editor.handle.createNode(nodeType, $nodeGraph.contextMenuInformation.contextMenuCoordinates.x, $nodeGraph.contextMenuInformation.contextMenuCoordinates.y);
}
function nodeBorderMask(nodeWidth: number, primaryInputExists: boolean, parameters: number, primaryOutputExists: boolean, exposedOutputs: number): string {
@ -779,35 +309,31 @@
<div
class="graph"
bind:this={graph}
on:wheel|nonpassive={scroll}
on:pointerdown={pointerDown}
on:pointermove={pointerMove}
on:pointerup={pointerUp}
on:dblclick={doubleClick}
style:--grid-spacing={`${gridSpacing}px`}
style:--grid-offset-x={`${transform.x * transform.scale}px`}
style:--grid-offset-y={`${transform.y * transform.scale}px`}
style:--grid-offset-x={`${$nodeGraph.transform.x}px`}
style:--grid-offset-y={`${$nodeGraph.transform.y}px`}
style:--dot-radius={`${dotRadius}px`}
data-node-graph
>
<BreadcrumbTrailButtons labels={["Document"].concat($nodeGraph.subgraphPath)} action={(index) => editor.handle.exitNestedNetwork($nodeGraph.subgraphPath?.length - index)} />
<!-- Right click menu for adding nodes -->
{#if contextMenuOpenCoordinates}
{#if $nodeGraph.contextMenuInformation}
<LayoutCol
class="context-menu"
data-context-menu
styles={{
left: `${contextMenuX}px`,
top: `${contextMenuY}px`,
...(toggleDisplayAsLayerNodeId === undefined
left: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.x * $nodeGraph.transform.scale + $nodeGraph.transform.x}px`,
top: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.y * $nodeGraph.transform.scale + $nodeGraph.transform.y}px`,
...($nodeGraph.contextMenuInformation.contextMenuData === "CreateNode"
? {
transform: `translate(${appearRightOfMouse ? -100 : 0}%, ${appearAboveMouse ? -100 : 0}%)`,
transform: `translate(0%, 0%)`,
width: `${ADD_NODE_MENU_WIDTH}px`,
height: `${ADD_NODE_MENU_HEIGHT}px`,
}
: {}),
}}
>
{#if toggleDisplayAsLayerNodeId === undefined}
{#if $nodeGraph.contextMenuInformation.contextMenuData === "CreateNode"}
<TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
<div class="list-results" on:wheel|passive|stopPropagation>
{#each nodeCategories as nodeCategory}
@ -824,34 +350,35 @@
{/each}
</div>
{:else}
{@const contextMenuData = $nodeGraph.contextMenuInformation.contextMenuData}
<LayoutRow class="toggle-layer-or-node">
<TextLabel>Display as</TextLabel>
<RadioInput
selectedIndex={toggleDisplayAsLayerCurrentlyIsNode ? 0 : 1}
selectedIndex={contextMenuData.currentlyIsNode ? 0 : 1}
entries={[
{
value: "node",
label: "Node",
action: () => {
toggleLayerDisplay(false);
toggleLayerDisplay(false, contextMenuData.nodeId);
},
},
{
value: "layer",
label: "Layer",
action: () => {
toggleLayerDisplay(true);
toggleLayerDisplay(true, contextMenuData.nodeId);
},
},
]}
disabled={!canBeToggledBetweenNodeAndLayer(toggleDisplayAsLayerNodeId)}
disabled={!canBeToggledBetweenNodeAndLayer(contextMenuData.nodeId)}
/>
</LayoutRow>
{/if}
</LayoutCol>
{/if}
<!-- Node connection wires -->
<div class="wires" style:transform={`scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`} style:transform-origin={`0 0`}>
<div class="wires" style:transform-origin={`0 0`} style:transform={`translate(${$nodeGraph.transform.x}px, ${$nodeGraph.transform.y}px) scale(${$nodeGraph.transform.scale})`}>
<svg>
{#each wirePaths as { pathString, dataType, thick, dashed }}
<path
@ -865,24 +392,28 @@
</svg>
</div>
<!-- Layers and nodes -->
<div class="layers-and-nodes" style:transform={`scale(${transform.scale}) translate(${transform.x}px, ${transform.y}px)`} style:transform-origin={`0 0`} bind:this={nodesContainer}>
<div
class="layers-and-nodes"
style:transform-origin={`0 0`}
style:transform={`translate(${$nodeGraph.transform.x}px, ${$nodeGraph.transform.y}px) scale(${$nodeGraph.transform.scale})`}
bind:this={nodesContainer}
>
<!-- Layers -->
{#each $nodeGraph.nodes.flatMap((node, nodeIndex) => (node.isLayer ? [{ node, nodeIndex }] : [])) as { node, nodeIndex } (nodeIndex)}
{@const clipPathId = String(Math.random()).substring(2)}
{@const stackDataInput = node.exposedInputs[0]}
{@const extraWidthToReachGridMultiple = 8}
{@const labelWidthGridCells = Math.ceil(((layerNameLabelWidths?.[String(node.id)] || 0) - extraWidthToReachGridMultiple) / 24)}
{@const layerAreaWidth = $nodeGraph.layerWidths.get(node.id) || 8}
<div
class="layer"
class:selected={showSelected($nodeGraph.selected, boxSelection, node.id, nodeIndex)}
class:selected={$nodeGraph.selected.includes(node.id)}
class:previewed={node.previewed}
class:disabled={!node.visible}
style:--offset-left={(node.position?.x || 0) + ($nodeGraph.selected.includes(node.id) ? draggingNodes?.roundX || 0 : 0)}
style:--offset-top={(node.position?.y || 0) + ($nodeGraph.selected.includes(node.id) ? draggingNodes?.roundY || 0 : 0)}
style:--offset-left={(node.position?.x || 0) - 1}
style:--offset-top={node.position?.y || 0}
style:--clip-path-id={`url(#${clipPathId})`}
style:--data-color={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()}-dim)`}
style:--label-width={labelWidthGridCells}
style:--layer-area-width={layerAreaWidth}
style:--node-chain-area-left-extension={node.exposedInputs.length === 0 ? 0 : 1.5}
data-node={node.id}
bind:this={nodeElements[nodeIndex]}
@ -966,16 +497,18 @@
{/if}
<div class="details">
<!-- TODO: Allow the user to edit the name, just like in the Layers panel -->
<span title={editor.handle.inDevelopmentMode() ? `Node ID: ${node.id}` : undefined} bind:offsetWidth={layerNameLabelWidths[String(node.id)]}>
<span title={editor.handle.inDevelopmentMode() ? `Node ID: ${node.id}` : undefined}>
{node.alias}
</span>
</div>
<IconButton
class={"visibility"}
data-visibility-button
action={(e) => (toggleNodeVisibilityGraph(node.id), e?.stopPropagation())}
size={24}
icon={node.visible ? "EyeVisible" : "EyeHidden"}
action={() => {
/*Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown*/
}}
tooltip={node.visible ? "Visible" : "Hidden"}
/>
@ -983,10 +516,7 @@
<defs>
<clipPath id={clipPathId}>
<!-- Keep this equation in sync with the equivalent one in the CSS rule for `.layer { width: ... }` below -->
<path
clip-rule="evenodd"
d={layerBorderMask(72 + 8 + 24 * Math.max(3, labelWidthGridCells) + 8 + 12 + extraWidthToReachGridMultiple, node.exposedInputs.length === 0 ? 0 : 36)}
/>
<path clip-rule="evenodd" d={layerBorderMask(24 * layerAreaWidth - 12, node.exposedInputs.length === 0 ? 0 : 36)} />
</clipPath>
</defs>
</svg>
@ -998,11 +528,11 @@
{@const clipPathId = String(Math.random()).substring(2)}
<div
class="node"
class:selected={showSelected($nodeGraph.selected, boxSelection, node.id, nodeIndex)}
class:selected={$nodeGraph.selected.includes(node.id)}
class:previewed={node.previewed}
class:disabled={!node.visible}
style:--offset-left={(node.position?.x || 0) + ($nodeGraph.selected.includes(node.id) ? draggingNodes?.roundX || 0 : 0)}
style:--offset-top={(node.position?.y || 0) + ($nodeGraph.selected.includes(node.id) ? draggingNodes?.roundY || 0 : 0)}
style:--offset-left={node.position?.x || 0}
style:--offset-top={node.position?.y || 0}
style:--clip-path-id={`url(#${clipPathId})`}
style:--data-color={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()}-dim)`}
@ -1129,13 +659,13 @@
</div>
<!-- Box select widget -->
{#if boxSelection}
{#if $nodeGraph.box}
<div
class="box-selection"
style:left={`${Math.min(boxSelection.startX, boxSelection.endX)}px`}
style:top={`${Math.min(boxSelection.startY, boxSelection.endY)}px`}
style:width={`${Math.abs(boxSelection.startX - boxSelection.endX)}px`}
style:height={`${Math.abs(boxSelection.startY - boxSelection.endY)}px`}
style:left={`${Math.min($nodeGraph.box.startX, $nodeGraph.box.endX)}px`}
style:top={`${Math.min($nodeGraph.box.startY, $nodeGraph.box.endY)}px`}
style:width={`${Math.abs($nodeGraph.box.startX - $nodeGraph.box.endX)}px`}
style:height={`${Math.abs($nodeGraph.box.startY - $nodeGraph.box.endY)}px`}
></div>
{/if}
@ -1155,9 +685,8 @@
height: 100%;
background-size: var(--grid-spacing) var(--grid-spacing);
background-position: calc(var(--grid-offset-x) - var(--dot-radius)) calc(var(--grid-offset-y) - var(--dot-radius));
background-image: radial-gradient(circle at var(--dot-radius) var(--dot-radius), var(--color-f-white) var(--dot-radius), transparent 0),
radial-gradient(circle at var(--dot-radius) var(--dot-radius), var(--color-3-darkgray) var(--dot-radius), transparent 0);
background-repeat: no-repeat, repeat;
background-image: radial-gradient(circle at var(--dot-radius) var(--dot-radius), var(--color-3-darkgray) var(--dot-radius), transparent 0);
background-repeat: repeat;
image-rendering: pixelated;
mix-blend-mode: screen;
}
@ -1403,7 +932,7 @@
--extra-width-to-reach-grid-multiple: 8px;
--node-chain-area-left-extension: 0;
// Keep this equation in sync with the equivalent one in the Svelte template `<clipPath><path d="layerBorderMask(...)" /></clipPath>` above
width: calc(72px + 8px + 24px * Max(3, var(--label-width)) + 8px + 12px + var(--extra-width-to-reach-grid-multiple));
width: calc(24px * var(--layer-area-width) - 12px);
padding-left: calc(var(--node-chain-area-left-extension) * 24px);
margin-left: calc((1.5 - var(--node-chain-area-left-extension)) * 24px);

View file

@ -159,7 +159,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
function onPointerDown(e: PointerEvent) {
const { target } = e;
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport]");
const isTargetingCanvas = target instanceof Element && (target.closest("[data-viewport]") || target.closest("[data-node-graph]"));
const inDialog = target instanceof Element && target.closest("[data-dialog] [data-floating-menu-content]");
const inTextInput = target === textToolInteractiveInputElement;
@ -209,7 +209,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
function onWheelScroll(e: WheelEvent) {
const { target } = e;
const isTargetingCanvas = target instanceof Element && target.closest("[data-viewport]");
const isTargetingCanvas = target instanceof Element && (target.closest("[data-viewport]") || target.closest("[data-node-graph]"));
// Redirect vertical scroll wheel movement into a horizontal scroll on a horizontally scrollable element
// There seems to be no possible way to properly employ the browser's smooth scrolling interpolation

View file

@ -2,30 +2,62 @@ import { writable } from "svelte/store";
import { type Editor } from "@graphite/wasm-communication/editor";
import {
type Box,
type ContextMenuInformation,
type FrontendNode,
type FrontendNodeWire as FrontendNodeWire,
type FrontendNodeType,
type WirePath,
UpdateBox,
UpdateContextMenuInformation,
UpdateLayerWidths,
UpdateNodeGraph,
UpdateNodeGraphSelection,
UpdateNodeGraphTransform,
UpdateNodeTypes,
UpdateNodeThumbnail,
UpdateSubgraphPath,
UpdateWirePathInProgress,
UpdateZoomWithScroll,
} from "@graphite/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createNodeGraphState(editor: Editor) {
const { subscribe, update } = writable({
box: undefined as Box | undefined,
contextMenuInformation: undefined as ContextMenuInformation | undefined,
layerWidths: new Map<bigint, number>(),
nodes: [] as FrontendNode[],
wires: [] as FrontendNodeWire[],
wirePathInProgress: undefined as WirePath | undefined,
nodeTypes: [] as FrontendNodeType[],
zoomWithScroll: false as boolean,
thumbnails: new Map<bigint, string>(),
selected: [] as bigint[],
subgraphPath: [] as string[],
transform: { scale: 1, x: 0, y: 0 },
});
// Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(UpdateBox, (updateBox) => {
update((state) => {
state.box = updateBox.box;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateContextMenuInformation, (updateContextMenuInformation) => {
update((state) => {
state.contextMenuInformation = updateContextMenuInformation.contextMenuInformation;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateLayerWidths, (updateLayerWidths) => {
update((state) => {
state.layerWidths = updateLayerWidths.layerWidths;
return state;
});
});
// TODO: Add a way to only update the nodes that have changed
editor.subscriptions.subscribeJsMessage(UpdateNodeGraph, (updateNodeGraph) => {
update((state) => {
state.nodes = updateNodeGraph.nodes;
@ -39,6 +71,12 @@ export function createNodeGraphState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphTransform, (updateNodeGraphTransform) => {
update((state) => {
state.transform = updateNodeGraphTransform.transform;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateNodeTypes, (updateNodeTypes) => {
update((state) => {
state.nodeTypes = updateNodeTypes.nodeTypes;
@ -57,6 +95,12 @@ export function createNodeGraphState(editor: Editor) {
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateWirePathInProgress, (updateWirePathInProgress) => {
update((state) => {
state.wirePathInProgress = updateWirePathInProgress.wirePath;
return state;
});
});
editor.subscriptions.subscribeJsMessage(UpdateZoomWithScroll, (updateZoomWithScroll) => {
update((state) => {
state.zoomWithScroll = updateZoomWithScroll.zoomWithScroll;

View file

@ -25,6 +25,31 @@ export type XY = { x: number; y: number };
// for details about how to transform the JSON from wasm-bindgen into classes.
// ============================================================================
export class UpdateBox extends JsMessage {
readonly box!: Box | undefined;
}
const ContextTupleToVec2 = Transform((data) => {
if (data.obj.contextMenuInformation === undefined) return undefined;
const contextMenuCoordinates = { x: data.obj.contextMenuInformation.contextMenuCoordinates[0], y: data.obj.contextMenuInformation.contextMenuCoordinates[1] };
let contextMenuData = data.obj.contextMenuInformation.contextMenuData;
if (contextMenuData.ToggleLayer !== undefined) {
contextMenuData = { nodeId: contextMenuData.ToggleLayer.nodeId, currentlyIsNode: contextMenuData.ToggleLayer.currentlyIsNode };
}
return { contextMenuCoordinates, contextMenuData };
});
export class UpdateContextMenuInformation extends JsMessage {
@ContextTupleToVec2
readonly contextMenuInformation!: ContextMenuInformation | undefined;
}
const LayerWidths = Transform(({ obj }) => obj.layerWidths);
export class UpdateLayerWidths extends JsMessage {
@LayerWidths
readonly layerWidths!: Map<bigint, number>;
}
export class UpdateNodeGraph extends JsMessage {
@Type(() => FrontendNode)
readonly nodes!: FrontendNode[];
@ -33,6 +58,10 @@ export class UpdateNodeGraph extends JsMessage {
readonly wires!: FrontendNodeWire[];
}
export class UpdateNodeGraphTransform extends JsMessage {
readonly transform!: NodeGraphTransform;
}
export class UpdateNodeTypes extends JsMessage {
@Type(() => FrontendNode)
readonly nodeTypes!: FrontendNodeType[];
@ -58,6 +87,10 @@ export class UpdateSubgraphPath extends JsMessage {
readonly subgraphPath!: string[];
}
export class UpdateWirePathInProgress extends JsMessage {
readonly wirePath!: WirePath | undefined;
}
export class UpdateZoomWithScroll extends JsMessage {
readonly zoomWithScroll!: boolean;
}
@ -84,6 +117,22 @@ export class FrontendDocumentDetails extends DocumentDetails {
readonly id!: bigint;
}
export class Box {
readonly startX!: number;
readonly startY!: number;
readonly endX!: number;
readonly endY!: number;
}
export type ContextMenuInformation = {
contextMenuCoordinates: XY;
contextMenuData: "CreateNode" | { nodeId: bigint; currentlyIsNode: boolean };
};
export type FrontendGraphDataType = "General" | "Raster" | "VectorData" | "Number" | "Graphic" | "Artboard";
export class FrontendGraphInput {
@ -130,6 +179,8 @@ export class FrontendNode {
@TupleToVec2
readonly position!: XY | undefined;
//TODO: Store field for the width of the left node chain
readonly previewed!: boolean;
readonly visible!: boolean;
@ -159,6 +210,19 @@ export class FrontendNodeType {
readonly category!: string;
}
export class NodeGraphTransform {
readonly scale!: number;
readonly x!: number;
readonly y!: number;
}
export class WirePath {
readonly pathString!: string;
readonly dataType!: FrontendGraphDataType;
readonly thick!: boolean;
readonly dashed!: boolean;
}
export class IndexedDbDocumentDetails extends DocumentDetails {
@Transform(({ value }: { value: bigint }) => value.toString())
id!: string;
@ -1378,6 +1442,9 @@ export const messageMakers: Record<string, MessageMaker> = {
TriggerViewportResize,
TriggerVisitLink,
UpdateActiveDocument,
UpdateBox,
UpdateContextMenuInformation,
UpdateLayerWidths,
UpdateDialogButtons,
UpdateDialogColumn1,
UpdateDialogColumn2,
@ -1396,6 +1463,7 @@ export const messageMakers: Record<string, MessageMaker> = {
UpdateNodeGraph,
UpdateNodeGraphBarLayout,
UpdateNodeGraphSelection,
UpdateNodeGraphTransform,
UpdateNodeThumbnail,
UpdateNodeTypes,
UpdateOpenDocumentsList,
@ -1405,6 +1473,7 @@ export const messageMakers: Record<string, MessageMaker> = {
UpdateToolOptionsLayout,
UpdateToolShelfLayout,
UpdateWorkingColorsLayout,
UpdateWirePathInProgress,
UpdateZoomWithScroll,
} as const;
export type JsMessageType = keyof typeof messageMakers;