diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index c069265..c1b43f6 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -1,14 +1,13 @@ -import { createContext, useContext, useEffect, useRef, useState, type MouseEventHandler } from "react"; +import { useEffect, useRef, useState } from "react"; import Viz from 'viz.js'; import { Module, render } from 'viz.js/full.render.js'; import * as d3 from 'd3'; import { graphToDot } from "../Graphviz"; -import { Dropdown, type MenuProps } from "antd"; -import { Modal } from "antd"; -import { Input } from "antd"; +import { type MenuProps } from "antd"; import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb"; import { cloneDeep } from "lodash"; import NodeContextMenu from "./NodeContextMenu"; +import NodeRenameModal from "./NodeRenameModal"; export class GraphModel { nodes: NodeModel[] = []; @@ -39,6 +38,19 @@ export interface GraphLevel extends BreadcrumbItemType { } +export interface NodeContext { + nodeId: string; + nodeName?: string; + coords: { x: number, y: number }; + graph: GraphModel; + setGraph: React.Dispatch> +} + +export interface OpenNodeContext { + opened: boolean; + nodeContext: NodeContext | undefined; +} + const viz = new Viz({ Module, render }); const items: MenuProps['items'] = [ @@ -71,6 +83,8 @@ export default function Graph({ setGraphPath }) { const [nodeName, setSelectedNodeName] = useState(''); const [graphId, selectGraphId] = useState('main'); const [graphsPath, setGraphsPath] = useState([createPathSegment('main', 'Main')]) + const [nodeContext, openNodeContext] = useState(null) + const [nodeContextMenuOpened, openNodeContextMenu] = useState({ nodeContext: undefined, opened: false }) useEffect(() => { console.info(graphsPath); @@ -82,6 +96,12 @@ export default function Graph({ setGraphPath }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [graph]); + useEffect(() => { + if (nodeContext) { + openContextMenu(true); + } + }, [nodeContext]); + async function renderGraph() { const dot = graphToDot(graph); @@ -108,25 +128,24 @@ export default function Graph({ setGraphPath }) { }) .on('contextmenu', function (event) { const id = d3.select(this).attr('id'); - const name = d3.select(this).attr('label'); - handleOpenContextMenuClick(event, id, name); + const node = graph.nodes.find(n => n.id === id); + + if (!node) { + return; + } + + event.preventDefault(); + console.info(node.label); + openNodeContext({ + nodeId: id, + nodeName: node.label, + coords: { x: event.clientX, y: event.clientY }, + setGraph: setGraph, + graph: graph + }) }); } - function contextMenuOpenChange(open: boolean) { - if (!open) { - openContextMenu(false) - } - } - - function handleOpenContextMenuClick(event: React.MouseEvent, id: string, name: string) { - event.preventDefault(); - selectNodeId(id); - setSelectedNodeName(name) - setCoords({ x: event.clientX, y: event.clientY }); - openContextMenu(true); - } - function createChildNode(parentId: string) { const id = crypto.randomUUID(); setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: 'New node' }], edges: [...prev.edges, { from: parentId, to: id }] })); @@ -225,26 +244,17 @@ export default function Graph({ setGraphPath }) {
- - + - {/* document.body} - // 👇 Key part: manually position the dropdown - overlayStyle={{ - position: "absolute", - left: coords.x, - top: coords.y, - }}> - - */} - renameNode()} - onCancel={() => openRenameModal(false)} - > - setSelectedNodeName(e.target.value)} /> - + + ) } diff --git a/src/components/NodeContextMenu.tsx b/src/components/NodeContextMenu.tsx index 371c628..0d5bdef 100644 --- a/src/components/NodeContextMenu.tsx +++ b/src/components/NodeContextMenu.tsx @@ -1,5 +1,5 @@ import { Dropdown, type MenuProps } from "antd"; -import { useState } from "react"; +import type { EdgeModel, NodeContext } from "./Graph"; const items: MenuProps['items'] = [ { @@ -19,15 +19,21 @@ const items: MenuProps['items'] = [ } ] -export default function NodeContextMenu({ - coords, - openContextMenu, - contextMenuOpened } : - { - coords: {x:number, y:number}, - openContextMenu: React.Dispatch>, - contextMenuOpened: boolean - }) { +export default function NodeContextMenu({ + nodeContext, + contextMenuOpened, + openContextMenu, + openRenameModal +}: { + nodeContext: NodeContext, + contextMenuOpened: boolean, + openContextMenu: React.Dispatch>, + openRenameModal: React.Dispatch> +}) { + if (!contextMenuOpened) { + return; + } + function contextMenuOpenChange(open: boolean) { if (!open) { openContextMenu(false) @@ -35,32 +41,54 @@ export default function NodeContextMenu({ } const onMenuClick: MenuProps['onClick'] = ({ key }) => { - switch (key) { - case 'rename': { - //openRenameModal(true); - break; - } - case 'remove': { - //removeNode(nodeId); - break; - } - case 'subgraph': { - - - break; - } + switch (key) { + case 'rename': { + openRenameModal(true); + break; } - }; + case 'remove': { + removeNode(nodeContext.nodeId); + break; + } + case 'subgraph': { + + + break; + } + } + }; + + function removeNode(id: string) { + nodeContext.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: coords.x, - top: coords.y, + left: nodeContext.coords.x, + top: nodeContext.coords.y, }}> - ) } diff --git a/src/components/NodeRenameModal.tsx b/src/components/NodeRenameModal.tsx new file mode 100644 index 0000000..aca6940 --- /dev/null +++ b/src/components/NodeRenameModal.tsx @@ -0,0 +1,39 @@ +import { Input, Modal } from "antd"; +import type { NodeContext } from "./Graph"; +import { useState } from "react"; + +export default function NodeRenameModal({ + nodeContext, + renameModalOpened, + openRenameModal +} : { + nodeContext: NodeContext, + renameModalOpened: boolean, + openRenameModal: React.Dispatch> +}) { + if(!renameModalOpened){ + return; + } + const [nodeName, setSelectedNodeName] = useState(nodeContext.nodeName); + + function renameNode() { + const node = nodeContext.graph.nodes.find(n => n.id === nodeContext.nodeId); + if (!node) { + return; + } + node.label = nodeName; + nodeContext.setGraph(prev => ({ ...prev, nodes: nodeContext.graph.nodes })); + openRenameModal(false); + } + + return ( + renameNode()} + onCancel={() => openRenameModal(false)} + > + setSelectedNodeName(e.target.value)} /> + + ) +} \ No newline at end of file