Compare commits
22 Commits
6699a24137
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e342dc776 | |||
|
|
d5107ac6d3 | ||
|
|
63e204840f | ||
|
|
000aad362a | ||
| ee9a55f4e6 | |||
|
|
44378c2bf0 | ||
| 471bff1a8c | |||
|
|
9aa28a9fa3 | ||
| 4733916523 | |||
|
|
eb3a23ab03 | ||
| bba49e019e | |||
| c3f651f421 | |||
| 0eea473670 | |||
| 92ef00e78f | |||
| 2495041a2b | |||
| 2bf8a20f24 | |||
| 8bf3ab296d | |||
| 0af50e165a | |||
| dcdd4d621e | |||
| 1a479e931f | |||
| e1adf6b9b0 | |||
| 5b991ca8cd |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,4 +24,6 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
|
|
||||||
|
.mcp.json
|
||||||
119
README.md
119
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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
193
e2e/breadcrumb.spec.ts
Normal 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
174
e2e/cut-paste.spec.ts
Normal 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
233
e2e/load.spec.ts
Normal 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
195
e2e/save.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
142
e2e/subgraph-preservation.spec.ts
Normal file
142
e2e/subgraph-preservation.spec.ts
Normal 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
248
e2e/tree-navigation.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/App.tsx
63
src/App.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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(', ')}];`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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': {
|
||||||
@@ -69,11 +99,92 @@ export default function NodeContextMenu({
|
|||||||
const parenNodeId = graphContextValue.graphId === 'main' ? undefined : graphContextValue.graphId;
|
const parenNodeId = graphContextValue.graphId === 'main' ? undefined : graphContextValue.graphId;
|
||||||
addTreeNode(nodeContext, parenNodeId);
|
addTreeNode(nodeContext, parenNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
graphContextValue.setGraph(selectedGraph);
|
graphContextValue.setGraph(selectedGraph);
|
||||||
|
|
||||||
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
22
src/stores/CutStore.ts
Normal 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
11
src/stores/LoadStore.ts
Normal 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 })),
|
||||||
|
}));
|
||||||
@@ -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
64
src/utils/loadGraph.ts
Normal 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
53
src/utils/saveGraph.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user