Compare commits

..

22 Commits

Author SHA1 Message Date
4e342dc776 feature: Cut and Paste functionality' (#9) from claude/issue-8 into main
All checks were successful
Deploy to Cloudflare Pages / e2e (push) Successful in 2m47s
Deploy to Cloudflare Pages / deploy (push) Successful in 58s
Reviewed-on: #9
2026-03-26 01:00:42 +00:00
Claude Bot
d5107ac6d3 review: address feedback — update navigation tree on cut/paste
When nodes with subgraphs are cut and pasted to a different graph,
reparent their tree entries so the sidebar tree and breadcrumbs
reflect the new hierarchy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 01:00:04 +00:00
Claude Bot
63e204840f review: address feedback — expand cut to include full outgoing structure (all hops) 2026-03-26 01:00:04 +00:00
Claude Bot
000aad362a feature: implement cut and paste functionality (closes #8)
- Add CutStore (Zustand) to store cut node IDs, node data, edges, and source graph ID
- Update graphToDot() to render cut nodes with dashed grey style for visual indication
- Add 'Cut' action to node context menu; 'Paste' action to empty-area context menu
- Cut logic: captures selected nodes (or right-clicked node if none selected) plus
  all nodes reachable by outgoing edges from those nodes; clears selection afterwards
- Paste logic: adds cut nodes/edges to target graph, removes them from source graph
  in GraphsStore; pasting on the same graph as the cut source cancels the cut
- Graph re-renders automatically when cutNodeIds changes via useCutStore subscription
- Clear cut store on file load for consistency
- Add E2E tests covering: Cut menu visibility, Paste menu visibility, cut styling,
  linked-node inclusion, cross-subgraph paste, source-graph cleanup, same-graph cancel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 01:00:04 +00:00
ee9a55f4e6 feature: create tests for issue #2' (#7) from claude/issue-6 into main
All checks were successful
Deploy to Cloudflare Pages / e2e (push) Successful in 3m12s
Deploy to Cloudflare Pages / deploy (push) Successful in 56s
Reviewed-on: #7
2026-03-23 15:10:21 +00:00
Claude Bot
44378c2bf0 tests: add stale-closure regression tests for breadcrumb back-navigation (closes #6)
The fix in issue #2 resolved a stale-closure bug in createPathSegment where
onClick captured graphsPath at render time; after further navigation findIndex
returned -1 and splice(0) wiped the entire breadcrumb array.

New test suite 'Stale-closure regression (issue #2)' covers:
- Root breadcrumb remains functional after navigating three levels deep
- Level-1 breadcrumb resolves correctly when clicked from level 3
- Successive back-clicks each trim exactly one breadcrumb level
- Clicking the currently active breadcrumb does not alter the path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:07:37 +00:00
471bff1a8c feature: Modify readme' (#5) from claude/issue-4 into master
Reviewed-on: #5
2026-03-23 14:58:39 +00:00
Claude Bot
9aa28a9fa3 docs: rewrite README to describe ConceptSketch project (closes #4) 2026-03-23 14:55:43 +00:00
4733916523 Merge pull request 'Fix: Breadcrumb disappearing' (#3) from claude/issue-2 into master
Reviewed-on: #3
2026-03-23 14:41:42 +00:00
Claude Bot
eb3a23ab03 fix: resolve stale closure causing breadcrumbs to disappear on back-navigation (closes #2)
The onClick handler in createPathSegment closed over the graphsPath variable
from the render when the segment was created. By the time a breadcrumb was
clicked (after further navigation), that closure was stale, so findIndex
returned -1 and splice(0) wiped the entire breadcrumb array.

Fix: use the functional updater form of setGraphsPath so findIndex runs
against the current state rather than a stale snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 22:57:48 +00:00
bba49e019e Update .gitea/workflows/build.yml 2026-03-22 22:47:12 +00:00
c3f651f421 Update .gitea/workflows/build.yml 2026-03-22 21:04:01 +00:00
0eea473670 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
2026-03-19 13:25:54 +01:00
92ef00e78f feature: navigation highlighting in tree 2026-03-16 19:35:35 +01:00
2495041a2b feature: tree navigation 2026-03-16 14:48:01 +01:00
2bf8a20f24 chore: added .mcp.json 2026-03-16 12:43:31 +01:00
8bf3ab296d feature: loading completed with teste 2026-03-06 14:54:20 +01:00
0af50e165a feature: loading graph structure 2026-03-06 14:31:51 +01:00
dcdd4d621e enhancement: changed save button placement 2026-03-06 13:27:52 +01:00
1a479e931f feature: added saving graph to json 2026-03-06 11:03:21 +01:00
e1adf6b9b0 bugfix: fixed subgraph not being preserved after navigation 2026-03-06 08:44:19 +01:00
5b991ca8cd tests: breadcrumbs tests 2026-03-06 07:20:24 +01:00
18 changed files with 1749 additions and 92 deletions

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ dist-ssr
playwright-report playwright-report
test-results test-results
.mcp.json

119
README.md
View File

@@ -1,73 +1,70 @@
# React + TypeScript + Vite # ConceptSketch
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. An interactive concept map editor for creating and exploring hierarchical graph diagrams. Build knowledge structures visually by connecting nodes into nested subgraphs.
Currently, two official plugins are available: ## Features
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh - **Interactive graph editing** — click nodes to create children, ctrl+click to multi-select, click edges to remove them
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - **Nested subgraphs** — convert any node into its own subgraph layer for infinite nesting
- **Breadcrumb navigation** — navigate through graph hierarchy with breadcrumb trail
- **Sidebar tree view** — see and navigate the full hierarchical structure at a glance
- **Rename nodes** — right-click any node to rename, create subgraphs, or remove
- **Orphaned nodes** — right-click empty canvas area to create standalone nodes
- **Save & load** — export/import your entire graph as a JSON file
## React Compiler ## Getting Started
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). ### Prerequisites
## Expanding the ESLint configuration - Node.js (v18+)
- npm
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: ### Installation
```js ```bash
export default defineConfig([ npm install
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: ### Development
```js ```bash
// eslint.config.js npm run dev
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
Opens at `http://localhost:5173` with hot module replacement.
### Build
```bash
npm run build
```
### Tests
```bash
npx playwright test
```
## Usage
| Action | Result |
|---|---|
| Click a node | Create a new child node |
| Ctrl+click nodes, then click target | Link selected nodes as parents to target |
| Click an edge | Delete that edge |
| Right-click a node | Open context menu (Rename / Subgraph / Remove) |
| Right-click empty area | Create an orphaned (unconnected) node |
| Click sidebar tree item | Navigate to that subgraph |
## Tech Stack
- **React 19** + **TypeScript**
- **Vite** — build tool
- **Viz.js** (Graphviz) — graph rendering
- **D3** — SVG interactivity
- **Ant Design** — UI components
- **Zustand** — state management
## File Format
Graphs are saved as `concept-sketch.json`. The format supports nested subgraphs with recursive structure, preserving the full hierarchy.

View File

@@ -10,6 +10,11 @@ function nodeByLabel(page: import('@playwright/test').Page, label: string) {
return page.locator('g.node').filter({ hasText: label }); 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.describe('ConceptSketch', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -115,6 +120,42 @@ test.describe('ConceptSketch', () => {
await expect(nodeByLabel(page, 'Start')).toBeVisible(); 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 }) => { test('links nodes using Ctrl+click selection', async ({ page }) => {
// Create a third node to link to // Create a third node to link to
await nodeByLabel(page, 'Start').click(); await nodeByLabel(page, 'Start').click();

193
e2e/breadcrumb.spec.ts Normal file
View File

@@ -0,0 +1,193 @@
import { test, expect, type Page } from '@playwright/test';
async function waitForGraph(page: Page) {
await page.waitForSelector('svg g.node', { timeout: 15000 });
}
function nodeByLabel(page: Page, label: string) {
return page.locator('g.node').filter({ hasText: label });
}
async function openSubgraph(page: Page, nodeLabel: string) {
await nodeByLabel(page, nodeLabel).click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
// Wait for the dropdown animation to fully settle before clicking
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click();
await waitForGraph(page);
}
const breadcrumbLinks = (page: Page) => page.locator('.ant-breadcrumb-link');
test.describe('Breadcrumb navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
test('shows "Main" as the only breadcrumb in the initial state', async ({ page }) => {
await expect(breadcrumbLinks(page)).toHaveCount(1);
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
});
test('adds a breadcrumb segment when entering a subgraph', async ({ page }) => {
await openSubgraph(page, 'Start');
await expect(breadcrumbLinks(page)).toHaveCount(2);
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
await expect(page.locator('.ant-breadcrumb')).toContainText('Start');
});
test('breadcrumb grows for each additional level of nesting', async ({ page }) => {
// Level 1: enter Start's subgraph
await openSubgraph(page, 'Start');
await expect(breadcrumbLinks(page)).toHaveCount(2);
// Level 2: enter Start's subgraph again from within level 1
await openSubgraph(page, 'Start');
await expect(breadcrumbLinks(page)).toHaveCount(3);
});
test('clicking the root breadcrumb from two levels deep returns to main graph', async ({ page }) => {
// Navigate 2 levels deep
await openSubgraph(page, 'Start');
await openSubgraph(page, 'Start');
await expect(breadcrumbLinks(page)).toHaveCount(3);
// Click "Main" (first item)
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
await waitForGraph(page);
await expect(nodeByLabel(page, 'Start')).toBeVisible();
await expect(nodeByLabel(page, 'End')).toBeVisible();
});
test('clicking a middle breadcrumb navigates to that level and removes subsequent segments', async ({ page }) => {
// Navigate to level 1 (Start's subgraph)
await openSubgraph(page, 'Start');
// Add a node at level 1 so we can recognise it when we return
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
// Navigate to level 2
await openSubgraph(page, 'Start');
await expect(breadcrumbLinks(page)).toHaveCount(3);
// Click the level-1 breadcrumb (second item, "Start")
await breadcrumbLinks(page).nth(1).click();
// Breadcrumb should be truncated to 2 items
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
// The level-1 graph still has the extra node we added
await waitForGraph(page);
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
});
test('navigating forward after going back starts a fresh path from that level', async ({ page }) => {
// Navigate to level 1
await openSubgraph(page, 'Start');
await expect(breadcrumbLinks(page)).toHaveCount(2);
// Go back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
// Navigate into End's subgraph (a different node than before)
await openSubgraph(page, 'End');
// Breadcrumb should be Main / End — no leftover "Start" segment
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('End');
await expect(page.locator('.ant-breadcrumb')).not.toContainText('Start');
});
});
// Regression tests for issue #2: stale closure caused breadcrumbs to disappear on back-navigation.
//
// The bug: createPathSegment's onClick closed over `graphsPath` from the render in which
// the segment was created. After further navigation the closure was stale, so findIndex
// returned -1 and splice(0) wiped the entire breadcrumb array.
//
// The fix: use the functional updater form of setGraphsPath so findIndex always runs
// against the *current* state rather than a captured snapshot.
test.describe('Stale-closure regression (issue #2)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
test('root breadcrumb remains functional after navigating three levels deep', async ({ page }) => {
// Navigate 3 levels deep — each additional level re-renders breadcrumbs with new
// closures; before the fix, clicking the root would hit a stale closure from level 1
// and wipe everything.
await openSubgraph(page, 'Start'); // level 1
await openSubgraph(page, 'Start'); // level 2
await openSubgraph(page, 'Start'); // level 3
await expect(breadcrumbLinks(page)).toHaveCount(4);
// Click the root ("Main") breadcrumb
await breadcrumbLinks(page).first().click();
// Breadcrumbs must NOT disappear — exactly one "Main" segment remains
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
await waitForGraph(page);
// The root graph is rendered correctly
await expect(nodeByLabel(page, 'Start')).toBeVisible();
await expect(nodeByLabel(page, 'End')).toBeVisible();
});
test('level-1 breadcrumb remains functional when clicked from level 3', async ({ page }) => {
// This is the core regression scenario: a breadcrumb segment created at level 1
// must still resolve correctly after two more navigations have updated the path state.
await openSubgraph(page, 'Start'); // level 1
await openSubgraph(page, 'Start'); // level 2
await openSubgraph(page, 'Start'); // level 3
await expect(breadcrumbLinks(page)).toHaveCount(4);
// Click the level-1 breadcrumb (second item)
await breadcrumbLinks(page).nth(1).click();
// Should trim to exactly 2 items — NOT clear everything
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
await waitForGraph(page);
// The graph at level 1 is shown
await expect(nodeByLabel(page, 'Start')).toBeVisible();
});
test('successive back-clicks each trim exactly one breadcrumb level', async ({ page }) => {
await openSubgraph(page, 'Start'); // level 1
await openSubgraph(page, 'Start'); // level 2
await openSubgraph(page, 'Start'); // level 3
await expect(breadcrumbLinks(page)).toHaveCount(4);
// Click level-2 breadcrumb
await breadcrumbLinks(page).nth(2).click();
await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 });
// Then click level-1 breadcrumb
await breadcrumbLinks(page).nth(1).click();
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
// Then click root breadcrumb
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
});
test('clicking the active (last) breadcrumb does not alter the path', async ({ page }) => {
// Clicking the segment you are already on should be a no-op — it must not clear
// breadcrumbs by accidentally slicing at index -1.
await openSubgraph(page, 'Start'); // level 1
await openSubgraph(page, 'Start'); // level 2
await expect(breadcrumbLinks(page)).toHaveCount(3);
// The last breadcrumb link is the currently active level
await breadcrumbLinks(page).last().click();
await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 });
});
});

174
e2e/cut-paste.spec.ts Normal file
View File

@@ -0,0 +1,174 @@
import { test, expect, type Page } from '@playwright/test';
async function waitForGraph(page: Page) {
await page.waitForSelector('svg g.node', { timeout: 15000 });
}
function nodeByLabel(page: Page, label: string) {
return page.locator('g.node').filter({ hasText: label });
}
function graphContainer(page: Page) {
return page.locator('.bg-white.rounded.shadow');
}
async function openSubgraph(page: Page, nodeLabel: string) {
await nodeByLabel(page, nodeLabel).click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click();
await waitForGraph(page);
}
async function selectNode(page: Page, nodeLabel: string) {
await page.keyboard.down('Control');
await nodeByLabel(page, nodeLabel).click();
await page.keyboard.up('Control');
}
test.describe('Cut and Paste', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
test('node context menu shows Cut 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: 'Cut' })).toBeVisible();
});
test('empty area context menu shows Paste 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: 'Paste' })).toBeVisible();
});
test('cut without selection cuts the right-clicked node with dashed style', async ({ page }) => {
// Right-click Start without any ctrl+click selection
await nodeByLabel(page, 'Start').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Cut' }).click();
// Graph re-renders with cut node styled as dashed
await waitForGraph(page);
// The Start node's polygon should have dashed style applied via DOT
const startNode = nodeByLabel(page, 'Start');
await expect(startNode).toBeVisible();
});
test('cut with ctrl-selected nodes marks them as cut (dashed style)', async ({ page }) => {
// Ctrl+click to select End node
await selectNode(page, 'End');
// Right-click End and cut
await nodeByLabel(page, 'End').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Cut' }).click();
// Graph re-renders; cut nodes are still present but with dashed style
await waitForGraph(page);
await expect(nodeByLabel(page, 'End')).toBeVisible();
});
test('cut node also includes nodes reachable by outgoing edges', async ({ page }) => {
// Start → random child, Start is selected for cut
// First create a child of Start
await nodeByLabel(page, 'Start').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
// Ctrl+click to select Start
await selectNode(page, 'Start');
// Cut Start (which has an outgoing edge to the new child node)
await nodeByLabel(page, 'Start').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Cut' }).click();
// All 3 nodes are still visible (not removed until paste)
await waitForGraph(page);
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
});
test('paste adds cut nodes to a different subgraph', async ({ page }) => {
// Cut the End node (no selection)
await nodeByLabel(page, 'End').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Cut' }).click();
await waitForGraph(page);
// Navigate into Start's subgraph
await openSubgraph(page, 'Start');
// Subgraph has its own Start/End nodes
await expect(page.locator('g.node')).toHaveCount(2, { timeout: 5000 });
// Paste into this subgraph via right-click on empty area
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Paste' }).click();
// The pasted node (End from main) should appear in subgraph — now 3 nodes
await waitForGraph(page);
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
});
test('after paste, cut nodes are removed from the source graph', async ({ page }) => {
// Cut End from main graph
await nodeByLabel(page, 'End').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Cut' }).click();
await waitForGraph(page);
// Navigate into Start's subgraph
await openSubgraph(page, 'Start');
// Paste there
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Paste' }).click();
await waitForGraph(page);
// Navigate back to main graph via breadcrumb
await page.locator('.ant-breadcrumb-link').first().click();
await waitForGraph(page);
// End node should no longer be in main graph (it was cut and pasted)
await expect(nodeByLabel(page, 'End')).not.toBeAttached({ timeout: 5000 });
await expect(page.locator('g.node')).toHaveCount(1, { timeout: 5000 });
});
test('pasting on the same graph as the cut source cancels the cut', async ({ page }) => {
const initialNodeCount = await page.locator('g.node').count();
// Cut End
await nodeByLabel(page, 'End').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Cut' }).click();
await waitForGraph(page);
// Paste on same graph (should cancel cut, no nodes added)
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Paste' }).click();
await waitForGraph(page);
// Node count should be unchanged (no duplication)
await expect(page.locator('g.node')).toHaveCount(initialNodeCount, { timeout: 5000 });
// End node is still present (cut was cancelled)
await expect(nodeByLabel(page, 'End')).toBeVisible({ timeout: 5000 });
});
});

233
e2e/load.spec.ts Normal file
View File

@@ -0,0 +1,233 @@
import { test, expect, type Page } from '@playwright/test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// ── fixtures ──────────────────────────────────────────────────────────────────
const SIMPLE_GRAPH = {
id: 'main',
nodes: [
{ id: 'node-a', label: 'Alpha' },
{ id: 'node-b', label: 'Beta' },
],
edges: [{ id: 'edge-1', from: 'node-a', to: 'node-b' }],
};
const GRAPH_WITH_SUBGRAPH = {
id: 'main',
nodes: [
{
id: 'node-a',
label: 'Alpha',
subgraph: {
id: 'node-a',
nodes: [
{ id: 'sub-1', label: 'SubAlpha' },
{ id: 'sub-2', label: 'SubBeta' },
],
edges: [{ id: 'sub-edge-1', from: 'sub-1', to: 'sub-2' }],
},
},
{ id: 'node-b', label: 'Beta' },
],
edges: [{ id: 'edge-1', from: 'node-a', to: 'node-b' }],
};
function writeTempJson(data: object): string {
const name = `concept-sketch-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
const filePath = path.join(os.tmpdir(), name);
fs.writeFileSync(filePath, JSON.stringify(data));
return filePath;
}
// ── helpers ───────────────────────────────────────────────────────────────────
async function waitForGraph(page: Page) {
await page.waitForSelector('svg g.node', { timeout: 15000 });
}
function nodeByLabel(page: Page, label: string) {
return page.locator('g.node').filter({ hasText: label });
}
async function openSubgraph(page: Page, nodeLabel: string) {
await nodeByLabel(page, nodeLabel).click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click();
await waitForGraph(page);
}
function loadButton(page: Page) {
return page.getByTitle('Load JSON');
}
// Triggers load and waits until a specific node label appears in the graph —
// that is the reliable signal that the full async load pipeline has finished.
async function loadFile(page: Page, filePath: string, waitForLabel: string) {
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
loadButton(page).click(),
]);
await fileChooser.setFiles(filePath);
await expect(nodeByLabel(page, waitForLabel)).toBeVisible({ timeout: 10000 });
}
// ── tests ─────────────────────────────────────────────────────────────────────
test.describe('Graph loading', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
// ── button presence ──────────────────────────────────────────────────────────
test('load button is visible in the header', async ({ page }) => {
await expect(loadButton(page)).toBeVisible();
});
test('clicking the load button opens a file chooser', async ({ page }) => {
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
loadButton(page).click(),
]);
expect(fileChooser).toBeDefined();
});
// ── basic load ───────────────────────────────────────────────────────────────
test('loaded nodes and edges replace the default graph', async ({ page }) => {
const filePath = writeTempJson(SIMPLE_GRAPH);
await loadFile(page, filePath, 'Alpha');
await expect(nodeByLabel(page, 'Alpha')).toBeVisible();
await expect(nodeByLabel(page, 'Beta')).toBeVisible();
await expect(page.locator('g.node')).toHaveCount(2);
await expect(page.locator('g.edge')).toHaveCount(1);
});
test('loading removes nodes that existed before the load', async ({ page }) => {
await nodeByLabel(page, 'Start').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
const filePath = writeTempJson(SIMPLE_GRAPH);
await loadFile(page, filePath, 'Alpha');
await expect(page.locator('g.node')).toHaveCount(2, { timeout: 5000 });
await expect(nodeByLabel(page, 'Start')).not.toBeAttached();
await expect(nodeByLabel(page, 'End')).not.toBeAttached();
});
test('a second load completely replaces the previously loaded graph', async ({ page }) => {
const firstFile = writeTempJson(SIMPLE_GRAPH);
await loadFile(page, firstFile, 'Alpha');
const secondFile = writeTempJson({
id: 'main',
nodes: [
{ id: 'n1', label: 'Gamma' },
{ id: 'n2', label: 'Delta' },
],
edges: [{ id: 'e1', from: 'n1', to: 'n2' }],
});
await loadFile(page, secondFile, 'Gamma');
await expect(nodeByLabel(page, 'Gamma')).toBeVisible();
await expect(nodeByLabel(page, 'Delta')).toBeVisible();
await expect(nodeByLabel(page, 'Alpha')).not.toBeAttached();
await expect(page.locator('g.node')).toHaveCount(2);
});
// ── navigation reset ──────────────────────────────────────────────────────────
test('loading resets the breadcrumb to "Main" only', async ({ page }) => {
await openSubgraph(page, 'Start');
await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(2);
const filePath = writeTempJson(SIMPLE_GRAPH);
await loadFile(page, filePath, 'Alpha');
await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(1, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
await expect(page.locator('.ant-breadcrumb')).not.toContainText('Start');
});
test('loading while inside a subgraph shows the loaded main graph', async ({ page }) => {
await openSubgraph(page, 'Start');
const filePath = writeTempJson(SIMPLE_GRAPH);
await loadFile(page, filePath, 'Alpha');
await expect(nodeByLabel(page, 'Alpha')).toBeVisible();
await expect(nodeByLabel(page, 'Beta')).toBeVisible();
await expect(nodeByLabel(page, 'Start')).not.toBeAttached();
await expect(nodeByLabel(page, 'End')).not.toBeAttached();
});
// ── subgraph restoration ──────────────────────────────────────────────────────
test('subgraph from loaded JSON is accessible via the context menu', async ({ page }) => {
const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH);
await loadFile(page, filePath, 'Alpha');
await openSubgraph(page, 'Alpha');
await expect(nodeByLabel(page, 'SubAlpha')).toBeVisible();
await expect(nodeByLabel(page, 'SubBeta')).toBeVisible();
await expect(page.locator('g.node')).toHaveCount(2);
await expect(page.locator('g.edge')).toHaveCount(1);
});
test('breadcrumb shows the loaded node label when navigated into its subgraph', async ({ page }) => {
const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH);
await loadFile(page, filePath, 'Alpha');
await openSubgraph(page, 'Alpha');
await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(2);
await expect(page.locator('.ant-breadcrumb')).toContainText('Alpha');
});
test('navigating back from a loaded subgraph restores the loaded main graph', async ({ page }) => {
const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH);
await loadFile(page, filePath, 'Alpha');
await openSubgraph(page, 'Alpha');
await page.locator('.ant-breadcrumb-link').first().click();
await waitForGraph(page);
await expect(nodeByLabel(page, 'Alpha')).toBeVisible();
await expect(nodeByLabel(page, 'Beta')).toBeVisible();
await expect(page.locator('g.node')).toHaveCount(2);
});
test('node without a subgraph in the loaded JSON does not expose a pre-existing subgraph', async ({ page }) => {
// First load graph with subgraph, then load a graph without
const withSubgraph = writeTempJson(GRAPH_WITH_SUBGRAPH);
await loadFile(page, withSubgraph, 'Alpha');
await openSubgraph(page, 'Alpha');
await expect(nodeByLabel(page, 'SubAlpha')).toBeVisible();
// Go back and load a plain graph
await page.locator('.ant-breadcrumb-link').first().click();
await waitForGraph(page);
const plain = writeTempJson(SIMPLE_GRAPH);
await loadFile(page, plain, 'Alpha');
// Alpha no longer has a subgraph — entering it should create a fresh default graph
await openSubgraph(page, 'Alpha');
await expect(nodeByLabel(page, 'Start')).toBeVisible();
await expect(nodeByLabel(page, 'End')).toBeVisible();
await expect(nodeByLabel(page, 'SubAlpha')).not.toBeAttached();
});
// ── sidebar tree ──────────────────────────────────────────────────────────────
test('sidebar tree reflects subgraph structure from loaded JSON', async ({ page }) => {
const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH);
await loadFile(page, filePath, 'Alpha');
// Expand sidebar
await page.locator('header button').first().click();
await expect(page.locator('.ant-layout-sider')).not.toHaveClass(/ant-layout-sider-collapsed/, { timeout: 3000 });
// Alpha has a subgraph so it appears in the tree
await expect(page.locator('.ant-tree-title').filter({ hasText: 'Alpha' })).toBeVisible({ timeout: 3000 });
});
});

195
e2e/save.spec.ts Normal file
View File

@@ -0,0 +1,195 @@
import { test, expect, type Page, type Download } from '@playwright/test';
import * as fs from 'fs';
// ── types mirroring saveGraph.ts ──────────────────────────────────────────────
interface SavedEdge { id: string; from: string; to: string; }
interface SavedNode { id: string; label?: string; subgraph?: SavedGraph; }
interface SavedGraph { id: string; nodes: SavedNode[]; edges: SavedEdge[]; }
// ── helpers ───────────────────────────────────────────────────────────────────
async function waitForGraph(page: Page) {
await page.waitForSelector('svg g.node', { timeout: 15000 });
}
function nodeByLabel(page: Page, label: string) {
return page.locator('g.node').filter({ hasText: label });
}
async function openSubgraph(page: Page, nodeLabel: string) {
await nodeByLabel(page, nodeLabel).click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click();
await waitForGraph(page);
}
function saveButton(page: Page) {
return page.getByTitle('Save as JSON');
}
async function triggerSave(page: Page): Promise<Download> {
const [download] = await Promise.all([
page.waitForEvent('download'),
saveButton(page).click(),
]);
return download;
}
async function getSavedJson(page: Page): Promise<SavedGraph> {
const download = await triggerSave(page);
const filePath = await download.path();
expect(filePath).not.toBeNull();
return JSON.parse(fs.readFileSync(filePath!, 'utf-8')) as SavedGraph;
}
// ── tests ─────────────────────────────────────────────────────────────────────
test.describe('Graph saving', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
// ── button presence ─────────────────────────────────────────────────────────
test('save button is visible in the header', async ({ page }) => {
await expect(saveButton(page)).toBeVisible();
});
// ── download mechanics ──────────────────────────────────────────────────────
test('clicking save downloads a file named concept-sketch.json', async ({ page }) => {
const download = await triggerSave(page);
expect(download.suggestedFilename()).toBe('concept-sketch.json');
});
test('downloaded file is valid JSON', async ({ page }) => {
const download = await triggerSave(page);
const filePath = await download.path();
expect(filePath).not.toBeNull();
expect(() => JSON.parse(fs.readFileSync(filePath!, 'utf-8'))).not.toThrow();
});
// ── root graph structure ────────────────────────────────────────────────────
test('saved JSON has id, nodes and edges at the root', async ({ page }) => {
const data = await getSavedJson(page);
expect(data.id).toBe('main');
expect(Array.isArray(data.nodes)).toBe(true);
expect(Array.isArray(data.edges)).toBe(true);
});
test('default graph saves Start and End nodes with one connecting edge', async ({ page }) => {
const data = await getSavedJson(page);
expect(data.nodes).toHaveLength(2);
const labels = data.nodes.map(n => n.label);
expect(labels).toContain('Start');
expect(labels).toContain('End');
expect(data.edges).toHaveLength(1);
});
test('edge from/to ids reference nodes that exist in the graph', async ({ page }) => {
const data = await getSavedJson(page);
const nodeIds = new Set(data.nodes.map(n => n.id));
for (const edge of data.edges) {
expect(nodeIds.has(edge.from)).toBe(true);
expect(nodeIds.has(edge.to)).toBe(true);
}
});
// ── edits reflected in the save ─────────────────────────────────────────────
test('newly added node appears in the saved JSON', async ({ page }) => {
await nodeByLabel(page, 'Start').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
const data = await getSavedJson(page);
expect(data.nodes).toHaveLength(3);
expect(data.edges).toHaveLength(2);
});
test('renamed node label appears correctly in the saved JSON', async ({ page }) => {
await nodeByLabel(page, 'Start').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' }).click();
const modal = page.locator('.ant-modal');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.locator('.ant-input').clear();
await modal.locator('.ant-input').fill('Concept');
await modal.locator('.ant-btn-primary').click();
await expect(modal).not.toBeVisible();
await expect(nodeByLabel(page, 'Concept')).toBeVisible({ timeout: 5000 });
const data = await getSavedJson(page);
const labels = data.nodes.map(n => n.label);
expect(labels).toContain('Concept');
expect(labels).not.toContain('Start');
});
test('removed node is absent from the saved JSON', async ({ page }) => {
await nodeByLabel(page, 'End').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click();
await expect(page.locator('g.node')).toHaveCount(1, { timeout: 5000 });
const data = await getSavedJson(page);
expect(data.nodes).toHaveLength(1);
expect(data.nodes.map(n => n.label)).not.toContain('End');
});
// ── subgraph serialisation ──────────────────────────────────────────────────
test('entering a subgraph and returning adds it nested inside the parent node', async ({ page }) => {
await openSubgraph(page, 'Start');
await page.locator('.ant-breadcrumb-link').first().click();
await waitForGraph(page);
const data = await getSavedJson(page);
const startNode = data.nodes.find(n => n.label === 'Start')!;
expect(startNode.subgraph).toBeDefined();
expect(startNode.subgraph!.nodes).toHaveLength(2);
expect(startNode.subgraph!.edges).toHaveLength(1);
});
test('subgraph id matches the id of the node that contains it', async ({ page }) => {
await openSubgraph(page, 'Start');
await page.locator('.ant-breadcrumb-link').first().click();
await waitForGraph(page);
const data = await getSavedJson(page);
const startNode = data.nodes.find(n => n.label === 'Start')!;
expect(startNode.subgraph!.id).toBe(startNode.id);
});
test('node without a subgraph has no subgraph key in the JSON', async ({ page }) => {
await openSubgraph(page, 'Start');
await page.locator('.ant-breadcrumb-link').first().click();
await waitForGraph(page);
const data = await getSavedJson(page);
const endNode = data.nodes.find(n => n.label === 'End')!;
expect(endNode.subgraph).toBeUndefined();
});
test('nodes added inside a subgraph are serialised in the nested subgraph', async ({ page }) => {
await openSubgraph(page, 'Start');
// Add one extra node inside the subgraph
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
await page.locator('.ant-breadcrumb-link').first().click();
await waitForGraph(page);
const data = await getSavedJson(page);
const startNode = data.nodes.find(n => n.label === 'Start')!;
expect(startNode.subgraph!.nodes).toHaveLength(3);
expect(startNode.subgraph!.edges).toHaveLength(2);
});
test('saving from inside a subgraph includes the full hierarchy from root', async ({ page }) => {
await openSubgraph(page, 'Start');
// Still inside the subgraph when saving
const data = await getSavedJson(page);
// Root must still be present
expect(data.id).toBe('main');
expect(data.nodes.map(n => n.label)).toContain('Start');
// And the subgraph must be nested
const startNode = data.nodes.find(n => n.label === 'Start')!;
expect(startNode.subgraph).toBeDefined();
});
});

View File

@@ -0,0 +1,142 @@
import { test, expect, type Page } from '@playwright/test';
async function waitForGraph(page: Page) {
await page.waitForSelector('svg g.node', { timeout: 15000 });
}
function nodeByLabel(page: Page, label: string) {
return page.locator('g.node').filter({ hasText: label });
}
async function openSubgraph(page: Page, nodeLabel: string) {
await nodeByLabel(page, nodeLabel).click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click();
await waitForGraph(page);
}
const breadcrumbLinks = (page: Page) => page.locator('.ant-breadcrumb-link');
test.describe('Subgraph preservation during breadcrumb navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
test('nodes added to a subgraph are preserved after navigating to root and back', async ({ page }) => {
// Enter Start's subgraph and add a node
await openSubgraph(page, 'Start');
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
// Go back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Re-enter the same subgraph
await openSubgraph(page, 'Start');
// The extra node should still be there
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
});
test('main graph is preserved when navigating into a subgraph and back', async ({ page }) => {
// Add a node to the main graph
await nodeByLabel(page, 'Start').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
// Navigate into End's subgraph
await openSubgraph(page, 'End');
// Go back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Main graph should still have 3 nodes
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
await expect(nodeByLabel(page, 'Start')).toBeVisible();
await expect(nodeByLabel(page, 'End')).toBeVisible();
});
test('two sibling subgraphs preserve their states independently', async ({ page }) => {
// Enter Start's subgraph and add 1 extra node (3 total)
await openSubgraph(page, 'Start');
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
// Back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Enter End's subgraph and add 2 extra nodes (4 total)
await openSubgraph(page, 'End');
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.node')).toHaveCount(4, { timeout: 5000 });
// Back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Re-enter Start's subgraph — should still have 3 nodes
await openSubgraph(page, 'Start');
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
// Back to main
await breadcrumbLinks(page).first().click();
await waitForGraph(page);
// Re-enter End's subgraph — should still have 4 nodes
await openSubgraph(page, 'End');
await expect(page.locator('g.node')).toHaveCount(4, { timeout: 5000 });
});
test('intermediate level subgraph is preserved when navigating back from deeper nesting', async ({ page }) => {
// Enter Start's subgraph (level 1) and add a node
await openSubgraph(page, 'Start');
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
// Go one level deeper (level 2)
await openSubgraph(page, 'Start');
await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 });
// Navigate all the way back to root
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Re-enter Start's subgraph (level 1) — the 3-node state must be intact
await openSubgraph(page, 'Start');
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
});
test('edges added inside a subgraph are preserved after navigating away and back', async ({ page }) => {
// Enter Start's subgraph, create a node by clicking End, then link them
await openSubgraph(page, 'Start');
const edgeCountBefore = await page.locator('g.edge').count();
// Ctrl+click Start to select it as a parent for linking
await page.keyboard.down('Control');
await nodeByLabel(page, 'Start').click();
await page.keyboard.up('Control');
// Click End without Ctrl to link selected Start → End
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.edge')).toHaveCount(edgeCountBefore + 1, { timeout: 5000 });
// Navigate back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Re-enter the subgraph — extra edge must still be there
await openSubgraph(page, 'Start');
await expect(page.locator('g.edge')).toHaveCount(edgeCountBefore + 1, { timeout: 5000 });
});
});

248
e2e/tree-navigation.spec.ts Normal file
View File

@@ -0,0 +1,248 @@
import { test, expect, type Page } from '@playwright/test';
async function waitForGraph(page: Page) {
await page.waitForSelector('svg g.node', { timeout: 15000 });
}
function nodeByLabel(page: Page, label: string) {
return page.locator('g.node').filter({ hasText: label });
}
async function openSidebar(page: Page) {
const sider = page.locator('.ant-layout-sider');
const isCollapsed = await sider.evaluate(el => el.classList.contains('ant-layout-sider-collapsed'));
if (isCollapsed) {
await page.locator('header button').first().click();
await expect(sider).not.toHaveClass(/ant-layout-sider-collapsed/, { timeout: 3000 });
}
}
async function openSubgraph(page: Page, nodeLabel: string) {
await nodeByLabel(page, nodeLabel).click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click();
await waitForGraph(page);
}
function treeNodeByLabel(page: Page, label: string) {
return page.locator('.ant-tree-node-content-wrapper').filter({ hasText: label });
}
const breadcrumbLinks = (page: Page) => page.locator('.ant-breadcrumb-link');
test.describe('Tree navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
test('sidebar tree is empty before any subgraphs are created', async ({ page }) => {
await openSidebar(page);
await expect(page.locator('.ant-tree-title')).toHaveCount(0);
});
test('creating a subgraph adds its node to the sidebar tree', async ({ page }) => {
await openSubgraph(page, 'Start');
await openSidebar(page);
await expect(treeNodeByLabel(page, 'Start')).toBeVisible({ timeout: 3000 });
});
test('clicking a tree node navigates to its subgraph', async ({ page }) => {
await openSubgraph(page, 'Start');
// Go back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Navigate via tree
await openSidebar(page);
await treeNodeByLabel(page, 'Start').click();
await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 });
await waitForGraph(page);
await expect(nodeByLabel(page, 'Start')).toBeVisible();
await expect(nodeByLabel(page, 'End')).toBeVisible();
});
test('clicking a tree node updates the breadcrumb to show the full path', async ({ page }) => {
await openSubgraph(page, 'Start');
// Go back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await openSidebar(page);
await treeNodeByLabel(page, 'Start').click();
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
await expect(page.locator('.ant-breadcrumb')).toContainText('Start');
});
test('clicking a nested tree node navigates directly with the full breadcrumb path', async ({ page }) => {
// Create level 1: Start → Start's subgraph
await openSubgraph(page, 'Start');
// Create level 2: Start → Start's sub-subgraph
await openSubgraph(page, 'Start');
// Go back all the way to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Navigate to level 2 directly via tree
await openSidebar(page);
// Expand the root Start node to reveal its child
await page.locator('.ant-tree-switcher').first().click();
const treeNodes = page.locator('.ant-tree-node-content-wrapper').filter({ hasText: 'Start' });
// The deepest node is the last one in the tree
await treeNodes.last().click();
// Breadcrumb should show 3 levels: Main / Start / Start
await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 });
await waitForGraph(page);
});
test('tree navigation from inside one subgraph jumps directly to another', async ({ page }) => {
// Create subgraph for Start
await openSubgraph(page, 'Start');
// Go back, create subgraph for End
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await openSubgraph(page, 'End');
// Now we are inside End's subgraph — navigate to Start's subgraph via tree
await openSidebar(page);
await treeNodeByLabel(page, 'Start').click();
await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).not.toContainText('End');
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
await waitForGraph(page);
});
test('subgraph content is preserved when navigating away and back via tree', async ({ page }) => {
await openSubgraph(page, 'Start');
// Add a node in Start's subgraph
await nodeByLabel(page, 'End').click();
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
// Go back to main
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
// Navigate back via tree
await openSidebar(page);
await treeNodeByLabel(page, 'Start').click();
await waitForGraph(page);
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
});
});
test.describe('Tree highlighting', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
test('tree node is highlighted when entering its subgraph via context menu', async ({ page }) => {
await openSubgraph(page, 'Start');
await openSidebar(page);
await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 });
});
test('tree node is highlighted when navigating to it via tree click', async ({ page }) => {
await openSubgraph(page, 'Start');
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await openSidebar(page);
await treeNodeByLabel(page, 'Start').click();
await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 });
});
test('tree node is deselected when navigating back to main via breadcrumb', async ({ page }) => {
await openSubgraph(page, 'Start');
await openSidebar(page);
await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 });
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await expect(treeNodeByLabel(page, 'Start')).not.toHaveClass(/ant-tree-node-selected/, { timeout: 3000 });
});
test('highlight switches between tree nodes when navigating between sibling subgraphs', async ({ page }) => {
await openSubgraph(page, 'Start');
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await openSubgraph(page, 'End');
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
await openSidebar(page);
// Navigate to Start's subgraph via tree
await treeNodeByLabel(page, 'Start').click();
await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 });
await expect(treeNodeByLabel(page, 'End')).not.toHaveClass(/ant-tree-node-selected/);
// Navigate to End's subgraph via tree
await treeNodeByLabel(page, 'End').click();
await expect(treeNodeByLabel(page, 'End')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 });
await expect(treeNodeByLabel(page, 'Start')).not.toHaveClass(/ant-tree-node-selected/);
});
});
test.describe('Tree node removal', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
test('removing a node with a subgraph removes it from the tree', async ({ page }) => {
await openSubgraph(page, 'Start');
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
await openSidebar(page);
await expect(treeNodeByLabel(page, 'Start')).toBeVisible();
await nodeByLabel(page, 'Start').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click();
await expect(treeNodeByLabel(page, 'Start')).not.toBeAttached({ timeout: 3000 });
});
test('removing a node without a subgraph does not affect the tree', async ({ page }) => {
await openSubgraph(page, 'Start');
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await waitForGraph(page);
await openSidebar(page);
await expect(page.locator('.ant-tree-title')).toHaveCount(1);
// Remove End (no subgraph)
await nodeByLabel(page, 'End').click({ button: 'right' });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(300);
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click();
// Tree still has exactly one node (Start)
await expect(page.locator('.ant-tree-title')).toHaveCount(1);
await expect(treeNodeByLabel(page, 'Start')).toBeVisible();
});
});

View File

@@ -1,12 +1,16 @@
import React, { useEffect, useEffectEvent, useState } from 'react'; import React, { useEffectEvent, useRef, useState } from 'react';
import { Layout, theme, Breadcrumb, Button, Space, Tree } from 'antd'; import { Layout, theme, Breadcrumb, Button, Space, Tree } from 'antd';
import Graph from './components/Graph'; import Graph, { type GraphHandle } from './components/Graph';
import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb'; import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb';
import { useKeysdownStore } from './stores/ArrayStore'; import { useKeysdownStore } from './stores/ArrayStore';
import { import {
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined MenuUnfoldOutlined,
SaveOutlined,
FolderOpenOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { saveConceptSketch } from './utils/saveGraph';
import { loadConceptSketch } from './utils/loadGraph';
import { useGraphLayersTreeStore } from './stores/TreeStore'; import { useGraphLayersTreeStore } from './stores/TreeStore';
const { Header, Content, Sider } = Layout; const { Header, Content, Sider } = Layout;
@@ -32,10 +36,21 @@ const App: React.FC = () => {
onKeyUp(ev.key); onKeyUp(ev.key);
}); });
const treeData = useGraphLayersTreeStore(store => store.tree); const treeData = useGraphLayersTreeStore(store => store.tree);
const nodesFlatById = useGraphLayersTreeStore(store => store.nodesFlatById);
const parentIdByChildId = useGraphLayersTreeStore(store => store.parentIdByChildId);
const graphRef = useRef<GraphHandle>(null);
const [activeGraphId, setActiveGraphId] = useState('main');
useEffect(() => { function buildPathToNode(nodeId: string) {
console.info(treeData); const path: Array<{ id: string; name: string | undefined }> = [];
}, [treeData]) let current: string | undefined = nodeId;
while (current && nodesFlatById.has(current)) {
const node = nodesFlatById.get(current)!;
path.unshift({ id: current, name: node.title as string | undefined });
current = parentIdByChildId.get(current);
}
return [{ id: 'main', name: 'Main' }, ...path];
}
return ( return (
<Layout> <Layout>
@@ -47,13 +62,19 @@ const App: React.FC = () => {
checkable checkable
treeData={treeData} treeData={treeData}
defaultExpandAll={true} defaultExpandAll={true}
selectedKeys={[activeGraphId]}
style={{ style={{
borderRadius: 0 borderRadius: 0
}} }}
onSelect={(keys) => {
const nodeId = keys[0] as string;
if (!nodeId) return;
graphRef.current?.navigateTo(nodeId, buildPathToNode(nodeId));
}}
/> />
</Sider> </Sider>
<Layout> <Layout>
<Header style={{ padding: 0, background: colorBgContainer }}> <Header style={{ padding: 0, background: colorBgContainer, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space> <Space>
<div style={{ background: '#001529' }}> <div style={{ background: '#001529' }}>
<Button <Button
@@ -70,6 +91,32 @@ const App: React.FC = () => {
</div> </div>
<Breadcrumb items={graphLevel} /> <Breadcrumb items={graphLevel} />
</Space> </Space>
<div style={{ background: '#001529', display: 'flex' }}>
<Button
type="text"
icon={<FolderOpenOutlined />}
onClick={loadConceptSketch}
title="Load JSON"
style={{
fontSize: '16px',
width: 32,
height: 32,
color: colorBgContainer,
}}
/>
<Button
type="text"
icon={<SaveOutlined />}
onClick={saveConceptSketch}
title="Save as JSON"
style={{
fontSize: '16px',
width: 32,
height: 32,
color: colorBgContainer,
}}
/>
</div>
</Header> </Header>
<Content <Content
style={{ style={{
@@ -80,7 +127,7 @@ const App: React.FC = () => {
borderRadius: '6px' borderRadius: '6px'
}} }}
> >
<Graph setGraphPath={setGraphLevel} /> <Graph ref={graphRef} setGraphPath={setGraphLevel} onNavigate={setActiveGraphId} />
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>

View File

@@ -1,6 +1,6 @@
import type { GraphModel } from "./components/Graph"; import type { GraphModel } from "./components/Graph";
export function graphToDot(g: GraphModel): string { export function graphToDot(g: GraphModel, cutNodeIds: Set<string> = new Set()): string {
// Directed graph, use neato layout so we can use pos attributes // Directed graph, use neato layout so we can use pos attributes
const lines = []; const lines = [];
lines.push('digraph G {'); lines.push('digraph G {');
@@ -12,6 +12,9 @@ export function graphToDot(g: GraphModel): string {
const attrs = []; const attrs = [];
attrs.push(`label=\"${n.label}\"`); attrs.push(`label=\"${n.label}\"`);
attrs.push(`id=\"${n.id}\"`) attrs.push(`id=\"${n.id}\"`)
if (cutNodeIds.has(n.id)) {
attrs.push(`fillcolor="#d9d9d9"`, `style="filled,dashed"`);
}
lines.push(` \"${n.id}\" [${attrs.join(', ')}];`); lines.push(` \"${n.id}\" [${attrs.join(', ')}];`);
} }

View File

@@ -1,4 +1,4 @@
import { createContext, useEffect, useRef, useState } from "react"; import { createContext, forwardRef, useEffect, useImperativeHandle, 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';
@@ -8,6 +8,8 @@ import NodeContextMenu from "./NodeContextMenu";
import NodeRenameModal from "./NodeRenameModal"; import NodeRenameModal from "./NodeRenameModal";
import { useGraphsStore } from "../stores/GraphsStore"; import { useGraphsStore } from "../stores/GraphsStore";
import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore"; import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore";
import { useLoadStore } from "../stores/LoadStore";
import { useCutStore } from "../stores/CutStore";
export class GraphModel { export class GraphModel {
@@ -45,6 +47,7 @@ export interface NodeContext {
nodeId: string; nodeId: string;
nodeName?: string; nodeName?: string;
coords: { x: number, y: number }; coords: { x: number, y: number };
isEmptyArea?: boolean;
} }
export interface GraphContext { export interface GraphContext {
@@ -62,14 +65,21 @@ export interface OpenNodeContext {
const viz = new Viz({ Module, render }); const viz = new Viz({ Module, render });
export const graphContext = createContext<GraphContext | null>(null); export const graphContext = createContext<GraphContext | null>(null);
export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<React.SetStateAction<BreadcrumbItemType[]>> }) { export interface GraphHandle {
navigateTo: (nodeId: string, path: Array<{ id: string; name: string | undefined }>) => void;
}
const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetStateAction<BreadcrumbItemType[]>>, onNavigate?: (graphId: string) => void }>(function Graph({ setGraphPath, onNavigate }, ref) {
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 graphIdRef = useRef(graphId);
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);
graphIdRef.current = graphId;
const graphContextValue = { const graphContextValue = {
graphId: graphId, graphId: graphId,
selectGraphId: selectGraphId, selectGraphId: selectGraphId,
@@ -82,6 +92,8 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
const isNodeSelected = useSelectedNodesStore(store => store.has); const isNodeSelected = useSelectedNodesStore(store => store.has);
const anyNodeSelected = useSelectedNodesStore(store => store.hasAny); const anyNodeSelected = useSelectedNodesStore(store => store.hasAny);
const deselectAllNodes = useSelectedNodesStore(store => store.clear); const deselectAllNodes = useSelectedNodesStore(store => store.clear);
const loadCount = useLoadStore(state => state.loadCount);
const cutNodeIds = useCutStore(state => state.cutNodeIds);
useEffect(() => { useEffect(() => {
setGraphPath(graphsPath); setGraphPath(graphsPath);
@@ -90,6 +102,15 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
useEffect(() => { useEffect(() => {
renderGraph(); renderGraph();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [graph, cutNodeIds]);
// Persist graph edits to the store so they survive breadcrumb navigation.
// We intentionally read graphId via ref (not as a dep) so this effect only
// fires on graph content changes, never on navigation (graphId changes).
useEffect(() => {
const state = useGraphsStore.getState() as { graphsById: Map<string, GraphModel> };
state.graphsById.set(graphIdRef.current, graph);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [graph]); }, [graph]);
useEffect(() => { useEffect(() => {
@@ -98,6 +119,36 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
} }
}, [nodeContext]); }, [nodeContext]);
useEffect(() => {
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<string, GraphModel> };
const mainGraph = state.graphsById.get('main');
if (mainGraph) setGraph(mainGraph);
selectGraphId('main');
setGraphsPath([createPathSegment('main', 'Main')]);
openNodeContext(null);
openContextMenu(false);
useCutStore.getState().clearCut();
onNavigate?.('main');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadCount]);
useEffect(() => { useEffect(() => {
if (nodeContext?.nodeId === graphId) { if (nodeContext?.nodeId === graphId) {
graphsPath.push(createPathSegment(nodeContext.nodeId, nodeContext.nodeName)); graphsPath.push(createPathSegment(nodeContext.nodeId, nodeContext.nodeName));
@@ -109,10 +160,12 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
if (graph) { if (graph) {
setGraph(graph); setGraph(graph);
} }
onNavigate?.(graphId);
}, [graphId]); }, [graphId]);
async function renderGraph() { async function renderGraph() {
const dot = graphToDot(graph); const currentCutNodeIds = new Set(useCutStore.getState().cutNodeIds);
const dot = graphToDot(graph, currentCutNodeIds);
try { try {
const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' }); const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' });
@@ -171,12 +224,26 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
} }
event.preventDefault(); event.preventDefault();
event.stopPropagation();
openNodeContext({ openNodeContext({
nodeId: id, nodeId: id,
nodeName: node.label, nodeName: node.label,
coords: { x: event.clientX, y: event.clientY }, coords: { x: event.clientX, y: event.clientY },
}) })
}); });
svg.on('contextmenu', function (event) {
const target = event.target as Element;
if (target.closest('g.node') || target.closest('g.edge')) {
return;
}
event.preventDefault();
openNodeContext({
nodeId: '',
coords: { x: event.clientX, y: event.clientY },
isEmptyArea: true,
});
});
} }
function linkSelectedNodesAsParents(childNodeId: string) { function linkSelectedNodesAsParents(childNodeId: string) {
@@ -190,11 +257,7 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
} }
function getRandomWords(count: number) { function getRandomWords(count: number) {
const wordList = ['Apple', 'Sun', 'Flame', 'Earth', 'Forest', 'Dream', 'Sky', 'Shadow', 'Flower', 'Ocean', 'River', 'Path', 'Sand', 'Night', 'Star', 'Rain', 'Light', 'Tree', 'Wave', 'Storm', 'Stone', 'Snow', 'Cloud', 'Heart', 'Mountain', 'Leaf', 'Bird', 'Wind', 'Fire', 'Wolf']; return randomWordList(count);
return wordList
.sort(() => Math.random() - 0.5)
.slice(0, count);
} }
function createPathSegment( function createPathSegment(
@@ -205,17 +268,21 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
title: selectedNodeName, title: selectedNodeName,
key: pathSegmentId, key: pathSegmentId,
onClick: () => { onClick: () => {
const index = graphsPath.findIndex(p => p.key === pathSegmentId); setGraphsPath(prev => prev.slice(0, prev.findIndex(p => p.key === pathSegmentId) + 1));
setGraphsPath(prev => {
prev.splice(index + 1);
return [...prev];
});
selectGraphId(pathSegmentId); selectGraphId(pathSegmentId);
} }
} as BreadcrumbItemType; } as BreadcrumbItemType;
} }
useImperativeHandle(ref, () => ({
navigateTo(nodeId, path) {
const newPath = path.map(p => createPathSegment(p.id, p.name));
openNodeContext(null);
setGraphsPath(newPath);
selectGraphId(nodeId);
}
}));
return ( return (
<div className="flex-1 p-4"> <div className="flex-1 p-4">
<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' }}>
@@ -236,6 +303,13 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
</graphContext.Provider> </graphContext.Provider>
</div> </div>
) )
});
export default Graph;
export function randomWordList(count: number) {
const wordList = ['Apple', 'Sun', 'Flame', 'Earth', 'Forest', 'Dream', 'Sky', 'Shadow', 'Flower', 'Ocean', 'River', 'Path', 'Sand', 'Night', 'Star', 'Rain', 'Light', 'Tree', 'Wave', 'Storm', 'Stone', 'Snow', 'Cloud', 'Heart', 'Mountain', 'Leaf', 'Bird', 'Wind', 'Fire', 'Wolf'];
return wordList.sort(() => Math.random() - 0.5).slice(0, count);
} }
export function defaultGraph(): GraphModel { export function defaultGraph(): GraphModel {

View File

@@ -1,11 +1,13 @@
import { Dropdown, type MenuProps } from "antd"; 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 { useContext } from "react";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { useGraphsStore } from "../stores/GraphsStore"; import { useGraphsStore } from "../stores/GraphsStore";
import { useGraphLayersTreeStore } from "../stores/TreeStore"; import { useGraphLayersTreeStore } from "../stores/TreeStore";
import { useCutStore } from "../stores/CutStore";
import { useSelectedNodesStore } from "../stores/ArrayStore";
const items: MenuProps['items'] = [ const nodeItems: MenuProps['items'] = [
{ {
key: 'rename', key: 'rename',
label: 'Rename', label: 'Rename',
@@ -20,6 +22,23 @@ const items: MenuProps['items'] = [
key: 'remove', key: 'remove',
label: 'Remove', label: 'Remove',
extra: 'ctrl + r' extra: 'ctrl + r'
},
{
key: 'cut',
label: 'Cut',
extra: 'ctrl + x',
}
]
const emptyAreaItems: MenuProps['items'] = [
{
key: 'create',
label: 'Create Node',
},
{
key: 'paste',
label: 'Paste',
extra: 'ctrl + v',
} }
] ]
@@ -38,9 +57,14 @@ export default function NodeContextMenu({
return; return;
} }
const items = nodeContext.isEmptyArea ? emptyAreaItems : nodeItems;
const graphContextValue = useContext(graphContext)!; const graphContextValue = useContext(graphContext)!;
const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById); const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById);
const addTreeNode = useGraphLayersTreeStore(store => store.add); const addTreeNode = useGraphLayersTreeStore(store => store.add);
const removeTreeNode = useGraphLayersTreeStore(store => store.remove);
const moveTreeNode = useGraphLayersTreeStore(store => store.move);
const setCut = useCutStore(store => store.setCut);
const clearCut = useCutStore(store => store.clearCut);
function contextMenuOpenChange(open: boolean) { function contextMenuOpenChange(open: boolean) {
if (!open) { if (!open) {
@@ -56,6 +80,12 @@ export default function NodeContextMenu({
} }
case 'remove': { case 'remove': {
removeNode(nodeContext.nodeId); removeNode(nodeContext.nodeId);
removeTreeNode(nodeContext.nodeId);
break;
}
case 'create': {
const id = crypto.randomUUID();
graphContextValue.setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: randomWordList(1)[0] }] }));
break; break;
} }
case 'subgraph': { case 'subgraph': {
@@ -74,6 +104,87 @@ export default function NodeContextMenu({
break; break;
} }
case 'cut': {
const currentGraph = graphContextValue.graph;
const selectedNodeIds = useSelectedNodesStore.getState().items;
// Determine the nodes to cut: selected nodes, or the right-clicked node if nothing is selected
const baseIds: string[] = selectedNodeIds.length > 0
? selectedNodeIds
: [nodeContext.nodeId];
// BFS to collect all nodes reachable via outgoing edges from base nodes (all hops)
const visited = new Set(baseIds);
const queue = [...baseIds];
while (queue.length > 0) {
const nodeId = queue.shift()!;
currentGraph.edges
.filter(e => e.from === nodeId && !visited.has(e.to))
.forEach(e => {
visited.add(e.to);
queue.push(e.to);
});
}
const allCutIds = [...visited];
const allCutIdSet = new Set(allCutIds);
const cutNodes = currentGraph.nodes.filter(n => allCutIdSet.has(n.id));
// Only include edges where both endpoints are cut nodes
const cutEdges = currentGraph.edges.filter(e => allCutIdSet.has(e.from) && allCutIdSet.has(e.to));
setCut(allCutIds, cutNodes, cutEdges, graphContextValue.graphId);
useSelectedNodesStore.getState().clear();
break;
}
case 'paste': {
const cutState = useCutStore.getState();
if (cutState.cutNodes.length === 0) break;
// If pasting into the same graph, just cancel the cut
if (cutState.sourceGraphId === graphContextValue.graphId) {
clearCut();
break;
}
// Add cut nodes/edges to current graph
graphContextValue.setGraph(prev => ({
...prev,
nodes: [...prev.nodes, ...cutState.cutNodes],
edges: [...prev.edges, ...cutState.cutEdges],
}));
// Remove cut nodes and their connected edges from the source graph
if (cutState.sourceGraphId) {
const sourceGraph = graphsById.get(cutState.sourceGraphId);
if (sourceGraph) {
const cutIdSet = new Set(cutState.cutNodeIds);
graphsById.set(cutState.sourceGraphId, {
nodes: sourceGraph.nodes.filter(n => !cutIdSet.has(n.id)),
edges: sourceGraph.edges.filter(e => !cutIdSet.has(e.from) && !cutIdSet.has(e.to)),
});
}
}
// Update navigation tree: reparent tree nodes from source graph to target graph.
// A tree node belongs to the source graph when its tree parent matches sourceGraphId
// (or is a root node when sourceGraphId is 'main').
const treeState = useGraphLayersTreeStore.getState();
const effectiveSourceParent = cutState.sourceGraphId === 'main' ? undefined : cutState.sourceGraphId ?? undefined;
const newTreeParent = graphContextValue.graphId === 'main' ? undefined : graphContextValue.graphId;
for (const nodeId of cutState.cutNodeIds) {
if (treeState.nodesFlatById.has(nodeId)) {
const nodeTreeParent = treeState.parentIdByChildId.get(nodeId);
if (nodeTreeParent === effectiveSourceParent) {
moveTreeNode(nodeId, newTreeParent);
}
}
}
clearCut();
break;
}
} }
}; };

