diff --git a/frontend/src/components/network/hooks/useCanvasShortcuts.ts b/frontend/src/components/network/hooks/useCanvasShortcuts.ts new file mode 100644 index 00000000..80ccb130 --- /dev/null +++ b/frontend/src/components/network/hooks/useCanvasShortcuts.ts @@ -0,0 +1,222 @@ +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, + edges, + setNodes, + setEdges, + setIsDirty, + canvasRef, +}: { + nodes: Node[] + edges: Edge[] + setNodes: React.Dispatch> + setEdges: React.Dispatch> + setIsDirty: (dirty: boolean) => void + canvasRef: React.RefObject +}) { + 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]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isInputFocused()) return + + const ctrl = e.ctrlKey || e.metaKey + + 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 }) + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView]) + + return { + copyNodes, + pasteNodes, + duplicateNodes, + selectAll, + deleteSelected, + hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0, + } +}