Files
ConceptSketch/src/components/NodeContextMenu.tsx

208 lines
6.4 KiB
TypeScript

import { Dropdown, type MenuProps } from "antd";
import { defaultGraph, graphContext, GraphModel, randomWordList, type EdgeModel, type NodeContext } from "./Graph";
import { useContext } from "react";
import { cloneDeep } from "lodash";
import { useGraphsStore } from "../stores/GraphsStore";
import { useGraphLayersTreeStore } from "../stores/TreeStore";
import { useCutStore } from "../stores/CutStore";
import { useSelectedNodesStore } from "../stores/ArrayStore";
const nodeItems: MenuProps['items'] = [
{
key: 'rename',
label: 'Rename',
extra: 'ctrl + n',
},
{
key: 'subgraph',
label: 'Subgraph',
extra: 'ctrl + s',
},
{
key: 'remove',
label: 'Remove',
extra: 'ctrl + r'
},
{
key: 'cut',
label: 'Cut',
extra: 'ctrl + x',
}
]
const emptyAreaItems: MenuProps['items'] = [
{
key: 'create',
label: 'Create Node',
},
{
key: 'paste',
label: 'Paste',
extra: 'ctrl + v',
}
]
export default function NodeContextMenu({
nodeContext,
contextMenuOpened,
openContextMenu,
openRenameModal
}: {
nodeContext: NodeContext,
contextMenuOpened: boolean,
openContextMenu: React.Dispatch<React.SetStateAction<boolean>>,
openRenameModal: React.Dispatch<React.SetStateAction<boolean>>
}) {
if (!contextMenuOpened) {
return;
}
const items = nodeContext.isEmptyArea ? emptyAreaItems : nodeItems;
const graphContextValue = useContext(graphContext)!;
const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById);
const addTreeNode = useGraphLayersTreeStore(store => store.add);
const removeTreeNode = useGraphLayersTreeStore(store => store.remove);
const setCut = useCutStore(store => store.setCut);
const clearCut = useCutStore(store => store.clearCut);
function contextMenuOpenChange(open: boolean) {
if (!open) {
openContextMenu(false)
}
}
const onMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'rename': {
openRenameModal(true);
break;
}
case 'remove': {
removeNode(nodeContext.nodeId);
removeTreeNode(nodeContext.nodeId);
break;
}
case 'create': {
const id = crypto.randomUUID();
graphContextValue.setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: randomWordList(1)[0] }] }));
break;
}
case 'subgraph': {
graphsById.set(graphContextValue.graphId, cloneDeep(graphContextValue.graph));
graphContextValue.selectGraphId(nodeContext.nodeId);
let selectedGraph = graphsById.get(nodeContext.nodeId);
if (!selectedGraph) {
selectedGraph = defaultGraph();
graphsById.set(nodeContext.nodeId, selectedGraph);
const parenNodeId = graphContextValue.graphId === 'main' ? undefined : graphContextValue.graphId;
addTreeNode(nodeContext, parenNodeId);
}
graphContextValue.setGraph(selectedGraph);
break;
}
case 'cut': {
const currentGraph = graphContextValue.graph;
const selectedNodeIds = useSelectedNodesStore.getState().items;
// Determine the nodes to cut: selected nodes, or the right-clicked node if nothing is selected
const baseIds: string[] = selectedNodeIds.length > 0
? selectedNodeIds
: [nodeContext.nodeId];
// BFS to collect all nodes reachable via outgoing edges from base nodes (all hops)
const visited = new Set(baseIds);
const queue = [...baseIds];
while (queue.length > 0) {
const nodeId = queue.shift()!;
currentGraph.edges
.filter(e => e.from === nodeId && !visited.has(e.to))
.forEach(e => {
visited.add(e.to);
queue.push(e.to);
});
}
const allCutIds = [...visited];
const allCutIdSet = new Set(allCutIds);
const cutNodes = currentGraph.nodes.filter(n => allCutIdSet.has(n.id));
// Only include edges where both endpoints are cut nodes
const cutEdges = currentGraph.edges.filter(e => allCutIdSet.has(e.from) && allCutIdSet.has(e.to));
setCut(allCutIds, cutNodes, cutEdges, graphContextValue.graphId);
useSelectedNodesStore.getState().clear();
break;
}
case 'paste': {
const cutState = useCutStore.getState();
if (cutState.cutNodes.length === 0) break;
// If pasting into the same graph, just cancel the cut
if (cutState.sourceGraphId === graphContextValue.graphId) {
clearCut();
break;
}
// Add cut nodes/edges to current graph
graphContextValue.setGraph(prev => ({
...prev,
nodes: [...prev.nodes, ...cutState.cutNodes],
edges: [...prev.edges, ...cutState.cutEdges],
}));
// Remove cut nodes and their connected edges from the source graph
if (cutState.sourceGraphId) {
const sourceGraph = graphsById.get(cutState.sourceGraphId);
if (sourceGraph) {
const cutIdSet = new Set(cutState.cutNodeIds);
graphsById.set(cutState.sourceGraphId, {
nodes: sourceGraph.nodes.filter(n => !cutIdSet.has(n.id)),
edges: sourceGraph.edges.filter(e => !cutIdSet.has(e.from) && !cutIdSet.has(e.to)),
});
}
}
clearCut();
break;
}
}
};
function removeNode(id: string) {
graphContextValue.setGraph(prev => ({ ...prev, nodes: [...prev.nodes.filter(n => n.id !== nodeContext.nodeId)], edges: removeEdgesOfNode(id, prev.edges) }));
}
function removeEdgesOfNode(nodeId: string, edges: EdgeModel[]): EdgeModel[] {
const parentConnection = edges.find(e => e.to === nodeId);
if (parentConnection) {
edges.filter(e => e.from === nodeId).forEach(e => {
e.from = parentConnection.from;
});
edges = edges.filter(e => e.to !== nodeId);
}
if (!parentConnection) {
edges = edges.filter(e => e.from !== nodeId);
}
const result = [...edges];
return result;
}
return (
<Dropdown menu={{ items, onClick: onMenuClick }} trigger={['contextMenu']} open={contextMenuOpened} onOpenChange={contextMenuOpenChange} getPopupContainer={() => document.body}
// 👇 Key part: manually position the dropdown
overlayStyle={{
position: "absolute",
left: nodeContext.coords.x,
top: nodeContext.coords.y,
}}>
</Dropdown>
)
}