- Import/Export button in editor header: removed standalone Import button, moved draw.io import into Export/Import dropdown with labelled sections; fixes conceptual trap where Import implied operating on the current diagram - List page: replaced two identical Upload-icon Import buttons with a single dropdown (Import JSON / Import draw.io) with format descriptions - Empty state: replaced icon-in-box with a horizontal card featuring a static SVG topology preview, MSP-specific value prop, and dual CTAs - Keyboard shortcuts: new KeyboardShortcutsOverlay component (4-group grid), triggered by ? key or the ? button pinned to the canvas bottom-right corner; wired into useCanvasShortcuts hook - Fixed Share2 → FileOutput icon for draw.io export (Share2 = send to someone, FileOutput = export file format) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
305 lines
9.4 KiB
TypeScript
305 lines
9.4 KiB
TypeScript
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, // 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<React.SetStateAction<Node[]>>
|
|
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
|
|
setIsDirty: (dirty: boolean) => void
|
|
canvasRef: React.RefObject<HTMLDivElement | null>
|
|
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<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])
|
|
|
|
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,
|
|
}
|
|
}
|