Nodes selection store, linking and unlinking nodes
This commit is contained in:
17
src/App.tsx
17
src/App.tsx
@@ -1,7 +1,8 @@
|
|||||||
import React, { createContext, useState } from 'react';
|
import React, { createContext, useEffectEvent, useState } from 'react';
|
||||||
import { Layout, theme, Breadcrumb } from 'antd';
|
import { Layout, theme, Breadcrumb } from 'antd';
|
||||||
import Graph, { GraphModel } 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';
|
||||||
|
import { useKeysdownStore } from './stores/ArrayStore';
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
@@ -10,6 +11,20 @@ const App: React.FC = () => {
|
|||||||
token: { colorBgContainer, borderRadiusLG },
|
token: { colorBgContainer, borderRadiusLG },
|
||||||
} = theme.useToken();
|
} = theme.useToken();
|
||||||
const [graphLevel, setGraphLevel] = useState<BreadcrumbItemType[]>([])
|
const [graphLevel, setGraphLevel] = useState<BreadcrumbItemType[]>([])
|
||||||
|
const addKey = useKeysdownStore(state => state.add);
|
||||||
|
const removeKey = useKeysdownStore(state => state.remove);
|
||||||
|
const onKeydown = useEffectEvent((key: string) => {
|
||||||
|
addKey(key);
|
||||||
|
});
|
||||||
|
const onKeyUp = useEffectEvent((key: string) => {
|
||||||
|
removeKey(key);
|
||||||
|
})
|
||||||
|
document.addEventListener('keydown', (ev) => {
|
||||||
|
onKeydown(ev.key)
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', (ev) => {
|
||||||
|
onKeyUp(ev.key);
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export function graphToDot(g) {
|
|||||||
|
|
||||||
// edges
|
// edges
|
||||||
for (const e of g.edges) {
|
for (const e of g.edges) {
|
||||||
lines.push(` \"${e.from}\" -> \"${e.to}\";`);
|
const attrs = [];
|
||||||
|
attrs.push(`id=\"${e.id}\"`)
|
||||||
|
lines.push(` \"${e.from}\" -> \"${e.to}\" [${attrs.join(', ')}];`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// close
|
// close
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { graphToDot } from "../Graphviz";
|
|||||||
import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb";
|
import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb";
|
||||||
import NodeContextMenu from "./NodeContextMenu";
|
import NodeContextMenu from "./NodeContextMenu";
|
||||||
import NodeRenameModal from "./NodeRenameModal";
|
import NodeRenameModal from "./NodeRenameModal";
|
||||||
import { useGraphsStore } from "./GraphsCollection";
|
import { useGraphsStore } from "../stores/GraphsStore";
|
||||||
|
import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore";
|
||||||
|
|
||||||
|
|
||||||
export class GraphModel {
|
export class GraphModel {
|
||||||
@@ -17,10 +18,12 @@ export class GraphModel {
|
|||||||
export class EdgeModel {
|
export class EdgeModel {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
id: string;
|
||||||
|
|
||||||
constructor(from: string, to: string) {
|
constructor(from: string, to: string, id: string) {
|
||||||
this.from = from;
|
this.from = from;
|
||||||
this.to = to;
|
this.to = to;
|
||||||
|
this.id = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
|
|||||||
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 [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);
|
||||||
@@ -73,6 +76,12 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
|
|||||||
graph: graph,
|
graph: graph,
|
||||||
setGraph: setGraph
|
setGraph: setGraph
|
||||||
};
|
};
|
||||||
|
const isKeyPressed = useKeysdownStore(store => store.has);
|
||||||
|
const selectNode = useSelectedNodesStore(store => store.add);
|
||||||
|
const deselectNode = useSelectedNodesStore(store => store.remove);
|
||||||
|
const isNodeSelected = useSelectedNodesStore(store => store.has);
|
||||||
|
const anyNodeSelected = useSelectedNodesStore(store => store.hasAny);
|
||||||
|
const deselectAllNodes = useSelectedNodesStore(store => store.clear);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGraphPath(graphsPath);
|
setGraphPath(graphsPath);
|
||||||
@@ -119,12 +128,39 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
|
|||||||
|
|
||||||
function attachInteractions(svgElement: SVGSVGElement) {
|
function attachInteractions(svgElement: SVGSVGElement) {
|
||||||
const svg = d3.select(svgElement);
|
const svg = d3.select(svgElement);
|
||||||
|
svg.selectAll('g.edge')
|
||||||
|
.style('cursor', 'not-allowed')
|
||||||
|
.on('click', function () {
|
||||||
|
const d3Node = d3.select(this);
|
||||||
|
const id = d3Node.attr('id');
|
||||||
|
setGraph(prev => ({ ...prev, edges: [...prev.edges.filter(e => e.id !== id)] }));
|
||||||
|
})
|
||||||
svg.selectAll('g.node')
|
svg.selectAll('g.node')
|
||||||
.style('cursor', 'pointer')
|
.style('cursor', 'pointer')
|
||||||
.on('click', function (event) {
|
.on('click', function () {
|
||||||
const id = d3.select(this).attr('id');
|
const d3Node = d3.select(this);
|
||||||
createChildNode(id);
|
const d3Rect = d3Node.select('g.node polygon');
|
||||||
renderGraph();
|
const id = d3Node.attr('id');
|
||||||
|
if (isKeyPressed('Control')) {
|
||||||
|
if (isNodeSelected(id)) {
|
||||||
|
deselectNode(id);
|
||||||
|
d3Rect
|
||||||
|
.attr("stroke", "#000000");
|
||||||
|
} else {
|
||||||
|
selectNode(id);
|
||||||
|
d3Rect
|
||||||
|
.attr("stroke", "#1677ff")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (anyNodeSelected()) {
|
||||||
|
linkSelectedNodesAsParents(id);
|
||||||
|
deselectAllNodes();
|
||||||
|
d3.selectAll('g.node polygon')
|
||||||
|
.attr("stroke", "#000000");
|
||||||
|
} else {
|
||||||
|
createChildNode(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.on('contextmenu', function (event) {
|
.on('contextmenu', function (event) {
|
||||||
const id = d3.select(this).attr('id');
|
const id = d3.select(this).attr('id');
|
||||||
@@ -143,9 +179,27 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function linkSelectedNodesAsParents(childNodeId: string) {
|
||||||
|
const selectedNodesIds = useSelectedNodesStore.getState().items;
|
||||||
|
setGraph(prev => ({ ...prev, edges: [...prev.edges, ...selectedNodesIds.map(parentId => ({ from: parentId, to: childNodeId, id: crypto.randomUUID() }))] }))
|
||||||
|
}
|
||||||
|
|
||||||
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: getRandomWords(1)[0] }], edges: [...prev.edges, { from: parentId, to: id, id: crypto.randomUUID() }] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomWords(count: number) {
|
||||||
|
const wordList = [
|
||||||
|
"apple", "river", "mountain", "sky", "storm", "ocean", "forest", "dream",
|
||||||
|
"stone", "flame", "shadow", "cloud", "leaf", "wind", "fire", "earth",
|
||||||
|
"flower", "bird", "light", "night", "sun", "rain", "snow", "tree",
|
||||||
|
"wolf", "star", "sand", "wave", "heart", "path"
|
||||||
|
];
|
||||||
|
|
||||||
|
return wordList
|
||||||
|
.sort(() => Math.random() - 0.5)
|
||||||
|
.slice(0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPathSegment(
|
function createPathSegment(
|
||||||
@@ -199,11 +253,7 @@ export function defaultGraph(): GraphModel {
|
|||||||
{ id: end, label: 'End' }
|
{ id: end, label: 'End' }
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
{ from: start, to: end },
|
{ from: start, to: end, id: crypto.randomUUID() },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Dropdown, type MenuProps } from "antd";
|
|||||||
import { defaultGraph, graphContext, type EdgeModel, type NodeContext } from "./Graph";
|
import { defaultGraph, graphContext, type EdgeModel, type NodeContext } from "./Graph";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { cloneDeep } from "lodash";
|
import { cloneDeep } from "lodash";
|
||||||
import { useGraphsStore } from "./GraphsCollection";
|
import { useGraphsStore } from "../stores/GraphsStore";
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
const items: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
|
|||||||
42
src/stores/ArrayStore.tsx
Normal file
42
src/stores/ArrayStore.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface ArrayStore<T> {
|
||||||
|
items: T[];
|
||||||
|
add: (key: T) => void;
|
||||||
|
remove: (key: T) => void;
|
||||||
|
has: (key: T) => boolean;
|
||||||
|
hasAny: () => boolean;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createArrayStore<T>() {
|
||||||
|
return create<ArrayStore<T>>()((set, get) => ({
|
||||||
|
items: [],
|
||||||
|
add: (item) => set((state) => {
|
||||||
|
const newState = { items: [...state.items, item] };;
|
||||||
|
console.info(newState);
|
||||||
|
return newState;
|
||||||
|
}),
|
||||||
|
remove: (item) => set((state) => {
|
||||||
|
const newState = ({ items: [...state.items.filter(k => k !== item)] });
|
||||||
|
console.info(newState);
|
||||||
|
return newState;
|
||||||
|
}),
|
||||||
|
has: (key: T) => {
|
||||||
|
const state = get();
|
||||||
|
return state.items.indexOf(key) > -1;
|
||||||
|
},
|
||||||
|
hasAny: () => {
|
||||||
|
const state = get();
|
||||||
|
return state.items.length > 0;
|
||||||
|
},
|
||||||
|
clear: () => set(() => {
|
||||||
|
const newState = ({ items: [] });
|
||||||
|
console.info(newState);
|
||||||
|
return newState;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useKeysdownStore = createArrayStore<string>();
|
||||||
|
export const useSelectedNodesStore = createArrayStore<string>();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { GraphModel } from './Graph'
|
import type { GraphModel } from '../components/Graph'
|
||||||
|
|
||||||
export const useGraphsStore = create((set) => ({
|
export const useGraphsStore = create((set) => ({
|
||||||
graphsById: new Map<string, GraphModel>(),
|
graphsById: new Map<string, GraphModel>(),
|
||||||
Reference in New Issue
Block a user