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 position?: { x: number; y: number } style?: unknown } const validIds = getRegisteredNodeTypeIds() if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return 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 newId = getNextNodeId(raw.type, nds.map((n) => n.id)) const data: Record = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : (getDefaultDataForType(raw.type, newId) as Record) if (raw.type === 'config') data.title = `config-${newId}` const style = getDefaultStyle(raw.type) 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).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 }