mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-08 22:58:01 -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 { useCallback } from "react";
|
||||||
import { useReactFlow } from "@xyflow/react";
|
import { useReactFlow } from "@xyflow/react";
|
||||||
import { Key, storage } from "@/services/storage/local-storage";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { useNodeStore } from "../../../stores/nodeStore";
|
import { useNodeStore } from "../../../stores/nodeStore";
|
||||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||||
import { CustomEdge } from "../edges/CustomEdge";
|
import { CustomEdge } from "../edges/CustomEdge";
|
||||||
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
|
|
||||||
interface CopyableData {
|
interface CopyableData {
|
||||||
nodes: CustomNode[];
|
nodes: CustomNode[];
|
||||||
edges: CustomEdge[];
|
edges: CustomEdge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLIPBOARD_PREFIX = "autogpt-flow-data:";
|
||||||
|
|
||||||
export function useCopyPaste() {
|
export function useCopyPaste() {
|
||||||
// Only use useReactFlow for viewport (not managed by stores)
|
|
||||||
const { getViewport } = useReactFlow();
|
const { getViewport } = useReactFlow();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const handleCopyPaste = useCallback(
|
const handleCopyPaste = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
// Prevent copy/paste if any modal is open or if the focus is on an input element
|
|
||||||
const activeElement = document.activeElement;
|
const activeElement = document.activeElement;
|
||||||
const isInputField =
|
const isInputField =
|
||||||
activeElement?.tagName === "INPUT" ||
|
activeElement?.tagName === "INPUT" ||
|
||||||
@@ -28,7 +29,6 @@ export function useCopyPaste() {
|
|||||||
if (isInputField) return;
|
if (isInputField) return;
|
||||||
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
// COPY: Ctrl+C or Cmd+C
|
|
||||||
if (event.key === "c" || event.key === "C") {
|
if (event.key === "c" || event.key === "C") {
|
||||||
const { nodes } = useNodeStore.getState();
|
const { nodes } = useNodeStore.getState();
|
||||||
const { edges } = useEdgeStore.getState();
|
const { edges } = useEdgeStore.getState();
|
||||||
@@ -53,81 +53,102 @@ export function useCopyPaste() {
|
|||||||
edges: selectedEdges,
|
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") {
|
if (event.key === "v" || event.key === "V") {
|
||||||
const copiedDataString = storage.get(Key.COPIED_FLOW_DATA);
|
navigator.clipboard
|
||||||
if (copiedDataString) {
|
.readText()
|
||||||
const copiedData = JSON.parse(copiedDataString) as CopyableData;
|
.then((clipboardText) => {
|
||||||
const oldToNewIdMap: Record<string, string> = {};
|
if (!clipboardText.startsWith(CLIPBOARD_PREFIX)) {
|
||||||
|
return; // Not our data, ignore
|
||||||
|
}
|
||||||
|
|
||||||
// Get fresh viewport values at paste time to ensure correct positioning
|
const jsonString = clipboardText.slice(CLIPBOARD_PREFIX.length);
|
||||||
const { x, y, zoom } = getViewport();
|
const copiedData = JSON.parse(jsonString) as CopyableData;
|
||||||
const viewportCenter = {
|
const oldToNewIdMap: Record<string, string> = {};
|
||||||
x: (window.innerWidth / 2 - x) / zoom,
|
|
||||||
y: (window.innerHeight / 2 - y) / zoom,
|
|
||||||
};
|
|
||||||
|
|
||||||
let minX = Infinity,
|
const { x, y, zoom } = getViewport();
|
||||||
minY = Infinity,
|
const viewportCenter = {
|
||||||
maxX = -Infinity,
|
x: (window.innerWidth / 2 - x) / zoom,
|
||||||
maxY = -Infinity;
|
y: (window.innerHeight / 2 - y) / zoom,
|
||||||
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);
|
let minX = Infinity,
|
||||||
});
|
minY = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
// Add edges with updated source/target IDs
|
maxY = -Infinity;
|
||||||
const { addEdge } = useEdgeStore.getState();
|
copiedData.nodes.forEach((node) => {
|
||||||
copiedData.edges.forEach((edge) => {
|
minX = Math.min(minX, node.position.x);
|
||||||
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
|
minY = Math.min(minY, node.position.y);
|
||||||
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
|
maxX = Math.max(maxX, node.position.x);
|
||||||
|
maxY = Math.max(maxY, node.position.y);
|
||||||
addEdge({
|
|
||||||
source: newSourceId,
|
|
||||||
target: newTargetId,
|
|
||||||
sourceHandle: edge.sourceHandle ?? "",
|
|
||||||
targetHandle: edge.targetHandle ?? "",
|
|
||||||
data: {
|
|
||||||
...edge.data,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
return handleCopyPaste;
|
||||||
|
|||||||
Reference in New Issue
Block a user