Repository: griptape-ai/griptape-nodes
Pull request #1256: Paste to Mouse Position
Author: login: kateforsberg13
Created: 2025-05-19T22:20:54Z | Merged: 2025-05-21T17:37:30Z

- updates event to take position
- returns node names for paste so the editor knows which nodes to select.


Diff:
diff --git a/src/griptape_nodes/retained_mode/events/node_events.py b/src/griptape_nodes/retained_mode/events/node_events.py
index ea641ea1d0..c364333a8b 100644
--- a/src/griptape_nodes/retained_mode/events/node_events.py
+++ b/src/griptape_nodes/retained_mode/events/node_events.py
@@ -21,6 +21,13 @@
 from griptape_nodes.retained_mode.events.payload_registry import PayloadRegistry
 
 
+class NewPosition(NamedTuple):
+    """The X and Y position for the node to be copied to. Updates in the node metadata."""
+
+    x: float
+    y: float
+
+
 @dataclass
 @PayloadRegistry.register
 class CreateNodeRequest(RequestPayload):
@@ -338,23 +345,11 @@ class IndirectConnectionSerialization:
     serialized_connection_commands: list[IndirectConnectionSerialization]
 
 
-class NodeToTimestamp(NamedTuple):
-    """A named tuple for storing the node_name and the timestamp passed on selection.
-
-    Fields:
-        node_name: The name of the node selected
-        timestamp: The time the node was selected
-    """
-
-    node_name: str
-    timestamp: str
-
-
 @dataclass
 @PayloadRegistry.register
 class SerializeSelectedNodesToCommandsRequest(WorkflowNotAlteredMixin, RequestPayload):
     # They will be passed with node_name, timestamp
-    nodes_to_serialize: list[NodeToTimestamp]
+    nodes_to_serialize: list[str]
 
 
 @dataclass
