From 4f349281bdb929106184fea3b244c3f4effaa448 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:56:12 +0530 Subject: [PATCH] feat(frontend): switch copied graph storage from local storage to clipboard (#11578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changes 🏗️ This PR migrates the copy/paste functionality for graph nodes and edges from local storage to the Clipboard API. This change addresses storage limitations and enables cross-tab copying. https://github.com/user-attachments/assets/6ef55713-ca5b-4562-bb54-4c12db241d30 **Key changes:** - Replaced `localStorage` with `navigator.clipboard` API for copy/paste operations - Added `CLIPBOARD_PREFIX` constant (`"autogpt-flow-data:"`) to identify our clipboard data and prevent conflicts with other clipboard content - Added toast notifications to provide user feedback when copying nodes - Added error handling for clipboard read/write operations with console error logging - Removed dependency on `@/services/storage/local-storage` for copied flow data - Updated `useCopyPaste` hook to use async clipboard operations with proper promise handling **Benefits:** - ✅ Removes local storage size limitations (5-10MB) - ✅ Enables copying nodes between browser tabs/windows - ✅ Provides better user feedback through toast notifications - ✅ More standard approach using native browser Clipboard API ### 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] Select one or more nodes in the flow editor - [x] Press `Ctrl+C` (or `Cmd+C` on Mac) to copy nodes - [x] Verify toast notification appears showing "Copied successfully" with node count - [x] Press `Ctrl+V` (or `Cmd+V` on Mac) to paste nodes - [x] Verify nodes are pasted at viewport center with new unique IDs - [x] Verify edges between copied nodes are also pasted correctly - [x] Test copying nodes in one browser tab and pasting in another tab (should work) - [x] Test copying non-flow data (e.g., text) and verify paste doesn't interfere with flow editor --- .../FlowEditor/Flow/useCopyPaste.ts | 155 ++++++++++-------- 1 file changed, 88 insertions(+), 67 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts index 1114fb778c..7a8213da22 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts @@ -1,24 +1,25 @@ import { useCallback } from "react"; import { useReactFlow } from "@xyflow/react"; -import { Key, storage } from "@/services/storage/local-storage"; import { v4 as uuidv4 } from "uuid"; import { useNodeStore } from "../../../stores/nodeStore"; import { useEdgeStore } from "../../../stores/edgeStore"; import { CustomNode } from "../nodes/CustomNode/CustomNode"; import { CustomEdge } from "../edges/CustomEdge"; +import { useToast } from "@/components/molecules/Toast/use-toast"; interface CopyableData { nodes: CustomNode[]; edges: CustomEdge[]; } +const CLIPBOARD_PREFIX = "autogpt-flow-data:"; + export function useCopyPaste() { - // Only use useReactFlow for viewport (not managed by stores) const { getViewport } = useReactFlow(); + const { toast } = useToast(); const handleCopyPaste = useCallback( (event: KeyboardEvent) => { - // Prevent copy/paste if any modal is open or if the focus is on an input element const activeElement = document.activeElement; const isInputField = activeElement?.tagName === "INPUT" || @@ -28,7 +29,6 @@ export function useCopyPaste() { if (isInputField) return; if (event.ctrlKey || event.metaKey) { - // COPY: Ctrl+C or Cmd+C if (event.key === "c" || event.key === "C") { const { nodes } = useNodeStore.getState(); const { edges } = useEdgeStore.getState(); @@ -53,81 +53,102 @@ export function useCopyPaste() { edges: selectedEdges, }; - storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData)); + const clipboardText = `${CLIPBOARD_PREFIX}${JSON.stringify(copiedData)}`; + navigator.clipboard + .writeText(clipboardText) + .then(() => { + toast({ + title: "Copied successfully", + description: `${selectedNodes.length} node(s) copied to clipboard`, + }); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + }); } - // PASTE: Ctrl+V or Cmd+V if (event.key === "v" || event.key === "V") { - const copiedDataString = storage.get(Key.COPIED_FLOW_DATA); - if (copiedDataString) { - const copiedData = JSON.parse(copiedDataString) as CopyableData; - const oldToNewIdMap: Record = {}; + navigator.clipboard + .readText() + .then((clipboardText) => { + if (!clipboardText.startsWith(CLIPBOARD_PREFIX)) { + return; // Not our data, ignore + } - // Get fresh viewport values at paste time to ensure correct positioning - const { x, y, zoom } = getViewport(); - const viewportCenter = { - x: (window.innerWidth / 2 - x) / zoom, - y: (window.innerHeight / 2 - y) / zoom, - }; + const jsonString = clipboardText.slice(CLIPBOARD_PREFIX.length); + const copiedData = JSON.parse(jsonString) as CopyableData; + 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 = viewportCenter.x - (minX + maxX) / 2; - const offsetY = viewportCenter.y - (minY + maxY) / 2; - - // Deselect existing nodes first - useNodeStore.setState((state) => ({ - nodes: state.nodes.map((node) => ({ ...node, selected: false })), - })); - - // Create and add new nodes with UNIQUE IDs using UUID - copiedData.nodes.forEach((node) => { - const newNodeId = uuidv4(); - oldToNewIdMap[node.id] = newNodeId; - - const newNode: CustomNode = { - ...node, - id: newNodeId, - selected: true, - position: { - x: node.position.x + offsetX, - y: node.position.y + offsetY, - }, + const { x, y, zoom } = getViewport(); + const viewportCenter = { + x: (window.innerWidth / 2 - x) / zoom, + y: (window.innerHeight / 2 - y) / zoom, }; - useNodeStore.getState().addNode(newNode); - }); - - // Add edges with updated source/target IDs - const { addEdge } = useEdgeStore.getState(); - copiedData.edges.forEach((edge) => { - const newSourceId = oldToNewIdMap[edge.source] ?? edge.source; - const newTargetId = oldToNewIdMap[edge.target] ?? edge.target; - - addEdge({ - source: newSourceId, - target: newTargetId, - sourceHandle: edge.sourceHandle ?? "", - targetHandle: edge.targetHandle ?? "", - data: { - ...edge.data, - }, + 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 = viewportCenter.x - (minX + maxX) / 2; + const offsetY = viewportCenter.y - (minY + maxY) / 2; + + // Deselect existing nodes first + useNodeStore.setState((state) => ({ + nodes: state.nodes.map((node) => ({ + ...node, + selected: false, + })), + })); + + // Create and add new nodes with UNIQUE IDs using UUID + copiedData.nodes.forEach((node) => { + const newNodeId = uuidv4(); + oldToNewIdMap[node.id] = newNodeId; + + const newNode: CustomNode = { + ...node, + id: newNodeId, + selected: true, + position: { + x: node.position.x + offsetX, + y: node.position.y + offsetY, + }, + }; + + useNodeStore.getState().addNode(newNode); + }); + + // Add edges with updated source/target IDs + const { addEdge } = useEdgeStore.getState(); + copiedData.edges.forEach((edge) => { + const newSourceId = oldToNewIdMap[edge.source] ?? edge.source; + const newTargetId = oldToNewIdMap[edge.target] ?? edge.target; + + addEdge({ + source: newSourceId, + target: newTargetId, + sourceHandle: edge.sourceHandle ?? "", + targetHandle: edge.targetHandle ?? "", + data: { + ...edge.data, + }, + }); + }); + }) + .catch((error) => { + console.error("Failed to read from clipboard:", error); }); - } } } }, - [getViewport], + [getViewport, toast], ); return handleCopyPaste;