[ui] Refactor node pasting using graph partial serialization

Re-implement node pasting by relying on the graph partial serializer,
to serialize only the subset of selected nodes.
On pasting, use standard graph deserialization and import the content
of the serialized graph in the active graph instance.

Simplify the positioning of pasted nodes to only consider mouse position
or center of the graph, which works well for the major variety of use-cases.
Compute the offset to apply to imported nodes by using the de-serialized
graph content's bounding box.
This commit is contained in:
Yann Lanthony 2025-02-06 16:46:04 +01:00
parent f8f03b0bd5
commit d54ba012a0
4 changed files with 63 additions and 158 deletions

View file

@ -676,41 +676,6 @@ class Graph(BaseObject):
return duplicates
def pasteNodes(self, data, position):
"""
Paste node(s) in the graph with their connections. The connections can only be between
the pasted nodes and not with the rest of the graph.
Args:
data (dict): the dictionary containing the information about the nodes to paste, with their names and
links already updated to be added to the graph
position (list): the list of positions for each node to paste
Returns:
list: the list of Node objects that were pasted and added to the graph
"""
nodes = []
with GraphModification(self):
positionCnt = 0 # always valid because we know the data is sorted the same way as the position list
for key in sorted(data):
nodeType = data[key].get("nodeType", None)
if not nodeType: # this case should never occur, as the data should have been prefiltered first
pass
attributes = {}
attributes.update(data[key].get("inputs", {}))
attributes.update(data[key].get("outputs", {}))
attributes.update(data[key].get("internalInputs", {}))
node = Node(nodeType, position=position[positionCnt], **attributes)
self._addNode(node, key)
nodes.append(node)
positionCnt += 1
self._applyExpr()
return nodes
def outEdges(self, attribute):
""" Return the list of edges starting from the given attribute """
# type: (Attribute,) -> [Edge]

View file

