feature: add Create Node action for orphaned node creation

Right-clicking on empty graph area (outside any node or edge) now shows
a context menu with a single 'Create Node' option. Clicking it adds a new
node with a random label and no edges, allowing the user to wire it up
manually afterwards.

- NodeContext gains isEmptyArea flag to distinguish empty-area from node
  right-clicks
- Container div contextmenu handler covers the white space below the SVG;
  SVG-level handler covers empty space within the rendered graph
- node contextmenu handler calls stopPropagation so the SVG handler never
  fires for node clicks; SVG handler also guards via target.closest check
- NodeContextMenu renders emptyAreaItems (Create Node only) vs nodeItems
  (Rename / Subgraph / Remove) based on the flag
- randomWordList extracted as a named export so NodeContextMenu can reuse
  the same word bank
- Three new Playwright e2e tests cover: empty-area menu shows only Create
  Node, created node is orphaned (no new edges), node right-click does not
  show Create Node
This commit is contained in:
2026-03-19 13:25:54 +01:00
parent 92ef00e78f
commit 0eea473670
3 changed files with 93 additions and 7 deletions

View File

@@ -1,11 +1,11 @@
import { Dropdown, type MenuProps } from "antd";
import { defaultGraph, graphContext, GraphModel, type EdgeModel, type NodeContext } from "./Graph";
import { defaultGraph, graphContext, GraphModel, randomWordList, type EdgeModel, type NodeContext } from "./Graph";
import { useContext } from "react";
import { cloneDeep } from "lodash";
import { useGraphsStore } from "../stores/GraphsStore";
import { useGraphLayersTreeStore } from "../stores/TreeStore";
const items: MenuProps['items'] = [
const nodeItems: MenuProps['items'] = [
{
key: 'rename',
label: 'Rename',
@@ -23,6 +23,13 @@ const items: MenuProps['items'] = [
}
]
const emptyAreaItems: MenuProps['items'] = [
{
key: 'create',
label: 'Create Node',
}
]
export default function NodeContextMenu({
nodeContext,
contextMenuOpened,
@@ -38,6 +45,7 @@ export default function NodeContextMenu({
return;
}
const items = nodeContext.isEmptyArea ? emptyAreaItems : nodeItems;
const graphContextValue = useContext(graphContext)!;
const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById);
const addTreeNode = useGraphLayersTreeStore(store => store.add);
@@ -60,6 +68,11 @@ export default function NodeContextMenu({
removeTreeNode(nodeContext.nodeId);
break;
}
case 'create': {
const id = crypto.randomUUID();
graphContextValue.setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: randomWordList(1)[0] }] }));
break;
}
case 'subgraph': {
graphsById.set(graphContextValue.graphId, cloneDeep(graphContextValue.graph));
graphContextValue.selectGraphId(nodeContext.nodeId);