Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Improved node graph support for user created nodes
It should be possible to select a group of nodes, and then "group" them into a single node with a network implementation of those nodes. It should also be possible to change the number, order, and names of inputs/outputs. It could also be useful to "ungroup" a node. Once #2042 is complete, it should also be possible to change the annotated types for the inputs. Improved tooltips described in #2069 should also be added.
Allow users to escape from node implementations
Currently on dev.graphite.rs (the main branch) you can exit from a nested network (unless you refresh the page).

You can get into a nested network by:
- Drawing a rectangle
- Opening the node graph
- double clicking on the node with the text 'Untitled Layer'.


Discussion / hints:
This will also be an important part of making lambdas more useful and approachable, once we implement them. To produce constructs like loops.


Fix patch:
diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs
index 8f95e55be0..f5482cdeb1 100644
--- a/editor/src/messages/frontend/frontend_message.rs
+++ b/editor/src/messages/frontend/frontend_message.rs
@@ -131,6 +131,10 @@ pub enum FrontendMessage {
 	UpdateImportsExports {
 		imports: Vec<(FrontendGraphOutput, i32, i32)>,
 		exports: Vec<(FrontendGraphInput, i32, i32)>,
+		#[serde(rename = "addImport")]
+		add_import: Option<(i32, i32)>,
+		#[serde(rename = "addExport")]
+		add_export: Option<(i32, i32)>,
 	},
 	UpdateInSelectedNetwork {
 		#[serde(rename = "inSelectedNetwork")]
diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs
index ffb856ce52..b008ae123d 100644
--- a/editor/src/messages/input_mapper/input_mappings.rs
+++ b/editor/src/messages/input_mapper/input_mappings.rs
@@ -75,6 +75,7 @@ pub fn input_mappings() -> Mapping {
 		entry!(KeyDown(KeyL); modifiers=[Alt], action_dispatch=NodeGraphMessage::ToggleSelectedAsLayersOrNodes),
 		entry!(KeyDown(KeyC); modifiers=[Shift], action_dispatch=NodeGraphMessage::PrintSelectedNodeCoordinates),
 		entry!(KeyDown(KeyC); modifiers=[Alt], action_dispatch=NodeGraphMessage::SendClickTargets),
+		entry!(KeyDown(KeyM); modifiers=[Accel], action_dispatch=NodeGraphMessage::MergeSelectedNodes),
 		entry!(KeyUp(KeyC); action_dispatch=NodeGraphMessage::EndSendClickTargets),
 		entry!(KeyDown(ArrowUp); action_dispatch=NodeGraphMessage::ShiftSelectedNodes { direction: Direction::Up, rubber_band: false }),
 		entry!(KeyDown(ArrowRight); action_dispatch=NodeGraphMessage::ShiftSelectedNodes { direction: Direction::Right, rubber_band: false }),
diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs
index 5aa2b11c94..303ff572f6 100644
--- a/editor/src/messages/portfolio/document/document_message_handler.rs
+++ b/editor/src/messages/portfolio/document/document_message_handler.rs
@@ -326,7 +326,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
 				responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![id] });
 			}
 			DocumentMessage::DebugPrintDocument => {
-				info!("{:#?}", self.network_interface);
+				info!("{:?}", self.network_interface);
 			}
 			DocumentMessage::DeleteNode { node_id } => {
 				responses.add(DocumentMessage::StartTransaction);
@@ -1128,6 +1128,9 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
 				// TODO: Allow non layer nodes to have click targets
 				let layer_click_targets = click_targets
 					.into_iter()
+					.filter(|(node_id, _)|
+						// Ensure that the layer is in the document network to prevent logging an error
+						self.network_interface.network(&[]).unwrap().nodes.contains_key(node_id))
 					.filter_map(|(node_id, click_targets)| {
 						self.network_interface.is_layer(&node_id, &[]).then(|| {
 							let layer = LayerNodeIdentifier::new(node_id, &self.network_interface, &[]);
@@ -1223,11 +1226,19 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
 					self.network_interface.set_transform(transform, &self.breadcrumb_network_path);
 					let imports = self.network_interface.frontend_imports(&self.breadcrumb_network_path).unwrap_or_default();
 					let exports = self.network_interface.frontend_exports(&self.breadcrumb_network_path).unwrap_or_default();
+					let add_import = self.network_interface.frontend_import_modify(&self.breadcrumb_network_path);
+					let add_export = self.network_interface.frontend_export_modify(&self.breadcrumb_network_path);
+
 					responses.add(DocumentMessage::RenderRulers);
 					responses.add(DocumentMessage::RenderScrollbars);
 					responses.add(NodeGraphMessage::UpdateEdges);
 					responses.add(NodeGraphMessage::UpdateBoxSelection);
-					responses.add(FrontendMessage::UpdateImportsExports { imports, exports });
+					responses.add(FrontendMessage::UpdateImportsExports {
+						imports,
+						exports,
+						add_import,
+						add_export,
+					});
 					responses.add(FrontendMessage::UpdateNodeGraphTransform {
 						transform: Transform {
 							scale: transform.matrix2.x_axis.x,
diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
index afb6f8f3cb..9c1e61dcaa 100644
--- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
+++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
@@ -64,6 +64,23 @@ static DOCUMENT_NODE_TYPES: once_cell::sync::Lazy<Vec<DocumentNodeDefinition>> =
 /// The [`DocumentNode`] is the instance while these [`DocumentNodeDefinition`]s are the "classes" or "blueprints" from which the instances are built.
 fn static_nodes() -> Vec<DocumentNodeDefinition> {
 	let mut custom = vec![
+		// TODO: Auto-generate this from its proto node macro
+		DocumentNodeDefinition {
+			identifier: "Default Network",
+			category: "General",
+			node_template: NodeTemplate {
+				document_node: DocumentNode {
+					implementation: DocumentNodeImplementation::Network(NodeNetwork::default()),
+					..Default::default()
+				},
+				persistent_node_metadata: DocumentNodePersistentMetadata {
+					network_metadata: Some(NodeNetworkMetadata::default()),
+					..Default::default()
+				},
+			},
+			description: Cow::Borrowed("A default node network you can use to create your own custom nodes."),
+			properties: &node_properties::node_no_properties,
+		},
 		// TODO: Auto-generate this from its proto node macro
 		DocumentNodeDefinition {
 			identifier: "Identity",
@@ -80,7 +97,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
 					..Default::default()
 				},
 			},
-			description: Cow::Borrowed("The identity node simply passes its data through. You can use this to organize your node graph if you want."),
+			description: Cow::Borrowed("The identity node passes its data through. You can use this to organize your node graph."),
 			properties: &|_document_node, _node_id, _context| node_properties::string_properties("The identity node simply passes its data through"),
 		},
 		// TODO: Auto-generate this from its proto node macro
@@ -101,7 +118,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
 					..Default::default()
 				},
 			},
-			description: Cow::Borrowed("The Monitor node is used by the editor to access the data flowing through it"),
+			description: Cow::Borrowed("The Monitor node is used by the editor to access the data flowing through it."),
 			properties: &|_document_node, _node_id, _context| node_properties::string_properties("The Monitor node is used by the editor to access the data flowing through it"),
 		},
 		DocumentNodeDefinition {
@@ -208,7 +225,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
 					..Default::default()
 				},
 			},
-			description: Cow::Borrowed("The Merge node combines graphical data through composition"),
+			description: Cow::Borrowed("The Merge node combines graphical data through composition."),
 			properties: &node_properties::node_no_properties,
 		},
 		DocumentNodeDefinition {
@@ -319,7 +336,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
 					..Default::default()
 				},
 			},
-			description: Cow::Borrowed("Creates a new Artboard which can be used as a working surface"),
+			description: Cow::Borrowed("Creates a new Artboard which can be used as a working surface."),
 			properties: &node_properties::artboard_properties,
 		},
 		DocumentNodeDefinition {
@@ -719,7 +736,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
 					..Default::default()
 				},
 			},
-			description: Cow::Borrowed("Creates an embedded image with the given transform"),
+			description: Cow::Borrowed("Creates an embedded image with the given transform."),
 			properties: &|_document_node, _node_id, _context| node_properties::string_properties("Creates an embedded image with the given transform"),
 		},
 		DocumentNodeDefinition {
@@ -798,7 +815,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
 					..Default::default()
 				},
 			},
-			description: Cow::Borrowed("Generates different noise patters"),
+			description: Cow::Borrowed("Generates different noise patterns."),
 			properties: &node_properties::noise_pattern_properties,
 		},
 		// TODO: This needs to work with resolution-aware (raster with footprint, post-Cull node) data.
diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs
index 30f6d730ab..80553e730e 100644
--- a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs
+++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs
@@ -4,6 +4,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye
 use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate, OutputConnector};
 use crate::messages::prelude::*;
 
+use glam::IVec2;
 use graph_craft::document::value::TaggedValue;
 use graph_craft::document::{NodeId, NodeInput};
 use graph_craft::proto::GraphErrors;
@@ -16,6 +17,8 @@ pub enum NodeGraphMessage {
 		nodes: Vec<(NodeId, NodeTemplate)>,
 		new_ids: HashMap<NodeId, NodeId>,
 	},
+	AddImport,
+	AddExport,
 	Init,
 	SelectedNodesUpdated,
 	Copy,
@@ -68,6 +71,7 @@ pub enum NodeGraphMessage {
 		input_connector: InputConnector,
 		insert_node_input_index: usize,
 	},
