Skip to content

Commit 2729a11

Browse files
Merge pull request #79 from festim-dev/duplicate
Duplicate
2 parents f6cad3b + 4615a8c commit 2729a11

File tree

3 files changed

+216
-3
lines changed

3 files changed

+216
-3
lines changed

src/App.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,36 @@
5959
.read-the-docs {
6060
color: #888;
6161
} */
62+
63+
/* Context menu styles */
64+
.context-menu {
65+
position: absolute;
66+
background: rgb(73, 71, 71);
67+
border: 1px solid #ccc;
68+
border-radius: 4px;
69+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
70+
padding: 4px 0;
71+
z-index: 1000;
72+
min-width: 120px;
73+
}
74+
75+
.context-menu button {
76+
display: block;
77+
width: 100%;
78+
padding: 8px 16px;
79+
border: none;
80+
background: transparent;
81+
text-align: left;
82+
cursor: pointer;
83+
font-size: 14px;
84+
}
85+
86+
.context-menu button:hover {
87+
background-color: #f0f0f06c;
88+
}
89+
90+
.context-menu p {
91+
margin: 0.5em;
92+
font-size: 12px;
93+
color: #666;
94+
}

src/App.jsx

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useCallback, useEffect } from 'react';
1+
import React, { useState, useCallback, useEffect, useRef } from 'react';
22
import {
33
ReactFlow,
44
MiniMap,
@@ -12,6 +12,7 @@ import '@xyflow/react/dist/style.css';
1212
import './App.css';
1313
import Plot from 'react-plotly.js';
1414

15+
import ContextMenu from './ContextMenu.jsx';
1516

1617
// Importing node components
1718
import {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

src/ContextMenu.jsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React, { useCallback } from 'react';
2+
import { useReactFlow } from '@xyflow/react';
3+
4+
export default function ContextMenu({
5+
id,
6+
top,
7+
left,
8+
right,
9+
bottom,
10+
onClick,
11+
onDuplicate,
12+
...props
13+
}) {
14+
const { setNodes, setEdges } = useReactFlow();
15+
16+
const duplicateNode = useCallback(() => {
17+
onDuplicate(id);
18+
}, [id, onDuplicate]);
19+
20+
const deleteNode = useCallback(() => {
21+
setNodes((nodes) => nodes.filter((node) => node.id !== id));
22+
setEdges((edges) => edges.filter((edge) => edge.source !== id && edge.target !== id));
23+
onClick && onClick(); // Close menu after action
24+
}, [id, setNodes, setEdges, onClick]);
25+
26+
return (
27+
<div
28+
style={{ top, left, right, bottom }}
29+
className="context-menu"
30+
{...props}
31+
>
32+
<p style={{ margin: '0.5em' }}>
33+
<small>node: {id}</small>
34+
</p>
35+
<button onClick={duplicateNode}>duplicate</button>
36+
<button onClick={deleteNode}>delete</button>
37+
</div>
38+
);
39+
}

0 commit comments

Comments
 (0)