From cd4963a4bdd66dc71eea742ce05571942240e773 Mon Sep 17 00:00:00 2001 From: tymurbaniak Date: Tue, 11 Nov 2025 01:25:59 +0100 Subject: [PATCH] refactor node context menu III, added zustand --- package-lock.json | 34 +++++- package.json | 3 +- src/App.tsx | 12 +- src/components/Graph.tsx | 174 +++++++++------------------- src/components/GraphsCollection.tsx | 11 ++ src/components/NodeContextMenu.tsx | 20 +++- src/components/NodeRenameModal.tsx | 9 +- 7 files changed, 124 insertions(+), 139 deletions(-) create mode 100644 src/components/GraphsCollection.tsx diff --git a/package-lock.json b/package-lock.json index 00dc050..2edf164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "lodash": "^4.17.21", "react": "^19.1.1", "react-dom": "^19.1.1", - "viz.js": "^2.1.2" + "viz.js": "^2.1.2", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -2008,7 +2009,7 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -5238,6 +5239,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 41eecda..78cac2c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "lodash": "^4.17.21", "react": "^19.1.1", "react-dom": "^19.1.1", - "viz.js": "^2.1.2" + "viz.js": "^2.1.2", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/src/App.tsx b/src/App.tsx index 327dcae..661b2cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; +import React, { createContext, useState } from 'react'; import { Layout, theme, Breadcrumb } from 'antd'; -import Graph, { NodeModel } from './components/Graph'; +import Graph, { GraphModel } from './components/Graph'; import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb'; const { Content } = Layout; @@ -11,14 +11,10 @@ const App: React.FC = () => { } = theme.useToken(); const [graphLevel, setGraphLevel] = useState([]) - function setGraphLevelPath(path: BreadcrumbItemType[]) { - setGraphLevel(path) - } - return ( - + { borderRadius: borderRadiusLG, }} > - + diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index c1b43f6..c865f74 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -1,13 +1,13 @@ -import { useEffect, useRef, useState } from "react"; +import { createContext, 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 { type MenuProps } from "antd"; import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb"; -import { cloneDeep } from "lodash"; import NodeContextMenu from "./NodeContextMenu"; import NodeRenameModal from "./NodeRenameModal"; +import { useGraphsStore } from "./GraphsCollection"; + export class GraphModel { nodes: NodeModel[] = []; @@ -42,8 +42,13 @@ export interface NodeContext { nodeId: string; nodeName?: string; coords: { x: number, y: number }; +} + +export interface GraphContext { + graphId: string; + selectGraphId: React.Dispatch>; graph: GraphModel; - setGraph: React.Dispatch> + setGraph: React.Dispatch>; } export interface OpenNodeContext { @@ -52,42 +57,24 @@ export interface OpenNodeContext { } const viz = new Viz({ Module, render }); +export const graphContext = createContext(null); -const items: MenuProps['items'] = [ - { - key: 'rename', - label: 'Rename', - extra: 'ctrl + n', - }, - { - key: 'subgraph', - label: 'Subgraph', - extra: 'ctrl + s', - }, - { - key: 'remove', - label: 'Remove', - extra: 'ctrl + r' - } -] - -const graphsById = new Map(); - -export default function Graph({ setGraphPath }) { +export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch> }) { const containerRef = useRef(null); const [graph, setGraph] = useState(defaultGraph()); const [contextMenuOpened, openContextMenu] = useState(false); - const [renameModalOpened, openRenameModal] = useState(false); - const [coords, setCoords] = useState({ x: 0, y: 0 }); - const [nodeId, selectNodeId] = useState(''); - const [nodeName, setSelectedNodeName] = useState(''); + const [renameModalOpened, openRenameModal] = useState(false); 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 }) + const [nodeContext, openNodeContext] = useState(null); + const graphContextValue = { + graphId: graphId, + selectGraphId: selectGraphId, + graph: graph, + setGraph: setGraph + }; useEffect(() => { - console.info(graphsPath); setGraphPath(graphsPath); }, [graphsPath]) @@ -102,6 +89,19 @@ export default function Graph({ setGraphPath }) { } }, [nodeContext]); + useEffect(() => { + if (nodeContext?.nodeId === graphId) { + graphsPath.push(createPathSegment(nodeContext.nodeId, nodeContext.nodeName)); + setGraphsPath([...graphsPath]) + } + const state = useGraphsStore.getState(); + const graphsById = state.graphsById; + const graph = graphsById.get(graphId); + if (graph) { + setGraph(graph); + } + }, [graphId]); + async function renderGraph() { const dot = graphToDot(graph); @@ -135,13 +135,10 @@ export default function Graph({ setGraphPath }) { } event.preventDefault(); - console.info(node.label); openNodeContext({ nodeId: id, nodeName: node.label, coords: { x: event.clientX, y: event.clientY }, - setGraph: setGraph, - graph: graph }) }); } @@ -151,90 +148,21 @@ export default function Graph({ setGraphPath }) { setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: 'New node' }], edges: [...prev.edges, { from: parentId, to: id }] })); } - function removeNode(id: string) { - setGraph(prev => ({ ...prev, nodes: [...prev.nodes.filter(n => n.id !== 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; - } - - function renameNode() { - const node = graph.nodes.find(n => n.id === nodeId); - if (!node) { - return; - } - node.label = nodeName; - setGraph(prev => ({ ...prev, nodes: graph.nodes })); - openRenameModal(false); - } - - const onMenuClick: MenuProps['onClick'] = ({ key }) => { - switch (key) { - case 'rename': { - openRenameModal(true); - break; - } - case 'remove': { - removeNode(nodeId); - break; - } - case 'subgraph': { - const selectedNode = graph.nodes.find(n => n.id === nodeId); - const selectedNodeName = selectedNode?.label; - graphsById.set(graphId, cloneDeep(graph)); - selectGraphId(nodeId); - let selectedGraph = graphsById.get(nodeId); - - if (!selectedGraph) { - selectedGraph = defaultGraph(); - } - - setGraph(selectedGraph); - renderGraph(); - graphsPath.push(createPathSegment(nodeId, selectedNodeName)); - setGraphsPath([...graphsPath]) - - break; - } - } - }; - - function createPathSegment(pathSegmentId: string, selectedNodeName: string | undefined): BreadcrumbItemType { + function createPathSegment( + pathSegmentId: string, + selectedNodeName: string | undefined) + : BreadcrumbItemType { return { title: selectedNodeName, key: pathSegmentId, - onClick: (e) => { - console.info(e); + onClick: () => { const index = graphsPath.findIndex(p => p.key === pathSegmentId); setGraphsPath(prev => { prev.splice(index + 1); return [...prev]; }); - const graph = graphsById.get(pathSegmentId); - if (graph) { - selectGraphId(pathSegmentId); - setGraph(graph); - renderGraph(); - } else { - throw Error(`No graph with id '${pathSegmentId}'`); - } + selectGraphId(pathSegmentId); } } as BreadcrumbItemType; } @@ -244,22 +172,24 @@ export default function Graph({ setGraphPath }) {
- - - - + + + + + + ) } -function defaultGraph(): GraphModel { +export function defaultGraph(): GraphModel { const start = crypto.randomUUID(); const end = crypto.randomUUID(); diff --git a/src/components/GraphsCollection.tsx b/src/components/GraphsCollection.tsx new file mode 100644 index 0000000..d4958ea --- /dev/null +++ b/src/components/GraphsCollection.tsx @@ -0,0 +1,11 @@ +import { create } from 'zustand' +import type { GraphModel } from './Graph' + +export const useGraphsStore = create((set) => ({ + graphsById: new Map(), + addGraph: (id: string, graph: GraphModel) => set((state: Map) => { + const newMap = new Map(state); + newMap.set(id, graph); + return { nodes: newMap }; + }) +})) \ No newline at end of file diff --git a/src/components/NodeContextMenu.tsx b/src/components/NodeContextMenu.tsx index 0d5bdef..783e6f5 100644 --- a/src/components/NodeContextMenu.tsx +++ b/src/components/NodeContextMenu.tsx @@ -1,5 +1,8 @@ import { Dropdown, type MenuProps } from "antd"; -import type { EdgeModel, NodeContext } from "./Graph"; +import { defaultGraph, graphContext, type EdgeModel, type NodeContext } from "./Graph"; +import { useContext } from "react"; +import { cloneDeep } from "lodash"; +import { useGraphsStore } from "./GraphsCollection"; const items: MenuProps['items'] = [ { @@ -34,6 +37,9 @@ export default function NodeContextMenu({ return; } + const graphContextValue = useContext(graphContext)!; + const graphsById = useGraphsStore((s) => s.graphsById); + function contextMenuOpenChange(open: boolean) { if (!open) { openContextMenu(false) @@ -51,7 +57,17 @@ export default function NodeContextMenu({ 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); + } + + console.info(graphsById) + graphContextValue.setGraph(selectedGraph); break; } @@ -59,7 +75,7 @@ export default function NodeContextMenu({ }; function removeNode(id: string) { - nodeContext.setGraph(prev => ({ ...prev, nodes: [...prev.nodes.filter(n => n.id !== nodeContext.nodeId)], edges: removeEdgesOfNode(id, prev.edges) })); + 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[] { diff --git a/src/components/NodeRenameModal.tsx b/src/components/NodeRenameModal.tsx index aca6940..8cea96a 100644 --- a/src/components/NodeRenameModal.tsx +++ b/src/components/NodeRenameModal.tsx @@ -1,6 +1,6 @@ import { Input, Modal } from "antd"; -import type { NodeContext } from "./Graph"; -import { useState } from "react"; +import { graphContext, type NodeContext } from "./Graph"; +import { useContext, useState } from "react"; export default function NodeRenameModal({ nodeContext, @@ -15,14 +15,15 @@ export default function NodeRenameModal({ return; } const [nodeName, setSelectedNodeName] = useState(nodeContext.nodeName); + const graphContextValue = useContext(graphContext)!; function renameNode() { - const node = nodeContext.graph.nodes.find(n => n.id === nodeContext.nodeId); + const node = graphContextValue.graph.nodes.find(n => n.id === nodeContext.nodeId); if (!node) { return; } node.label = nodeName; - nodeContext.setGraph(prev => ({ ...prev, nodes: nodeContext.graph.nodes })); + graphContextValue.setGraph(prev => ({ ...prev, nodes: graphContextValue.graph.nodes })); openRenameModal(false); }