Compare commits

...

17 Commits

Author SHA1 Message Date
Claude Bot
04387d66a9 fix: sync tree node title on graph node rename (closes #10) 2026-03-31 07:26:09 +00:00
471bff1a8c feature: Modify readme' (#5) from claude/issue-4 into master
Reviewed-on: #5
2026-03-23 14:58:39 +00:00
Claude Bot
9aa28a9fa3 docs: rewrite README to describe ConceptSketch project (closes #4) 2026-03-23 14:55:43 +00:00
4733916523 Merge pull request 'Fix: Breadcrumb disappearing' (#3) from claude/issue-2 into master
Reviewed-on: #3
2026-03-23 14:41:42 +00:00
Claude Bot
eb3a23ab03 fix: resolve stale closure causing breadcrumbs to disappear on back-navigation (closes #2)
The onClick handler in createPathSegment closed over the graphsPath variable
from the render when the segment was created. By the time a breadcrumb was
clicked (after further navigation), that closure was stale, so findIndex
returned -1 and splice(0) wiped the entire breadcrumb array.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 22:57:48 +00:00
bba49e019e Update .gitea/workflows/build.yml 2026-03-22 22:47:12 +00:00
c3f651f421 Update .gitea/workflows/build.yml 2026-03-22 21:04:01 +00:00
0eea473670 feature: add Create Node action for orphaned node creation
Right-clicking on empty graph area (outside any node or edge) now shows
a context menu with a single 'Create Node' option. Clicking it adds a new
node with a random label and no edges, allowing the user to wire it up
manually afterwards.

- NodeContext gains isEmptyArea flag to distinguish empty-area from node
  right-clicks
- Container div contextmenu handler covers the white space below the SVG;
  SVG-level handler covers empty space within the rendered graph
- node contextmenu handler calls stopPropagation so the SVG handler never
  fires for node clicks; SVG handler also guards via target.closest check
- NodeContextMenu renders emptyAreaItems (Create Node only) vs nodeItems
  (Rename / Subgraph / Remove) based on the flag
- randomWordList extracted as a named export so NodeContextMenu can reuse
  the same word bank
- Three new Playwright e2e tests cover: empty-area menu shows only Create
  Node, created node is orphaned (no new edges), node right-click does not
  show Create Node
2026-03-19 13:25:54 +01:00
92ef00e78f feature: navigation highlighting in tree 2026-03-16 19:35:35 +01:00
2495041a2b feature: tree navigation 2026-03-16 14:48:01 +01:00
2bf8a20f24 chore: added .mcp.json 2026-03-16 12:43:31 +01:00
8bf3ab296d feature: loading completed with teste 2026-03-06 14:54:20 +01:00
0af50e165a feature: loading graph structure 2026-03-06 14:31:51 +01:00
dcdd4d621e enhancement: changed save button placement 2026-03-06 13:27:52 +01:00
1a479e931f feature: added saving graph to json 2026-03-06 11:03:21 +01:00
e1adf6b9b0 bugfix: fixed subgraph not being preserved after navigation 2026-03-06 08:44:19 +01:00
5b991ca8cd tests: breadcrumbs tests 2026-03-06 07:20:24 +01:00
16 changed files with 1340 additions and 90 deletions

2
.gitignore vendored
View File

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

119
README.md
View File

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

View File

@@ -10,6 +10,11 @@ function nodeByLabel(page: import('@playwright/test').Page, label: string) {
return page.locator('g.node').filter({ hasText: label }); return page.locator('g.node').filter({ hasText: label });
} }
// Helper: the graph container div (empty space below the viz.js SVG)
function graphContainer(page: import('@playwright/test').Page) {
return page.locator('.bg-white.rounded.shadow');
}
test.describe('ConceptSketch', () => { test.describe('ConceptSketch', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -115,6 +120,42 @@ test.describe('ConceptSketch', () => {
await expect(nodeByLabel(page, 'Start')).toBeVisible(); await expect(nodeByLabel(page, 'Start')).toBeVisible();
}); });
test('right-clicking empty graph area shows only Create Node option', async ({ page }) => {
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
const dropdown = page.locator('.ant-dropdown:visible');
await expect(dropdown).toBeVisible({ timeout: 3000 });
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' })).toBeVisible();
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' })).not.toBeAttached();
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' })).not.toBeAttached();
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' })).not.toBeAttached();
});
test('creates an orphaned node via Create Node in the context menu', async ({ page }) => {
const initialNodeCount = await page.locator('g.node').count();
const initialEdgeCount = await page.locator('g.edge').count();
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' }).click();
await expect(page.locator('g.node')).toHaveCount(initialNodeCount + 1, { timeout: 5000 });
// No new edge — node is orphaned
await expect(page.locator('g.edge')).toHaveCount(initialEdgeCount, { timeout: 5000 });
});
test('right-clicking a node does not show Create Node option', async ({ page }) => {
await nodeByLabel(page, 'Start').click({ button: 'right' });
const dropdown = page.locator('.ant-dropdown:visible');
await expect(dropdown).toBeVisible({ timeout: 3000 });
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' })).not.toBeAttached();
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' })).toBeVisible();
});
test('links nodes using Ctrl+click selection', async ({ page }) => { test('links nodes using Ctrl+click selection', async ({ page }) => {
// Create a third node to link to // Create a third node to link to
await nodeByLabel(page, 'Start').click(); await nodeByLabel(page, 'Start').click();

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

@@ -0,0 +1,106 @@
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');
});
});

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

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

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

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

View File

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

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

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

View File

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

View File

@@ -1,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,7 @@ 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";
export class GraphModel { export class GraphModel {
@@ -45,6 +46,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 +64,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 +91,7 @@ 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);
useEffect(() => { useEffect(() => {
setGraphPath(graphsPath); setGraphPath(graphsPath);
@@ -92,12 +102,50 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [graph]); }, [graph]);
// 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]);
useEffect(() => { useEffect(() => {
if (nodeContext) { if (nodeContext) {
openContextMenu(true); openContextMenu(true);
} }
}, [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);
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,6 +157,7 @@ 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() {
@@ -171,12 +220,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 +253,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 +264,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 +299,13 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
</graphContext.Provider> </graphContext.Provider>
</div> </div>
) )
});
export default Graph;
export function randomWordList(count: number) {
const wordList = ['Apple', 'Sun', 'Flame', 'Earth', 'Forest', 'Dream', 'Sky', 'Shadow', 'Flower', 'Ocean', 'River', 'Path', 'Sand', 'Night', 'Star', 'Rain', 'Light', 'Tree', 'Wave', 'Storm', 'Stone', 'Snow', 'Cloud', 'Heart', 'Mountain', 'Leaf', 'Bird', 'Wind', 'Fire', 'Wolf'];
return wordList.sort(() => Math.random() - 0.5).slice(0, count);
} }
export function defaultGraph(): GraphModel { export function defaultGraph(): GraphModel {

View File

@@ -1,11 +1,11 @@
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";
const items: MenuProps['items'] = [ const nodeItems: MenuProps['items'] = [
{ {
key: 'rename', key: 'rename',
label: 'Rename', label: 'Rename',
@@ -23,6 +23,13 @@ const items: MenuProps['items'] = [
} }
] ]
const emptyAreaItems: MenuProps['items'] = [
{
key: 'create',
label: 'Create Node',
}
]
export default function NodeContextMenu({ export default function NodeContextMenu({
nodeContext, nodeContext,
contextMenuOpened, contextMenuOpened,
@@ -38,9 +45,11 @@ 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);
function contextMenuOpenChange(open: boolean) { function contextMenuOpenChange(open: boolean) {
if (!open) { if (!open) {
@@ -56,6 +65,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': {

View File

@@ -1,6 +1,7 @@
import { Input, Modal } from "antd"; import { Input, Modal } from "antd";
import { graphContext, type NodeContext } from "./Graph"; import { graphContext, type NodeContext } from "./Graph";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { useGraphLayersTreeStore } from "../stores/TreeStore";
export default function NodeRenameModal({ export default function NodeRenameModal({
nodeContext, nodeContext,
@@ -16,6 +17,7 @@ export default function NodeRenameModal({
} }
const [nodeName, setSelectedNodeName] = useState(nodeContext.nodeName); const [nodeName, setSelectedNodeName] = useState(nodeContext.nodeName);
const graphContextValue = useContext(graphContext)!; const graphContextValue = useContext(graphContext)!;
const renameTreeNode = useGraphLayersTreeStore(state => state.rename);
function renameNode() { function renameNode() {
const node = graphContextValue.graph.nodes.find(n => n.id === nodeContext.nodeId); const node = graphContextValue.graph.nodes.find(n => n.id === nodeContext.nodeId);
@@ -24,6 +26,7 @@ export default function NodeRenameModal({
} }
node.label = nodeName; node.label = nodeName;
graphContextValue.setGraph(prev => ({ ...prev, nodes: graphContextValue.graph.nodes })); graphContextValue.setGraph(prev => ({ ...prev, nodes: graphContextValue.graph.nodes }));
renameTreeNode(nodeContext.nodeId, nodeName);
openRenameModal(false); openRenameModal(false);
} }

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

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

View File

@@ -10,6 +10,8 @@ export interface TreeStore {
tree: TreeDataNode[]; tree: TreeDataNode[];
add: (childNode: NodeContext, parentNodeId: string | undefined) => void; add: (childNode: NodeContext, parentNodeId: string | undefined) => void;
remove: (nodeId: string) => void; remove: (nodeId: string) => void;
rename: (nodeId: string, newName: string) => 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,27 @@ export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
parentIdByChildId: state.parentIdByChildId, parentIdByChildId: state.parentIdByChildId,
tree: createTree([...state.rootNodes], nodesFlatById) tree: createTree([...state.rootNodes], nodesFlatById)
} }
}) }),
rename: (nodeId, newName) => set((state) => {
const node = state.nodesFlatById.get(nodeId);
if (!node) {
return state;
}
const nodesFlatById = new Map(state.nodesFlatById);
nodesFlatById.set(nodeId, { ...node, title: newName });
return {
...state,
nodesFlatById,
rootNodes: [...state.rootNodes],
tree: createTree([...state.rootNodes], nodesFlatById),
};
}),
reset: () => set({
nodesFlatById: new Map<React.Key, TreeDataNode>(),
parentIdByChildId: new Map<React.Key, string>(),
rootNodes: [],
tree: [],
}),
})); }));
export function createTree(nodes: TreeDataNode[], nodesFlatById: Map<React.Key, TreeDataNode>): TreeDataNode[] { export function createTree(nodes: TreeDataNode[], nodesFlatById: Map<React.Key, TreeDataNode>): TreeDataNode[] {

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

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

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

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