diff --git a/src/App.tsx b/src/App.tsx index 6492a03..44f5233 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, @@ -31,9 +32,8 @@ import { ContextMenuGroup } from "@/components/ui/context-menu" import { Button } from '@/components/ui/button' -import { Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react' -// Generate short unique node ids when missing -const genId = () => `node_${Math.random().toString(36).slice(2, 9)}` +import { ClipboardPaste, Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react' +import { DEFAULT_NODE_STYLE, genId, getDefaultDataForType } from './lib/flowUtils' const SNAP_GRID: [number, number] = [15, 15] const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ @@ -41,13 +41,6 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1], }) -const DEFAULT_NODE_STYLE: Record = { - config: { width: 320, height: 320 }, - render: { width: 384, height: 320 }, - variable: { width: 224, height: 180 }, - function: { width: 288, height: 260 }, -} - const initialNodes: Node[] = [ { id: 'config-1', @@ -77,7 +70,7 @@ export default function App() { const wrapperRef = React.useRef(null) const lastClickRef = React.useRef<{ clientX: number; clientY: number } | null>(null) - const [contextTarget, setContextTarget] = React.useState(null) + const [contextTarget, setContextTarget] = React.useState(null) const nodeTypes = React.useMemo( () => ({ config: ConfigNode, render: RenderingNode, variable: VariableNode, function: FunctionNode }), @@ -95,69 +88,92 @@ export default function App() { setRfInstance(instance) }, []) + const onContextMenuCapture = React.useCallback((ev: React.MouseEvent) => { + const target = ev.target as HTMLElement + const nodeEl = target.closest('.react-flow__node') + if (nodeEl) { + ev.preventDefault() + ev.stopPropagation() + } + }, []) + const onCanvasContextMenu = React.useCallback((ev: React.MouseEvent) => { ev.preventDefault() - const target = ev.target as HTMLElement - const nodeEl = target.closest('.react-flow__node') as HTMLElement | null - const handleEl = target.closest('.react-flow__handle') as HTMLElement | null const clientX = ev.clientX const clientY = ev.clientY - lastClickRef.current = { clientX, clientY } - - if (nodeEl && !handleEl) { - const nodeId = nodeEl.dataset?.id || nodeEl.getAttribute('data-id') || (nodeEl.id && nodeEl.id.startsWith('reactflow__node-') ? nodeEl.id.replace('reactflow__node-', '') : undefined) - setContextTarget({ type: 'node', nodeId, clientX, clientY }) - return - } - setContextTarget({ type: 'canvas', clientX, clientY }) }, []) + const getMenuPosition = React.useCallback(() => { + if (!rfInstance) return null + const click = contextTarget ?? lastClickRef.current + const clientX = click?.clientX ?? window.innerWidth / 2 + const clientY = click?.clientY ?? window.innerHeight / 2 + try { + const screenToFlow = rfInstance.screenToFlowPosition ?? rfInstance.project + const p = screenToFlow.call(rfInstance, { x: clientX, y: clientY }) + return snapToGrid(p.x, p.y) + } catch { + return snapToGrid(clientX, clientY) + } + }, [rfInstance, contextTarget]) + const createNode = React.useCallback( (type: string) => { - if (!rfInstance) return - const click = contextTarget ?? lastClickRef.current - const clientX = click?.clientX ?? window.innerWidth / 2 - const clientY = click?.clientY ?? window.innerHeight / 2 - let position: { x: number; y: number } - try { - // Convert screen (client) coordinates to flow coordinates — same position as where the context menu opened - const screenToFlow = rfInstance.screenToFlowPosition ?? rfInstance.project - position = screenToFlow.call(rfInstance, { x: clientX, y: clientY }) - } catch { - position = { x: clientX, y: clientY } - } - position = snapToGrid(position.x, position.y) - - const id = genId() + const position = getMenuPosition() + if (position == null) return + const newId = genId() const typeMap: Record = { config: 'config', render: 'render', variable: 'variable', function: 'function', } - const dataMap: Record = { - config: { plantuml: '@startuml\n\n@enduml\n', title: `config-${id}` }, - render: {}, - variable: { value: '', valueType: 'string' }, - function: { body: '// args[0], args[1], ...\nreturn args[0];' }, - } const nodeType = typeMap[type] ?? 'config' + const dataMap = getDefaultDataForType(nodeType, newId) const newNode: Node = { - id, + id: newId, type: nodeType, position: { x: position.x, y: position.y }, - data: dataMap[type] ?? {}, + data: dataMap, style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config, } setNodes((nds) => nds.concat(newNode)) lastClickRef.current = null setContextTarget(null) }, - [rfInstance, setNodes, contextTarget] + [getMenuPosition, setNodes] ) + const VALID_NODE_TYPES = ['config', 'render', 'variable', 'function'] as const + + const pasteNode = React.useCallback(async () => { + const position = getMenuPosition() + if (position == null) return + try { + const text = await navigator.clipboard?.readText() + if (!text) return + const raw = JSON.parse(text) as { id?: string; type?: string; data?: any; position?: { x: number; y: number }; style?: any } + if (!raw || typeof raw.type !== 'string' || !VALID_NODE_TYPES.includes(raw.type as any)) return + const newId = genId() + const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {} + if (raw.type === 'config' && data.title != null) data.title = `config-${newId}` + const newNode: Node = { + id: newId, + type: raw.type as Node['type'], + position: { x: position.x, y: position.y }, + data, + style: DEFAULT_NODE_STYLE[raw.type] ?? DEFAULT_NODE_STYLE.config, + } + setNodes((nds) => nds.concat(newNode)) + lastClickRef.current = null + setContextTarget(null) + } catch { + // Invalid clipboard or not a copied node — do nothing + } + }, [getMenuPosition, setNodes]) + const deleteNode = React.useCallback((id: string | undefined) => { if (!id) return setNodes((nds) => nds.filter((n) => n.id !== id)) @@ -166,7 +182,13 @@ export default function App() { }, [setNodes, setEdges]) return ( -
+