refactor context menu II

This commit is contained in:
2025-11-10 21:33:12 +01:00
parent 80f9044729
commit 8e630839a0
3 changed files with 144 additions and 67 deletions

View File

@@ -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 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 { Dropdown, type MenuProps } from "antd"; import { type MenuProps } from "antd";
import { Modal } from "antd";
import { Input } from "antd";
import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb"; import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import NodeContextMenu from "./NodeContextMenu"; import NodeContextMenu from "./NodeContextMenu";
import NodeRenameModal from "./NodeRenameModal";
export class GraphModel { export class GraphModel {
nodes: NodeModel[] = []; 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<React.SetStateAction<GraphModel>>
}
export interface OpenNodeContext {
opened: boolean;
nodeContext: NodeContext | undefined;
}
const viz = new Viz({ Module, render }); const viz = new Viz({ Module, render });
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
@@ -71,6 +83,8 @@ export default function Graph({ setGraphPath }) {
const [nodeName, setSelectedNodeName] = 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 [nodeContextMenuOpened, openNodeContextMenu] = useState<OpenNodeContext>({ nodeContext: undefined, opened: false })
useEffect(() => { useEffect(() => {
console.info(graphsPath); console.info(graphsPath);
@@ -82,6 +96,12 @@ export default function Graph({ setGraphPath }) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [graph]); }, [graph]);
useEffect(() => {
if (nodeContext) {
openContextMenu(true);
}
}, [nodeContext]);
async function renderGraph() { async function renderGraph() {
const dot = graphToDot(graph); const dot = graphToDot(graph);
@@ -108,25 +128,24 @@ export default function Graph({ setGraphPath }) {
}) })
.on('contextmenu', function (event) { .on('contextmenu', function (event) {
const id = d3.select(this).attr('id'); const id = d3.select(this).attr('id');
const name = d3.select(this).attr('label'); const node = graph.nodes.find(n => n.id === id);
handleOpenContextMenuClick(event, id, name);
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<HTMLDivElement, MouseEvent>, id: string, name: string) {
event.preventDefault();
selectNodeId(id);
setSelectedNodeName(name)
setCoords({ x: event.clientX, y: event.clientY });
openContextMenu(true);
}
function createChildNode(parentId: string) { function createChildNode(parentId: string) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
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 }] }));
@@ -225,26 +244,17 @@ 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>
<NodeContextMenu coords={coords} openContextMenu={openContextMenu} contextMenuOpened={contextMenuOpened}> <NodeContextMenu
nodeContext={nodeContext!}
contextMenuOpened={contextMenuOpened}
openContextMenu={openContextMenu}
openRenameModal={openRenameModal}>
</NodeContextMenu> </NodeContextMenu>
{/* <Dropdown menu={{ items, onClick: onMenuClick }} trigger={['contextMenu']} open={contextMenuOpened} onOpenChange={contextMenuOpenChange} getPopupContainer={() => document.body} <NodeRenameModal
// 👇 Key part: manually position the dropdown nodeContext={nodeContext!}
overlayStyle={{ renameModalOpened={renameModalOpened}
position: "absolute", openRenameModal={openRenameModal}>
left: coords.x, </NodeRenameModal>
top: coords.y,
}}>
</Dropdown> */}
<Modal
title="Rename"
open={renameModalOpened}
onOk={() => renameNode()}
onCancel={() => openRenameModal(false)}
>
<Input value={nodeName} onChange={(e) => setSelectedNodeName(e.target.value)} />
</Modal>
</div> </div>
) )
} }

View File

@@ -1,5 +1,5 @@
import { Dropdown, type MenuProps } from "antd"; import { Dropdown, type MenuProps } from "antd";
import { useState } from "react"; import type { EdgeModel, NodeContext } from "./Graph";
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
{ {
@@ -19,15 +19,21 @@ const items: MenuProps['items'] = [
} }
] ]
export default function NodeContextMenu({ export default function NodeContextMenu({
coords, nodeContext,
openContextMenu, contextMenuOpened,
contextMenuOpened } : openContextMenu,
{ openRenameModal
coords: {x:number, y:number}, }: {
openContextMenu: React.Dispatch<React.SetStateAction<boolean>>, nodeContext: NodeContext,
contextMenuOpened: boolean contextMenuOpened: boolean,
}) { openContextMenu: React.Dispatch<React.SetStateAction<boolean>>,
openRenameModal: React.Dispatch<React.SetStateAction<boolean>>
}) {
if (!contextMenuOpened) {
return;
}
function contextMenuOpenChange(open: boolean) { function contextMenuOpenChange(open: boolean) {
if (!open) { if (!open) {
openContextMenu(false) openContextMenu(false)
@@ -35,32 +41,54 @@ export default function NodeContextMenu({
} }
const onMenuClick: MenuProps['onClick'] = ({ key }) => { const onMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) { switch (key) {
case 'rename': { case 'rename': {
//openRenameModal(true); openRenameModal(true);
break; break;
}
case 'remove': {
//removeNode(nodeId);
break;
}
case 'subgraph': {
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 ( return (
<Dropdown menu={{ items, onClick: onMenuClick }} trigger={['contextMenu']} open={contextMenuOpened} onOpenChange={contextMenuOpenChange} getPopupContainer={() => document.body} <Dropdown menu={{ items, onClick: onMenuClick }} trigger={['contextMenu']} open={contextMenuOpened} onOpenChange={contextMenuOpenChange} getPopupContainer={() => document.body}
// 👇 Key part: manually position the dropdown // 👇 Key part: manually position the dropdown
overlayStyle={{ overlayStyle={{
position: "absolute", position: "absolute",
left: coords.x, left: nodeContext.coords.x,
top: coords.y, top: nodeContext.coords.y,
}}> }}>
</Dropdown> </Dropdown>
) )
} }

View File

@@ -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<React.SetStateAction<boolean>>
}) {
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 (
<Modal
title="Rename"
open={renameModalOpened}
onOk={() => renameNode()}
onCancel={() => openRenameModal(false)}
>
<Input value={nodeName} onChange={(e) => setSelectedNodeName(e.target.value)} />
</Modal>
)
}