+	MergeSelectedNodes,
 	MoveLayerToStack {
 		layer: LayerNodeIdentifier,
 		parent: LayerNodeIdentifier,
@@ -126,19 +130,23 @@ pub enum NodeGraphMessage {
 		node_id: NodeId,
 		alias: String,
 	},
+	SetToNodeOrLayer {
+		node_id: NodeId,
+		is_layer: bool,
+	},
 	ShiftNodePosition {
 		node_id: NodeId,
 		x: i32,
 		y: i32,
 	},
-	SetToNodeOrLayer {
-		node_id: NodeId,
-		is_layer: bool,
-	},
 	ShiftSelectedNodes {
 		direction: Direction,
 		rubber_band: bool,
 	},
+	ShiftSelectedNodesByAmount {
+		graph_delta: IVec2,
+		rubber_band: bool,
+	},
 	TogglePreview {
 		node_id: NodeId,
 	},
diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
index 1d4a996fe0..2700c93835 100644
--- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
+++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
@@ -8,7 +8,9 @@ use crate::messages::portfolio::document::graph_operation::utility_types::Modify
 use crate::messages::portfolio::document::node_graph::document_node_definitions::NodePropertiesContext;
 use crate::messages::portfolio::document::node_graph::utility_types::{ContextMenuData, Direction, FrontendGraphDataType};
 use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
-use crate::messages::portfolio::document::utility_types::network_interface::{self, InputConnector, NodeNetworkInterface, NodeTemplate, OutputConnector, Previewing, TypeSource};
+use crate::messages::portfolio::document::utility_types::network_interface::{
+	self, InputConnector, NodeNetworkInterface, NodeTemplate, NodeTypePersistentMetadata, OutputConnector, Previewing, TypeSource,
+};
 use crate::messages::portfolio::document::utility_types::nodes::{CollapsedLayers, LayerPanelEntry};
 use crate::messages::prelude::*;
 use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
@@ -97,6 +99,8 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 
 				responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![new_layer_id] });
 			}
+			NodeGraphMessage::AddImport => network_interface.add_import(graph_craft::document::value::TaggedValue::None, true, -1, String::new(), breadcrumb_network_path),
+			NodeGraphMessage::AddExport => network_interface.add_export(graph_craft::document::value::TaggedValue::None, -1, String::new(), breadcrumb_network_path),
 			NodeGraphMessage::Init => {
 				responses.add(BroadcastMessage::SubscribeEvent {
 					on: BroadcastEvent::SelectionChanged,
@@ -345,6 +349,171 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 			} => {
 				network_interface.insert_node_between(&node_id, &input_connector, insert_node_input_index, selection_network_path);
 			}
+			NodeGraphMessage::MergeSelectedNodes => {
+				let new_ids = network_interface
+					.selected_nodes(breadcrumb_network_path)
+					.unwrap()
+					.selected_nodes()
+					.map(|id| (*id, *id))
+					.collect::<HashMap<NodeId, NodeId>>();
+
+				let copied_nodes = network_interface.copy_nodes(&new_ids, breadcrumb_network_path).collect::<Vec<_>>();
+				let selected_node_ids = copied_nodes.iter().map(|(node_id, _)| *node_id).collect::<HashSet<_>>();
+				let selected_node_ids_vec = copied_nodes.iter().map(|(node_id, _)| *node_id).collect::<Vec<_>>();
+				// Mapping of the encapsulating node inputs/outputs to where it needs to be connected
+				let mut input_connections = Vec::new();
+				let mut output_connections = Vec::new();
+				// Mapping of the inner nodes that need to be connected to the imports/exports
+				let mut import_connections = Vec::new();
+				let mut export_connections = Vec::new();
+				// Scan current nodes top to bottom and find all inputs/outputs connected to nodes that are not in the copied nodes. These will represent the new imports and exports.
+				let Some(nodes_sorted_top_to_bottom) =
+					network_interface.nodes_sorted_top_to_bottom(network_interface.selected_nodes(breadcrumb_network_path).unwrap().selected_nodes(), breadcrumb_network_path)
+				else {
+					return;
+				};
+				//Ensure that nodes can be grouped by checking if there is an unselected node between selected nodes
+				for selected_node_id in &selected_node_ids {
+					for input_index in 0..network_interface.number_of_inputs(selected_node_id, breadcrumb_network_path) {
+						let input_connector = InputConnector::node(*selected_node_id, input_index);
+						if let Some(upstream_deselected_node_id) = network_interface
+							.upstream_output_connector(&input_connector, breadcrumb_network_path)
+							.and_then(|output_connector| output_connector.node_id())
+							.filter(|node_id| !selected_node_ids.contains(node_id))
+						{
+							for upstream_node_id in
+								network_interface.upstream_flow_back_from_nodes(vec![upstream_deselected_node_id], breadcrumb_network_path, network_interface::FlowType::UpstreamFlow)
+							{
+								if selected_node_ids.contains(&upstream_node_id) {
+									responses.add(DialogMessage::DisplayDialogError {
+										title: "Error Grouping Nodes".to_string(),
+										description: "A discontinuous selection of nodes cannot be grouped.\nEnsure no deselected nodes are between selected nodes".to_string(),
+									});
+									return;
+								}
+							}
+						}
+					}
+				}
+				for node_id in nodes_sorted_top_to_bottom {
+					for input_index in 0..network_interface.number_of_inputs(&node_id, breadcrumb_network_path) {
+						let current_input_connector = InputConnector::node(node_id, input_index);
+						let Some(upstream_connector) = network_interface.upstream_output_connector(&current_input_connector, breadcrumb_network_path) else {
+							continue;
+						};
+						if upstream_connector
+							.node_id()
+							.is_some_and(|upstream_node_id| selected_node_ids.iter().any(|copied_id| *copied_id == upstream_node_id))
+						{
+							continue;
+						}
+
+						// If the upstream connection is not part of the copied nodes, then connect it to the new imports, or add it if it has not already been added.
+						let import_index = input_connections.iter().position(|old_connection| old_connection == &upstream_connector).unwrap_or_else(|| {
+							input_connections.push(upstream_connector);
+							input_connections.len() - 1
+						});
+						import_connections.push((current_input_connector, import_index));
+					}
+					for output_index in 0..network_interface.number_of_outputs(&node_id, breadcrumb_network_path) {
+						let current_output_connector = OutputConnector::node(node_id, output_index);
+						let Some(outward_wires) = network_interface.outward_wires(breadcrumb_network_path) else {
+							log::error!("Could not get outward wires in upstream_nodes_below_layer");
+							continue;
+						};
+						let Some(downstream_connections) = outward_wires.get(&current_output_connector).cloned() else {
+							log::error!("Could not get downstream connections for {current_output_connector:?}");
+							continue;
+						};
+
+						// The output gets connected to all the previous inputs the node was connected to
+						let mut connect_output_to = Vec::new();
+						for downstream_connection in downstream_connections {
+							if downstream_connection.node_id().is_some_and(|downstream_node_id| selected_node_ids.contains(&downstream_node_id)) {
+								continue;
+							}
+							connect_output_to.push(downstream_connection);
+						}
+						if !connect_output_to.is_empty() {
+							// Every output connected to some non selected node forms a new export
+							export_connections.push(current_output_connector);
+							output_connections.push(connect_output_to);
+						}
+					}
+				}
+
+				// Use the network interface to add a default node, then set the imports, exports, paste the nodes inside, and connect them to the imports/exports
+				let encapsulating_node_id = NodeId::new();
+				let mut default_node_template = document_node_definitions::resolve_document_node_type("Default Network")
+					.expect("Default Network node should exist")
+					.default_node_template();
+				let Some(center_of_selected_nodes) = network_interface.selected_nodes_bounding_box(breadcrumb_network_path).map(|[a, b]| (a + b) / 2.) else {
+					log::error!("Could not get center of selected_nodes");
+					return;
+				};
+				let center_of_selected_nodes_grid_space = IVec2::new((center_of_selected_nodes.x / 24. + 0.5).floor() as i32, (center_of_selected_nodes.y / 24. + 0.5).floor() as i32);
+				default_node_template.persistent_node_metadata.node_type_metadata = NodeTypePersistentMetadata::node(center_of_selected_nodes_grid_space - IVec2::new(3, 1));
+				responses.add(DocumentMessage::AddTransaction);
+				responses.add(NodeGraphMessage::InsertNode {
+					node_id: encapsulating_node_id,
+					node_template: default_node_template,
+				});
+				responses.add(NodeGraphMessage::SetDisplayNameImpl {
+					node_id: encapsulating_node_id,
+					alias: "Untitled Node".to_string(),
+				});
+
+				responses.add(DocumentMessage::EnterNestedNetwork { node_id: encapsulating_node_id });
+				for _ in 0..input_connections.len() {
+					responses.add(NodeGraphMessage::AddImport);
+				}
+				for _ in 0..output_connections.len() {
+					responses.add(NodeGraphMessage::AddExport);
+				}
+				responses.add(NodeGraphMessage::AddNodes { nodes: copied_nodes, new_ids });
+				responses.add(NodeGraphMessage::SelectedNodesSet { nodes: selected_node_ids_vec.clone() });
+
+				// Shift the nodes back to the origin
+				responses.add(NodeGraphMessage::ShiftSelectedNodesByAmount {
+					graph_delta: -center_of_selected_nodes_grid_space - IVec2::new(2, 2),
+					rubber_band: false,
+				});
+
+				for (input_connector, import_index) in import_connections {
+					responses.add(NodeGraphMessage::CreateWire {
+						output_connector: OutputConnector::Import(import_index),
+						input_connector,
+					});
+				}
+				for (export_index, output_connector) in export_connections.into_iter().enumerate() {
+					responses.add(NodeGraphMessage::CreateWire {
+						output_connector,
+						input_connector: InputConnector::Export(export_index),
+					});
+				}
+				responses.add(DocumentMessage::ExitNestedNetwork { steps_back: 1 });
+				for (input_index, output_connector) in input_connections.into_iter().enumerate() {
+					responses.add(NodeGraphMessage::CreateWire {
+						output_connector,
+						input_connector: InputConnector::node(encapsulating_node_id, input_index),
+					});
+				}
+				for (output_index, input_connectors) in output_connections.into_iter().enumerate() {
+					for input_connector in input_connectors {
+						responses.add(NodeGraphMessage::CreateWire {
+							output_connector: OutputConnector::node(encapsulating_node_id, output_index),
+							input_connector,
+						});
+					}
+				}
+				responses.add(NodeGraphMessage::DeleteNodes {
+					node_ids: selected_node_ids_vec,
+					delete_children: false,
+				});
+				responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![encapsulating_node_id] });
+				responses.add(NodeGraphMessage::SendGraph);
+				responses.add(NodeGraphMessage::RunDocumentGraph);
+			}
 			NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index } => {
 				network_interface.move_layer_to_stack(layer, parent, insert_index, selection_network_path);
 			}
@@ -390,6 +559,23 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 
 				let node_graph_point = network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport.inverse().transform_point2(click);
 
