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.
This commit is contained in:
Abhimanyu Yadav
2025-11-10 21:09:53 +05:30
committed by GitHub
parent 711c439642
commit 9371528aab
7 changed files with 150 additions and 5 deletions

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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);

View File

@@ -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]);
}

View File

@@ -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<CopyPasteStore>(() => ({
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<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 = 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 ?? "",
});
});
},
}));

View File

@@ -16,7 +16,7 @@ type EdgeStore = {
setConnections: (connections: Connection[]) => void;
addConnection: (
conn: Omit<Connection, "edge_id"> & { edge_id?: string },
) => Connection;
) => void;
removeConnection: (edge_id: string) => void;
upsertMany: (conns: Connection[]) => void;

View File

@@ -66,10 +66,11 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
}
},
addNode: (node) =>
addNode: (node) => {
set((state) => ({
nodes: [...state.nodes, node],
})),
}));
},
addBlock: (block: BlockInfo) => {
const customNodeData = convertBlockInfoIntoCustomNodeData(block);
get().incrementNodeCounter();