Add drag to connect nodes (#901)

* Add drag to connect nodes

* Clean up node graph code

* Close node list with escape or click

* Check if line is contained within box

* Shift the nodes

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube 2022-12-22 21:14:21 +00:00 committed by Keavon Chambers
parent 49b9b8cfec
commit 8f4f7b3cf1
7 changed files with 163 additions and 12 deletions

1
Cargo.lock generated
View File

@ -1576,6 +1576,7 @@ dependencies = [
name = "graphite-wasm" name = "graphite-wasm"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"bezier-rs",
"graph-craft", "graph-craft",
"graphite-document-legacy", "graphite-document-legacy",
"graphite-editor", "graphite-editor",

View File

@ -61,4 +61,7 @@ pub enum NodeGraphMessage {
input_index: usize, input_index: usize,
value: TaggedValue, value: TaggedValue,
}, },
ShiftNode {
node_id: NodeId,
},
} }

View File

@ -530,6 +530,53 @@ impl MessageHandler<NodeGraphMessage, (&mut Document, &InputPreprocessorMessageH
} }
} }
} }
NodeGraphMessage::ShiftNode { node_id } => {
let Some(network) = self.get_active_network_mut(document) else{
warn!("No network");
return;
};
let outwards_links = network.collect_outwards_links();
let required_shift = |left: NodeId, right: NodeId, network: &NodeNetwork| {
if let (Some(left), Some(right)) = (network.nodes.get(&left), network.nodes.get(&right)) {
if right.metadata.position.0 < left.metadata.position.0 {
0
} else {
(8 - (right.metadata.position.0 - left.metadata.position.0)).max(0)
}
} else {
0
}
};
let shift_node = |node_id: NodeId, shift: i32, network: &mut NodeNetwork| {
if let Some(node) = network.nodes.get_mut(&node_id) {
node.metadata.position.0 += shift
}
};
// Shift the actual node
let inputs = network
.nodes
.get(&node_id)
.map_or(&Vec::new(), |node| &node.inputs)
.iter()
.filter_map(|input| if let NodeInput::Node(previous_id) = input { Some(*previous_id) } else { None })
.collect::<Vec<_>>();
for input_node in inputs {
let shift = required_shift(input_node, node_id, network);
shift_node(node_id, shift, network);
}
// Shift nodes connected to the output port of the specified node
for &decendant in outwards_links.get(&node_id).unwrap_or(&Vec::new()) {
let shift = required_shift(node_id, decendant, network);
let mut stack = vec![decendant];
while let Some(id) = stack.pop() {
shift_node(id, shift, network);
stack.extend(outwards_links.get(&id).unwrap_or(&Vec::new()).iter().copied())
}
}
Self::send_graph(network, responses);
}
} }
} }

View File

