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