Skip to content

Commit 9371528

Browse files
authored
feat(frontend): add copy paste functionality in new builder (#11339)
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.
1 parent 711c439 commit 9371528

File tree

7 files changed

+150
-5
lines changed

7 files changed

+150
-5
lines changed

autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { GraphLoadingBox } from "./components/GraphLoadingBox";
1212
import { BuilderActions } from "../../BuilderActions/BuilderActions";
1313
import { RunningBackground } from "./components/RunningBackground";
1414
import { useGraphStore } from "../../../stores/graphStore";
15+
import { useCopyPasteKeyboard } from "../../../hooks/useCopyPasteKeyboard";
1516

1617
export const Flow = () => {
1718
const nodes = useNodeStore(useShallow((state) => state.nodes));
@@ -27,6 +28,8 @@ export const Flow = () => {
2728
// This hook is used for websocket realtime updates.
2829
useFlowRealtime();
2930

31+
useCopyPasteKeyboard();
32+
3033
const { isFlowContentLoading } = useFlow();
3134
const { isGraphRunning } = useGraphStore();
3235
return (

autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88

99
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
1010
import { XIcon } from "@phosphor-icons/react";
11+
import { memo } from "react";
1112

1213
const CustomEdge = ({
1314
id,
@@ -56,4 +57,4 @@ const CustomEdge = ({
5657
);
5758
};
5859

59-
export default CustomEdge;
60+
export default memo(CustomEdge);

autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import {
66
} from "@xyflow/react";
77
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
88
import { useCallback, useMemo } from "react";
9+
import { useShallow } from "zustand/react/shallow";
910

1011
export const useCustomEdge = () => {
11-
const connections = useEdgeStore((s) => s.connections);
12+
const connections = useEdgeStore(useShallow((s) => s.connections));
1213
const addConnection = useEdgeStore((s) => s.addConnection);
1314
const removeConnection = useEdgeStore((s) => s.removeConnection);
1415

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useEffect } from "react";
2+
import { useCopyPasteStore } from "../stores/copyPasteStore";
3+
4+
export function useCopyPasteKeyboard() {
5+
const { copySelectedNodes, pasteNodes } = useCopyPasteStore();
6+
7+
useEffect(() => {
8+
const handleKeyDown = (event: KeyboardEvent) => {
9+
const activeElement = document.activeElement;
10+
const isInputField =
11+
activeElement?.tagName === "INPUT" ||
12+
activeElement?.tagName === "TEXTAREA" ||
13+
activeElement?.getAttribute("contenteditable") === "true";
14+
15+
if (isInputField) return;
16+
17+
if (
18+
(event.ctrlKey || event.metaKey) &&
19+
(event.key === "c" || event.key === "C")
20+
) {
21+
event.preventDefault();
22+
copySelectedNodes();
23+
}
24+
25+
if (
26+
(event.ctrlKey || event.metaKey) &&
27+
(event.key === "v" || event.key === "V")
28+
) {
29+
event.preventDefault();
30+
pasteNodes();
31+
}
32+
};
33+
34+
window.addEventListener("keydown", handleKeyDown);
35+
return () => {
36+
window.removeEventListener("keydown", handleKeyDown);
37+
};
38+
}, [copySelectedNodes, pasteNodes]);
39+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { create } from "zustand";
2+
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
3+
import { Connection, useEdgeStore } from "./edgeStore";
4+
import { Key, storage } from "@/services/storage/local-storage";
5+
import { useNodeStore } from "./nodeStore";
6+
7+
interface CopyableData {
8+
nodes: CustomNode[];
9+
connections: Connection[];
10+
}
11+
12+
type CopyPasteStore = {
13+
copySelectedNodes: () => void;
14+
pasteNodes: () => void;
15+
};
16+
17+
export const useCopyPasteStore = create<CopyPasteStore>(() => ({
18+
copySelectedNodes: () => {
19+
const { nodes } = useNodeStore.getState();
20+
const { connections } = useEdgeStore.getState();
21+
22+
const selectedNodes = nodes.filter((node) => node.selected);
23+
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
24+
25+
const selectedConnections = connections.filter(
26+
(conn) =>
27+
selectedNodeIds.has(conn.source) && selectedNodeIds.has(conn.target),
28+
);
29+
30+
const copiedData: CopyableData = {
31+
nodes: selectedNodes.map((node) => ({
32+
...node,
33+
data: {
34+
...node.data,
35+
},
36+
})),
37+
connections: selectedConnections,
38+
};
39+
40+
storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData));
41+
},
42+
43+
pasteNodes: () => {
44+
const copiedDataString = storage.get(Key.COPIED_FLOW_DATA);
45+
if (!copiedDataString) return;
46+
47+
const copiedData = JSON.parse(copiedDataString) as CopyableData;
48+
const { addNode } = useNodeStore.getState();
49+
const { addConnection } = useEdgeStore.getState();
50+
51+
const oldToNewIdMap: Record<string, string> = {};
52+
53+
let minX = Infinity,
54+
minY = Infinity,
55+
maxX = -Infinity,
56+
maxY = -Infinity;
57+
58+
copiedData.nodes.forEach((node) => {
59+
minX = Math.min(minX, node.position.x);
60+
minY = Math.min(minY, node.position.y);
61+
maxX = Math.max(maxX, node.position.x);
62+
maxY = Math.max(maxY, node.position.y);
63+
});
64+
65+
const offsetX = 50;
66+
const offsetY = 50;
67+
68+
useNodeStore.setState((state) => ({
69+
nodes: state.nodes.map((node) => ({ ...node, selected: false })),
70+
}));
71+
72+
copiedData.nodes.forEach((node) => {
73+
const { incrementNodeCounter, nodeCounter } = useNodeStore.getState();
74+
incrementNodeCounter();
75+
oldToNewIdMap[node.id] = (nodeCounter + 1).toString();
76+
77+
addNode({
78+
...node,
79+
id: (nodeCounter + 1).toString(),
80+
position: {
81+
x: node.position.x + offsetX,
82+
y: node.position.y + offsetY,
83+
},
84+
selected: true,
85+
});
86+
});
87+
88+
copiedData.connections.forEach((conn) => {
89+
const newSourceId = oldToNewIdMap[conn.source] ?? conn.source;
90+
const newTargetId = oldToNewIdMap[conn.target] ?? conn.target;
91+
92+
addConnection({
93+
source: newSourceId,
94+
target: newTargetId,
95+
sourceHandle: conn.sourceHandle ?? "",
96+
targetHandle: conn.targetHandle ?? "",
97+
});
98+
});
99+
},
100+
}));

autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type EdgeStore = {
1616
setConnections: (connections: Connection[]) => void;
1717
addConnection: (
1818
conn: Omit<Connection, "edge_id"> & { edge_id?: string },
19-
) => Connection;
19+
) => void;
2020
removeConnection: (edge_id: string) => void;
2121
upsertMany: (conns: Connection[]) => void;
2222

autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
6666
}
6767
},
6868

69-
addNode: (node) =>
69+
addNode: (node) => {
7070
set((state) => ({
7171
nodes: [...state.nodes, node],
72-
})),
72+
}));
73+
},
7374
addBlock: (block: BlockInfo) => {
7475
const customNodeData = convertBlockInfoIntoCustomNodeData(block);
7576
get().incrementNodeCounter();

0 commit comments

Comments
 (0)