1- import React , { useState , useCallback , useEffect } from 'react' ;
1+ import React , { useState , useCallback , useEffect , useRef } from 'react' ;
22import {
33 ReactFlow ,
44 MiniMap ,
@@ -12,6 +12,7 @@ import '@xyflow/react/dist/style.css';
1212import './App.css' ;
1313import Plot from 'react-plotly.js' ;
1414
15+ import ContextMenu from './ContextMenu.jsx' ;
1516
1617// Importing node components
1718import { ProcessNode , ProcessNodeHorizontal } from './ProcessNode' ;
@@ -65,7 +66,11 @@ export default function App() {
6566 const [ simulationResults , setSimulationResults ] = useState ( null ) ;
6667 const [ selectedEdge , setSelectedEdge ] = useState ( null ) ;
6768 const [ nodeCounter , setNodeCounter ] = useState ( 1 ) ;
68-
69+ const [ menu , setMenu ] = useState ( null ) ;
70+ const [ copiedNode , setCopiedNode ] = useState ( null ) ;
71+ const [ copyFeedback , setCopyFeedback ] = useState ( '' ) ;
72+ const ref = useRef ( null ) ;
73+
6974 // Solver parameters state
7075 const [ solverParams , setSolverParams ] = useState ( {
7176 dt : '0.01' ,
@@ -501,6 +506,7 @@ export default function App() {
501506 const onPaneClick = ( ) => {
502507 setSelectedNode ( null ) ;
503508 setSelectedEdge ( null ) ;
509+ setMenu ( null ) ; // Close context menu when clicking on pane
504510 // Reset all edge styles when deselecting
505511 setEdges ( ( eds ) =>
506512 eds . map ( ( e ) => ( {
@@ -617,6 +623,28 @@ export default function App() {
617623 setNodes ( ( nds ) => [ ...nds , newNode ] ) ;
618624 setNodeCounter ( ( count ) => count + 1 ) ;
619625 } ;
626+
627+ // Function to pop context menu when right-clicking on a node
628+ const onNodeContextMenu = useCallback (
629+ ( event , node ) => {
630+ // Prevent native context menu from showing
631+ event . preventDefault ( ) ;
632+
633+ // Calculate position of the context menu. We want to make sure it
634+ // doesn't get positioned off-screen.
635+ const pane = ref . current . getBoundingClientRect ( ) ;
636+ setMenu ( {
637+ id : node . id ,
638+ top : event . clientY < pane . height - 200 && event . clientY ,
639+ left : event . clientX < pane . width - 200 && event . clientX ,
640+ right : event . clientX >= pane . width - 200 && pane . width - event . clientX ,
641+ bottom :
642+ event . clientY >= pane . height - 200 && pane . height - event . clientY ,
643+ } ) ;
644+ } ,
645+ [ setMenu ] ,
646+ ) ;
647+
620648 // Function to delete the selected node
621649 const deleteSelectedNode = ( ) => {
622650 if ( selectedNode ) {
@@ -649,6 +677,47 @@ export default function App() {
649677 setSelectedEdge ( null ) ;
650678 }
651679 } ;
680+
681+ // Function to duplicate a node
682+ const duplicateNode = useCallback ( ( nodeId , options = { } ) => {
683+ const node = nodes . find ( n => n . id === nodeId ) ;
684+ if ( ! node ) return ;
685+
686+ const newNodeId = nodeCounter . toString ( ) ;
687+
688+ // Calculate position based on source (context menu vs keyboard)
689+ let position ;
690+ if ( options . fromKeyboard ) {
691+ // For keyboard shortcuts, place the duplicate at a more visible offset
692+ position = {
693+ x : node . position . x + 100 ,
694+ y : node . position . y + 100 ,
695+ } ;
696+ } else {
697+ // For context menu, use smaller offset
698+ position = {
699+ x : node . position . x + 50 ,
700+ y : node . position . y + 50 ,
701+ } ;
702+ }
703+
704+ const newNode = {
705+ ...node ,
706+ selected : false ,
707+ dragging : false ,
708+ id : newNodeId ,
709+ position,
710+ data : {
711+ ...node . data ,
712+ label : node . data . label ? node . data . label . replace ( node . id , newNodeId ) : `${ node . type } ${ newNodeId } `
713+ }
714+ } ;
715+
716+ setNodes ( ( nds ) => [ ...nds , newNode ] ) ;
717+ setNodeCounter ( ( count ) => count + 1 ) ;
718+ setMenu ( null ) ; // Close the context menu
719+ } , [ nodes , nodeCounter , setNodeCounter , setNodes , setMenu ] ) ;
720+
652721 // Keyboard event handler for deleting selected items
653722 useEffect ( ( ) => {
654723 const handleKeyDown = ( event ) => {
@@ -657,6 +726,35 @@ export default function App() {
657726 return ;
658727 }
659728
729+ // Handle Ctrl+C (copy)
730+ if ( event . ctrlKey && event . key === 'c' && selectedNode ) {
731+ event . preventDefault ( ) ;
732+ setCopiedNode ( selectedNode ) ;
733+ setCopyFeedback ( `Copied: ${ selectedNode . data . label || selectedNode . id } ` ) ;
734+
735+ // Clear feedback after 2 seconds
736+ setTimeout ( ( ) => {
737+ setCopyFeedback ( '' ) ;
738+ } , 2000 ) ;
739+
740+ console . log ( 'Node copied:' , selectedNode . id ) ;
741+ return ;
742+ }
743+
744+ // Handle Ctrl+V (paste)
745+ if ( event . ctrlKey && event . key === 'v' && copiedNode ) {
746+ event . preventDefault ( ) ;
747+ duplicateNode ( copiedNode . id , { fromKeyboard : true } ) ;
748+ return ;
749+ }
750+
751+ // Handle Ctrl+D (duplicate selected node directly)
752+ if ( event . ctrlKey && event . key === 'd' && selectedNode ) {
753+ event . preventDefault ( ) ;
754+ duplicateNode ( selectedNode . id , { fromKeyboard : true } ) ;
755+ return ;
756+ }
757+
660758 if ( event . key === 'Delete' || event . key === 'Backspace' ) {
661759 if ( selectedEdge ) {
662760 deleteSelectedEdge ( ) ;
@@ -670,7 +768,7 @@ export default function App() {
670768 return ( ) => {
671769 document . removeEventListener ( 'keydown' , handleKeyDown ) ;
672770 } ;
673- } , [ selectedEdge , selectedNode ] ) ;
771+ } , [ selectedEdge , selectedNode , copiedNode , duplicateNode , setCopyFeedback ] ) ;
674772
675773 return (
676774 < div style = { { width : '100vw' , height : '100vh' , position : 'relative' } } >
@@ -749,6 +847,7 @@ export default function App() {
749847 { activeTab === 'graph' && (
750848 < div style = { { width : '100%' , height : '100%' , paddingTop : '50px' } } >
751849 < ReactFlow
850+ ref = { ref }
752851 nodes = { nodes }
753852 edges = { edges }
754853 onNodesChange = { onNodesChange }
@@ -757,11 +856,32 @@ export default function App() {
757856 onNodeClick = { onNodeClick }
758857 onEdgeClick = { onEdgeClick }
759858 onPaneClick = { onPaneClick }
859+ onNodeContextMenu = { onNodeContextMenu }
760860 nodeTypes = { nodeTypes }
761861 >
762862 < Controls />
763863 < MiniMap />
764864 < Background variant = "dots" gap = { 12 } size = { 1 } />
865+ { menu && < ContextMenu onClick = { onPaneClick } onDuplicate = { duplicateNode } { ...menu } /> }
866+ { copyFeedback && (
867+ < div
868+ style = { {
869+ position : 'absolute' ,
870+ top : 20 ,
871+ left : '50%' ,
872+ transform : 'translateX(-50%)' ,
873+ backgroundColor : '#78A083' ,
874+ color : 'white' ,
875+ padding : '8px 16px' ,
876+ borderRadius : 4 ,
877+ zIndex : 1000 ,
878+ fontSize : '14px' ,
879+ boxShadow : '0 2px 8px rgba(0,0,0,0.2)' ,
880+ } }
881+ >
882+ { copyFeedback }
883+ </ div >
884+ ) }
765885 < button
766886 style = { {
767887 position : 'absolute' ,
@@ -900,6 +1020,27 @@ export default function App() {
9001020 >
9011021 Run
9021022 </ button >
1023+ < div
1024+ style = { {
1025+ position : 'absolute' ,
1026+ bottom : '50%' ,
1027+ right : 20 ,
1028+ backgroundColor : 'rgba(0, 0, 0, 0.31)' ,
1029+ color : 'white' ,
1030+ padding : '8px 12px' ,
1031+ borderRadius : 4 ,
1032+ fontSize : '12px' ,
1033+ zIndex : 10 ,
1034+ maxWidth : '200px' ,
1035+ } }
1036+ >
1037+ < strong > Keyboard Shortcuts:</ strong > < br />
1038+ Ctrl+C: Copy selected node< br />
1039+ Ctrl+V: Paste copied node< br />
1040+ Ctrl+D: Duplicate selected node< br />
1041+ Del/Backspace: Delete selection< br />
1042+ Right-click: Context menu
1043+ </ div >
9031044 </ ReactFlow >
9041045 { selectedNode && (
9051046 < div
0 commit comments