feat: add useCanvasShortcuts hook for copy/paste/duplicate
Keyboard shortcuts with preventDefault and input guard. Clipboard stores nodes with relative positions and edge indices. Paste computes canvas center via screenToFlowPosition. Duplicate offsets +30px. Supports both device and group nodes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
222
frontend/src/components/network/hooks/useCanvasShortcuts.ts
Normal file
222
frontend/src/components/network/hooks/useCanvasShortcuts.ts
Normal file
@@ -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<string, unknown>
|
||||
style?: React.CSSProperties
|
||||
relativePosition: { x: number; y: number }
|
||||
}>
|
||||
edges: Array<{
|
||||
sourceIndex: number
|
||||
targetIndex: number
|
||||
type?: string
|
||||
data?: Record<string, unknown>
|
||||
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<React.SetStateAction<Node[]>>
|
||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
|
||||
setIsDirty: (dirty: boolean) => void
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
||||
const clipboardRef = useRef<ClipboardData | null>(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<string, unknown> : 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<string, unknown>,
|
||||
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<string, unknown> : 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<string, string>()
|
||||
|
||||
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<string, unknown>,
|
||||
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<string, unknown> : 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user