refactor context menu II
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/components/NodeRenameModal.tsx
Normal file
39
src/components/NodeRenameModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user