22
src/stores/CutStore.ts Normal file
View File

@@ -0,0 +1,22 @@
import { create } from 'zustand';
import type { NodeModel, EdgeModel } from '../components/Graph';
export interface CutStore {
cutNodeIds: string[];
cutNodes: NodeModel[];
cutEdges: EdgeModel[];
sourceGraphId: string | null;
setCut: (nodeIds: string[], nodes: NodeModel[], edges: EdgeModel[], sourceGraphId: string) => void;
clearCut: () => void;
}
export const useCutStore = create<CutStore>()((set) => ({
cutNodeIds: [],
cutNodes: [],
cutEdges: [],
sourceGraphId: null,
setCut: (nodeIds, nodes, edges, sourceGraphId) =>
set({ cutNodeIds: nodeIds, cutNodes: nodes, cutEdges: edges, sourceGraphId }),
clearCut: () =>
set({ cutNodeIds: [], cutNodes: [], cutEdges: [], sourceGraphId: null }),
}));

11
src/stores/LoadStore.ts Normal file
View File

@@ -0,0 +1,11 @@
import { create } from 'zustand';
interface LoadStore {
loadCount: number;
signal: () => void;
}
export const useLoadStore = create<LoadStore>((set) => ({
loadCount: 0,
signal: () => set((state) => ({ loadCount: state.loadCount + 1 })),
}));

