161 lines
5.7 KiB
TypeScript
161 lines
5.7 KiB
TypeScript
import React, { useCallback, useContext, useEffect } from 'react'
|
|
import { useReactFlow } from '@xyflow/react'
|
|
import type { Node } from '@xyflow/react'
|
|
import FlowContext from '@/lib/flowContext'
|
|
import { getNextNodeId, getDefaultDataForType } from '@/lib/flowUtils'
|
|
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/nodeRegistry'
|
|
|
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
|
|
|
function isMod(ev: KeyboardEvent) {
|
|
return ev.ctrlKey || ev.metaKey
|
|
}
|
|
|
|
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
|
|
export function FlowKeyboardShortcuts() {
|
|
const { fitView, screenToFlowPosition } = useReactFlow()
|
|
const ctx = useContext(FlowContext)
|
|
const nodes = ctx?.nodes ?? []
|
|
const setNodes = ctx?.setNodes
|
|
const setConnectionFrom = ctx?.setConnectionFrom
|
|
const flowActionsRef = ctx?.flowActionsRef
|
|
|
|
const pasteAtViewportCenter = useCallback(async () => {
|
|
if (!setNodes || !screenToFlowPosition) return
|
|
try {
|
|
const text = await navigator.clipboard?.readText()
|
|
if (!text) return
|
|
const raw = JSON.parse(text) as {
|
|
id?: string
|
|
type?: string
|
|
data?: Record<string, unknown>
|
|
position?: { x: number; y: number }
|
|
style?: unknown
|
|
}
|
|
const validIds = getRegisteredNodeTypeIds()
|
|
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
|
|
const nodeType = raw.type
|
|
const pane = document.querySelector('.react-flow__viewport')
|
|
const rect = pane?.getBoundingClientRect()
|
|
const center = rect
|
|
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
|
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
|
const position = screenToFlowPosition(center)
|
|
setNodes((nds: Node[]) => {
|
|
const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
|
|
const newId = getNextNodeId(nodeType, existingIds)
|
|
const data: Record<string, unknown> =
|
|
raw.data != null && typeof raw.data === 'object'
|
|
? { ...raw.data }
|
|
: (getDefaultDataForType(nodeType, newId) as Record<string, unknown>)
|
|
if (nodeType === 'config') data.title = `config-${newId}`
|
|
const style = getDefaultStyle(nodeType)
|
|
const newNode: Node = {
|
|
id: newId,
|
|
type: raw.type as Node['type'],
|
|
position: { x: position.x, y: position.y },
|
|
data,
|
|
style,
|
|
}
|
|
return nds.concat(newNode)
|
|
})
|
|
} catch {
|
|
// Invalid clipboard or not a copied node
|
|
}
|
|
}, [setNodes, screenToFlowPosition])
|
|
|
|
const doFitView = useCallback(() => {
|
|
fitView?.({ duration: 200 })
|
|
}, [fitView])
|
|
|
|
useEffect(() => {
|
|
if (flowActionsRef) {
|
|
flowActionsRef.current = { pasteAtViewportCenter, fitView: doFitView }
|
|
return () => {
|
|
flowActionsRef.current = null
|
|
}
|
|
}
|
|
}, [flowActionsRef, pasteAtViewportCenter, doFitView])
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (ev: KeyboardEvent) => {
|
|
if (ev.key === 'Escape') {
|
|
setConnectionFrom?.(null)
|
|
setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false })))
|
|
ev.preventDefault()
|
|
return
|
|
}
|
|
if (ev.key === 'c' && isMod(ev) && !ev.shiftKey) {
|
|
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
|
|
if (selectedNodes.length === 1) {
|
|
const node = selectedNodes[0] as Node & { selected?: boolean }
|
|
const copy = {
|
|
id: node.id,
|
|
type: node.type,
|
|
data: node.data,
|
|
position: node.position,
|
|
style: node.style,
|
|
}
|
|
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => {})
|
|
ev.preventDefault()
|
|
}
|
|
return
|
|
}
|
|
if (ev.key === 'v' && isMod(ev) && !ev.shiftKey) {
|
|
pasteAtViewportCenter()
|
|
ev.preventDefault()
|
|
return
|
|
}
|
|
if (ev.key === 'd' && isMod(ev) && !ev.shiftKey) {
|
|
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
|
|
if (selectedNodes.length > 0 && setNodes) {
|
|
setNodes((nds: Node[]) => {
|
|
const existingIds = nds.map((n) => n.id)
|
|
const toAdd: Node[] = []
|
|
for (const node of selectedNodes) {
|
|
const n = node as Node & { selected?: boolean }
|
|
const pos = n.position ?? { x: 0, y: 0 }
|
|
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
|
|
existingIds.push(newId)
|
|
const newNode: Node = {
|
|
id: newId,
|
|
type: n.type,
|
|
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
|
|
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
|
|
style: getDefaultStyle(String(n.type)),
|
|
}
|
|
if (
|
|
newNode.data &&
|
|
typeof newNode.data === 'object' &&
|
|
'title' in newNode.data &&
|
|
String(n.type) === 'config'
|
|
) {
|
|
;(newNode.data as Record<string, unknown>).title = `${newId}`
|
|
}
|
|
toAdd.push(newNode)
|
|
}
|
|
return nds.concat(toAdd)
|
|
})
|
|
ev.preventDefault()
|
|
}
|
|
return
|
|
}
|
|
if (ev.key === '0' && isMod(ev) && !ev.shiftKey) {
|
|
doFitView()
|
|
ev.preventDefault()
|
|
return
|
|
}
|
|
}
|
|
window.addEventListener('keydown', onKeyDown, true)
|
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
}, [
|
|
nodes,
|
|
setNodes,
|
|
setConnectionFrom,
|
|
pasteAtViewportCenter,
|
|
doFitView,
|
|
])
|
|
|
|
return null
|
|
}
|