@ -211,15 +211,27 @@ class PasteNodesCommand(GraphCommand):
"""
Handle node pasting in a Graph.
"""
def __init__(self, graph, data, position=None, parent=None):
def __init__(self, graph: "Graph", data: dict, position: Position, parent=None):
super(PasteNodesCommand, self).__init__(graph, parent)
self.data = data
self.position = position
self.nodeNames = []
self.nodeNames: list[str] = []
def redoImpl(self):
data = self.graph.updateImportedProject(self.data)
nodes = self.graph.pasteNodes(data, self.position)
graph = Graph("")
try:
graph._deserialize(self.data)
except:
return False
boundingBoxCenter = self._boundingBoxCenter(graph.nodes)
offset = Position(self.position.x - boundingBoxCenter.x, self.position.y - boundingBoxCenter.y)
for node in graph.nodes:
node.position = Position(node.position.x + offset.x, node.position.y + offset.y)
nodes = self.graph.importGraphContent(graph)
self.nodeNames = [node.name for node in nodes]
self.setText("Paste Node{} ({})".format("s" if len(self.nodeNames) > 1 else "", ", ".join(self.nodeNames)))
return nodes
@ -228,6 +240,24 @@ class PasteNodesCommand(GraphCommand):
for name in self.nodeNames:
self.graph.removeNode(name)
def _boundingBox(self, nodes) -> tuple[int, int, int, int]:
if not nodes:
return (0, 0, 0 , 0)
minX = maxX = nodes[0].x
minY = maxY = nodes[0].y
for node in nodes[1:]:
minX = min(minX, node.x)
minY = min(minY, node.y)
maxX = max(maxX, node.x)
maxY = max(maxY, node.y)
return (minX, minY, maxX, maxY)
def _boundingBoxCenter(self, nodes):
minX, minY, maxX, maxY = self._boundingBox(nodes)
return Position((minX + maxX) / 2, (minY + maxY) / 2)
class ImportProjectCommand(GraphCommand):
"""

View file

@ -1050,126 +1050,43 @@ class UIGraph(QObject):
"""
if not self._nodeSelection.hasSelection():
return ""
serializedSelection = {node.name: node.toDict() for node in self.iterSelectedNodes()}
return json.dumps(serializedSelection, indent=4)
graphData = self._graph.serializePartial(self.getSelectedNodes())
return json.dumps(graphData, indent=4)
@Slot(str, QPoint, bool, result=list)
def pasteNodes(self, clipboardContent, position=None, centerPosition=False) -> list[Node]:
@Slot(str, QPoint, result=list)
def pasteNodes(self, serializedData: str, position: Optional[QPoint]=None) -> list[Node]:
"""
Parse the content of the clipboard to see whether it contains
valid node descriptions. If that is the case, the nodes described
in the clipboard are built with the available information.
Otherwise, nothing is done.
Import string-serialized graph content `serializedData` in the current graph, optionally at the given
`position`.
If the `serializedData` does not contain valid serialized graph data, nothing is done.
This function does not need to be preceded by a call to "getSelectedNodesContent".
Any clipboard content that contains at least a node type with a valid JSON
formatting (dictionary form with double quotes around the keys and values)
can be used to generate a node.
This method can be used with the result of "getSelectedNodesContent".
But it also accepts any serialized content that matches the graph data or graph content format.
For example, it is enough to have:
{"nodeName_1": {"nodeType":"CameraInit"}, "nodeName_2": {"nodeType":"FeatureMatching"}}
in the clipboard to create a default CameraInit and a default FeatureMatching nodes.
in `serializedData` to create a default CameraInit and a default FeatureMatching nodes.
Args:
clipboardContent (str): the string contained in the clipboard, that may or may not contain valid
node information
position (QPoint): the position of the mouse in the Graph Editor when the function was called
centerPosition (bool): whether the provided position is not the top-left corner of the pasting
zone, but its center
serializedData: The string-serialized graph data.
position: The position where to paste the nodes. If None, the nodes are pasted at (0, 0).
Returns:
list: the list of Node objects that were pasted and added to the graph
"""
if not clipboardContent:
return
try:
d = json.loads(clipboardContent)
except ValueError as e:
raise ValueError(e)
graphData = json.loads(serializedData)
except json.JSONDecodeError:
logging.warning("Content is not a valid JSON string.")
return []
if not isinstance(d, dict):
raise ValueError("The clipboard does not contain a valid node. Cannot paste it.")
pos = Position(position.x(), position.y()) if position else Position(0, 0)
result = self.push(commands.PasteNodesCommand(self._graph, graphData, pos))
if result is False:
logging.warning("Content is not a valid graph data.")
return []
return result
# If the clipboard contains a header, then a whole file is contained in the clipboard
# Extract the "graph" part and paste it all, ignore the rest
if d.get("header", None):
d = d.get("graph", None)
if not d:
return
if isinstance(position, QPoint):
position = Position(position.x(), position.y())
if self.hoveredNode:
# If a node is hovered, add an offset to prevent complete occlusion
position = Position(position.x + self.layout.gridSpacing, position.y + self.layout.gridSpacing)
# Get the position of the first node in a zone whose top-left corner is the mouse and the bottom-right
# corner the (x, y) coordinates, with x the maximum of all the nodes' position along the x-axis, and y the
# maximum of all the nodes' position along the y-axis. All nodes with a position will be placed relatively
# to the first node within that zone.
firstNodePos = None
minX = 0
maxX = 0
minY = 0
maxY = 0
for key in sorted(d):
nodeType = d[key].get("nodeType", None)
if not nodeType:
raise ValueError("Invalid node description: no provided node type for '{}'".format(key))
pos = d[key].get("position", None)
if pos:
if not firstNodePos:
firstNodePos = pos
minX = pos[0]
maxX = pos[0]
minY = pos[1]
maxY = pos[1]
else:
if minX > pos[0]:
minX = pos[0]
if maxX < pos[0]:
maxX = pos[0]
if minY > pos[1]:
minY = pos[1]
if maxY < pos[1]:
maxY = pos[1]
# Ensure there will not be an error if no node has a specified position
if not firstNodePos:
firstNodePos = [0, 0]
# Position of the first node within the zone
position = Position(position.x + firstNodePos[0] - minX, position.y + firstNodePos[1] - minY)
if centerPosition: # Center the zone around the mouse's position (mouse's position might be artificial)
maxX = maxX + self.layout.nodeWidth # maxX and maxY are the position of the furthest node's top-left corner
maxY = maxY + self.layout.nodeHeight # We want the position of the furthest node's bottom-right corner
position = Position(position.x - ((maxX - minX) / 2), position.y - ((maxY - minY) / 2))
finalPosition = None
prevPosition = None
positions = []
for key in sorted(d):
currentPosition = d[key].get("position", None)
if not finalPosition:
finalPosition = position
else:
if prevPosition and currentPosition:
# If the nodes both have a position, recreate the distance between them with a different
# starting point
x = finalPosition.x + (currentPosition[0] - prevPosition[0])
y = finalPosition.y + (currentPosition[1] - prevPosition[1])
finalPosition = Position(x, y)
else:
# If either the current node or previous one lacks a position, use a custom one
finalPosition = Position(finalPosition.x + self.layout.gridSpacing + self.layout.nodeWidth, finalPosition.y)
prevPosition = currentPosition
positions.append(finalPosition)
return self.push(commands.PasteNodesCommand(self.graph, d, position=positions))
undoStack = Property(QObject, lambda self: self._undoStack, constant=True)
graphChanged = Signal()

View file

@ -82,25 +82,18 @@ Item {
/// Paste content of clipboard to graph editor and create new node if valid
function pasteNodes() {
var finalPosition = undefined
var centerPosition = false
let finalPosition = undefined;
if (mouseArea.containsMouse) {
if (uigraph.hoveredNode !== null) {
var node = nodeDelegate(uigraph.hoveredNode)
finalPosition = Qt.point(node.mousePosition.x + node.x, node.mousePosition.y + node.y)
} else {
finalPosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)
}
finalPosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY);
} else {
finalPosition = getCenterPosition()
centerPosition = true
finalPosition = getCenterPosition();
}
var copiedContent = Clipboard.getText()
var nodes = uigraph.pasteNodes(copiedContent, finalPosition, centerPosition)
const copiedContent = Clipboard.getText();
const nodes = uigraph.pasteNodes(copiedContent, finalPosition);
if (nodes.length > 0) {
uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes)
uigraph.selectedNode = nodes[0];
uigraph.selectNodes(nodes);
}
}