diff --git a/src/App.tsx b/src/App.tsx index 661b2cb..42f850f 100644 --- a/src/App.tsx +++ b/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 Graph, { GraphModel } from './components/Graph'; import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb'; +import { useKeysdownStore } from './stores/ArrayStore'; const { Content } = Layout; @@ -10,6 +11,20 @@ const App: React.FC = () => { token: { colorBgContainer, borderRadiusLG }, } = theme.useToken(); const [graphLevel, setGraphLevel] = useState([]) + 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 ( diff --git a/src/Graphviz.tsx b/src/Graphviz.tsx index b11b594..72ab7da 100644 --- a/src/Graphviz.tsx +++ b/src/Graphviz.tsx @@ -15,7 +15,9 @@ export function graphToDot(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 diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index c865f74..4be463e 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -6,7 +6,8 @@ import { graphToDot } from "../Graphviz"; import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb"; import NodeContextMenu from "./NodeContextMenu"; import NodeRenameModal from "./NodeRenameModal"; -import { useGraphsStore } from "./GraphsCollection"; +import { useGraphsStore } from "../stores/GraphsStore"; +import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore"; export class GraphModel { @@ -17,10 +18,12 @@ export class GraphModel { export class EdgeModel { from: string; to: string; + id: string; - constructor(from: string, to: string) { + constructor(from: string, to: string, id: string) { this.from = from; this.to = to; + this.id = id; } } @@ -63,7 +66,7 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch(null); @@ -73,6 +76,12 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch 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(() => { setGraphPath(graphsPath); @@ -119,12 +128,39 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch ({ ...prev, edges: [...prev.edges.filter(e => e.id !== id)] })); + }) svg.selectAll('g.node') .style('cursor', 'pointer') - .on('click', function (event) { - const id = d3.select(this).attr('id'); - createChildNode(id); - renderGraph(); + .on('click', function () { + const d3Node = d3.select(this); + const d3Rect = d3Node.select('g.node polygon'); + 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) { const id = d3.select(this).attr('id'); @@ -143,9 +179,27 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch ({ ...prev, edges: [...prev.edges, ...selectedNodesIds.map(parentId => ({ from: parentId, to: childNodeId, id: crypto.randomUUID() }))] })) + } + 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 }] })); + 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( @@ -199,11 +253,7 @@ export function defaultGraph(): GraphModel { { id: end, label: 'End' } ], edges: [ - { from: start, to: end }, + { from: start, to: end, id: crypto.randomUUID() }, ] }; } - - - - diff --git a/src/components/NodeContextMenu.tsx b/src/components/NodeContextMenu.tsx index 783e6f5..bbaa1c4 100644 --- a/src/components/NodeContextMenu.tsx +++ b/src/components/NodeContextMenu.tsx @@ -2,7 +2,7 @@ import { Dropdown, type MenuProps } from "antd"; import { defaultGraph, graphContext, type EdgeModel, type NodeContext } from "./Graph"; import { useContext } from "react"; import { cloneDeep } from "lodash"; -import { useGraphsStore } from "./GraphsCollection"; +import { useGraphsStore } from "../stores/GraphsStore"; const items: MenuProps['items'] = [ { diff --git a/src/stores/ArrayStore.tsx b/src/stores/ArrayStore.tsx new file mode 100644 index 0000000..49a43f5 --- /dev/null +++ b/src/stores/ArrayStore.tsx @@ -0,0 +1,42 @@ +import { create } from 'zustand' + +export interface ArrayStore { + items: T[]; + add: (key: T) => void; + remove: (key: T) => void; + has: (key: T) => boolean; + hasAny: () => boolean; + clear: () => void; +} + +export function createArrayStore() { + return create>()((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(); +export const useSelectedNodesStore = createArrayStore(); \ No newline at end of file diff --git a/src/components/GraphsCollection.tsx b/src/stores/GraphsStore.tsx similarity index 85% rename from src/components/GraphsCollection.tsx rename to src/stores/GraphsStore.tsx index d4958ea..7b1e892 100644 --- a/src/components/GraphsCollection.tsx +++ b/src/stores/GraphsStore.tsx @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { GraphModel } from './Graph' +import type { GraphModel } from '../components/Graph' export const useGraphsStore = create((set) => ({ graphsById: new Map(),