refactor node context menu III, added zustand

This commit is contained in:
2025-11-11 01:25:59 +01:00
parent 8e630839a0
commit cd4963a4bd
7 changed files with 124 additions and 139 deletions

34
package-lock.json generated
View File

@@ -13,7 +13,8 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"viz.js": "^2.1.2" "viz.js": "^2.1.2",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
@@ -2008,7 +2009,7 @@
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -5238,6 +5239,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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
}
}
} }
} }
} }

View File

@@ -15,7 +15,8 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"viz.js": "^2.1.2" "viz.js": "^2.1.2",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { createContext, useState } from 'react';
import { Layout, theme, Breadcrumb } from 'antd'; 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'; import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb';
const { Content } = Layout; const { Content } = Layout;
@@ -11,10 +11,6 @@ const App: React.FC = () => {
} = theme.useToken(); } = theme.useToken();
const [graphLevel, setGraphLevel] = useState<BreadcrumbItemType[]>([]) const [graphLevel, setGraphLevel] = useState<BreadcrumbItemType[]>([])
function setGraphLevelPath(path: BreadcrumbItemType[]) {
setGraphLevel(path)
}
return ( return (
<Layout> <Layout>
<Layout> <Layout>
@@ -28,7 +24,7 @@ const App: React.FC = () => {
borderRadius: borderRadiusLG, borderRadius: borderRadiusLG,
}} }}
> >
<Graph setGraphPath={setGraphLevelPath} /> <Graph setGraphPath={setGraphLevel} />
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>

View File