@@ -374,7 +369,7 @@ class SerializeSelectedNodesToCommandsResultFailure(WorkflowNotAlteredMixin, Res
 @dataclass
 @PayloadRegistry.register
 class DeserializeSelectedNodesFromCommandsRequest(WorkflowNotAlteredMixin, RequestPayload):
-    pass
+    positions: list[NewPosition] | None = None
 
 
 @dataclass
@@ -410,13 +405,13 @@ class DeserializeNodeFromCommandsResultFailure(ResultPayloadFailure):
 @dataclass
 @PayloadRegistry.register
 class DuplicateSelectedNodesRequest(WorkflowNotAlteredMixin, RequestPayload):
-    nodes_to_duplicate: list[NodeToTimestamp]
+    nodes_to_duplicate: list[str]
 
 
 @dataclass
 @PayloadRegistry.register
 class DuplicateSelectedNodesResultSuccess(WorkflowAlteredMixin, ResultPayloadSuccess):
-    pass
+    node_names: list[str]
 
 
 @dataclass
diff --git a/src/griptape_nodes/retained_mode/managers/node_manager.py b/src/griptape_nodes/retained_mode/managers/node_manager.py
index 5b422d44ec..2608e87471 100644
--- a/src/griptape_nodes/retained_mode/managers/node_manager.py
+++ b/src/griptape_nodes/retained_mode/managers/node_manager.py
@@ -1,6 +1,5 @@
 import logging
 import pickle
-from datetime import datetime
 from typing import Any, cast
 from uuid import uuid4
 
@@ -1704,9 +1703,8 @@ def on_serialize_selected_nodes_to_commands(
         self, request: SerializeSelectedNodesToCommandsRequest
     ) -> ResultPayload:
         """This will take the selected nodes in the Object manager and serialize them into commands."""
+        # These have already been sorted by the time they were selected.
         nodes_to_serialize = request.nodes_to_serialize
-        # Sorts tuples in order based on the timestamp
-        sorted_nodes = sorted(nodes_to_serialize, key=lambda x: datetime.fromisoformat(x[1]))
         # This is node_uuid to the serialization command.
         node_commands = {}
         # Node Name to UUID
@@ -1718,8 +1716,8 @@ def on_serialize_selected_nodes_to_commands(
         unique_uuid_to_values = {}
         # And track how values map into that map.
         serialized_parameter_value_tracker = SerializedParameterValueTracker()
-        selected_node_names = [values[0] for values in sorted_nodes]
-        for node_name, _ in sorted_nodes:
+        selected_node_names = [values[0] for values in nodes_to_serialize]
+        for node_name, _ in nodes_to_serialize:
             result = self.on_serialize_node_to_commands(
                 SerializeNodeToCommandsRequest(
                     node_name=node_name,
@@ -1774,16 +1772,27 @@ def on_serialize_selected_nodes_to_commands(
         GriptapeNodes.ContextManager()._clipboard.parameter_uuid_to_values = unique_uuid_to_values
         return SerializeSelectedNodesToCommandsResultSuccess(final_result)
 
-    def on_deserialize_selected_nodes_from_commands(
+    def on_deserialize_selected_nodes_from_commands(  # noqa: C901
         self,
-        request: DeserializeSelectedNodesFromCommandsRequest,  # noqa: ARG002
+        request: DeserializeSelectedNodesFromCommandsRequest,
     ) -> ResultPayload:
         commands = GriptapeNodes.ContextManager()._clipboard.node_commands
         if commands is None:
             return DeserializeSelectedNodesFromCommandsResultFailure()
         connections = commands.serialized_connection_commands
         node_uuid_to_name = {}
-        for node_command in commands.serialized_node_commands:
+        # Enumerate because positions is in the same order as the node commands.
+        for i, node_command in enumerate(commands.serialized_node_commands):
+            if request.positions is not None and len(request.positions) > i:
+                if node_command.create_node_command.metadata is None:
+                    node_command.create_node_command.metadata = {
+                        "position": {"x": request.positions[i][0], "y": request.positions[i][1]}
+                    }
+                else:
+                    node_command.create_node_command.metadata["position"] = {
+                        "x": request.positions[i][0],
+                        "y": request.positions[i][1],
+                    }
             result = self.on_deserialize_node_from_commands(
                 DeserializeNodeFromCommandsRequest(serialized_node_commands=node_command)
             )
@@ -1836,12 +1845,12 @@ def on_duplicate_selected_nodes(self, request: DuplicateSelectedNodesRequest) ->
             details = "Failed to serialized selected nodes."
             logger.error(details)
             return DuplicateSelectedNodesResultFailure()
-        GriptapeNodes.handle_request(DeserializeSelectedNodesFromCommandsRequest())
-        if not result.succeeded():
+        result = GriptapeNodes.handle_request(DeserializeSelectedNodesFromCommandsRequest(positions=None))
+        if not isinstance(result, DeserializeSelectedNodesFromCommandsResultSuccess):
             details = "Failed to deserialize selected nodes."
             logger.error(details)
             return DuplicateSelectedNodesResultFailure()
-        return DuplicateSelectedNodesResultSuccess()
+        return DuplicateSelectedNodesResultSuccess(result.node_names)
 
     @staticmethod
     def _manage_alter_details(parameter: Parameter, base_node_obj: BaseNode) -> dict:


Reviews:
user: login: SavagePencil
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T22:31:18Z
user: login: SavagePencil
body: wish this could be kept in the editor, but if it works it works.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-19T22:31:32Z
user: login: kateforsberg13
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T23:50:47Z
user: login: collindutter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T00:16:45Z
user: login: SavagePencil
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T01:03:53Z
user: login: kateforsberg13
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:14:29Z
user: login: kateforsberg13
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:21:35Z
user: login: kateforsberg13
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T22:17:16Z
user: login: SavagePencil
body: Would still like to get X/Y stuff out of the engine, but this works for now.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-21T17:33:57Z

Review comments:
diff_hunk: @@ -374,7 +381,7 @@ class SerializeSelectedNodesToCommandsResultFailure(WorkflowNotAlteredMixin, Res
 @dataclass
 @PayloadRegistry.register
 class DeserializeSelectedNodesFromCommandsRequest(WorkflowNotAlteredMixin, RequestPayload):
-    pass
+    position: NewPosition | None
path: src/griptape_nodes/retained_mode/events/node_events.py
user: login: SavagePencil
body: Can we default this to `None`
created_at: 2025-05-19T22:31:18Z
author_association: MEMBER
diff_hunk: @@ -374,7 +381,7 @@ class SerializeSelectedNodesToCommandsResultFailure(WorkflowNotAlteredMixin, Res
 @dataclass
 @PayloadRegistry.register
 class DeserializeSelectedNodesFromCommandsRequest(WorkflowNotAlteredMixin, RequestPayload):
-    pass
+    position: NewPosition | None
path: src/griptape_nodes/retained_mode/events/node_events.py
user: login: kateforsberg13
body: I had to change this to a dict because the editor was having problems passing in the tuple properly. what do you think? I know named tuple is better. 
created_at: 2025-05-19T23:50:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1774,16 +1774,35 @@ def on_serialize_selected_nodes_to_commands(
         GriptapeNodes.ContextManager()._clipboard.parameter_uuid_to_values = unique_uuid_to_values
         return SerializeSelectedNodesToCommandsResultSuccess(final_result)
 
-    def on_deserialize_selected_nodes_from_commands(
+    def on_deserialize_selected_nodes_from_commands(  # noqa: C901
         self,
-        request: DeserializeSelectedNodesFromCommandsRequest,  # noqa: ARG002
+        request: DeserializeSelectedNodesFromCommandsRequest,
     ) -> ResultPayload:
         commands = GriptapeNodes.ContextManager()._clipboard.node_commands
         if commands is None:
             return DeserializeSelectedNodesFromCommandsResultFailure()
         connections = commands.serialized_connection_commands
         node_uuid_to_name = {}
-        for node_command in commands.serialized_node_commands:
+        new_position = request.position
+        offset = {"x": 0, "y": 0}
+        old_position = {"x": 0, "y": 0}
+        for i, node_command in enumerate(commands.serialized_node_commands):
+            if (
+                new_position is not None
+                and node_command.create_node_command.metadata is not None
+                and "position" in node_command.create_node_command.metadata
+            ):
+                if i == 0:
+                    old_position = node_command.create_node_command.metadata["position"]
+                else:
+                    offset = {
+                        "x": node_command.create_node_command.metadata["position"]["x"] - old_position["x"],
+                        "y": node_command.create_node_command.metadata["position"]["y"] - old_position["y"],
+                    }
+                node_command.create_node_command.metadata["position"] = {
+                    "x": new_position["x"] + offset["x"],
+                    "y": new_position["y"] + offset["y"],
+                }
path: src/griptape_nodes/retained_mode/managers/node_manager.py
user: login: collindutter
body: This seems like an odd thing to have in the engine but I'm sure I'm just missing some context. Can you provide a bit more info on why this is needed?
created_at: 2025-05-20T00:16:45Z
author_association: MEMBER
diff_hunk: @@ -374,7 +374,7 @@ class SerializeSelectedNodesToCommandsResultFailure(WorkflowNotAlteredMixin, Res
 @dataclass
 @PayloadRegistry.register
 class DeserializeSelectedNodesFromCommandsRequest(WorkflowNotAlteredMixin, RequestPayload):
-    pass
+    position: dict[str, float] | None = None
path: src/griptape_nodes/retained_mode/events/node_events.py
user: login: SavagePencil
body: We're doing NamedTuple in other areas. What's the issue?
created_at: 2025-05-20T01:03:49Z
author_association: MEMBER
diff_hunk: @@ -374,7 +374,7 @@ class SerializeSelectedNodesToCommandsResultFailure(WorkflowNotAlteredMixin, Res
 @dataclass
 @PayloadRegistry.register
 class DeserializeSelectedNodesFromCommandsRequest(WorkflowNotAlteredMixin, RequestPayload):
-    pass
+    position: dict[str, float] | None = None
path: src/griptape_nodes/retained_mode/events/node_events.py
user: login: kateforsberg13
body: ```
  File "/Users/aodhanroche/Documents/GitHub/Griptaping/griptape-nodes/src/griptape_nodes/retained_mode/managers/node_manager.py", line 1786, in on_deserialize_selected_nodes_from_commands
    new_position = {"x": request.position.x, "y": request.position.y} if request.position is not None else None
                         ^^^^^^^^^^^^^^^^^^
AttributeError: 'dict' object has no attribute 'x'
make: *** [run] Error 1
aodhanroche@Aodhans-MacBook-Pro griptape-nodes %
```


I can see if we can figure it out to use the named tuple. 
created_at: 2025-05-20T16:14:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1774,16 +1774,35 @@ def on_serialize_selected_nodes_to_commands(
         GriptapeNodes.ContextManager()._clipboard.parameter_uuid_to_values = unique_uuid_to_values
         return SerializeSelectedNodesToCommandsResultSuccess(final_result)
 
-    def on_deserialize_selected_nodes_from_commands(
+    def on_deserialize_selected_nodes_from_commands(  # noqa: C901
         self,
-        request: DeserializeSelectedNodesFromCommandsRequest,  # noqa: ARG002
+        request: DeserializeSelectedNodesFromCommandsRequest,
     ) -> ResultPayload:
         commands = GriptapeNodes.ContextManager()._clipboard.node_commands
         if commands is None:
             return DeserializeSelectedNodesFromCommandsResultFailure()
         connections = commands.serialized_connection_commands
         node_uuid_to_name = {}
-        for node_command in commands.serialized_node_commands:
+        new_position = request.position
+        offset = {"x": 0, "y": 0}
+        old_position = {"x": 0, "y": 0}
+        for i, node_command in enumerate(commands.serialized_node_commands):
+            if (
+                new_position is not None
+                and node_command.create_node_command.metadata is not None
+                and "position" in node_command.create_node_command.metadata
+            ):
+                if i == 0:
+                    old_position = node_command.create_node_command.metadata["position"]
+                else:
+                    offset = {
+                        "x": node_command.create_node_command.metadata["position"]["x"] - old_position["x"],
+                        "y": node_command.create_node_command.metadata["position"]["y"] - old_position["y"],
+                    }
+                node_command.create_node_command.metadata["position"] = {
+                    "x": new_position["x"] + offset["x"],
+                    "y": new_position["y"] + offset["y"],
+                }
path: src/griptape_nodes/retained_mode/managers/node_manager.py
user: login: kateforsberg13
body: When we copy and paste, the nodes need to paste in the new mouse position but keep their relative positions. Since we're already storing node position in metadata instead of in the editor, I'm doing the calculation here in order to move them all based on the new position. We could move to the editor but I'm assuming the editor would have to do calls in order to keep track of the current position of the nodes and that it could be a bunch of extra events.
created_at: 2025-05-20T16:21:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1774,16 +1774,35 @@ def on_serialize_selected_nodes_to_commands(
         GriptapeNodes.ContextManager()._clipboard.parameter_uuid_to_values = unique_uuid_to_values
         return SerializeSelectedNodesToCommandsResultSuccess(final_result)
 
-    def on_deserialize_selected_nodes_from_commands(
+    def on_deserialize_selected_nodes_from_commands(  # noqa: C901
         self,
-        request: DeserializeSelectedNodesFromCommandsRequest,  # noqa: ARG002
+        request: DeserializeSelectedNodesFromCommandsRequest,
     ) -> ResultPayload:
         commands = GriptapeNodes.ContextManager()._clipboard.node_commands
         if commands is None:
             return DeserializeSelectedNodesFromCommandsResultFailure()
         connections = commands.serialized_connection_commands
         node_uuid_to_name = {}
-        for node_command in commands.serialized_node_commands:
+        new_position = request.position
+        offset = {"x": 0, "y": 0}
+        old_position = {"x": 0, "y": 0}
+        for i, node_command in enumerate(commands.serialized_node_commands):
+            if (
+                new_position is not None
+                and node_command.create_node_command.metadata is not None
+                and "position" in node_command.create_node_command.metadata
+            ):
+                if i == 0:
+                    old_position = node_command.create_node_command.metadata["position"]
+                else:
+                    offset = {
+                        "x": node_command.create_node_command.metadata["position"]["x"] - old_position["x"],
+                        "y": node_command.create_node_command.metadata["position"]["y"] - old_position["y"],
+                    }
+                node_command.create_node_command.metadata["position"] = {
+                    "x": new_position["x"] + offset["x"],
+                    "y": new_position["y"] + offset["y"],
+                }
path: src/griptape_nodes/retained_mode/managers/node_manager.py
user: login: kateforsberg13
body: we moved this
created_at: 2025-05-20T22:17:15Z
author_association: CONTRIBUTOR

PR comments:
user: login: kateforsberg13
created_at: 2025-05-20T21:41:37Z
body: We've moved the logic to keep track of the spacing between the nodes to the editor. the editor will now pass a **dict** to us. It'll have the node names of the nodes that were copied and the positions the new nodes should be created in based on the node it is based on. It had to be a dict because we are unsure if the order will always match. 
author_association: CONTRIBUTOR
user: login: kateforsberg13
created_at: 2025-05-20T23:22:04Z
body: We've changed logic again! 
Now - the timestamp sorting is happening on the editor side, and the engine is receiving a sorted list of node names. 
On paste - we're receiving the positions in the same sorted order as the original sorted list.
author_association: CONTRIBUTOR