@ -300,6 +300,8 @@ import { defineComponent, nextTick } from "vue";
import type { IconName } from "@/utility-functions/icons"; import type { IconName } from "@/utility-functions/icons";
import type { FrontendNodeLink } from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import TextButton from "@/components/widgets/buttons/TextButton.vue"; import TextButton from "@/components/widgets/buttons/TextButton.vue";
@ -388,6 +390,15 @@ export default defineComponent({
}, },
}, },
methods: { methods: {
resolveLink(link: FrontendNodeLink, containerBounds: HTMLDivElement): { nodePrimaryOutput: HTMLDivElement | undefined; nodePrimaryInput: HTMLDivElement | undefined } {
const connectorIndex = Number(link.linkEndInputIndex);
const nodePrimaryOutput = (containerBounds.querySelector(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined) as HTMLDivElement | undefined;
const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined;
const nodePrimaryInput = nodeInputConnectors?.[connectorIndex] as HTMLDivElement | undefined;
return { nodePrimaryOutput, nodePrimaryInput };
},
async refreshLinks(): Promise<void> { async refreshLinks(): Promise<void> {
await nextTick(); await nextTick();
@ -396,13 +407,7 @@ export default defineComponent({
const links = this.nodeGraph.state.links; const links = this.nodeGraph.state.links;
this.nodeLinkPaths = links.flatMap((link, index) => { this.nodeLinkPaths = links.flatMap((link, index) => {
const connectorIndex = Number(link.linkEndInputIndex); const { nodePrimaryInput, nodePrimaryOutput } = this.resolveLink(link, containerBounds);
const nodePrimaryOutput = (containerBounds.querySelector(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined) as HTMLDivElement | undefined;
const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined;
const nodePrimaryInput = nodeInputConnectors?.[connectorIndex] as HTMLDivElement | undefined;
if (!nodePrimaryInput || !nodePrimaryOutput) return []; if (!nodePrimaryInput || !nodePrimaryOutput) return [];
if (this.disconnecting?.linkIndex === index) return []; if (this.disconnecting?.linkIndex === index) return [];
@ -419,9 +424,9 @@ export default defineComponent({
}; };
return iconMap[nodeName] || "NodeNodes"; return iconMap[nodeName] || "NodeNodes";
}, },
buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string { buildWirePathLocations(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): { x: number; y: number }[] {
const containerBounds = (this.$refs.nodesContainer as HTMLDivElement | undefined)?.getBoundingClientRect(); const containerBounds = (this.$refs.nodesContainer as HTMLDivElement | undefined)?.getBoundingClientRect();
if (!containerBounds) return "[error]"; if (!containerBounds) return [];
const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1; const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1;
const outY = verticalOut ? outputBounds.y + 1 : outputBounds.y + outputBounds.height / 2; const outY = verticalOut ? outputBounds.y + 1 : outputBounds.y + outputBounds.height / 2;
@ -443,9 +448,17 @@ export default defineComponent({
const horizontalCurve = horizontalCurveAmount * curveLength; const horizontalCurve = horizontalCurveAmount * curveLength;
const verticalCurve = verticalCurveAmount * curveLength; const verticalCurve = verticalCurveAmount * curveLength;
return `M${outConnectorX},${outConnectorY} C${verticalOut ? outConnectorX : outConnectorX + horizontalCurve},${verticalOut ? outConnectorY - verticalCurve : outConnectorY} ${ return [
verticalIn ? inConnectorX : inConnectorX - horizontalCurve { x: outConnectorX, y: outConnectorY },
},${verticalIn ? inConnectorY + verticalCurve : inConnectorY} ${inConnectorX},${inConnectorY}`; { x: verticalOut ? outConnectorX : outConnectorX + horizontalCurve, y: verticalOut ? outConnectorY - verticalCurve : outConnectorY },
{ x: verticalIn ? inConnectorX : inConnectorX - horizontalCurve, y: verticalIn ? inConnectorY + verticalCurve : inConnectorY },
{ x: inConnectorX, y: inConnectorY },
];
},
buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string {
const locations = this.buildWirePathLocations(outputBounds, inputBounds, verticalOut, verticalIn);
if (locations.length === 0) return "[error]";
return `M${locations[0].x},${locations[0].y} C${locations[1].x},${locations[1].y} ${locations[2].x},${locations[2].y} ${locations[3].x},${locations[3].y}`;
}, },
createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement | DOMRect, verticalOut: boolean, verticalIn: boolean): [string, string] { createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement | DOMRect, verticalOut: boolean, verticalIn: boolean): [string, string] {
const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort; const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort;
@ -493,8 +506,16 @@ export default defineComponent({
this.transform.x -= scrollY / this.transform.scale; this.transform.x -= scrollY / this.transform.scale;
} }
}, },
keydown(e: KeyboardEvent): void {
if (e.key.toLowerCase() === "escape") {
this.nodeListLocation = undefined;
document.removeEventListener("keydown", this.keydown);
}
},
// TODO: Move the event listener from the graph to the window so dragging outside the graph area (or even the browser window) works // TODO: Move the event listener from the graph to the window so dragging outside the graph area (or even the browser window) works
pointerDown(e: PointerEvent) { pointerDown(e: PointerEvent) {
if (this.nodeListLocation && !(e.target as HTMLElement).closest("[data-node-list]")) this.nodeListLocation = undefined;
if (e.button === 2) { if (e.button === 2) {
const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el; const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el;
const graph = graphDiv?.getBoundingClientRect() || new DOMRect(); const graph = graphDiv?.getBoundingClientRect() || new DOMRect();
@ -502,6 +523,8 @@ export default defineComponent({
x: Math.round(((e.clientX - graph.x) / this.transform.scale - this.transform.x) / GRID_SIZE), x: Math.round(((e.clientX - graph.x) / this.transform.scale - this.transform.x) / GRID_SIZE),
y: Math.round(((e.clientY - graph.y) / this.transform.scale - this.transform.y) / GRID_SIZE), y: Math.round(((e.clientY - graph.y) / this.transform.scale - this.transform.y) / GRID_SIZE),
}; };
document.addEventListener("keydown", this.keydown);
return; return;
} }
@ -599,6 +622,8 @@ export default defineComponent({
} }
}, },
pointerUp(e: PointerEvent) { pointerUp(e: PointerEvent) {
const containerBounds = this.$refs.nodesContainer as HTMLDivElement | undefined;
if (!containerBounds) return;
this.panning = false; this.panning = false;
if (this.disconnecting) { if (this.disconnecting) {
@ -632,6 +657,48 @@ export default defineComponent({
} }
} }
this.editor.instance.moveSelectedNodes(this.draggingNodes.roundX, this.draggingNodes.roundY); this.editor.instance.moveSelectedNodes(this.draggingNodes.roundX, this.draggingNodes.roundY);
// Check if this node should be inserted between two other nodes
if (this.selected.length === 1) {
const selectedNodeId = this.selected[0];
const selectedNode = containerBounds.querySelector(`[data-node="${String(selectedNodeId)}"]`);
// Check that neither the input or output of the selected node are already connected.
const notConnected =
this.nodeGraph.state.links.findIndex((link) => link.linkStart === selectedNodeId || (link.linkEnd === selectedNodeId && link.linkEndInputIndex === BigInt(0))) === -1;
const input = selectedNode?.querySelector(`[data-port="input"]`);
const output = selectedNode?.querySelector(`[data-port="output"]`);
// TODO: Make sure inputs are correctly typed
if (selectedNode && notConnected && input && output) {
// Find the link that the node has been dragged on top of
const link = this.nodeGraph.state.links.find((link): boolean => {
const { nodePrimaryInput, nodePrimaryOutput } = this.resolveLink(link, containerBounds);
if (!nodePrimaryInput || !nodePrimaryOutput) return false;
const wireCurveLocations = this.buildWirePathLocations(nodePrimaryOutput.getBoundingClientRect(), nodePrimaryInput.getBoundingClientRect(), false, false);
const selectedNodeBounds = selectedNode.getBoundingClientRect();
const containerBoundsBounds = containerBounds.getBoundingClientRect();
return this.editor.instance.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 link then connect it into the middle.
if (link) {
this.editor.instance.connectNodesByLink(link.linkStart, selectedNodeId, 0);
this.editor.instance.connectNodesByLink(selectedNodeId, link.linkEnd, Number(link.linkEndInputIndex));
this.editor.instance.shiftNode(selectedNodeId);
}
}
}
this.draggingNodes = undefined; this.draggingNodes = undefined;
this.selectIfNotDragged = undefined; this.selectIfNotDragged = undefined;
} }

View File

@ -28,6 +28,7 @@ serde-wasm-bindgen = "0.4.1"
js-sys = "0.3.55" js-sys = "0.3.55"
wasm-bindgen-futures = "0.4.33" wasm-bindgen-futures = "0.4.33"
ron = {version = "0.8", optional = true} ron = {version = "0.8", optional = true}
bezier-rs = { path = "../../libraries/bezier-rs" }
[dev-dependencies] [dev-dependencies]
wasm-bindgen-test = "0.3.22" wasm-bindgen-test = "0.3.22"

View File

@ -587,6 +587,13 @@ impl JsEditorHandle {
self.dispatch(message); self.dispatch(message);
} }
/// Shifts the node and its children to stop nodes going ontop of each other
#[wasm_bindgen(js_name = shiftNode)]
pub fn shift_node(&self, node_id: u64) {
let message = NodeGraphMessage::ShiftNode { node_id };
self.dispatch(message);
}
/// Notifies the backend that the user disconnected a node /// Notifies the backend that the user disconnected a node
#[wasm_bindgen(js_name = disconnectNodes)] #[wasm_bindgen(js_name = disconnectNodes)]
pub fn disconnect_nodes(&self, node_id: u64, input_index: usize) { pub fn disconnect_nodes(&self, node_id: u64, input_index: usize) {
@ -594,6 +601,18 @@ impl JsEditorHandle {
self.dispatch(message); self.dispatch(message);
} }
/// Check for intersections between the curve and a rectangle defined by opposite corners
#[wasm_bindgen(js_name = rectangleIntersects)]
pub fn rectangle_intersects(&self, bezier_x: Vec<f64>, bezier_y: Vec<f64>, top: f64, left: f64, bottom: f64, right: f64) -> bool {
let bezier = bezier_rs::Bezier::from_cubic_dvec2(
(bezier_x[0], bezier_y[0]).into(),
(bezier_x[1], bezier_y[1]).into(),
(bezier_x[2], bezier_y[2]).into(),
(bezier_x[3], bezier_y[3]).into(),
);
!bezier.rectangle_intersections((left, top).into(), (right, bottom).into()).is_empty() || bezier.is_contained_within((left, top).into(), (right, bottom).into())
}
/// Creates a new document node in the node graph /// Creates a new document node in the node graph
#[wasm_bindgen(js_name = createNode)] #[wasm_bindgen(js_name = createNode)]
pub fn create_node(&self, node_type: String, x: i32, y: i32) { pub fn create_node(&self, node_type: String, x: i32, y: i32) {

View File

@ -180,6 +180,19 @@ impl NodeNetwork {
.collect(); .collect();
} }
/// Collect a hashmap of nodes with a list of the nodes that use it as input
pub fn collect_outwards_links(&self) -> HashMap<NodeId, Vec<NodeId>> {
let mut outwards_links: HashMap<u64, Vec<u64>> = HashMap::new();
for (node_id, node) in &self.nodes {
for input in &node.inputs {
if let NodeInput::Node(ref_id) = input {
outwards_links.entry(*ref_id).or_default().push(*node_id)
}
}
}
outwards_links
}
pub fn flatten(&mut self, node: NodeId) { pub fn flatten(&mut self, node: NodeId) {
self.flatten_with_fns(node, merge_ids, generate_uuid) self.flatten_with_fns(node, merge_ids, generate_uuid)
} }