@@ -1,13 +1,13 @@
import { useEffect, useRef, useState } from "react"; import { createContext, useEffect, useRef, useState } from "react";
import Viz from 'viz.js'; import Viz from 'viz.js';
import { Module, render } from 'viz.js/full.render.js'; import { Module, render } from 'viz.js/full.render.js';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { graphToDot } from "../Graphviz"; import { graphToDot } from "../Graphviz";
import { type MenuProps } from "antd";
import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb"; import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb";
import { cloneDeep } from "lodash";
import NodeContextMenu from "./NodeContextMenu"; import NodeContextMenu from "./NodeContextMenu";
import NodeRenameModal from "./NodeRenameModal"; import NodeRenameModal from "./NodeRenameModal";
import { useGraphsStore } from "./GraphsCollection";
export class GraphModel { export class GraphModel {
nodes: NodeModel[] = []; nodes: NodeModel[] = [];
@@ -42,8 +42,13 @@ export interface NodeContext {
nodeId: string; nodeId: string;
nodeName?: string; nodeName?: string;
coords: { x: number, y: number }; coords: { x: number, y: number };
}
export interface GraphContext {
graphId: string;
selectGraphId: React.Dispatch<React.SetStateAction<string>>;
graph: GraphModel; graph: GraphModel;
setGraph: React.Dispatch<React.SetStateAction<GraphModel>> setGraph: React.Dispatch<React.SetStateAction<GraphModel>>;
} }
export interface OpenNodeContext { export interface OpenNodeContext {
@@ -52,42 +57,24 @@ export interface OpenNodeContext {
} }
const viz = new Viz({ Module, render }); const viz = new Viz({ Module, render });
export const graphContext = createContext<GraphContext | null>(null);
const items: MenuProps['items'] = [ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<React.SetStateAction<BreadcrumbItemType[]>> }) {
{
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<string, GraphModel>();
export default function Graph({ setGraphPath }) {
const containerRef = useRef(null); const containerRef = useRef(null);
const [graph, setGraph] = useState(defaultGraph()); const [graph, setGraph] = useState(defaultGraph());
const [contextMenuOpened, openContextMenu] = useState(false); const [contextMenuOpened, openContextMenu] = useState(false);
const [renameModalOpened, openRenameModal] = useState(false); const [renameModalOpened, openRenameModal] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [nodeId, selectNodeId] = useState('');
const [nodeName, setSelectedNodeName] = useState('');
const [graphId, selectGraphId] = useState('main'); const [graphId, selectGraphId] = useState('main');
const [graphsPath, setGraphsPath] = useState([createPathSegment('main', 'Main')]) const [graphsPath, setGraphsPath] = useState([createPathSegment('main', 'Main')])
const [nodeContext, openNodeContext] = useState<null | NodeContext>(null) const [nodeContext, openNodeContext] = useState<null | NodeContext>(null);
const [nodeContextMenuOpened, openNodeContextMenu] = useState<OpenNodeContext>({ nodeContext: undefined, opened: false }) const graphContextValue = {
graphId: graphId,
selectGraphId: selectGraphId,
graph: graph,
setGraph: setGraph
};
useEffect(() => { useEffect(() => {
console.info(graphsPath);
setGraphPath(graphsPath); setGraphPath(graphsPath);
}, [graphsPath]) }, [graphsPath])
@@ -102,6 +89,19 @@ export default function Graph({ setGraphPath }) {
} }
}, [nodeContext]); }, [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() { async function renderGraph() {
const dot = graphToDot(graph); const dot = graphToDot(graph);
@@ -135,13 +135,10 @@ export default function Graph({ setGraphPath }) {
} }
event.preventDefault(); event.preventDefault();
console.info(node.label);
openNodeContext({ openNodeContext({
nodeId: id, nodeId: id,
nodeName: node.label, nodeName: node.label,
coords: { x: event.clientX, y: event.clientY }, 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 }] })); setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: 'New node' }], edges: [...prev.edges, { from: parentId, to: id }] }));
} }
function removeNode(id: string) { function createPathSegment(
setGraph(prev => ({ ...prev, nodes: [...prev.nodes.filter(n => n.id !== nodeId)], edges: removeEdgesOfNode(id, prev.edges) })); pathSegmentId: string,
} selectedNodeName: string | undefined)
: BreadcrumbItemType {
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 {
return { return {
title: selectedNodeName, title: selectedNodeName,
key: pathSegmentId, key: pathSegmentId,
onClick: (e) => { onClick: () => {
console.info(e);
const index = graphsPath.findIndex(p => p.key === pathSegmentId); const index = graphsPath.findIndex(p => p.key === pathSegmentId);
setGraphsPath(prev => { setGraphsPath(prev => {
prev.splice(index + 1); prev.splice(index + 1);
return [...prev]; return [...prev];
}); });
const graph = graphsById.get(pathSegmentId);
if (graph) {
selectGraphId(pathSegmentId); selectGraphId(pathSegmentId);
setGraph(graph);
renderGraph();
} else {
throw Error(`No graph with id '${pathSegmentId}'`);
}
} }
} as BreadcrumbItemType; } as BreadcrumbItemType;
} }
@@ -244,6 +172,7 @@ export default function Graph({ setGraphPath }) {
<div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{ minHeight: '600px', overflow: 'auto' }}> <div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{ minHeight: '600px', overflow: 'auto' }}>
</div> </div>
<graphContext.Provider value={graphContextValue}>
<NodeContextMenu <NodeContextMenu
nodeContext={nodeContext!} nodeContext={nodeContext!}
contextMenuOpened={contextMenuOpened} contextMenuOpened={contextMenuOpened}
@@ -255,11 +184,12 @@ export default function Graph({ setGraphPath }) {
renameModalOpened={renameModalOpened} renameModalOpened={renameModalOpened}
openRenameModal={openRenameModal}> openRenameModal={openRenameModal}>
</NodeRenameModal> </NodeRenameModal>
</graphContext.Provider>
</div> </div>
) )
} }
function defaultGraph(): GraphModel { export function defaultGraph(): GraphModel {
const start = crypto.randomUUID(); const start = crypto.randomUUID();
const end = crypto.randomUUID(); const end = crypto.randomUUID();

View File

@@ -0,0 +1,11 @@
import { create } from 'zustand'
import type { GraphModel } from './Graph'
export const useGraphsStore = create((set) => ({
graphsById: new Map<string, GraphModel>(),
addGraph: (id: string, graph: GraphModel) => set((state: Map<string, GraphModel>) => {
const newMap = new Map(state);
newMap.set(id, graph);
return { nodes: newMap };
})
}))

View File

@@ -1,5 +1,8 @@
import { Dropdown, type MenuProps } from "antd"; 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'] = [ const items: MenuProps['items'] = [
{ {
@@ -34,6 +37,9 @@ export default function NodeContextMenu({
return; return;
} }
const graphContextValue = useContext(graphContext)!;
const graphsById = useGraphsStore((s) => s.graphsById);
function contextMenuOpenChange(open: boolean) { function contextMenuOpenChange(open: boolean) {
if (!open) { if (!open) {
openContextMenu(false) openContextMenu(false)
@@ -51,7 +57,17 @@ export default function NodeContextMenu({
break; break;
} }
case 'subgraph': { 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; break;
} }
@@ -59,7 +75,7 @@ export default function NodeContextMenu({
}; };
function removeNode(id: string) { 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[] { function removeEdgesOfNode(nodeId: string, edges: EdgeModel[]): EdgeModel[] {

View File

@@ -1,6 +1,6 @@
import { Input, Modal } from "antd"; import { Input, Modal } from "antd";
import type { NodeContext } from "./Graph"; import { graphContext, type NodeContext } from "./Graph";
import { useState } from "react"; import { useContext, useState } from "react";
export default function NodeRenameModal({ export default function NodeRenameModal({
nodeContext, nodeContext,
@@ -15,14 +15,15 @@ export default function NodeRenameModal({
return; return;
} }
const [nodeName, setSelectedNodeName] = useState(nodeContext.nodeName); const [nodeName, setSelectedNodeName] = useState(nodeContext.nodeName);
const graphContextValue = useContext(graphContext)!;
function renameNode() { 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) { if (!node) {
return; return;
} }
node.label = nodeName; node.label = nodeName;
nodeContext.setGraph(prev => ({ ...prev, nodes: nodeContext.graph.nodes })); graphContextValue.setGraph(prev => ({ ...prev, nodes: graphContextValue.graph.nodes }));
openRenameModal(false); openRenameModal(false);
} }