|
| 1 | +import { useCallback, useRef, useEffect } from "react"; |
| 2 | +import { type Node, type Edge } from "@xyflow/react"; |
| 3 | +import { useLingui } from "@lingui/react"; |
| 4 | + |
| 5 | +interface UseCanvasCopyPasteProps { |
| 6 | + nodes: Node[]; |
| 7 | + edges: Edge[]; |
| 8 | + nodeIdCounter: number; |
| 9 | + setNodes: (nodes: Node[] | ((nodes: Node[]) => Node[])) => void; |
| 10 | + setEdges: (edges: Edge[] | ((edges: Edge[]) => Edge[])) => void; |
| 11 | + setNodeIdCounter: (counter: number | ((counter: number) => number)) => void; |
| 12 | + mousePositionRef: React.RefObject<{ x: number; y: number }>; |
| 13 | +} |
| 14 | + |
| 15 | +export function useCanvasCopyPaste({ |
| 16 | + nodes, |
| 17 | + edges, |
| 18 | + nodeIdCounter, |
| 19 | + setNodes, |
| 20 | + setEdges, |
| 21 | + setNodeIdCounter, |
| 22 | + mousePositionRef, |
| 23 | +}: UseCanvasCopyPasteProps) { |
| 24 | + const { _ } = useLingui(); |
| 25 | + const copiedNodesRef = useRef<Node[]>([]); |
| 26 | + |
| 27 | + // Copy selected nodes |
| 28 | + const copyNodes = useCallback(() => { |
| 29 | + const selectedNodesList = nodes.filter((n) => n.selected); |
| 30 | + if (selectedNodesList.length > 0) { |
| 31 | + copiedNodesRef.current = selectedNodesList; |
| 32 | + } |
| 33 | + }, [nodes]); |
| 34 | + |
| 35 | + // Paste copied nodes |
| 36 | + const pasteNodes = useCallback(() => { |
| 37 | + if (copiedNodesRef.current.length === 0) return; |
| 38 | + |
| 39 | + // Calculate the center of the copied nodes |
| 40 | + const copiedNodePositions = copiedNodesRef.current.map((n) => n.position); |
| 41 | + const minX = Math.min(...copiedNodePositions.map((p) => p.x)); |
| 42 | + const minY = Math.min(...copiedNodePositions.map((p) => p.y)); |
| 43 | + const maxX = Math.max(...copiedNodePositions.map((p) => p.x)); |
| 44 | + const maxY = Math.max(...copiedNodePositions.map((p) => p.y)); |
| 45 | + const centerX = (minX + maxX) / 2; |
| 46 | + const centerY = (minY + maxY) / 2; |
| 47 | + |
| 48 | + // Use mouse position as paste target |
| 49 | + const targetX = mousePositionRef.current.x; |
| 50 | + const targetY = mousePositionRef.current.y; |
| 51 | + |
| 52 | + const newNodes: Node[] = []; |
| 53 | + const oldToNewIdMap = new Map<string, string>(); |
| 54 | + |
| 55 | + // Create new nodes with updated IDs and positions |
| 56 | + copiedNodesRef.current.forEach((copiedNode) => { |
| 57 | + const newId = `node-${nodeIdCounter + newNodes.length}`; |
| 58 | + oldToNewIdMap.set(copiedNode.id, newId); |
| 59 | + |
| 60 | + // Calculate relative position from center and apply to mouse position |
| 61 | + const offsetX = copiedNode.position.x - centerX; |
| 62 | + const offsetY = copiedNode.position.y - centerY; |
| 63 | + |
| 64 | + const newNode: Node = { |
| 65 | + ...copiedNode, |
| 66 | + id: newId, |
| 67 | + position: { |
| 68 | + x: targetX + offsetX, |
| 69 | + y: targetY + offsetY, |
| 70 | + }, |
| 71 | + selected: false, |
| 72 | + data: { |
| 73 | + ...copiedNode.data, |
| 74 | + label: copiedNode.data.label + " (Copy)", |
| 75 | + processing: undefined, |
| 76 | + error: undefined, |
| 77 | + runTrigger: undefined, |
| 78 | + }, |
| 79 | + }; |
| 80 | + newNodes.push(newNode); |
| 81 | + }); |
| 82 | + |
| 83 | + // Copy edges that connect the copied nodes |
| 84 | + const copiedNodeIds = new Set(copiedNodesRef.current.map((n) => n.id)); |
| 85 | + const newEdges: Edge[] = edges |
| 86 | + .filter( |
| 87 | + (edge) => |
| 88 | + copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target) |
| 89 | + ) |
| 90 | + .map((edge) => ({ |
| 91 | + ...edge, |
| 92 | + id: `e${oldToNewIdMap.get(edge.source)}-${oldToNewIdMap.get( |
| 93 | + edge.target |
| 94 | + )}`, |
| 95 | + source: oldToNewIdMap.get(edge.source)!, |
| 96 | + target: oldToNewIdMap.get(edge.target)!, |
| 97 | + })); |
| 98 | + |
| 99 | + setNodes((nds) => [...nds, ...newNodes]); |
| 100 | + setEdges((eds) => [...eds, ...newEdges]); |
| 101 | + setNodeIdCounter((c) => c + newNodes.length); |
| 102 | + }, [edges, nodeIdCounter, setNodes, setEdges, setNodeIdCounter, mousePositionRef]); |
| 103 | + |
| 104 | + // Keyboard shortcuts |
| 105 | + useEffect(() => { |
| 106 | + const handleKeyDown = (event: KeyboardEvent) => { |
| 107 | + // Copy: Cmd+C (Mac) or Ctrl+C (Windows/Linux) |
| 108 | + if ((event.metaKey || event.ctrlKey) && event.key === "c") { |
| 109 | + // Don't interfere with text input copy |
| 110 | + const target = event.target as HTMLElement; |
| 111 | + if ( |
| 112 | + target.tagName === "INPUT" || |
| 113 | + target.tagName === "TEXTAREA" || |
| 114 | + target.isContentEditable |
| 115 | + ) { |
| 116 | + return; |
| 117 | + } |
| 118 | + event.preventDefault(); |
| 119 | + copyNodes(); |
| 120 | + } |
| 121 | + |
| 122 | + // Paste: Cmd+V (Mac) or Ctrl+V (Windows/Linux) |
| 123 | + if ((event.metaKey || event.ctrlKey) && event.key === "v") { |
| 124 | + // Don't interfere with text input paste |
| 125 | + const target = event.target as HTMLElement; |
| 126 | + if ( |
| 127 | + target.tagName === "INPUT" || |
| 128 | + target.tagName === "TEXTAREA" || |
| 129 | + target.isContentEditable |
| 130 | + ) { |
| 131 | + return; |
| 132 | + } |
| 133 | + event.preventDefault(); |
| 134 | + pasteNodes(); |
| 135 | + } |
| 136 | + }; |
| 137 | + |
| 138 | + window.addEventListener("keydown", handleKeyDown); |
| 139 | + return () => window.removeEventListener("keydown", handleKeyDown); |
| 140 | + }, [copyNodes, pasteNodes]); |
| 141 | + |
| 142 | + return { copyNodes, pasteNodes }; |
| 143 | +} |
0 commit comments