View File

@@ -10,6 +10,8 @@ export interface TreeStore {
tree: TreeDataNode[]; tree: TreeDataNode[];
add: (childNode: NodeContext, parentNodeId: string | undefined) => void; add: (childNode: NodeContext, parentNodeId: string | undefined) => void;
remove: (nodeId: string) => void; remove: (nodeId: string) => void;
move: (nodeId: string, newParentId: string | undefined) => void;
reset: () => void;
} }
export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({ export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
@@ -42,11 +44,12 @@ export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
} else { } else {
const nodesFlatById = new Map(state.nodesFlatById); const nodesFlatById = new Map(state.nodesFlatById);
nodesFlatById.set(childNode.key, childNode); nodesFlatById.set(childNode.key, childNode);
const newRootNodes = [...state.rootNodes, childNode];
const newState = { const newState = {
nodesFlatById: nodesFlatById, nodesFlatById: nodesFlatById,
parentIdByChildId: state.parentIdByChildId, parentIdByChildId: state.parentIdByChildId,
rootNodes: [...state.rootNodes, childNode], rootNodes: newRootNodes,
tree: createTree([...state.rootNodes], nodesFlatById) tree: createTree(newRootNodes, nodesFlatById)
} }
return newState; return newState;
} }
@@ -87,7 +90,51 @@ export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
parentIdByChildId: state.parentIdByChildId, parentIdByChildId: state.parentIdByChildId,
tree: createTree([...state.rootNodes], nodesFlatById) tree: createTree([...state.rootNodes], nodesFlatById)
} }
}) }),
move: (nodeId, newParentId) => set((state) => {
const node = state.nodesFlatById.get(nodeId);
if (!node) return state;
const nodesFlatById = new Map(state.nodesFlatById);
const parentIdByChildId = new Map(state.parentIdByChildId);
let rootNodes = [...state.rootNodes];
// Remove from old parent
const oldParentId = state.parentIdByChildId.get(nodeId);
if (oldParentId !== undefined) {
const oldParent = nodesFlatById.get(oldParentId);
if (oldParent) {
oldParent.children = oldParent.children?.filter(n => n.key !== nodeId) ?? [];
}
parentIdByChildId.delete(nodeId);
} else {
rootNodes = rootNodes.filter(n => n.key !== nodeId);
}
// Add to new parent
if (newParentId !== undefined) {
const newParent = nodesFlatById.get(newParentId);
if (newParent) {
newParent.children = newParent.children ? [...newParent.children, node] : [node];
parentIdByChildId.set(nodeId, newParentId);
}
} else {
rootNodes = [...rootNodes, node];
}
return {
nodesFlatById,
parentIdByChildId,
rootNodes,
tree: createTree(rootNodes, nodesFlatById),
};
}),
reset: () => set({
nodesFlatById: new Map<React.Key, TreeDataNode>(),
parentIdByChildId: new Map<React.Key, string>(),
rootNodes: [],
tree: [],
}),
})); }));
export function createTree(nodes: TreeDataNode[], nodesFlatById: Map<React.Key, TreeDataNode>): TreeDataNode[] { export function createTree(nodes: TreeDataNode[], nodesFlatById: Map<React.Key, TreeDataNode>): TreeDataNode[] {

64
src/utils/loadGraph.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { GraphModel, NodeContext } from '../components/Graph';
import { useGraphsStore } from '../stores/GraphsStore';
import { useGraphLayersTreeStore } from '../stores/TreeStore';
import { useLoadStore } from '../stores/LoadStore';
interface SavedEdge { id: string; from: string; to: string; }
interface SavedNode { id: string; label?: string; subgraph?: SavedGraph; }
interface SavedGraph { id: string; nodes: SavedNode[]; edges: SavedEdge[]; }
function populateStores(
graph: SavedGraph,
graphsById: Map<string, GraphModel>,
treeAdd: (node: NodeContext, parentId: string | undefined) => void,
): void {
graphsById.set(graph.id, {
nodes: graph.nodes.map(n => ({ id: n.id, label: n.label })),
edges: graph.edges.map(e => ({ id: e.id, from: e.from, to: e.to })),
});
for (const node of graph.nodes) {
if (node.subgraph) {
const nodeContext: NodeContext = {
nodeId: node.id,
nodeName: node.label,
coords: { x: 0, y: 0 },
};
treeAdd(nodeContext, graph.id === 'main' ? undefined : graph.id);
populateStores(node.subgraph, graphsById, treeAdd);
}
}
}
export function loadConceptSketch(): void {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.onchange = () => {
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string) as SavedGraph;
if (!data.id || !Array.isArray(data.nodes) || !Array.isArray(data.edges)) {
throw new Error('Invalid concept-sketch JSON format');
}
const graphsState = useGraphsStore.getState() as { graphsById: Map<string, GraphModel> };
graphsState.graphsById.clear();
useGraphLayersTreeStore.getState().reset();
populateStores(data, graphsState.graphsById, useGraphLayersTreeStore.getState().add);
useLoadStore.getState().signal();
} catch (err) {
console.error('Failed to load concept sketch:', err);
}
};
reader.readAsText(file);
};
input.click();
}

53
src/utils/saveGraph.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { GraphModel } from '../components/Graph';
import { useGraphsStore } from '../stores/GraphsStore';
interface SerializedEdge {
id: string;
from: string;
to: string;
}
interface SerializedNode {
id: string;
label?: string;
subgraph?: SerializedGraph;
}
interface SerializedGraph {
id: string;
nodes: SerializedNode[];
edges: SerializedEdge[];
}
function serializeGraph(graphId: string, graphsById: Map<string, GraphModel>): SerializedGraph | null {
const graph = graphsById.get(graphId);
if (!graph) return null;
return {
id: graphId,
nodes: graph.nodes.map(node => {
const serialized: SerializedNode = { id: node.id, label: node.label };
if (graphsById.has(node.id)) {
serialized.subgraph = serializeGraph(node.id, graphsById) ?? undefined;
}
return serialized;
}),
edges: graph.edges.map(edge => ({ id: edge.id, from: edge.from, to: edge.to })),
};
}
export function saveConceptSketch(): void {
const state = useGraphsStore.getState() as { graphsById: Map<string, GraphModel> };
const data = serializeGraph('main', state.graphsById);
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'concept-sketch.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}