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:
parent
49b9b8cfec
commit
8f4f7b3cf1
|
|
@ -1576,6 +1576,7 @@ dependencies = [
|
|||
name = "graphite-wasm"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"bezier-rs",
|
||||
"graph-craft",
|
||||
"graphite-document-legacy",
|
||||
"graphite-editor",
|
||||
|
|
|
|||
|
|
@ -61,4 +61,7 @@ pub enum NodeGraphMessage {
|
|||
input_index: usize,
|
||||
value: TaggedValue,
|
||||
},
|
||||
ShiftNode {
|
||||
node_id: NodeId,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -300,6 +300,8 @@ import { defineComponent, nextTick } from "vue";
|
|||
|
||||
import type { IconName } from "@/utility-functions/icons";
|
||||
|
||||
import type { FrontendNodeLink } from "@/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import TextButton from "@/components/widgets/buttons/TextButton.vue";
|
||||
|
|
@ -388,6 +390,15 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
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> {
|
||||
await nextTick();
|
||||
|
||||
|
|
@ -396,13 +407,7 @@ export default defineComponent({
|
|||
|
||||
const links = this.nodeGraph.state.links;
|
||||
this.nodeLinkPaths = links.flatMap((link, index) => {
|
||||
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;
|
||||
|
||||
const { nodePrimaryInput, nodePrimaryOutput } = this.resolveLink(link, containerBounds);
|
||||
if (!nodePrimaryInput || !nodePrimaryOutput) return [];
|
||||
if (this.disconnecting?.linkIndex === index) return [];
|
||||
|
||||
|
|
@ -419,9 +424,9 @@ export default defineComponent({
|
|||
};
|
||||
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();
|
||||
if (!containerBounds) return "[error]";
|
||||
if (!containerBounds) return [];
|
||||
|
||||
const outX = verticalOut ? outputBounds.x + outputBounds.width / 2 : outputBounds.x + outputBounds.width - 1;
|
||||
const outY = verticalOut ? outputBounds.y + 1 : outputBounds.y + outputBounds.height / 2;
|
||||
|
|
@ -443,9 +448,17 @@ export default defineComponent({
|
|||
const horizontalCurve = horizontalCurveAmount * curveLength;
|
||||
const verticalCurve = verticalCurveAmount * curveLength;
|
||||
|
||||
return `M${outConnectorX},${outConnectorY} C${verticalOut ? outConnectorX : outConnectorX + horizontalCurve},${verticalOut ? outConnectorY - verticalCurve : outConnectorY} ${
|
||||
verticalIn ? inConnectorX : inConnectorX - horizontalCurve
|
||||
},${verticalIn ? inConnectorY + verticalCurve : inConnectorY} ${inConnectorX},${inConnectorY}`;
|
||||
return [
|
||||
{ x: outConnectorX, y: outConnectorY },
|
||||
{ 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] {
|
||||
const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort;
|
||||
|
|
@ -493,8 +506,16 @@ export default defineComponent({
|
|||
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
|
||||
pointerDown(e: PointerEvent) {
|
||||
if (this.nodeListLocation && !(e.target as HTMLElement).closest("[data-node-list]")) this.nodeListLocation = undefined;
|
||||
|
||||
if (e.button === 2) {
|
||||
const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el;
|
||||
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),
|
||||
y: Math.round(((e.clientY - graph.y) / this.transform.scale - this.transform.y) / GRID_SIZE),
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", this.keydown);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -599,6 +622,8 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
pointerUp(e: PointerEvent) {
|
||||
const containerBounds = this.$refs.nodesContainer as HTMLDivElement | undefined;
|
||||
if (!containerBounds) return;
|
||||
this.panning = false;
|
||||
|
||||
if (this.disconnecting) {
|
||||
|
|
@ -632,6 +657,48 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
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.selectIfNotDragged = undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ serde-wasm-bindgen = "0.4.1"
|
|||
js-sys = "0.3.55"
|
||||
wasm-bindgen-futures = "0.4.33"
|
||||
ron = {version = "0.8", optional = true}
|
||||
bezier-rs = { path = "../../libraries/bezier-rs" }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.22"
|
||||
|
|
|
|||
|
|
@ -587,6 +587,13 @@ impl JsEditorHandle {
|
|||
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
|
||||
#[wasm_bindgen(js_name = disconnectNodes)]
|
||||
pub fn disconnect_nodes(&self, node_id: u64, input_index: usize) {
|
||||
|
|
@ -594,6 +601,18 @@ impl JsEditorHandle {
|
|||
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
|
||||
#[wasm_bindgen(js_name = createNode)]
|
||||
pub fn create_node(&self, node_type: String, x: i32, y: i32) {
|
||||
|
|
|
|||
|
|
@ -180,6 +180,19 @@ impl NodeNetwork {
|
|||
.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) {
|
||||
self.flatten_with_fns(node, merge_ids, generate_uuid)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue