diff --git a/package-lock.json b/package-lock.json index a94393d..00dc050 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "antd": "^5.28.0", "d3": "^7.9.0", + "lodash": "^4.17.21", "react": "^19.1.1", "react-dom": "^19.1.1", "viz.js": "^2.1.2" @@ -17,6 +18,7 @@ "devDependencies": { "@eslint/js": "^9.36.0", "@types/d3": "^7.4.3", + "@types/lodash": "^4.17.20", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -1984,6 +1986,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", @@ -3754,6 +3763,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index 6ede1c5..41eecda 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "antd": "^5.28.0", "d3": "^7.9.0", + "lodash": "^4.17.21", "react": "^19.1.1", "react-dom": "^19.1.1", "viz.js": "^2.1.2" @@ -19,6 +20,7 @@ "devDependencies": { "@eslint/js": "^9.36.0", "@types/d3": "^7.4.3", + "@types/lodash": "^4.17.20", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", diff --git a/src/App.tsx b/src/App.tsx index 5b4a24d..327dcae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,38 @@ -import { useEffect, useRef, useState } from "react"; -import { GraphRenderer } from "./Graph"; +import React, { useState } from 'react'; +import { Layout, theme, Breadcrumb } from 'antd'; +import Graph, { NodeModel } from './components/Graph'; +import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb'; -export default function App() { - const containerRef = useRef(null); - const firstLevelGraph = new GraphRenderer(containerRef); +const { Content } = Layout; + +const App: React.FC = () => { + const { + token: { colorBgContainer, borderRadiusLG }, + } = theme.useToken(); + const [graphLevel, setGraphLevel] = useState([]) + + function setGraphLevelPath(path: BreadcrumbItemType[]) { + setGraphLevel(path) + } - useEffect(() => { - firstLevelGraph.render(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }); - return ( -
-
-
+ + + + + + + + ); -} \ No newline at end of file +}; + +export default App; \ No newline at end of file diff --git a/src/Graph.tsx b/src/Graph.tsx deleted file mode 100644 index 9fb7437..0000000 --- a/src/Graph.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useState } from "react"; -import Viz from 'viz.js'; -import { Module, render } from 'viz.js/full.render.js'; -import { graphToDot } from "./Graphviz"; -import * as d3 from 'd3'; -import { NodeContextMenu } from "./NodeContextMenu"; - -const viz = new Viz({ Module, render }); - -export class GraphRenderer { - graph: Graph; - setGraph: React.Dispatch>; - containerRef: React.RefObject; - contextMenu: NodeContextMenu; - - constructor(containerRef: React.RefObject) { - [this.graph, this.setGraph] = useState(defaultGraph()); - this.containerRef = containerRef; - this.contextMenu = new NodeContextMenu(containerRef); - } - - public async render() { - const dot = graphToDot(this.graph); - - try { - const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' }); - const container = this.containerRef.current; - if (!container) return; - container.innerHTML = ''; - container.appendChild(svgElement); - this.attachInteractions(svgElement); - } catch (e) { - console.error('Viz render error', e); - } - } - - attachInteractions(svgElement: SVGSVGElement) { - const svg = d3.select(svgElement); - const self = this; - svg.selectAll('g.node') - .style('cursor', 'pointer') - .on('click', function (event) { - event.stopPropagation(); - const id = d3.select(this).attr('id'); - self.createChildNode(id); - self.render(); - }) - .on('contextmenu', function (event) { - event.preventDefault(); - event.stopPropagation(); - const id = d3.select(this).attr('id'); - const { clientX: x, clientY: y } = event; - self.contextMenu.setContextMenu({ x, y, nodeId: id }); - }); - } - - createChildNode(parentId: string) { - const id = crypto.randomUUID(); - this.setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: 'New node' }], edges: [...prev.edges, { from: parentId, to: id }] })); - } -} - -function defaultGraph(): Graph { - return { - nodes: [ - { id: 'A', label: 'A' }, - { id: 'B', label: 'B' }, - { id: 'C', label: 'C' } - ], - edges: [ - { from: 'A', to: 'B' }, - { from: 'B', to: 'C' } - ] - }; -} - -export class Graph { - nodes: Node[] = []; - edges: Edge[] = []; -} - -export class Edge { - from: string; - to: string; - - constructor(from: string, to: string) { - this.from = from; - this.to = to; - } -} - -export class Node { - public id: string; - public label?: string; - - constructor(id: string) { - this.id = id; - } -} \ No newline at end of file diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx new file mode 100644 index 0000000..71bba0d --- /dev/null +++ b/src/components/Graph.tsx @@ -0,0 +1,265 @@ +import { createContext, useContext, useEffect, useRef, useState, type MouseEventHandler } 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 { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb"; +import { cloneDeep } from "lodash"; + +export class GraphModel { + nodes: NodeModel[] = []; + edges: EdgeModel[] = []; +} + +export class EdgeModel { + from: string; + to: string; + + constructor(from: string, to: string) { + this.from = from; + this.to = to; + } +} + +export class NodeModel { + public id: string; + public label?: string; + + constructor(id: string, name: string | undefined = undefined) { + this.id = id; + this.label = name; + } +} + +export interface GraphLevel extends BreadcrumbItemType { + +} + +const viz = new Viz({ Module, render }); + +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 }) { + 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')]) + + useEffect(() => { + console.info(graphsPath); + setGraphPath(graphsPath); + }, [graphsPath]) + + useEffect(() => { + renderGraph(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph]); + + async function renderGraph() { + const dot = graphToDot(graph); + + try { + const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' }); + const container = containerRef.current; + if (!container) return; + container.innerHTML = ''; + container.appendChild(svgElement); + attachInteractions(svgElement); + } catch (e) { + console.error('Viz render error', e); + } + } + + function attachInteractions(svgElement: SVGSVGElement) { + const svg = d3.select(svgElement); + svg.selectAll('g.node') + .style('cursor', 'pointer') + .on('click', function (event) { + const id = d3.select(this).attr('id'); + createChildNode(id); + renderGraph(); + }) + .on('contextmenu', function (event) { + const id = d3.select(this).attr('id'); + const name = d3.select(this).attr('label'); + handleOpenContextMenuClick(event, id, name); + }); + } + + 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 }] })); + } + + 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 { + return { + title: selectedNodeName, + key: pathSegmentId, + onClick: (e) => { + console.info(e); + 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}'`); + } + } + } as BreadcrumbItemType; + } + + return ( +
+
+ +
+ 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)} /> + +
+ ) +} + +function defaultGraph(): GraphModel { + const start = crypto.randomUUID(); + const end = crypto.randomUUID(); + + return { + nodes: [ + { id: start, label: 'Start' }, + { id: end, label: 'End' } + ], + edges: [ + { from: start, to: end }, + ] + }; +} + + + + diff --git a/src/index.css b/src/index.css index 08a3ac9..e69de29 100644 --- a/src/index.css +++ b/src/index.css @@ -1,68 +0,0 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -}