From 3982e20faac134d08c855d3089e0fa1fd07a9626 Mon Sep 17 00:00:00 2001 From: Bently Date: Wed, 16 Oct 2024 21:21:01 +0100 Subject: [PATCH] feat(frontend): Allow copy and pasting of blocks between flows (#8346) --- .../frontend/src/components/Flow.tsx | 66 +--------- .../frontend/src/hooks/useCopyPaste.ts | 122 ++++++++++++++++++ 2 files changed, 127 insertions(+), 61 deletions(-) create mode 100644 autogpt_platform/frontend/src/hooks/useCopyPaste.ts diff --git a/autogpt_platform/frontend/src/components/Flow.tsx b/autogpt_platform/frontend/src/components/Flow.tsx index 7d7af8c2de80..7501ca1e4588 100644 --- a/autogpt_platform/frontend/src/components/Flow.tsx +++ b/autogpt_platform/frontend/src/components/Flow.tsx @@ -45,6 +45,7 @@ import RunnerUIWrapper, { import PrimaryActionBar from "@/components/PrimaryActionButton"; import { useToast } from "@/components/ui/use-toast"; import { forceLoad } from "@sentry/nextjs"; +import { useCopyPaste } from "../hooks/useCopyPaste"; // This is for the history, this is the minimum distance a block must move before it is logged // It helps to prevent spamming the history with small movements especially when pressing on a input in a block @@ -459,6 +460,8 @@ const FlowEditor: React.FC<{ history.redo(); }; + const handleCopyPaste = useCopyPaste(getNextNodeId); + const handleKeyDown = useCallback( (event: KeyboardEvent) => { // Prevent copy/paste if any modal is open or if the focus is on an input element @@ -470,68 +473,9 @@ const FlowEditor: React.FC<{ if (isAnyModalOpen || isInputField) return; - if (event.ctrlKey || event.metaKey) { - if (event.key === "c" || event.key === "C") { - // Copy selected nodes - const selectedNodes = nodes.filter((node) => node.selected); - const selectedEdges = edges.filter((edge) => edge.selected); - setCopiedNodes(selectedNodes); - setCopiedEdges(selectedEdges); - } - if (event.key === "v" || event.key === "V") { - // Paste copied nodes - if (copiedNodes.length > 0) { - const oldToNewNodeIDMap: Record = {}; - const pastedNodes = copiedNodes.map((node, index) => { - const newNodeId = (nodeId + index).toString(); - oldToNewNodeIDMap[node.id] = newNodeId; - return { - ...node, - id: newNodeId, - position: { - x: node.position.x + 20, // Offset pasted nodes - y: node.position.y + 20, - }, - data: { - ...node.data, - status: undefined, // Reset status - executionResults: undefined, // Clear output data - }, - }; - }); - setNodes((existingNodes) => - // Deselect copied nodes - existingNodes.map((node) => ({ ...node, selected: false })), - ); - addNodes(pastedNodes); - setNodeId((prevId) => prevId + copiedNodes.length); - - const pastedEdges = copiedEdges.map((edge) => { - const newSourceId = oldToNewNodeIDMap[edge.source] ?? edge.source; - const newTargetId = oldToNewNodeIDMap[edge.target] ?? edge.target; - return { - ...edge, - id: `${newSourceId}_${edge.sourceHandle}_${newTargetId}_${edge.targetHandle}_${Date.now()}`, - source: newSourceId, - target: newTargetId, - }; - }); - addEdges(pastedEdges); - } - } - } + handleCopyPaste(event); }, - [ - isAnyModalOpen, - nodes, - edges, - copiedNodes, - setNodes, - addNodes, - copiedEdges, - addEdges, - nodeId, - ], + [isAnyModalOpen, handleCopyPaste], ); useEffect(() => { diff --git a/autogpt_platform/frontend/src/hooks/useCopyPaste.ts b/autogpt_platform/frontend/src/hooks/useCopyPaste.ts new file mode 100644 index 000000000000..c5c6400fa865 --- /dev/null +++ b/autogpt_platform/frontend/src/hooks/useCopyPaste.ts @@ -0,0 +1,122 @@ +import { useCallback } from "react"; +import { Node, Edge, useReactFlow, useViewport } from "@xyflow/react"; + +export function useCopyPaste(getNextNodeId: () => string) { + const { setNodes, addEdges, getNodes, getEdges } = useReactFlow(); + const { x, y, zoom } = useViewport(); + + const handleCopyPaste = useCallback( + (event: KeyboardEvent) => { + if (event.ctrlKey || event.metaKey) { + if (event.key === "c" || event.key === "C") { + const selectedNodes = getNodes().filter((node) => node.selected); + const selectedEdges = getEdges().filter((edge) => edge.selected); + + const copiedData = { + nodes: selectedNodes.map((node) => ({ + ...node, + data: { + ...node.data, + connections: [], + }, + })), + edges: selectedEdges, + }; + + localStorage.setItem("copiedFlowData", JSON.stringify(copiedData)); + } + if (event.key === "v" || event.key === "V") { + const copiedDataString = localStorage.getItem("copiedFlowData"); + if (copiedDataString) { + const copiedData = JSON.parse(copiedDataString); + const oldToNewIdMap: Record = {}; + + const viewportCenter = { + x: (window.innerWidth / 2 - x) / zoom, + y: (window.innerHeight / 2 - y) / zoom, + }; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + copiedData.nodes.forEach((node: Node) => { + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x); + maxY = Math.max(maxY, node.position.y); + }); + + const offsetX = viewportCenter.x - (minX + maxX) / 2; + const offsetY = viewportCenter.y - (minY + maxY) / 2; + + const pastedNodes = copiedData.nodes.map((node: Node) => { + const newNodeId = getNextNodeId(); + oldToNewIdMap[node.id] = newNodeId; + return { + ...node, + id: newNodeId, + position: { + x: node.position.x + offsetX, + y: node.position.y + offsetY, + }, + data: { + ...node.data, + status: undefined, + executionResults: undefined, + }, + }; + }); + + const pastedEdges = copiedData.edges.map((edge: Edge) => { + const newSourceId = oldToNewIdMap[edge.source] ?? edge.source; + const newTargetId = oldToNewIdMap[edge.target] ?? edge.target; + return { + ...edge, + id: `${newSourceId}_${edge.sourceHandle}_${newTargetId}_${edge.targetHandle}_${Date.now()}`, + source: newSourceId, + target: newTargetId, + }; + }); + + setNodes((existingNodes) => [ + ...existingNodes.map((node) => ({ ...node, selected: false })), + ...pastedNodes, + ]); + addEdges(pastedEdges); + + setNodes((nodes) => { + return nodes.map((node) => { + if (oldToNewIdMap[node.id]) { + const nodeConnections = pastedEdges + .filter( + (edge) => + edge.source === node.id || edge.target === node.id, + ) + .map((edge) => ({ + edge_id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + })); + return { + ...node, + data: { + ...node.data, + connections: nodeConnections, + }, + }; + } + return node; + }); + }); + } + } + } + }, + [setNodes, addEdges, getNodes, getEdges, getNextNodeId, x, y, zoom], + ); + + return handleCopyPaste; +}