From 9371528aab157130d5881c2452e3ff8affbc6f5d Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:09:53 +0530 Subject: [PATCH] feat(frontend): add copy paste functionality in new builder (#11339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the new builder, I’ve added a copy-paste functionality using the keyboard. https://github.com/user-attachments/assets/3106ae86-3f47-4807-a598-9c0b166eaae9 ### Changes 🏗️ - Added useCopyPasteKeyboard hook for handling keyboard shortcuts - Created new copyPasteStore for state management - Implemented performance optimizations (memo on CustomEdge) - Updated nodeStore and edgeStore to support the functionality ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] The copy-paste functionality is working correctly as you can see in the video. --- .../build/components/FlowEditor/Flow/Flow.tsx | 3 + .../FlowEditor/edges/CustomEdge.tsx | 3 +- .../FlowEditor/edges/useCustomEdge.ts | 3 +- .../build/hooks/useCopyPasteKeyboard.ts | 39 +++++++ .../(platform)/build/stores/copyPasteStore.ts | 100 ++++++++++++++++++ .../app/(platform)/build/stores/edgeStore.ts | 2 +- .../app/(platform)/build/stores/nodeStore.ts | 5 +- 7 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/hooks/useCopyPasteKeyboard.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/stores/copyPasteStore.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx index 320e55024c..2f769ec84f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx @@ -12,6 +12,7 @@ import { GraphLoadingBox } from "./components/GraphLoadingBox"; import { BuilderActions } from "../../BuilderActions/BuilderActions"; import { RunningBackground } from "./components/RunningBackground"; import { useGraphStore } from "../../../stores/graphStore"; +import { useCopyPasteKeyboard } from "../../../hooks/useCopyPasteKeyboard"; export const Flow = () => { const nodes = useNodeStore(useShallow((state) => state.nodes)); @@ -27,6 +28,8 @@ export const Flow = () => { // This hook is used for websocket realtime updates. useFlowRealtime(); + useCopyPasteKeyboard(); + const { isFlowContentLoading } = useFlow(); const { isGraphRunning } = useGraphStore(); return ( diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx index 5abb894dd4..5887d8d012 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx @@ -8,6 +8,7 @@ import { import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { XIcon } from "@phosphor-icons/react"; +import { memo } from "react"; const CustomEdge = ({ id, @@ -56,4 +57,4 @@ const CustomEdge = ({ ); }; -export default CustomEdge; +export default memo(CustomEdge); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts index 53fd464f7c..35122d9afd 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts @@ -6,9 +6,10 @@ import { } from "@xyflow/react"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useCallback, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; export const useCustomEdge = () => { - const connections = useEdgeStore((s) => s.connections); + const connections = useEdgeStore(useShallow((s) => s.connections)); const addConnection = useEdgeStore((s) => s.addConnection); const removeConnection = useEdgeStore((s) => s.removeConnection); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useCopyPasteKeyboard.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useCopyPasteKeyboard.ts new file mode 100644 index 0000000000..b1b1b34774 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useCopyPasteKeyboard.ts @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useCopyPasteStore } from "../stores/copyPasteStore"; + +export function useCopyPasteKeyboard() { + const { copySelectedNodes, pasteNodes } = useCopyPasteStore(); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const activeElement = document.activeElement; + const isInputField = + activeElement?.tagName === "INPUT" || + activeElement?.tagName === "TEXTAREA" || + activeElement?.getAttribute("contenteditable") === "true"; + + if (isInputField) return; + + if ( + (event.ctrlKey || event.metaKey) && + (event.key === "c" || event.key === "C") + ) { + event.preventDefault(); + copySelectedNodes(); + } + + if ( + (event.ctrlKey || event.metaKey) && + (event.key === "v" || event.key === "V") + ) { + event.preventDefault(); + pasteNodes(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [copySelectedNodes, pasteNodes]); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/copyPasteStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/copyPasteStore.ts new file mode 100644 index 0000000000..2fc4f1f946 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/copyPasteStore.ts @@ -0,0 +1,100 @@ +import { create } from "zustand"; +import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode"; +import { Connection, useEdgeStore } from "./edgeStore"; +import { Key, storage } from "@/services/storage/local-storage"; +import { useNodeStore } from "./nodeStore"; + +interface CopyableData { + nodes: CustomNode[]; + connections: Connection[]; +} + +type CopyPasteStore = { + copySelectedNodes: () => void; + pasteNodes: () => void; +}; + +export const useCopyPasteStore = create(() => ({ + copySelectedNodes: () => { + const { nodes } = useNodeStore.getState(); + const { connections } = useEdgeStore.getState(); + + const selectedNodes = nodes.filter((node) => node.selected); + const selectedNodeIds = new Set(selectedNodes.map((node) => node.id)); + + const selectedConnections = connections.filter( + (conn) => + selectedNodeIds.has(conn.source) && selectedNodeIds.has(conn.target), + ); + + const copiedData: CopyableData = { + nodes: selectedNodes.map((node) => ({ + ...node, + data: { + ...node.data, + }, + })), + connections: selectedConnections, + }; + + storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData)); + }, + + pasteNodes: () => { + const copiedDataString = storage.get(Key.COPIED_FLOW_DATA); + if (!copiedDataString) return; + + const copiedData = JSON.parse(copiedDataString) as CopyableData; + const { addNode } = useNodeStore.getState(); + const { addConnection } = useEdgeStore.getState(); + + const oldToNewIdMap: Record = {}; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + + copiedData.nodes.forEach((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 = 50; + const offsetY = 50; + + useNodeStore.setState((state) => ({ + nodes: state.nodes.map((node) => ({ ...node, selected: false })), + })); + + copiedData.nodes.forEach((node) => { + const { incrementNodeCounter, nodeCounter } = useNodeStore.getState(); + incrementNodeCounter(); + oldToNewIdMap[node.id] = (nodeCounter + 1).toString(); + + addNode({ + ...node, + id: (nodeCounter + 1).toString(), + position: { + x: node.position.x + offsetX, + y: node.position.y + offsetY, + }, + selected: true, + }); + }); + + copiedData.connections.forEach((conn) => { + const newSourceId = oldToNewIdMap[conn.source] ?? conn.source; + const newTargetId = oldToNewIdMap[conn.target] ?? conn.target; + + addConnection({ + source: newSourceId, + target: newTargetId, + sourceHandle: conn.sourceHandle ?? "", + targetHandle: conn.targetHandle ?? "", + }); + }); + }, +})); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts index 4f38a542c2..afcf4d71fc 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts @@ -16,7 +16,7 @@ type EdgeStore = { setConnections: (connections: Connection[]) => void; addConnection: ( conn: Omit & { edge_id?: string }, - ) => Connection; + ) => void; removeConnection: (edge_id: string) => void; upsertMany: (conns: Connection[]) => void; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts index 9e999ce103..e50b2540d1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts @@ -66,10 +66,11 @@ export const useNodeStore = create((set, get) => ({ } }, - addNode: (node) => + addNode: (node) => { set((state) => ({ nodes: [...state.nodes, node], - })), + })); + }, addBlock: (block: BlockInfo) => { const customNodeData = convertBlockInfoIntoCustomNodeData(block); get().incrementNodeCounter();