From c904bb38a7a63567b353f0819921b687c2c40323 Mon Sep 17 00:00:00 2001 From: Kevin Lu Date: Fri, 26 Jun 2026 20:06:11 +0200 Subject: [PATCH 1/5] Implement cursor-based paste --- .../node_graph/node_graph_message_handler.rs | 51 ++++++++++++++++++- .../utility_types/network_interface.rs | 1 + 2 files changed, 50 insertions(+), 2 deletions(-) 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 239d87cfb0..3d9bf07d9a 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 @@ -12,7 +12,9 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions: use crate::messages::portfolio::document::node_graph::utility_types::{ContextMenuData, Direction, FrontendGraphDataType, NodeGraphErrorDiagnostic}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; -use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface, NodeTypePersistentMetadata, OutputConnector, Previewing}; +use crate::messages::portfolio::document::utility_types::network_interface::{ + self, FlowType, InputConnector, LayerPosition, NodeNetworkInterface, NodePosition, NodeTemplate, NodeTypePersistentMetadata, OutputConnector, Previewing, +}; use crate::messages::portfolio::document::utility_types::nodes::{CollapsedLayers, LayerPanelEntry}; use crate::messages::portfolio::document::utility_types::wires::{GraphWireStyle, WirePath, WirePathUpdate, build_vector_wire}; use crate::messages::prelude::*; @@ -776,6 +778,48 @@ impl<'a> MessageHandler> for NodeG return; } + // Get network path of node overlay + let Some(network_metadata) = network_interface.network_metadata(breadcrumb_network_path) else { + log::error!("Could not get network metadata in PasteNodes"); + return; + }; + + let cursor_viewport_location = ipp.mouse.position; + let cursor_to_node_graph = network_metadata + .persistent_metadata + .navigation_metadata + .node_graph_to_viewport + .inverse() + .transform_point2(cursor_viewport_location); + + // Sort the selected nodes by the new id so that we know which node was selected first + data.sort_by(|a, b| a.0.cmp(&b.0)); + + // Get position of the first node selected for copying that has an absolute position. Calculate paste offset from the cursor + // If no nodes with absolute position, then there is no offset from cursor + let copy_position_opt = data.iter().find_map(|(_, template)| match &template.persistent_node_metadata.node_type_metadata { + NodeTypePersistentMetadata::Layer(layer_metadata) => { + if let LayerPosition::Absolute(position) = &layer_metadata.position { + Some(position) + } else { + None + } + } + NodeTypePersistentMetadata::Node(node_metadata) => { + if let NodePosition::Absolute(position) = node_metadata.position() { + Some(position) + } else { + None + } + } + }); + + let copy_position = if let Some(position) = copy_position_opt { position } else { &IVec2::default() }; + let graph_delta = IVec2::new( + ((cursor_to_node_graph.x / 24.).round()) as i32 - copy_position.x, + ((cursor_to_node_graph.y / 24.).round()) as i32 - copy_position.y, + ); + responses.add(DocumentMessage::AddTransaction); let new_ids: HashMap<_, _> = nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect(); @@ -783,7 +827,10 @@ impl<'a> MessageHandler> for NodeG responses.add(NodeGraphMessage::AddNodes { nodes, new_ids: new_ids.clone() }); let nodes: Vec<_> = new_ids.values().copied().collect(); - responses.add(NodeGraphMessage::SelectedNodesSet { nodes }) + responses.add(NodeGraphMessage::SelectedNodesSet { nodes }); + + // Shift nodes based on offset + responses.add(NodeGraphMessage::ShiftSelectedNodesByAmount { graph_delta, rubber_band: true }) } NodeGraphMessage::PointerDown { shift_click, 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 b085432dd4..680c8d2d45 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -6931,6 +6931,7 @@ impl NodePersistentMetadata { pub fn new(position: NodePosition) -> Self { Self { position } } + pub fn position(&self) -> &NodePosition { &self.position } From 82e91faa7e14526a53fb829bdc266008d0213098 Mon Sep 17 00:00:00 2001 From: Kevin Lu Date: Fri, 26 Jun 2026 20:29:39 +0200 Subject: [PATCH 2/5] Use GRID_SIZE constant and copy IVec2 position --- .../document/node_graph/node_graph_message_handler.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 3d9bf07d9a..ee18fc8c0b 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 @@ -814,10 +814,10 @@ impl<'a> MessageHandler> for NodeG } }); - let copy_position = if let Some(position) = copy_position_opt { position } else { &IVec2::default() }; + let copy_position = copy_position_opt.copied().unwrap_or_default(); let graph_delta = IVec2::new( - ((cursor_to_node_graph.x / 24.).round()) as i32 - copy_position.x, - ((cursor_to_node_graph.y / 24.).round()) as i32 - copy_position.y, + ((cursor_to_node_graph.x / GRID_SIZE as f64).round()) as i32 - copy_position.x, + ((cursor_to_node_graph.y / GRID_SIZE as f64).round()) as i32 - copy_position.y, ); responses.add(DocumentMessage::AddTransaction); From 039bce061c3a25ae38fdc66933e984941cdf9cab Mon Sep 17 00:00:00 2001 From: Kevin Lu Date: Fri, 26 Jun 2026 21:16:19 +0200 Subject: [PATCH 3/5] Ran clippy --- .../portfolio/document/node_graph/node_graph_message_handler.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ee18fc8c0b..2c85f8ff37 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 @@ -793,7 +793,7 @@ impl<'a> MessageHandler> for NodeG .transform_point2(cursor_viewport_location); // Sort the selected nodes by the new id so that we know which node was selected first - data.sort_by(|a, b| a.0.cmp(&b.0)); + data.sort_by_key(|a| a.0); // Get position of the first node selected for copying that has an absolute position. Calculate paste offset from the cursor // If no nodes with absolute position, then there is no offset from cursor From b099f53fac06dbed9c176f9f3237ad31e5830b71 Mon Sep 17 00:00:00 2001 From: Kevin Lu Date: Thu, 23 Jul 2026 14:47:40 +0200 Subject: [PATCH 4/5] add paste nodes unit test --- .../node_graph/node_graph_message_handler.rs | 9 +- .../utility_types/network_interface.rs | 83 ++++++++++++++++++- editor/src/test_utils.rs | 12 +++ 3 files changed, 97 insertions(+), 7 deletions(-) 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 2c85f8ff37..4957971f5e 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 @@ -13,7 +13,7 @@ use crate::messages::portfolio::document::node_graph::utility_types::{ContextMen use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; use crate::messages::portfolio::document::utility_types::network_interface::{ - self, FlowType, InputConnector, LayerPosition, NodeNetworkInterface, NodePosition, NodeTemplate, NodeTypePersistentMetadata, OutputConnector, Previewing, + self, FlowType, InputConnector, LayerPosition, NodeNetworkInterface, NodePosition, NodeTypePersistentMetadata, OutputConnector, Previewing, }; use crate::messages::portfolio::document::utility_types::nodes::{CollapsedLayers, LayerPanelEntry}; use crate::messages::portfolio::document::utility_types::wires::{GraphWireStyle, WirePath, WirePathUpdate, build_vector_wire}; @@ -780,7 +780,7 @@ impl<'a> MessageHandler> for NodeG // Get network path of node overlay let Some(network_metadata) = network_interface.network_metadata(breadcrumb_network_path) else { - log::error!("Could not get network metadata in PasteNodes"); + log::error!("Could not get network metadata in InsertNodes"); return; }; @@ -792,12 +792,9 @@ impl<'a> MessageHandler> for NodeG .inverse() .transform_point2(cursor_viewport_location); - // Sort the selected nodes by the new id so that we know which node was selected first - data.sort_by_key(|a| a.0); - // Get position of the first node selected for copying that has an absolute position. Calculate paste offset from the cursor // If no nodes with absolute position, then there is no offset from cursor - let copy_position_opt = data.iter().find_map(|(_, template)| match &template.persistent_node_metadata.node_type_metadata { + let copy_position_opt = nodes.iter().find_map(|(_, template)| match &template.persistent_node_metadata.node_type_metadata { NodeTypePersistentMetadata::Layer(layer_metadata) => { if let LayerPosition::Absolute(position) = &layer_metadata.position { Some(position) 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 680c8d2d45..905185cc79 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -7075,7 +7075,10 @@ pub fn collect_node_resources(node: &DocumentNode, out: &mut HashSet #[cfg(test)] mod network_interface_tests { - use crate::test_utils::test_prelude::*; + use crate::{ + messages::portfolio::document::utility_types::network_interface::{LayerPosition, NodePosition, NodeTypePersistentMetadata}, + test_utils::test_prelude::*, + }; #[tokio::test] async fn copy_isolated_node() { let mut editor = EditorTestUtils::create(); @@ -7105,4 +7108,82 @@ mod network_interface_tests { "duplicated node should exist\nother nodes: {nodes:#?}\norignal {orignal:#?}" ); } + + #[tokio::test] + async fn copy_and_paste_nodes() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let rectangle = editor + .create_node_by_name_and_coordinates(DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER), (0, 0)) + .await; + let circle = editor + .create_node_by_name_and_coordinates(DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::circle::IDENTIFIER), (5, 5)) + .await; + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![circle, rectangle] }).await; + let frontend_messages = editor.handle_message(NodeGraphMessage::Copy).await; + let clipboard = frontend_messages + .into_iter() + .find_map(|msg| match msg { + FrontendMessage::TriggerClipboardWrite { content } => Some(content), + _ => None, + }) + .expect("copy message should be dispatched"); + println!("Clipboard: {clipboard}"); + // Move mouse then paste at new location + let mouse_pos = (24.0, 24.0); + + editor.move_mouse(mouse_pos.0, mouse_pos.1, ModifierKeys::empty(), MouseKeys::empty()).await; + editor.left_mousedown(mouse_pos.0, mouse_pos.1, ModifierKeys::empty()).await; + editor.left_mouseup(mouse_pos.0, mouse_pos.1, ModifierKeys::empty()).await; + editor + .handle_message(ClipboardMessage::ReadClipboard { + content: ClipboardContentRaw::Text(clipboard), + }) + .await; + + // For each pasted node, check that the change in position is based on the delta of the mouse position and original circle. The mouse position in grid coordinates is (1,1). Since the original + // circle is at (5,5), the new circle should be pasted at (1,1), and the new square should be pasted at (-4, -4). Because we preserve selection order, the first selected pasted node should be + // a circle, the second a rectangle. + let new_positions: Vec<&IVec2> = editor + .active_document() + .network_interface + .selected_nodes() + .selected_nodes() + .map(|node_id| { + let node_metadata = &editor + .active_document() + .network_interface + .document_network_metadata() + .persistent_metadata + .node_metadata + .get(node_id) + .expect("Node should exist") + .persistent_metadata + .node_type_metadata; + let new_position = match node_metadata { + NodeTypePersistentMetadata::Layer(layer_metadata) => { + if let LayerPosition::Absolute(position) = &layer_metadata.position { + Some(position) + } else { + None + } + } + NodeTypePersistentMetadata::Node(node_metadata) => { + if let NodePosition::Absolute(position) = node_metadata.position() { + Some(position) + } else { + None + } + } + } + .expect("Pasted node should have a position"); + new_position + }) + .collect(); + println!("positions of pasted nodes: {:?}", new_positions); + // Check position of pasted circle + assert_eq!(new_positions[0], &IVec2::new(1, 1)); + // Check position of pasted rectangle + assert_eq!(new_positions[1], &IVec2::new(-4, -4)); + } } diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index 84f67f0418..34861a9af2 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -330,6 +330,18 @@ impl EditorTestUtils { .await; node_id } + + pub async fn create_node_by_name_and_coordinates(&mut self, node_type: DefinitionIdentifier, xy: (i32, i32)) -> NodeId { + let node_id = NodeId::new(); + self.handle_message(NodeGraphMessage::CreateNodeFromContextMenu { + node_id: Some(node_id), + node_type, + xy: Some(xy), + add_transaction: true, + }) + .await; + node_id + } } pub trait FrontendMessageTestUtils { From 191a88251ae375f74fad12ee69a388490153dd4c Mon Sep 17 00:00:00 2001 From: Kevin Lu Date: Thu, 23 Jul 2026 15:35:15 +0200 Subject: [PATCH 5/5] sort copied nodes by original node id --- .../portfolio/document/utility_types/network_interface.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 905185cc79..8c7a3d3feb 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -372,7 +372,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 + /// Returns an iterator, sorted by original node id, of all persistent metadata for a node and their ids pub fn copy_nodes<'a>(&'a mut self, new_ids: &'a HashMap, network_path: &'a [NodeId]) -> impl Iterator + 'a { let mut new_nodes = new_ids .iter() @@ -442,6 +442,7 @@ impl NodeNetworkInterface { } } } + new_nodes.sort_by_key(|a| a.0); new_nodes.into_iter().map(move |(new, node_id, node)| (new, self.map_ids(node, &node_id, new_ids, network_path))) }