+				let Some(modify_import_export) = network_interface.modify_import_export(selection_network_path) else {
+					log::error!("Could not get modify import export in PointerDown");
+					return;
+				};
+
+				if modify_import_export.add_export.intersect_point_no_stroke(node_graph_point) {
+					responses.add(DocumentMessage::AddTransaction);
+					responses.add(NodeGraphMessage::AddExport);
+					responses.add(NodeGraphMessage::SendGraph);
+					return;
+				} else if modify_import_export.add_import.intersect_point_no_stroke(node_graph_point) {
+					responses.add(DocumentMessage::AddTransaction);
+					responses.add(NodeGraphMessage::AddImport);
+					responses.add(NodeGraphMessage::SendGraph);
+					return;
+				}
+
 				if network_interface
 					.layer_click_target_from_click(click, network_interface::LayerClickTargetTypes::Grip, selection_network_path)
 					.is_some()
@@ -674,20 +860,8 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 						if ipp.keyboard.get(crate::messages::tool::tool_messages::tool_prelude::Key::Alt as usize) {
 							responses.add(NodeGraphMessage::DuplicateSelectedNodes);
 							// Duplicating sets a 2x2 offset, so shift the nodes back to the original position
-							responses.add(NodeGraphMessage::ShiftSelectedNodes {
-								direction: Direction::Up,
-								rubber_band: false,
-							});
-							responses.add(NodeGraphMessage::ShiftSelectedNodes {
-								direction: Direction::Up,
-								rubber_band: false,
-							});
-							responses.add(NodeGraphMessage::ShiftSelectedNodes {
-								direction: Direction::Left,
-								rubber_band: false,
-							});
-							responses.add(NodeGraphMessage::ShiftSelectedNodes {
-								direction: Direction::Left,
+							responses.add(NodeGraphMessage::ShiftSelectedNodesByAmount {
+								graph_delta: IVec2::new(-2, -2),
 								rubber_band: false,
 							});
 							self.preview_on_mouse_up = None;
@@ -704,43 +878,7 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 					graph_delta.x -= previous_round_x;
 					graph_delta.y -= previous_round_y;
 
-					while graph_delta != IVec2::ZERO {
-						match graph_delta.x.cmp(&0) {
-							Ordering::Greater => {
-								responses.add(NodeGraphMessage::ShiftSelectedNodes {
-									direction: Direction::Right,
-									rubber_band: true,
-								});
-								graph_delta.x -= 1;
-							}
-							Ordering::Less => {
-								responses.add(NodeGraphMessage::ShiftSelectedNodes {
-									direction: Direction::Left,
-									rubber_band: true,
-								});
-								graph_delta.x += 1;
-							}
-							Ordering::Equal => {}
-						}
-
-						match graph_delta.y.cmp(&0) {
-							Ordering::Greater => {
-								responses.add(NodeGraphMessage::ShiftSelectedNodes {
-									direction: Direction::Down,
-									rubber_band: true,
-								});
-								graph_delta.y -= 1;
-							}
-							Ordering::Less => {
-								responses.add(NodeGraphMessage::ShiftSelectedNodes {
-									direction: Direction::Up,
-									rubber_band: true,
-								});
-								graph_delta.y += 1;
-							}
-							Ordering::Equal => {}
-						}
-					}
+					responses.add(NodeGraphMessage::ShiftSelectedNodesByAmount { graph_delta, rubber_band: true });
 				} else if self.box_selection_start.is_some() {
 					responses.add(NodeGraphMessage::UpdateBoxSelection);
 				}
@@ -1094,7 +1232,14 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 					let (layer_widths, chain_widths, has_left_input_wire) = network_interface.collect_layer_widths(breadcrumb_network_path);
 					let imports = network_interface.frontend_imports(breadcrumb_network_path).unwrap_or_default();
 					let exports = network_interface.frontend_exports(breadcrumb_network_path).unwrap_or_default();
-					responses.add(FrontendMessage::UpdateImportsExports { imports, exports });
+					let add_import = network_interface.frontend_import_modify(breadcrumb_network_path);
+					let add_export = network_interface.frontend_export_modify(breadcrumb_network_path);
+					responses.add(FrontendMessage::UpdateImportsExports {
+						imports,
+						exports,
+						add_import,
+						add_export,
+					});
 					responses.add(FrontendMessage::UpdateNodeGraph { nodes, wires });
 					responses.add(FrontendMessage::UpdateLayerWidths {
 						layer_widths,
@@ -1110,7 +1255,14 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 					// Send the new edges to the frontend
 					let imports = network_interface.frontend_imports(breadcrumb_network_path).unwrap_or_default();
 					let exports = network_interface.frontend_exports(breadcrumb_network_path).unwrap_or_default();
-					responses.add(FrontendMessage::UpdateImportsExports { imports, exports });
+					let add_import = network_interface.frontend_import_modify(breadcrumb_network_path);
+					let add_export = network_interface.frontend_export_modify(breadcrumb_network_path);
+					responses.add(FrontendMessage::UpdateImportsExports {
+						imports,
+						exports,
+						add_import,
+						add_export,
+					});
 				}
 			}
 			NodeGraphMessage::SetInputValue { node_id, input_index, value } => {
@@ -1142,7 +1294,45 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 					responses.add(DocumentMessage::RenderScrollbars);
 				}
 			}
+			NodeGraphMessage::ShiftSelectedNodesByAmount { mut graph_delta, rubber_band } => {
+				while graph_delta != IVec2::ZERO {
+					match graph_delta.x.cmp(&0) {
+						Ordering::Greater => {
+							responses.add(NodeGraphMessage::ShiftSelectedNodes {
+								direction: Direction::Right,
+								rubber_band,
+							});
+							graph_delta.x -= 1;
+						}
+						Ordering::Less => {
+							responses.add(NodeGraphMessage::ShiftSelectedNodes {
+								direction: Direction::Left,
+								rubber_band,
+							});
+							graph_delta.x += 1;
+						}
+						Ordering::Equal => {}
+					}
 
+					match graph_delta.y.cmp(&0) {
+						Ordering::Greater => {
+							responses.add(NodeGraphMessage::ShiftSelectedNodes {
+								direction: Direction::Down,
+								rubber_band,
+							});
+							graph_delta.y -= 1;
+						}
+						Ordering::Less => {
+							responses.add(NodeGraphMessage::ShiftSelectedNodes {
+								direction: Direction::Up,
+								rubber_band,
+							});
+							graph_delta.y += 1;
+						}
+						Ordering::Equal => {}
+					}
+				}
+			}
 			NodeGraphMessage::ToggleSelectedAsLayersOrNodes => {
 				let Some(selected_nodes) = network_interface.selected_nodes(selection_network_path) else {
 					log::error!("Could not get selected nodes in NodeGraphMessage::ToggleSelectedAsLayersOrNodes");
@@ -1435,6 +1625,7 @@ impl NodeGraphMessageHandler {
 				Cut,
 				DeleteSelectedNodes,
 				DuplicateSelectedNodes,
+				MergeSelectedNodes,
 				ToggleSelectedAsLayersOrNodes,
 				ToggleSelectedLocked,
 				ToggleSelectedVisibility,
@@ -1930,18 +2121,18 @@ impl NodeGraphMessageHandler {
 			let exposed_inputs = inputs.flatten().collect();
 
 			let output_types = network_interface.output_types(&node_id, breadcrumb_network_path);
-			let primary_output_type = output_types.first().expect("Primary output should always exist");
-			let frontend_data_type = if let Some((output_type, _)) = primary_output_type {
+			let primary_output_type = output_types.first().cloned().flatten();
+			let frontend_data_type = if let Some((output_type, _)) = &primary_output_type {
 				FrontendGraphDataType::with_type(output_type)
 			} else {
 				FrontendGraphDataType::General
 			};
 			let connected_to = outward_wires.get(&OutputConnector::node(node_id, 0)).cloned().unwrap_or_default();
-			let primary_output = if network_interface.has_primary_output(&node_id, breadcrumb_network_path) {
+			let primary_output = if network_interface.has_primary_output(&node_id, breadcrumb_network_path) && !output_types.is_empty() {
 				Some(FrontendGraphOutput {
 					data_type: frontend_data_type,
 					name: "Output 1".to_string(),
-					resolved_type: primary_output_type.clone().map(|(input, type_source)| format!("{input:?} from {type_source:?}")),
+					resolved_type: primary_output_type.map(|(input, type_source)| format!("{input:?} from {type_source:?}")),
 					connected_to,
 				})
 			} else {
@@ -1967,7 +2158,8 @@ impl NodeGraphMessageHandler {
 					.output_names
 					.get(index)
 					.map(|output_name| output_name.to_string())
-					.unwrap_or(format!("Output {}", index + 1));
+					.filter(|output_name| !output_name.is_empty())
+					.unwrap_or_else(|| exposed_output.clone().map(|(output_type, _)| output_type.nested_type().to_string()).unwrap_or_default());
 
 				let connected_to = outward_wires.get(&OutputConnector::node(node_id, index)).cloned().unwrap_or_default();
 				exposed_outputs.push(FrontendGraphOutput {
@@ -2204,10 +2396,8 @@ fn frontend_inputs_lookup(breadcrumb_network_path: &[NodeId], network_interface:
 	let Some(network) = network_interface.network(breadcrumb_network_path) else {
 		return Default::default();
 	};
-	let network_metadata = network_interface.network_metadata(breadcrumb_network_path);
 	let mut frontend_inputs_lookup = HashMap::new();
 	for (&node_id, node) in network.nodes.iter() {
-		let node_metadata = network_metadata.and_then(|network_metadata| network_metadata.persistent_metadata.node_metadata.get(&node_id));
 		let mut inputs = Vec::with_capacity(node.inputs.len());
 		for (index, input) in node.inputs.iter().enumerate() {
 			let is_exposed = input.is_exposed_to_frontend(breadcrumb_network_path.is_empty());
@@ -2219,7 +2409,7 @@ fn frontend_inputs_lookup(breadcrumb_network_path: &[NodeId], network_interface:
 			}
 
 			// Get the name from the metadata here (since it also requires a reference to the `network_interface`)
-			let name = node_metadata.and_then(|node_metadata| node_metadata.persistent_metadata.input_names.get(index)).cloned();
+			let name = network_interface.input_name(&node_id, index, breadcrumb_network_path);
 
 			// Get the output connector that feeds into this input (done here as well for simplicity)
 			let connector = OutputConnector::from_input(input);
diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs
index a6d62053f3..95dca57036 100644
--- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs
+++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs
@@ -173,30 +173,17 @@ impl NodeNetworkInterface {
 	}
 
 	pub fn chain_width(&self, node_id: &NodeId, network_path: &[NodeId]) -> u32 {
-		if self.number_of_inputs(node_id, network_path) > 1 {
+		if self.number_of_displayed_inputs(node_id, network_path) > 1 {
 			let mut last_chain_node_distance = 0u32;
 			// Iterate upstream from the layer, and get the number of nodes distance to the last node with Position::Chain
 			for (index, node_id) in self
-				.upstream_flow_back_from_nodes(vec![*node_id], network_path, FlowType::HorizontalFlow)
+				.upstream_flow_back_from_nodes(vec![*node_id], network_path, FlowType::HorizontalPrimaryOutputFlow)
 				.skip(1)
 				.enumerate()
 				.collect::<Vec<_>>()
 			{
-				let Some(network_metadata) = self.network_metadata(network_path) else {
-					log::error!("Could not get nested network_metadata in chain_width");
-					return 0;
-				};
 				// Check if the node is positioned as a chain
-				let is_chain = network_metadata
-					.persistent_metadata
-					.node_metadata
-					.get(&node_id)
-					.map(|node_metadata| &node_metadata.persistent_metadata.node_type_metadata)
-					.is_some_and(|node_type_metadata| match node_type_metadata {
-						NodeTypePersistentMetadata::Node(node_persistent_metadata) => matches!(node_persistent_metadata.position, NodePosition::Chain),
-						_ => false,
-					});
-				if is_chain {
+				if self.is_chain(&node_id, network_path) {
 					last_chain_node_distance = (index as u32) + 1;
 				} else {
 					return last_chain_node_distance * 7 + 1;
@@ -269,6 +256,7 @@ impl NodeNetworkInterface {
 			encapsulating_node.inputs.len()
 		} else {
 			// There is one(?) import to the document network, but the imports are not displayed
+			// I think this is zero now that the scope system has been added
 			1
 		}
 	}
@@ -283,19 +271,31 @@ impl NodeNetworkInterface {
 		}
 	}
 
-	fn number_of_inputs(&self, node_id: &NodeId, network_path: &[NodeId]) -> usize {
+	fn number_of_displayed_inputs(&self, node_id: &NodeId, network_path: &[NodeId]) -> usize {
 		let Some(network) = self.network(network_path) else {
-			log::error!("Could not get network in number_of_input");
+			log::error!("Could not get network in number_of_displayed_inputs");
 			return 0;
 		};
 		let Some(node) = network.nodes.get(node_id) else {
-			log::error!("Could not get node {node_id} in number_of_input");
+			log::error!("Could not get node {node_id} in number_of_displayed_inputs");
 			return 0;
 		};
 		node.inputs.iter().filter(|input| input.is_exposed_to_frontend(network_path.is_empty())).count()
 	}
 
-	fn number_of_outputs(&self, node_id: &NodeId, network_path: &[NodeId]) -> usize {
+	pub fn number_of_inputs(&self, node_id: &NodeId, network_path: &[NodeId]) -> usize {
+		let Some(network) = self.network(network_path) else {
+			log::error!("Could not get network in number_of_inputs");
+			return 0;
+		};
+		let Some(node) = network.nodes.get(node_id) else {
+			log::error!("Could not get node {node_id} in number_of_inputs");
+			return 0;
+		};
+		node.inputs.len()
+	}
+
+	pub fn number_of_outputs(&self, node_id: &NodeId, network_path: &[NodeId]) -> usize {
 		let Some(network) = self.network(network_path) else {
 			log::error!("Could not get network in number_of_outputs");
 			return 0;
@@ -314,7 +314,7 @@ impl NodeNetworkInterface {
 	/// Creates a copy for each node by disconnecting nodes which are not connected to other copied nodes.
 	/// Returns an iterator of all persistent metadata for a node and their ids
 	pub fn copy_nodes<'a>(&'a mut self, new_ids: &'a HashMap<NodeId, NodeId>, network_path: &'a [NodeId]) -> impl Iterator<Item = (NodeId, NodeTemplate)> + 'a {
-		new_ids
+		let mut new_nodes = new_ids
 			.iter()
 			.filter_map(|(node_id, &new)| {
 				self.create_node_template(node_id, network_path).and_then(|mut node_template| {
@@ -342,7 +342,7 @@ impl NodeNetworkInterface {
 						};
 					}
 
-					// Ensure a chain node has a selected downstream layer, and set absolute nodes to a chain if there is a downstream layer
+					// If a chain node does not have a selected downstream layer, then set the position to absolute
 					let downstream_layer = self.downstream_layer(node_id, network_path);
 					if downstream_layer.map_or(true, |downstream_layer| new_ids.keys().all(|key| *key != downstream_layer.to_node())) {
 						let Some(position) = self.position(node_id, network_path) else {
@@ -352,20 +352,6 @@ impl NodeNetworkInterface {
 						node_template.persistent_node_metadata.node_type_metadata = NodeTypePersistentMetadata::Node(NodePersistentMetadata {
 							position: NodePosition::Absolute(position),
 						});
-					} else if !self.is_layer(node_id, network_path) {
-						if let Some(downstream_layer) = downstream_layer {
-							if self
-								.upstream_flow_back_from_nodes(vec![downstream_layer.to_node()], network_path, FlowType::HorizontalFlow)
-								.skip(1)
-								.take_while(|node_id| !self.is_layer(node_id, network_path))
-								.any(|upstream_node| upstream_node == *node_id)
-							{
-								match &mut node_template.persistent_node_metadata.node_type_metadata {
-									NodeTypePersistentMetadata::Node(node_metadata) => node_metadata.position = NodePosition::Chain,
-									NodeTypePersistentMetadata::Layer(_) => log::error!("Node is not be a layer"),
-								};
-							}
-						}
 					}
 
 					// Shift all absolute nodes 2 to the right and 2 down
@@ -383,12 +369,25 @@ impl NodeNetworkInterface {
 						}
 					}
 
-					Some((new, node_id, node_template))
+					Some((new, *node_id, node_template))
 				})
 			})
-			.collect::<Vec<_>>()
-			.into_iter()
-			.map(move |(new, node_id, node)| (new, self.map_ids(node, node_id, new_ids, network_path)))
+			.collect::<Vec<_>>();
+
+		for old_id in new_nodes.iter().map(|(_, old_id, _)| *old_id).collect::<Vec<_>>() {
+			// Try set all selected nodes upstream of a layer to be chain nodes
+			if self.is_layer(&old_id, network_path) {
+				for valid_upstream_chain_node in self.valid_upstream_chain_nodes(&InputConnector::node(old_id, 1), network_path) {
+					if let Some(node_template) = new_nodes.iter_mut().find_map(|(_, old_id, template)| (*old_id == valid_upstream_chain_node).then_some(template)) {
+						match &mut node_template.persistent_node_metadata.node_type_metadata {
+							NodeTypePersistentMetadata::Node(node_metadata) => node_metadata.position = NodePosition::Chain,
+							NodeTypePersistentMetadata::Layer(_) => log::error!("Node cannot be a layer"),
+						};
+					}
+				}
+			}
+		}
+		new_nodes.into_iter().map(move |(new, node_id, node)| (new, self.map_ids(node, &node_id, new_ids, network_path)))
 	}
 
 	/// Create a node template from an existing node.
@@ -838,6 +837,32 @@ impl NodeNetworkInterface {
 		})
 	}
 
+	pub fn frontend_import_modify(&mut self, network_path: &[NodeId]) -> Option<(i32, i32)> {
+		(!network_path.is_empty())
+			.then(|| {
+				self.modify_import_export(network_path).and_then(|modify_import_export_click_target| {
+					modify_import_export_click_target
+						.add_export
+						.bounding_box()
+						.map(|bounding_box| (bounding_box[0].x as i32, bounding_box[0].y as i32))
+				})
+			})
+			.flatten()
+	}
+
+	pub fn frontend_export_modify(&mut self, network_path: &[NodeId]) -> Option<(i32, i32)> {
+		(!network_path.is_empty())
+			.then(|| {
+				self.modify_import_export(network_path).and_then(|modify_import_export_click_target| {
+					modify_import_export_click_target
+						.add_import
+						.bounding_box()
+						.map(|bounding_box| (bounding_box[0].x as i32, bounding_box[0].y as i32))
+				})
+			})
+			.flatten()
+	}
+
 	pub fn height_from_click_target(&mut self, node_id: &NodeId, network_path: &[NodeId]) -> Option<u32> {
 		let mut node_height: Option<u32> = self
 			.node_click_targets(node_id, network_path)
@@ -1023,7 +1048,16 @@ impl NodeNetworkInterface {
 			.and_then(|node_metadata| node_metadata.persistent_metadata.reference.as_ref().map(|reference| reference.to_string()))
 	}
 
-	pub fn display_name(&self, node_id: &NodeId, network_path: &[NodeId]) -> String {
+	// None means that the type will be used
+	pub fn input_name(&self, node_id: &NodeId, index: usize, network_path: &[NodeId]) -> Option<String> {
+		self.node_metadata(node_id, network_path)
+			.and_then(|node_metadata| node_metadata.persistent_metadata.input_names.get(index))
+			.cloned()
+			.filter(|s| !s.is_empty())
+	}
+
+	// Use frontend display name instead
+	fn display_name(&self, node_id: &NodeId, network_path: &[NodeId]) -> String {
 		let Some(node_metadata) = self.node_metadata(node_id, network_path) else {
 			log::error!("Could not get node_metadata in display_name");
 			return "".to_string();
@@ -1889,6 +1923,109 @@ impl NodeNetworkInterface {
 		network_metadata.transient_metadata.import_export_ports.unload();
 	}
 
+	pub fn modify_import_export(&mut self, network_path: &[NodeId]) -> Option<&ModifyImportExportClickTarget> {
+		let Some(network_metadata) = self.network_metadata(network_path) else {
+			log::error!("Could not get nested network_metadata in modify_import_export");
+			return None;
+		};
+		if !network_metadata.transient_metadata.modify_import_export.is_loaded() {
+			self.load_modify_import_export(network_path);
+		}
+		let Some(network_metadata) = self.network_metadata(network_path) else {
+			log::error!("Could not get nested network_metadata in modify_import_export");
+			return None;
+		};
+		let TransientMetadata::Loaded(click_targets) = &network_metadata.transient_metadata.modify_import_export else {
+			log::error!("could not load modify import export ports");
+			return None;
+		};
+		Some(click_targets)
+	}
+
+	pub fn load_modify_import_export(&mut self, network_path: &[NodeId]) {
+		let Some(all_nodes_bounding_box) = self.all_nodes_bounding_box(network_path).cloned() else {
+			log::error!("Could not get all nodes bounding box in load_export_ports");
+			return;
+		};
+		let Some(rounded_network_edge_distance) = self.rounded_network_edge_distance(network_path).cloned() else {
+			log::error!("Could not get rounded_network_edge_distance in load_export_ports");
+			return;
+		};
+		let Some(network_metadata) = self.network_metadata(network_path) else {
+			log::error!("Could not get nested network_metadata in load_export_ports");
+			return;
+		};
+		let Some(network) = self.network(network_path) else {
+			log::error!("Could not get current network in load_export_ports");
+			return;
+		};
+
+		let viewport_top_right = network_metadata
+			.persistent_metadata
+			.navigation_metadata
+			.node_graph_to_viewport
+			.inverse()
+			.transform_point2(rounded_network_edge_distance.exports_to_edge_distance);
+		let offset_from_top_right = if network
+			.exports
+			.first()
+			.is_some_and(|export| export.as_node().is_some_and(|export_node| self.is_layer(&export_node, network_path)))
+		{
+			DVec2::new(2. * GRID_SIZE as f64, -2. * GRID_SIZE as f64)
+		} else {
+			DVec2::new(4. * GRID_SIZE as f64, 0.)
+		};
+
+		let bounding_box_top_right = DVec2::new((all_nodes_bounding_box[1].x / 24. + 0.5).floor() * 24., (all_nodes_bounding_box[0].y / 24. + 0.5).floor() * 24.) + offset_from_top_right;
+		let export_top_right = DVec2::new(viewport_top_right.x.max(bounding_box_top_right.x), viewport_top_right.y.min(bounding_box_top_right.y));
+		let add_export_center = export_top_right + DVec2::new(0., network.exports.len() as f64 * 24.);
+		let add_export = ClickTarget::new(Subpath::new_ellipse(add_export_center - DVec2::new(8., 8.), add_export_center + DVec2::new(8., 8.)), 0.);
+
+		let viewport_top_left = network_metadata
+			.persistent_metadata
+			.navigation_metadata
+			.node_graph_to_viewport
+			.inverse()
+			.transform_point2(rounded_network_edge_distance.imports_to_edge_distance);
+
+		let offset_from_top_left = if network
+			.exports
+			.first()
+			.is_some_and(|export| export.as_node().is_some_and(|export_node| self.is_layer(&export_node, network_path)))
+		{
+			DVec2::new(-4. * GRID_SIZE as f64, -2. * GRID_SIZE as f64)
+		} else {
+			DVec2::new(-4. * GRID_SIZE as f64, 0.)
+		};
+
+		let bounding_box_top_left = DVec2::new((all_nodes_bounding_box[0].x / 24. + 0.5).floor() * 24., (all_nodes_bounding_box[0].y / 24. + 0.5).floor() * 24.) + offset_from_top_left;
+		let import_top_left = DVec2::new(viewport_top_left.x.min(bounding_box_top_left.x), viewport_top_left.y.min(bounding_box_top_left.y));
+		let add_import_center = import_top_left + DVec2::new(0., self.number_of_displayed_imports(network_path) as f64 * 24.);
+		let add_import = ClickTarget::new(Subpath::new_ellipse(add_import_center - DVec2::new(8., 8.), add_import_center + DVec2::new(8., 8.)), 0.);
+
+		let Some(network_metadata) = self.network_metadata_mut(network_path) else {
+			log::error!("Could not get current network in load_modify_import_export");
+			return;
+		};
+
+		network_metadata.transient_metadata.modify_import_export = TransientMetadata::Loaded(ModifyImportExportClickTarget {
+			add_export,
+			add_import,
+			remove_imports: Vec::new(),
+			remove_exports: Vec::new(),
+			move_import: Vec::new(),
+			move_export: Vec::new(),
+		});
+	}
+
+	fn unload_modify_import_export(&mut self, network_path: &[NodeId]) {
+		let Some(network_metadata) = self.network_metadata_mut(network_path) else {
+			log::error!("Could not get nested network_metadata in unload_export_ports");
+			return;
+		};
+		network_metadata.transient_metadata.modify_import_export.unload();
+	}
+
 	pub fn rounded_network_edge_distance(&mut self, network_path: &[NodeId]) -> Option<&NetworkEdgeDistance> {
 		let Some(network_metadata) = self.network_metadata(network_path) else {
 			log::error!("Could not get nested network_metadata in rounded_network_edge_distance");
@@ -2054,24 +2191,35 @@ impl NodeNetworkInterface {
 		for (current_node_id, node) in network.nodes.iter() {
 			for (input_index, input) in node.inputs.iter().enumerate() {
 				if let NodeInput::Node { node_id, output_index, .. } = input {
-					let outward_wires_entry = outward_wires
-						.get_mut(&OutputConnector::node(*node_id, *output_index))
-						.expect("All output connectors should be initialized");
+					// If this errors then there is an input to a node that does not exist
+					let outward_wires_entry = outward_wires.get_mut(&OutputConnector::node(*node_id, *output_index)).unwrap_or_else(|| {
+						panic!(
+							"Output connector {:?} should be initialized for each node output from a node",
+							OutputConnector::node(*node_id, *output_index)
+						)
+					});
 					outward_wires_entry.push(InputConnector::node(*current_node_id, input_index));
 				} else if let NodeInput::Network { import_index, .. } = input {
-					let outward_wires_entry = outward_wires.get_mut(&OutputConnector::Import(*import_index)).expect("All output connectors should be initialized");
+					let outward_wires_entry = outward_wires
+						.get_mut(&OutputConnector::Import(*import_index))
+						.unwrap_or_else(|| panic!("Output connector {:?} should be initialized for each import from a node", OutputConnector::Import(*import_index)));
 					outward_wires_entry.push(InputConnector::node(*current_node_id, input_index));
 				}
 			}
 		}
 		for (export_index, export) in network.exports.iter().enumerate() {
 			if let NodeInput::Node { node_id, output_index, .. } = export {
-				let outward_wires_entry = outward_wires
-					.get_mut(&OutputConnector::node(*node_id, *output_index))
-					.expect("All output connectors should be initialized");
+				let outward_wires_entry = outward_wires.get_mut(&OutputConnector::node(*node_id, *output_index)).unwrap_or_else(|| {
+					panic!(
+						"Output connector {:?} should be initialized for each node input from exports",
+						OutputConnector::node(*node_id, *output_index)
+					)
+				});
 				outward_wires_entry.push(InputConnector::Export(export_index));
 			} else if let NodeInput::Network { import_index, .. } = export {
-				let outward_wires_entry = outward_wires.get_mut(&OutputConnector::Import(*import_index)).expect("All output connectors should be initialized");
+				let outward_wires_entry = outward_wires
+					.get_mut(&OutputConnector::Import(*import_index))
+					.unwrap_or_else(|| panic!("Output connector {:?} should be initialized between imports and exports", OutputConnector::Import(*import_index)));
 				outward_wires_entry.push(InputConnector::Export(export_index));
 			}
 		}
@@ -2242,7 +2390,7 @@ impl NodeNetworkInterface {
 				output_row_count += 1;
 			}
 
-			let height = std::cmp::max(input_row_count, output_row_count) as u32 * crate::consts::GRID_SIZE;
+			let height = input_row_count.max(output_row_count).max(1) as u32 * crate::consts::GRID_SIZE;
 			let width = 5 * crate::consts::GRID_SIZE;
 			let node_click_target_top_left = node_top_left + DVec2::new(0., 12.);
 			let node_click_target_bottom_right = node_click_target_top_left + DVec2::new(width as f64, height as f64);
@@ -2560,7 +2708,7 @@ impl NodeNetworkInterface {
 	}
 
 	pub fn is_eligible_to_be_layer(&mut self, node_id: &NodeId, network_path: &[NodeId]) -> bool {
-		let input_count = self.number_of_inputs(node_id, network_path);
+		let input_count = self.number_of_displayed_inputs(node_id, network_path);
 		let output_count = self.number_of_outputs(node_id, network_path);
 
 		self.node_metadata(node_id, network_path)
@@ -2762,11 +2910,17 @@ impl NodeNetworkInterface {
 			log::error!("Could not get nested network_metadata in selected_nodes_bounding_box_viewport");
 			return None;
 		};
+		let node_graph_to_viewport = network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport;
+		self.selected_nodes_bounding_box(network_path)
+			.map(|[a, b]| [node_graph_to_viewport.transform_point2(a), node_graph_to_viewport.transform_point2(b)])
+	}
+
+	/// Get the combined bounding box of the click targets of the selected nodes in the node graph in layer space
+	pub fn selected_nodes_bounding_box(&mut self, network_path: &[NodeId]) -> Option<[DVec2; 2]> {
 		let Some(selected_nodes) = self.selected_nodes(network_path) else {
 			log::error!("Could not get selected nodes in selected_nodes_bounding_box_viewport");
 			return None;
 		};
-		let node_graph_to_viewport = network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport;
 		selected_nodes
 			.selected_nodes()
 			.cloned()
@@ -2774,7 +2928,7 @@ impl NodeNetworkInterface {
 			.iter()
 			.filter_map(|node_id| {
 				self.node_click_targets(node_id, network_path)
-					.and_then(|transient_node_metadata| transient_node_metadata.node_click_target.bounding_box_with_transform(node_graph_to_viewport))
+					.and_then(|transient_node_metadata| transient_node_metadata.node_click_target.bounding_box())
 			})
 			.reduce(graphene_core::renderer::Quad::combine_bounds)
 	}
@@ -2979,6 +3133,7 @@ impl NodeNetworkInterface {
 		};
 		network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport = transform;
 		self.unload_import_export_ports(network_path);
+		self.unload_modify_import_export(network_path);
 	}
 
 	// This should be run whenever the pan ends, a zoom occurs, or the network is opened
@@ -2990,6 +3145,7 @@ impl NodeNetworkInterface {
 		network_metadata.persistent_metadata.navigation_metadata.node_graph_top_right = node_graph_top_right;
 		self.unload_rounded_network_edge_distance(network_path);
 		self.unload_import_export_ports(network_path);
+		self.unload_modify_import_export(network_path);
 	}
 
 	pub fn vector_modify(&mut self, node_id: &NodeId, modification_type: VectorModificationType) {
@@ -3010,13 +3166,6 @@ impl NodeNetworkInterface {
 
 	/// Inserts a new export at insert index. If the insert index is -1 it is inserted at the end. The output_name is used by the encapsulating node.
 	pub fn add_export(&mut self, default_value: TaggedValue, insert_index: isize, output_name: String, network_path: &[NodeId]) {
-		// Set the parent node (if it exists) to be a non layer if it is no longer eligible to be a layer
-		if let Some(parent_id) = network_path.last().cloned() {
-			if !self.is_eligible_to_be_layer(&parent_id, network_path) && self.is_layer(&parent_id, network_path) {
-				self.set_to_node_or_layer(&parent_id, network_path, false);
-			}
-		};
-
 		let Some(network) = self.network_mut(network_path) else {
 			log::error!("Could not get nested network in add_export");
 			return;
@@ -3031,6 +3180,14 @@ impl NodeNetworkInterface {
 
 		self.transaction_modified();
 
+		let mut encapsulating_path = network_path.to_vec();
+		// Set the parent node (if it exists) to be a non layer if it is no longer eligible to be a layer
+		if let Some(parent_id) = encapsulating_path.pop() {
+			if !self.is_eligible_to_be_layer(&parent_id, &encapsulating_path) && self.is_layer(&parent_id, &encapsulating_path) {
+				self.set_to_node_or_layer(&parent_id, &encapsulating_path, false);
+			}
+		};
+
 		// There will not be an encapsulating node if the network is the document network
 		if let Some(encapsulating_node_metadata) = self.encapsulating_node_metadata_mut(network_path) {
 			if insert_index == -1 {
@@ -3042,6 +3199,7 @@ impl NodeNetworkInterface {
 
 		// Update the export ports and outward wires for the current network
 		self.unload_import_export_ports(network_path);
+		self.unload_modify_import_export(network_path);
 		self.unload_outward_wires(network_path);
 
 		// Update the outward wires and bounding box for all nodes in the encapsulating network
@@ -3062,17 +3220,22 @@ impl NodeNetworkInterface {
 	}
 
 	/// Inserts a new input at insert index. If the insert index is -1 it is inserted at the end. The output_name is used by the encapsulating node.
-	pub fn add_input(&mut self, node_id: &NodeId, network_path: &[NodeId], default_value: TaggedValue, exposed: bool, insert_index: isize, input_name: String) {
+	pub fn add_import(&mut self, default_value: TaggedValue, exposed: bool, insert_index: isize, input_name: String, network_path: &[NodeId]) {
+		let mut encapsulating_network_path = network_path.to_vec();
+		let Some(node_id) = encapsulating_network_path.pop() else {
+			log::error!("Cannot add import for document network");
+			return;
+		};
 		// Set the node to be a non layer if it is no longer eligible to be a layer
-		if !self.is_eligible_to_be_layer(node_id, network_path) && self.is_layer(node_id, network_path) {
-			self.set_to_node_or_layer(node_id, network_path, false);
+		if !self.is_eligible_to_be_layer(&node_id, &encapsulating_network_path) && self.is_layer(&node_id, &encapsulating_network_path) {
+			self.set_to_node_or_layer(&node_id, &encapsulating_network_path, false);
 		}
 
-		let Some(network) = self.network_mut(network_path) else {
+		let Some(network) = self.network_mut(&encapsulating_network_path) else {
 			log::error!("Could not get nested network in insert_input");
 			return;
 		};
-		let Some(node) = network.nodes.get_mut(node_id) else {
+		let Some(node) = network.nodes.get_mut(&node_id) else {
 			log::error!("Could not get node in insert_input");
 			return;
 		};
@@ -3086,7 +3249,7 @@ impl NodeNetworkInterface {
 
 		self.transaction_modified();
 
-		let Some(node_metadata) = self.node_metadata_mut(node_id, network_path) else {
+		let Some(node_metadata) = self.node_metadata_mut(&node_id, &encapsulating_network_path) else {
 			log::error!("Could not get node_metadata in insert_input");
 			return;
 		};
@@ -3103,14 +3266,18 @@ impl NodeNetworkInterface {
 		}
 
 		// Update the click targets for the node
-		self.unload_node_click_targets(node_id, network_path);
+		self.unload_node_click_targets(&node_id, &encapsulating_network_path);
 
 		// Update the transient network metadata bounding box for all nodes and outward wires
-		self.unload_all_nodes_bounding_box(network_path);
+		self.unload_all_nodes_bounding_box(&encapsulating_network_path);
+
+		// Unload the metadata for the nested network
 		self.unload_outward_wires(network_path);
+		self.unload_import_export_ports(network_path);
+		self.unload_modify_import_export(network_path);
 
 		// If the input is inserted as the first input, then it may have affected the document metadata structure
-		if network_path.is_empty() && (insert_index == 0 || insert_index == 1) {
+		if encapsulating_network_path.is_empty() && (insert_index == 0 || insert_index == 1) {
 			self.load_structure();
 		}
 	}
@@ -3582,7 +3749,7 @@ impl NodeNetworkInterface {
 				continue;
 			}
 
-			for input_index in 0..self.number_of_inputs(delete_node_id, network_path) {
+			for input_index in 0..self.number_of_displayed_inputs(delete_node_id, network_path) {
 				self.disconnect_input(&InputConnector::node(*delete_node_id, input_index), network_path);
 			}
 
@@ -3907,6 +4074,7 @@ impl NodeNetworkInterface {
 		self.unload_upstream_node_click_targets(vec![*node_id], network_path);
 		self.unload_all_nodes_bounding_box(network_path);
 		self.unload_import_export_ports(network_path);
+		self.unload_modify_import_export(network_path);
 		self.load_structure();
 	}
 
@@ -4076,74 +4244,84 @@ impl NodeNetworkInterface {
 		self.unload_all_nodes_bounding_box(network_path);
 	}
 
-	/// Input connector is the input to the layer
-	pub fn try_set_upstream_to_chain(&mut self, input_connector: &InputConnector, network_path: &[NodeId]) {
-		// If the new input is to a non layer node on the same y position as the input connector, or the input connector is the side input of a layer, then set it to chain position
-		if let InputConnector::Node {
+	fn valid_upstream_chain_nodes(&mut self, input_connector: &InputConnector, network_path: &[NodeId]) -> Vec<NodeId> {
+		let InputConnector::Node {
 			node_id: input_connector_node_id,
 			input_index,
 		} = input_connector
-		{
-			let mut set_position_to_chain = false;
-			if self.is_layer(input_connector_node_id, network_path) && *input_index == 1 || self.is_chain(input_connector_node_id, network_path) && *input_index == 0 {
-				let mut downstream_id = *input_connector_node_id;
-				for upstream_node in self
-					.upstream_flow_back_from_nodes(vec![*input_connector_node_id], network_path, FlowType::HorizontalFlow)
-					.skip(1)
-					.collect::<Vec<_>>()
-				{
-					if self.is_layer(&upstream_node, network_path) {
-						break;
-					}
-					if !self.has_primary_output(&upstream_node, network_path) {
-						break;
-					}
-					let Some(outward_wires) = self.outward_wires(network_path).and_then(|outward_wires| outward_wires.get(&OutputConnector::node(upstream_node, 0))) else {
-						log::error!("Could not get outward wires in try_set_upstream_to_chain");
-						break;
-					};
-					if outward_wires.len() != 1 {
-						break;
-					}
-					let downstream_position = self.position(&downstream_id, network_path);
-					let upstream_node_position = self.position(&upstream_node, network_path);
-					if let (Some(input_connector_position), Some(new_upstream_node_position)) = (downstream_position, upstream_node_position) {
-						if input_connector_position.y == new_upstream_node_position.y
-							&& new_upstream_node_position.x >= input_connector_position.x - 9
-							&& new_upstream_node_position.x <= input_connector_position.x
-						{
-							set_position_to_chain = true;
-							self.set_chain_position(&upstream_node, network_path);
-						} else {
-							break;
-						}
+		else {
+			return Vec::new();
+		};
+		let mut set_position_to_chain = Vec::new();
+		if self.is_layer(input_connector_node_id, network_path) && *input_index == 1 || self.is_chain(input_connector_node_id, network_path) && *input_index == 0 {
+			let mut downstream_id = *input_connector_node_id;
+			for upstream_node in self
+				.upstream_flow_back_from_nodes(vec![*input_connector_node_id], network_path, FlowType::HorizontalFlow)
+				.skip(1)
+				.collect::<Vec<_>>()
+			{
+				if self.is_layer(&upstream_node, network_path) {
+					break;
+				}
+				if !self.has_primary_output(&upstream_node, network_path) {
+					break;
+				}
+				let Some(outward_wires) = self.outward_wires(network_path).and_then(|outward_wires| outward_wires.get(&OutputConnector::node(upstream_node, 0))) else {
+					log::error!("Could not get outward wires in try_set_upstream_to_chain");
+					break;
+				};
+				if outward_wires.len() != 1 {
+					break;
+				}
+				let downstream_position = self.position(&downstream_id, network_path);
+				let upstream_node_position = self.position(&upstream_node, network_path);
+				if let (Some(input_connector_position), Some(new_upstream_node_position)) = (downstream_position, upstream_node_position) {
+					if input_connector_position.y == new_upstream_node_position.y
+						&& new_upstream_node_position.x >= input_connector_position.x - 9
+						&& new_upstream_node_position.x <= input_connector_position.x
+					{
+						set_position_to_chain.push(upstream_node);
 					} else {
 						break;
 					}
-					downstream_id = upstream_node;
+				} else {
+					break;
 				}
+				downstream_id = upstream_node;
 			}
-			// Reload click target of the layer which used to encapsulate the node
-			if set_position_to_chain {
-				let mut downstream_layer = Some(*input_connector_node_id);
-				while let Some(downstream_layer_id) = downstream_layer {
-					if downstream_layer_id == *input_connector_node_id || !self.is_layer(&downstream_layer_id, network_path) {
-						let Some(outward_wires) = self.outward_wires(network_path) else {
-							log::error!("Could not get outward wires in try_set_upstream_to_chain");
-							downstream_layer = None;
-							break;
-						};
-						downstream_layer = outward_wires
-							.get(&OutputConnector::node(downstream_layer_id, 0))
-							.and_then(|outward_wires| if outward_wires.len() == 1 { outward_wires[0].node_id() } else { None });
-					} else {
+		}
+		set_position_to_chain
+	}
+
+	/// Input connector is the input to the layer
+	pub fn try_set_upstream_to_chain(&mut self, input_connector: &InputConnector, network_path: &[NodeId]) {
+		// If the new input is to a non layer node on the same y position as the input connector, or the input connector is the side input of a layer, then set it to chain position
+
+		let valid_upstream_chain_nodes = self.valid_upstream_chain_nodes(input_connector, network_path);
+
+		for node_id in &valid_upstream_chain_nodes {
+			self.set_chain_position(node_id, network_path);
+		}
+		// Reload click target of the layer which used to encapsulate the node
+		if !valid_upstream_chain_nodes.is_empty() {
+			let mut downstream_layer = Some(input_connector.node_id().unwrap());
+			while let Some(downstream_layer_id) = downstream_layer {
+				if downstream_layer_id == input_connector.node_id().unwrap() || !self.is_layer(&downstream_layer_id, network_path) {
+					let Some(outward_wires) = self.outward_wires(network_path) else {
+						log::error!("Could not get outward wires in try_set_upstream_to_chain");
+						downstream_layer = None;
 						break;
-					}
-				}
-				if let Some(downstream_layer) = downstream_layer {
-					self.unload_node_click_targets(&downstream_layer, network_path);
+					};
+					downstream_layer = outward_wires
+						.get(&OutputConnector::node(downstream_layer_id, 0))
+						.and_then(|outward_wires| if outward_wires.len() == 1 { outward_wires[0].node_id() } else { None });
+				} else {
+					break;
 				}
 			}
+			if let Some(downstream_layer) = downstream_layer {
+				self.unload_node_click_targets(&downstream_layer, network_path);
+			}
 		}
 	}
 
@@ -4200,6 +4378,21 @@ impl NodeNetworkInterface {
 		}
 	}
 
+	pub fn nodes_sorted_top_to_bottom<'a>(&mut self, node_ids: impl Iterator<Item = &'a NodeId>, network_path: &[NodeId]) -> Option<Vec<NodeId>> {
+		let mut node_ids_with_position = node_ids
+			.filter_map(|&node_id| {
+				let Some(position) = self.position(&node_id, network_path) else {
+					log::error!("Could not get position for node {node_id} in shift_selected_nodes");
+					return None;
+				};
+				Some((node_id, position.y))
+			})
+			.collect::<Vec<(NodeId, i32)>>();
+
+		node_ids_with_position.sort_unstable_by(|a, b| a.1.cmp(&b.1));
+		Some(node_ids_with_position.into_iter().map(|(node_id, _)| node_id).collect::<Vec<_>>())
+	}
+
 	/// Used when moving layer by the layer panel, does not run any pushing logic. Moves all sole dependents of the layer as well.
 	/// Ensure that the layer is absolute position.
 	pub fn shift_absolute_node_position(&mut self, layer: &NodeId, shift: IVec2, network_path: &[NodeId]) {
@@ -4287,25 +4480,16 @@ impl NodeNetworkInterface {
 			}
 		}
 
-		let mut node_ids_with_position = node_ids
-			.iter()
-			.filter_map(|&node_id| {
-				let Some(position) = self.position(&node_id, network_path) else {
-					log::error!("Could not get position for node {node_id} in shift_selected_nodes");
-					return None;
-				};
-				Some((node_id, position.y))
-			})
-			.collect::<Vec<(NodeId, i32)>>();
+		let Some(mut sorted_node_ids) = self.nodes_sorted_top_to_bottom(node_ids.iter(), network_path) else {
+			return;
+		};
 
-		if node_ids_with_position.len() != node_ids.len() {
+		if sorted_node_ids.len() != node_ids.len() {
 			log::error!("Could not get position for all nodes in shift_selected_nodes");
 			return;
 		}
 
-		node_ids_with_position.sort_unstable_by(|a, b| a.1.cmp(&b.1));
 		// If shifting down, then the lowest node (greatest y value) should be shifted first
-		let mut sorted_node_ids = node_ids_with_position.into_iter().map(|(node_id, _)| node_id).collect::<Vec<_>>();
 		if direction == Direction::Down {
 			sorted_node_ids.reverse();
 		}
@@ -4884,7 +5068,7 @@ impl NodeNetworkInterface {
 
 	// Insert a node onto a wire. Ensure insert_node_input_index is an exposed input
 	pub fn insert_node_between(&mut self, node_id: &NodeId, input_connector: &InputConnector, insert_node_input_index: usize, network_path: &[NodeId]) {
-		if self.number_of_inputs(node_id, network_path) == 0 {
+		if self.number_of_displayed_inputs(node_id, network_path) == 0 {
 			log::error!("Cannot insert a node onto a wire with no exposed inputs");
 			return;
 		}
@@ -4954,6 +5138,8 @@ pub enum FlowType {
 	PrimaryFlow,
 	/// Iterate over the secondary input (inclusive) for layer nodes and primary input for non layer nodes.
 	HorizontalFlow,
+	/// Same as horizontal flow, but only iterates over connections to primary outputs
+	HorizontalPrimaryOutputFlow,
 	/// Upstream flow starting from the either the node (inclusive) or secondary input of the layer (not inclusive).
 	LayerChildrenUpstreamFlow,
 }
@@ -4976,7 +5162,7 @@ impl<'a> Iterator for FlowIter<'a> {
 			let node_id = self.stack.pop()?;
 
 			if let (Some(document_node), Some(node_metadata)) = (self.network.nodes.get(&node_id), self.network_metadata.persistent_metadata.node_metadata.get(&node_id)) {
-				let skip = if self.flow_type == FlowType::HorizontalFlow && node_metadata.persistent_metadata.is_layer() {
+				let skip = if matches!(self.flow_type, FlowType::HorizontalFlow | FlowType::HorizontalPrimaryOutputFlow) && node_metadata.persistent_metadata.is_layer() {
 					1
 				} else {
 					0
@@ -4984,7 +5170,11 @@ impl<'a> Iterator for FlowIter<'a> {
 				let take = if self.flow_type == FlowType::UpstreamFlow { usize::MAX } else { 1 };
 				let inputs = document_node.inputs.iter().skip(skip).take(take);
 
-				let node_ids = inputs.filter_map(|input| if let NodeInput::Node { node_id, .. } = input { Some(node_id) } else { None });
+				let node_ids = inputs.filter_map(|input| match input {
+					NodeInput::Node { output_index, .. } if self.flow_type == FlowType::HorizontalPrimaryOutputFlow && *output_index != 0 => None,
+					NodeInput::Node { node_id, .. } => Some(node_id),
+					_ => None,
+				});
 
 				self.stack.extend(node_ids);
 
@@ -5311,10 +5501,25 @@ pub struct NodeNetworkTransientMetadata {
 	// pub wire_paths: Vec<WirePath>
 	/// All export connector click targets
 	pub import_export_ports: TransientMetadata<Ports>,
+	/// Click targets for adding, removing, and moving import/export ports
+	pub modify_import_export: TransientMetadata<ModifyImportExportClickTarget>,
 	// Distance to the edges of the network, where the import/export ports are displayed. Rounded to nearest grid space when the panning ends.
 	pub rounded_network_edge_distance: TransientMetadata<NetworkEdgeDistance>,
 }
 
+#[derive(Debug, Clone)]
+pub struct ModifyImportExportClickTarget {
+	// Plus icon that appears below all imports/exports
+	pub add_import: ClickTarget,
+	pub add_export: ClickTarget,
+	// Subtract icon that appears when hovering over an import/export
+	pub remove_imports: Vec<ClickTarget>,
+	pub remove_exports: Vec<ClickTarget>,
+	// Grip drag icon that appears when hovering over an import/export
+	pub move_import: Vec<ClickTarget>,
+	pub move_export: Vec<ClickTarget>,
+}
+
 #[derive(Debug, Clone)]
 pub struct NetworkEdgeDistance {
 	/// The viewport pixel distance between the left edge of the node graph and the exports.
@@ -5368,7 +5573,8 @@ pub struct DocumentNodePersistentMetadata {
 	/// A name chosen by the user for this instance of the node. Empty indicates no given name, in which case the reference name is displayed to the user in italics.
 	#[serde(default)]
 	pub display_name: String,
-	/// TODO: Should input/output names always be the same length as the inputs/outputs of the DocumentNode?
+	/// Input/Output names may not be the same length as the number of inputs/outputs. They are the same as the nested networks Imports/Exports.
+	/// If the string is empty/DNE, then it uses the type.
 	pub input_names: Vec<String>,
 	pub output_names: Vec<String>,
 	/// Indicates to the UI if a primary output should be drawn for this node.
diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs
index 484036e2b0..c658fb1de4 100644
--- a/editor/src/messages/portfolio/portfolio_message_handler.rs
+++ b/editor/src/messages/portfolio/portfolio_message_handler.rs
@@ -465,17 +465,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
 					let Some(ref reference) = node_metadata.persistent_metadata.reference.clone() else {
 						continue;
 					};
-					if let Some(node_definition) = resolve_document_node_type(reference) {
-						let document_node = node_definition.default_node_template().document_node;
-						document.network_interface.set_manual_compostion(node_id, &[], document_node.manual_composition);
-						// if ["Fill", "Stroke", "Splines from Points", "Sample Subpaths", "Sample Points", "Copy to Points", "Path", "Scatter Points"].contains(&reference.as_str()) {
-						// 	document.network_interface.set_implementation(node_id, &[], document_node.implementation);
-						// }
-						document.network_interface.replace_implementation(node_id, &[], document_node.implementation);
-						document
-							.network_interface
-							.replace_implementation_metadata(node_id, &[], node_definition.default_node_template().persistent_node_metadata);
-					}
+
 					let Some(node) = document.network_interface.network(&[]).unwrap().nodes.get(node_id) else {
 						log::error!("could not get node in deserialize_document");
 						continue;
@@ -597,7 +587,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
 
 					// Upgrade artboard name being passed as hidden value input to "To Artboard"
 					if reference == "Artboard" {
-						let label = document.network_interface.display_name(node_id, &[]);
+						let label = document.network_interface.frontend_display_name(node_id, &[]);
 						document
 							.network_interface
 							.set_input(&InputConnector::node(NodeId(0), 1), NodeInput::value(TaggedValue::String(label), false), &[*node_id]);
diff --git a/frontend/src/components/views/Graph.svelte b/frontend/src/components/views/Graph.svelte
index fec5097af8..bfa6592113 100644
--- a/frontend/src/components/views/Graph.svelte
+++ b/frontend/src/components/views/Graph.svelte
@@ -13,8 +13,10 @@
 	import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
 	import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
 	import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
+	import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
 	import RadioInput from "@graphite/components/widgets/inputs/RadioInput.svelte";
 	import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
+	import Separator from "@graphite/components/widgets/labels/Separator.svelte";
 	import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
 	const GRID_COLLAPSE_SPACING = 10;
 	const GRID_SIZE = 24;
@@ -309,6 +311,15 @@
 		});
 		return connectedNode?.isLayer || false;
 	}
+
+	function zipWithUndefined(arr1: FrontendGraphInput[], arr2: FrontendGraphOutput[]) {
+		const maxLength = Math.max(arr1.length, arr2.length);
+		const result = [];
+		for (let i = 0; i < maxLength; i++) {
+			result.push([arr1[i], arr2[i]]);
+		}
+		return result;
+	}
 </script>
 
 <div
@@ -357,6 +368,10 @@
 						disabled={!canBeToggledBetweenNodeAndLayer(contextMenuData.nodeId)}
 					/>
 				</LayoutRow>
+				<Separator type="Section" direction="Vertical" />
+				<LayoutRow class="merge-selected-nodes">
+					<TextButton label="Merge Selected Nodes" action={() => editor.handle.mergeSelectedNodes()} />
+				</LayoutRow>
 			{/if}
 		</LayoutCol>
 	{/if}
@@ -424,6 +439,19 @@
 			</svg>
 			<p class="import-text" style:--offset-left={position.x / 24} style:--offset-top={position.y / 24}>{outputMetadata.name}</p>
 		{/each}
+		{#if $nodeGraph.addImport !== undefined}
+			<div class="plus" style:--offset-left={$nodeGraph.addImport.x / 24} style:--offset-top={$nodeGraph.addImport.y / 24}>
+				<IconButton
+					class={"visibility"}
+					data-visibility-button
+					size={24}
+					icon={"Add"}
+					action={() => {
+						/* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */
+					}}
+				/>
+			</div>
+		{/if}
 		{#each $nodeGraph.exports as { inputMetadata, position }, index}
 			<svg
 				xmlns="http://www.w3.org/2000/svg"
@@ -446,6 +474,19 @@
 			</svg>
 			<p class="export-text" style:--offset-left={position.x / 24} style:--offset-top={position.y / 24}>{inputMetadata.name}</p>
 		{/each}
+		{#if $nodeGraph.addExport !== undefined}
+			<div class="plus" style:--offset-left={$nodeGraph.addExport.x / 24} style:--offset-top={$nodeGraph.addExport.y / 24}>
+				<IconButton
+					class={"visibility"}
+					data-visibility-button
+					size={24}
+					icon={"Add"}
+					action={() => {
+						/* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */
+					}}
+				/>
+			</div>
+		{/if}
 	</div>
 
 	<!-- Layers and nodes -->
@@ -603,7 +644,7 @@
 
 		<!-- Nodes -->
 		{#each Array.from($nodeGraph.nodes.values()).flatMap((node, nodeIndex) => (node.isLayer ? [] : [{ node, nodeIndex }])) as { node, nodeIndex } (nodeIndex)}
-			{@const exposedInputsOutputs = [...node.exposedInputs, ...node.exposedOutputs]}
+			{@const exposedInputsOutputs = zipWithUndefined(node.exposedInputs, node.exposedOutputs)}
 			{@const clipPathId = String(Math.random()).substring(2)}
 			{@const description = (node.reference && $nodeGraph.nodeDescriptions.get(node.reference)) || undefined}
 			<div
@@ -633,9 +674,11 @@
 				<!-- Secondary rows -->
 				{#if exposedInputsOutputs.length > 0}
 					<div class="secondary" class:in-selected-network={$nodeGraph.inSelectedNetwork}>
-						{#each exposedInputsOutputs as secondary, index}
-							<div class={`secondary-row expanded ${index < node.exposedInputs.length ? "input" : "output"}`}>
-								<TextLabel tooltip={secondary.name}>{secondary.name}</TextLabel>
+						{#each exposedInputsOutputs as [input, output]}
+							<div class={`secondary-row expanded ${input !== undefined ? "input" : "output"}`}>
+								<TextLabel tooltip={input !== undefined ? input.name : output.name}>
+									{input !== undefined ? input.name : output.name}
+								</TextLabel>
 							</div>
 						{/each}
 					</div>
@@ -796,6 +839,10 @@
 				line-height: 24px;
 				margin-right: 8px;
 			}
+
+			.merge-selected-nodes {
+				justify-content: center;
+			}
 		}
 
 		.click-targets {
@@ -869,6 +916,14 @@
 				left: calc(var(--offset-left) * 24px);
 			}
 
+			.plus {
+				margin-top: -4px;
+				margin-left: -4px;
+				position: absolute;
+				top: calc(var(--offset-top) * 24px);
+				left: calc(var(--offset-left) * 24px);
+			}
+
 			.export-text {
 				position: absolute;
 				margin-top: 0;
diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte
index 8e4c9b663f..baa3a94b20 100644
--- a/frontend/src/components/widgets/WidgetSpan.svelte
+++ b/frontend/src/components/widgets/WidgetSpan.svelte
@@ -174,7 +174,7 @@
 		{/if}
 		{@const breadcrumbTrailButtons = narrowWidgetProps(component.props, "BreadcrumbTrailButtons")}
 		{#if breadcrumbTrailButtons}
-			<BreadcrumbTrailButtons {...exclude(breadcrumbTrailButtons)} action={(index) => widgetValueCommitAndUpdate(index, index)} />
+			<BreadcrumbTrailButtons {...exclude(breadcrumbTrailButtons)} action={(breadcrumbIndex) => widgetValueCommitAndUpdate(index, breadcrumbIndex)} />
 		{/if}
 		{@const textInput = narrowWidgetProps(component.props, "TextInput")}
 		{#if textInput}
diff --git a/frontend/src/state-providers/node-graph.ts b/frontend/src/state-providers/node-graph.ts
index 2366a30fee..faafc31662 100644
--- a/frontend/src/state-providers/node-graph.ts
+++ b/frontend/src/state-providers/node-graph.ts
@@ -36,6 +36,8 @@ export function createNodeGraphState(editor: Editor) {
 		hasLeftInputWire: new Map<bigint, boolean>(),
 		imports: [] as { outputMetadata: FrontendGraphOutput; position: { x: number; y: number } }[],
 		exports: [] as { inputMetadata: FrontendGraphInput; position: { x: number; y: number } }[],
+		addImport: undefined as { x: number; y: number } | undefined,
+		addExport: undefined as { x: number; y: number } | undefined,
 		nodes: new Map<bigint, FrontendNode>(),
 		wires: [] as FrontendNodeWire[],
 		wirePathInProgress: undefined as WirePath | undefined,
@@ -80,6 +82,8 @@ export function createNodeGraphState(editor: Editor) {
 		update((state) => {
 			state.imports = updateImportsExports.imports;
 			state.exports = updateImportsExports.exports;
+			state.addImport = updateImportsExports.addImport;
+			state.addExport = updateImportsExports.addExport;
 			return state;
 		});
 	});
diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts
index 3b94dabb23..c6fdf9167d 100644
--- a/frontend/src/wasm-communication/messages.ts
+++ b/frontend/src/wasm-communication/messages.ts
@@ -61,6 +61,12 @@ export class UpdateImportsExports extends JsMessage {
 
 	@ExportsToVec2Array
 	readonly exports!: { inputMetadata: FrontendGraphInput; position: XY }[];
+
+	@TupleToVec2
+	readonly addImport!: XY | undefined;
+
+	@TupleToVec2
+	readonly addExport!: XY | undefined;
 }
 
 export class UpdateInSelectedNetwork extends JsMessage {
diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs
index 2742d22fab..bb7f976aab 100644
--- a/frontend/wasm/src/editor_api.rs
+++ b/frontend/wasm/src/editor_api.rs
@@ -569,6 +569,13 @@ impl EditorHandle {
 		self.dispatch(message);
 	}
 
+	/// Merge a group of nodes into a subnetwork
+	#[wasm_bindgen(js_name = mergeSelectedNodes)]
+	pub fn merge_nodes(&self) {
+		let message = NodeGraphMessage::MergeSelectedNodes;
+		self.dispatch(message);
+	}
+
 	/// 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) {
@@ -763,9 +770,7 @@ impl EditorHandle {
 					document
 						.network_interface
 						.replace_implementation(&node_id, &[], DocumentNodeImplementation::proto("graphene_core::ToArtboardNode"));
-					document
-						.network_interface
-						.add_input(&node_id, &[], TaggedValue::IVec2(glam::IVec2::default()), false, 2, "".to_string());
+					document.network_interface.add_import(TaggedValue::IVec2(glam::IVec2::default()), false, 2, "".to_string(), &[node_id]);
 				}
 			}
 		}


Test patch: