diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index e56eee6..b467c88 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -10,6 +10,11 @@ function nodeByLabel(page: import('@playwright/test').Page, label: string) { return page.locator('g.node').filter({ hasText: label }); } +// Helper: the graph container div (empty space below the viz.js SVG) +function graphContainer(page: import('@playwright/test').Page) { + return page.locator('.bg-white.rounded.shadow'); +} + test.describe('ConceptSketch', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -115,6 +120,42 @@ test.describe('ConceptSketch', () => { await expect(nodeByLabel(page, 'Start')).toBeVisible(); }); + test('right-clicking empty graph area shows only Create Node option', async ({ page }) => { + await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } }); + + const dropdown = page.locator('.ant-dropdown:visible'); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' })).toBeVisible(); + await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' })).not.toBeAttached(); + await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' })).not.toBeAttached(); + await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' })).not.toBeAttached(); + }); + + test('creates an orphaned node via Create Node in the context menu', async ({ page }) => { + const initialNodeCount = await page.locator('g.node').count(); + const initialEdgeCount = await page.locator('g.edge').count(); + + await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } }); + + await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); + await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' }).click(); + + await expect(page.locator('g.node')).toHaveCount(initialNodeCount + 1, { timeout: 5000 }); + // No new edge — node is orphaned + await expect(page.locator('g.edge')).toHaveCount(initialEdgeCount, { timeout: 5000 }); + }); + + test('right-clicking a node does not show Create Node option', async ({ page }) => { + await nodeByLabel(page, 'Start').click({ button: 'right' }); + + const dropdown = page.locator('.ant-dropdown:visible'); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' })).not.toBeAttached(); + await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' })).toBeVisible(); + }); + test('links nodes using Ctrl+click selection', async ({ page }) => { // Create a third node to link to await nodeByLabel(page, 'Start').click(); diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index d383630..29f4ea0 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -46,6 +46,7 @@ export interface NodeContext { nodeId: string; nodeName?: string; coords: { x: number, y: number }; + isEmptyArea?: boolean; } export interface GraphContext { @@ -116,6 +117,22 @@ const Graph = forwardRef { + const container = containerRef.current as unknown as HTMLElement; + if (!container) return; + const handler = (event: MouseEvent) => { + if ((event.target as Element).closest('svg')) return; + event.preventDefault(); + openNodeContext({ + nodeId: '', + coords: { x: event.clientX, y: event.clientY }, + isEmptyArea: true, + }); + }; + container.addEventListener('contextmenu', handler); + return () => container.removeEventListener('contextmenu', handler); + }, []); + useEffect(() => { if (loadCount === 0) return; const state = useGraphsStore.getState() as { graphsById: Map }; @@ -203,12 +220,26 @@ const Graph = forwardRef Math.random() - 0.5) - .slice(0, count); + return randomWordList(count); } function createPathSegment( @@ -281,6 +308,11 @@ const Graph = forwardRef Math.random() - 0.5).slice(0, count); +} + export function defaultGraph(): GraphModel { const start = crypto.randomUUID(); const end = crypto.randomUUID(); diff --git a/src/components/NodeContextMenu.tsx b/src/components/NodeContextMenu.tsx index 7c7360f..0365a3e 100644 --- a/src/components/NodeContextMenu.tsx +++ b/src/components/NodeContextMenu.tsx @@ -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 }).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);