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",
"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
}
}
}
}
}

View File

@@ -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",

View File

@@ -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<BreadcrumbItemType[]>([])
function setGraphLevelPath(path: BreadcrumbItemType[]) {
setGraphLevel(path)
}
return (
<Layout>
<Layout>
<Breadcrumb style={{ margin: '8px 0 0 16px' }} items={graphLevel} />
<Breadcrumb style={{ margin: '8px 0 0 16px' }} items={graphLevel} />
<Content
style={{
margin: '8px 16px',
@@ -28,7 +24,7 @@ const App: React.FC = () => {
borderRadius: borderRadiusLG,
}}
>
<Graph setGraphPath={setGraphLevelPath} />
<Graph setGraphPath={setGraphLevel} />
</Content>
</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 { 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<React.SetStateAction<string>>;
graph: GraphModel;
setGraph: React.Dispatch<React.SetStateAction<GraphModel>>
setGraph: React.Dispatch<React.SetStateAction<GraphModel>>;
}
export interface OpenNodeContext {
@@ -52,42 +57,24 @@ export interface OpenNodeContext {
}
const viz = new Viz({ Module, render });
export const graphContext = createContext<GraphContext | null>(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<string, GraphModel>();
export default function Graph({ setGraphPath }) {
export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<React.SetStateAction<BreadcrumbItemType[]>> }) {
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 [graphId, selectGraphId] = useState('main');
const [graphsPath, setGraphsPath] = useState([createPathSegment('main', 'Main')])
const [nodeContext, openNodeContext] = useState<null | NodeContext>(null)
const [nodeContextMenuOpened, openNodeContextMenu] = useState<OpenNodeContext>({ nodeContext: undefined, opened: false })
const [nodeContext, openNodeContext] = useState<null | NodeContext>(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 }) {
<div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{ minHeight: '600px', overflow: 'auto' }}>
</div>
<NodeContextMenu
nodeContext={nodeContext!}
contextMenuOpened={contextMenuOpened}
openContextMenu={openContextMenu}
openRenameModal={openRenameModal}>
</NodeContextMenu>
<NodeRenameModal
nodeContext={nodeContext!}
renameModalOpened={renameModalOpened}
openRenameModal={openRenameModal}>
</NodeRenameModal>
<graphContext.Provider value={graphContextValue}>
<NodeContextMenu
nodeContext={nodeContext!}
contextMenuOpened={contextMenuOpened}
openContextMenu={openContextMenu}
openRenameModal={openRenameModal}>
</NodeContextMenu>
<NodeRenameModal
nodeContext={nodeContext!}
renameModalOpened={renameModalOpened}
openRenameModal={openRenameModal}>
</NodeRenameModal>
</graphContext.Provider>
</div>
)
}
function defaultGraph(): GraphModel {
export function defaultGraph(): GraphModel {
const start = 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 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[] {

View File

@@ -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);
}