import { useCallback, useEffect, useRef } from 'react' import { useReactFlow, type Node, type Edge } from '@xyflow/react' interface ClipboardData { nodes: Array<{ type: string data: Record style?: React.CSSProperties relativePosition: { x: number; y: number } }> edges: Array<{ sourceIndex: number targetIndex: number type?: string data?: Record label?: string }> } function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` } function isInputFocused(): boolean { const tag = document.activeElement?.tagName return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' } export function useCanvasShortcuts({ nodes: _nodes, // eslint-disable-line @typescript-eslint/no-unused-vars edges, setNodes, setEdges, setIsDirty, canvasRef, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts, }: { nodes: Node[] edges: Edge[] setNodes: React.Dispatch> setEdges: React.Dispatch> setIsDirty: (dirty: boolean) => void canvasRef: React.RefObject onUndo: () => void onRedo: () => void onNudge: (dx: number, dy: number) => void onSetMode: (mode: 'select' | 'pan' | 'connect') => void onToggleShortcuts: () => void }) { const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow() const clipboardRef = useRef(null) const getSelectedNodes = useCallback((): Node[] => { return getNodes().filter(n => n.selected) }, [getNodes]) const copyNodes = useCallback(() => { const selected = getSelectedNodes() if (selected.length === 0) return const centroid = { x: selected.reduce((sum, n) => sum + n.position.x, 0) / selected.length, y: selected.reduce((sum, n) => sum + n.position.y, 0) / selected.length, } const selectedIds = new Set(selected.map(n => n.id)) const clipNodes = selected.map(n => ({ type: n.type || 'device', data: structuredClone(n.data), style: n.style ? { ...n.style } : undefined, relativePosition: { x: n.position.x - centroid.x, y: n.position.y - centroid.y, }, })) const selectedList = selected.map(n => n.id) const clipEdges = edges .filter(e => selectedIds.has(e.source) && selectedIds.has(e.target)) .map(e => ({ sourceIndex: selectedList.indexOf(e.source), targetIndex: selectedList.indexOf(e.target), type: e.type, data: e.data ? structuredClone(e.data) as Record : undefined, label: typeof e.label === 'string' ? e.label : undefined, })) clipboardRef.current = { nodes: clipNodes, edges: clipEdges } }, [getSelectedNodes, edges]) const pasteNodes = useCallback(() => { const clipboard = clipboardRef.current if (!clipboard || clipboard.nodes.length === 0) return const canvasEl = canvasRef.current if (!canvasEl) return const rect = canvasEl.getBoundingClientRect() const center = screenToFlowPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }) const newNodeIds: string[] = [] const newNodes: Node[] = clipboard.nodes.map(cn => { const prefix = cn.type === 'group' ? 'group' : 'device' const id = generateId(prefix) newNodeIds.push(id) return { id, type: cn.type, position: { x: center.x + cn.relativePosition.x, y: center.y + cn.relativePosition.y, }, data: structuredClone(cn.data) as Record, style: cn.style ? { ...cn.style } : undefined, selected: true, } }) const newEdges: Edge[] = clipboard.edges.map(ce => ({ id: generateId('edge'), source: newNodeIds[ce.sourceIndex], target: newNodeIds[ce.targetIndex], type: ce.type, data: ce.data ? structuredClone(ce.data) as Record : undefined, label: ce.label, })) setNodes(nds => [ ...nds.map(n => ({ ...n, selected: false })), ...newNodes, ]) setEdges(eds => [...eds, ...newEdges]) setIsDirty(true) }, [canvasRef, screenToFlowPosition, setNodes, setEdges, setIsDirty]) const duplicateNodes = useCallback(() => { const selected = getSelectedNodes() if (selected.length === 0) return const selectedIds = new Set(selected.map(n => n.id)) const idMap = new Map() const newNodes: Node[] = selected.map(n => { const prefix = n.type === 'group' ? 'group' : 'device' const newId = generateId(prefix) idMap.set(n.id, newId) return { id: newId, type: n.type, position: { x: n.position.x + 30, y: n.position.y + 30 }, data: structuredClone(n.data) as Record, style: n.style ? { ...n.style } : undefined, selected: true, } }) const newEdges: Edge[] = edges .filter(e => selectedIds.has(e.source) && selectedIds.has(e.target)) .map(e => ({ id: generateId('edge'), source: idMap.get(e.source)!, target: idMap.get(e.target)!, type: e.type, data: e.data ? structuredClone(e.data) as Record : undefined, label: e.label, })) setNodes(nds => [ ...nds.map(n => ({ ...n, selected: false })), ...newNodes, ]) setEdges(eds => [...eds, ...newEdges]) setIsDirty(true) }, [getSelectedNodes, edges, setNodes, setEdges, setIsDirty]) const selectAll = useCallback(() => { rfSetNodes(nds => nds.map(n => ({ ...n, selected: true }))) }, [rfSetNodes]) const deleteSelected = useCallback(() => { const selected = getSelectedNodes() if (selected.length === 0) return const selectedIds = new Set(selected.map(n => n.id)) setNodes(nds => nds.filter(n => !selectedIds.has(n.id))) setEdges(eds => eds.filter(e => !selectedIds.has(e.source) && !selectedIds.has(e.target))) setIsDirty(true) }, [getSelectedNodes, setNodes, setEdges, setIsDirty]) const bringSelectedToFront = useCallback(() => { const selected = getSelectedNodes() if (!selected.length) return const selectedIds = new Set(selected.map(n => n.id)) setNodes(nds => { const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0)) return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: maxZ + 1 } : n) }) setIsDirty(true) }, [getSelectedNodes, setNodes, setIsDirty]) const sendSelectedToBack = useCallback(() => { const selected = getSelectedNodes() if (!selected.length) return const selectedIds = new Set(selected.map(n => n.id)) setNodes(nds => { const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0)) return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: minZ - 1 } : n) }) setIsDirty(true) }, [getSelectedNodes, setNodes, setIsDirty]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (isInputFocused()) return const ctrl = e.ctrlKey || e.metaKey // Undo: Ctrl+Z / Cmd+Z if (e.key === 'z' && ctrl && !e.shiftKey) { e.preventDefault() onUndo() return } // Redo: Ctrl+Y or Ctrl+Shift+Z if ((e.key === 'y' && ctrl) || (e.key === 'z' && ctrl && e.shiftKey)) { e.preventDefault() onRedo() return } // Arrow key nudging if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault() const delta = e.shiftKey ? 10 : 1 switch (e.key) { case 'ArrowUp': onNudge(0, -delta); break case 'ArrowDown': onNudge(0, delta); break case 'ArrowLeft': onNudge(-delta, 0); break case 'ArrowRight': onNudge( delta, 0); break } return } // Mode shortcuts: V = select, H = pan, C = connect if (!ctrl && e.key === 'v') { onSetMode('select') return } if (!ctrl && e.key === 'h') { onSetMode('pan') return } if (!ctrl && e.key === 'c') { onSetMode('connect') return } if (ctrl && e.key === 'c') { e.preventDefault() copyNodes() } else if (ctrl && e.key === 'v') { e.preventDefault() pasteNodes() } else if (ctrl && e.key === 'd') { e.preventDefault() duplicateNodes() } else if (ctrl && e.key === 'a') { e.preventDefault() selectAll() } else if (ctrl && e.shiftKey && (e.key === 'f' || e.key === 'F')) { e.preventDefault() fitView({ padding: 0.2 }) } else if (e.key === ']' && !ctrl) { e.preventDefault() bringSelectedToFront() } else if (e.key === '[' && !ctrl) { e.preventDefault() sendSelectedToBack() } else if (e.key === '?' && !ctrl) { e.preventDefault() onToggleShortcuts() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts]) return { copyNodes, pasteNodes, duplicateNodes, selectAll, deleteSelected, bringSelectedToFront, sendSelectedToBack, hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0, } }