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>, openRenameModal: React.Dispatch> }) { if (!contextMenuOpened) { return; } const items = nodeContext.isEmptyArea ? emptyAreaItems : nodeItems; const graphContextValue = useContext(graphContext)!; const graphsById = useGraphsStore((s) => (s as { graphsById: Map }).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 ( document.body} // 👇 Key part: manually position the dropdown overlayStyle={{ position: "absolute", left: nodeContext.coords.x, top: nodeContext.coords.y, }}> ) }