mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-07 22:33:57 -05:00
feat(frontend): switch copied graph storage from local storage to clipboard (#11578)
### 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
This commit is contained in:
@@ -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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user