Skip to content

Commit 13d6065

Browse files
committed
Added node context menu with copy, delete, cut and duplicate actions
1 parent e7a9787 commit 13d6065

File tree

3 files changed

+112
-6
lines changed

3 files changed

+112
-6
lines changed

TODO.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
# TODO
22

3-
- [ ] Add Graph inspector panel on the left
4-
- [ ] Show output descriptions
5-
63
- [ ] Add node context menu
74
- [ ] Copy node
85
- [ ] Delete node

src/VisualScripting.js

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import examples from './examples';
1212
import { saveAs } from 'file-saver';
1313
import GraphInspector from './components/GraphInspector';
1414
import Node from './engine/Node';
15+
import NodeContextMenu from './components/NodeContextMenu';
1516

1617
const VisualScripting = () => {
1718
// #region State Declarations
@@ -50,6 +51,7 @@ const VisualScripting = () => {
5051
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
5152
const [tabs, setTabs] = useState([{ id: 'untitled-1', title: 'Untitled-1', type: 'Export' }]);
5253
const [activeTab, setActiveTab] = useState('untitled-1');
54+
const [nodeContextMenu, setNodeContextMenu] = useState({ visible: false, x: 0, y: 0 });
5355
// #endregion
5456

5557
// #region Drawing Functions
@@ -72,7 +74,16 @@ const VisualScripting = () => {
7274
e.preventDefault();
7375
const rect = canvasRef.current.getBoundingClientRect();
7476
const { x, y } = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
75-
setContextMenu({ visible: true, x, y });
77+
78+
const clickedNode = findClickedNode(x, y);
79+
if (clickedNode) {
80+
setNodeContextMenu({ visible: true, x, y });
81+
if (!selectedNodes.includes(clickedNode)) {
82+
setSelectedNodes([clickedNode]);
83+
}
84+
} else {
85+
setContextMenu({ visible: true, x, y });
86+
}
7687
setNeedsRedraw(true);
7788
};
7889

@@ -83,6 +94,9 @@ const VisualScripting = () => {
8394
if (contextMenu.visible) {
8495
setContextMenu({ ...contextMenu, visible: false });
8596
}
97+
if (nodeContextMenu.visible) {
98+
setNodeContextMenu({ ...nodeContextMenu, visible: false });
99+
}
86100

87101
if (connecting) {
88102
const clickedPort = findClickedPort(x, y);
@@ -117,6 +131,13 @@ const VisualScripting = () => {
117131
const rect = canvasRef.current.getBoundingClientRect();
118132
const { x, y } = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
119133

134+
if (contextMenu.visible) {
135+
setContextMenu({ ...contextMenu, visible: false });
136+
}
137+
if (nodeContextMenu.visible) {
138+
setNodeContextMenu({ ...nodeContextMenu, visible: false });
139+
}
140+
120141
const clickedPort = findClickedPort(x, y);
121142
if (clickedPort) {
122143
const node = nodes.find(n => n.id === clickedPort.nodeId);
@@ -229,8 +250,20 @@ const VisualScripting = () => {
229250
for (const node of nodes) {
230251
const nodeType = nodeTypes[node.type];
231252
const dimensions = renderer.getNodeDimensions(node, canvasRef.current.getContext('2d'));
232-
const clickedPort = node.findClickedPort(x, y, dimensions, nodeType);
233-
if (clickedPort) return clickedPort;
253+
254+
// Check input ports
255+
for (let i = 0; i < nodeType.inputs.length; i++) {
256+
if (node.isPortClicked(x, y, i, true, dimensions)) {
257+
return node.getPortPosition(i, true, dimensions);
258+
}
259+
}
260+
261+
// Check output ports
262+
for (let i = 0; i < nodeType.outputs.length; i++) {
263+
if (node.isPortClicked(x, y, i, false, dimensions)) {
264+
return node.getPortPosition(i, false, dimensions);
265+
}
266+
}
234267
}
235268
return null;
236269
};
@@ -721,6 +754,37 @@ const VisualScripting = () => {
721754
};
722755
// #endregion
723756

757+
// #region Handle Node Context Menu Actions
758+
const handleNodeContextMenuAction = (action) => {
759+
switch (action) {
760+
case 'copy':
761+
setCopiedNodes([...selectedNodes]);
762+
break;
763+
case 'delete':
764+
deleteSelectedNodes();
765+
break;
766+
case 'cut':
767+
setCopiedNodes([...selectedNodes]);
768+
deleteSelectedNodes();
769+
break;
770+
case 'duplicate':
771+
const newNodes = selectedNodes.map(node => {
772+
// Create a proper Node instance using the static create method
773+
const duplicatedNode = Node.create(node.type, node.x + 20, node.y + 20, nodeTypes);
774+
// Copy over the properties
775+
duplicatedNode.properties = { ...node.properties };
776+
return duplicatedNode;
777+
});
778+
setNodes([...nodes, ...newNodes]);
779+
setSelectedNodes(newNodes);
780+
break;
781+
default:
782+
console.log(`Unhandled node context menu action: ${action}`);
783+
}
784+
setNodeContextMenu({ ...nodeContextMenu, visible: false });
785+
};
786+
// #endregion
787+
724788
// #region Render
725789
return (
726790
<div
@@ -818,6 +882,13 @@ const VisualScripting = () => {
818882
addNode={addNode}
819883
camera={camera}
820884
/>
885+
<NodeContextMenu
886+
visible={nodeContextMenu.visible}
887+
x={nodeContextMenu.x}
888+
y={nodeContextMenu.y}
889+
camera={camera}
890+
onAction={handleNodeContextMenuAction}
891+
/>
821892
</div>
822893
</>
823894
) : activeTab === 'settings' ? (

src/components/NodeContextMenu.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from 'react';
2+
import styles from './ContextMenu.module.css';
3+
4+
const NodeContextMenu = ({ visible, x, y, camera, onAction }) => {
5+
if (!visible) return null;
6+
7+
const menuItems = [
8+
{ icon: 'fa-copy', label: 'Copy', action: 'copy' },
9+
{ icon: 'fa-trash', label: 'Delete', action: 'delete' },
10+
{ icon: 'fa-cut', label: 'Cut', action: 'cut' },
11+
{ icon: 'fa-clone', label: 'Duplicate', action: 'duplicate' },
12+
];
13+
14+
return (
15+
<div
16+
className={styles.contextMenu}
17+
style={{
18+
top: `${y * camera.scale + camera.y}px`,
19+
left: `${x * camera.scale + camera.x}px`,
20+
}}
21+
>
22+
<div className={styles.mainMenu}>
23+
{menuItems.map(({ icon, label, action }) => (
24+
<button
25+
key={action}
26+
onClick={() => onAction(action)}
27+
className={styles.nodeButton}
28+
>
29+
<i className={`fas ${icon} ${styles.icon}`}></i>
30+
{label}
31+
</button>
32+
))}
33+
</div>
34+
</div>
35+
);
36+
};
37+
38+
export default NodeContextMenu;

0 commit comments

Comments
 (0)