Compare commits

...

10 Commits

Author SHA1 Message Date
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
14 changed files with 1262 additions and 22 deletions

4
.gitignore vendored
View File

@@ -24,4 +24,6 @@ dist-ssr
*.sw?
playwright-report
test-results
test-results
.mcp.json

View File

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

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 Graph from './components/Graph';
import Graph, { type GraphHandle } from './components/Graph';
import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb';
import { useKeysdownStore } from './stores/ArrayStore';
import {
MenuFoldOutlined,
MenuUnfoldOutlined
MenuUnfoldOutlined,
SaveOutlined,
FolderOpenOutlined,
} from '@ant-design/icons';
import { saveConceptSketch } from './utils/saveGraph';
import { loadConceptSketch } from './utils/loadGraph';
import { useGraphLayersTreeStore } from './stores/TreeStore';
const { Header, Content, Sider } = Layout;
@@ -32,10 +36,21 @@ const App: React.FC = () => {
onKeyUp(ev.key);
});
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(() => {
console.info(treeData);
}, [treeData])
function buildPathToNode(nodeId: string) {
const path: Array<{ id: string; name: string | undefined }> = [];
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 (
<Layout>
@@ -47,13 +62,19 @@ const App: React.FC = () => {
checkable
treeData={treeData}
defaultExpandAll={true}
selectedKeys={[activeGraphId]}
style={{
borderRadius: 0
}}
onSelect={(keys) => {
const nodeId = keys[0] as string;
if (!nodeId) return;
graphRef.current?.navigateTo(nodeId, buildPathToNode(nodeId));
}}
/>
</Sider>
<Layout>
<Header style={{ padding: 0, background: colorBgContainer }}>
<Header style={{ padding: 0, background: colorBgContainer, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<div style={{ background: '#001529' }}>
<Button
@@ -70,6 +91,32 @@ const App: React.FC = () => {
</div>
<Breadcrumb items={graphLevel} />
</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>
<Content
style={{
@@ -80,7 +127,7 @@ const App: React.FC = () => {
borderRadius: '6px'
}}
>
<Graph setGraphPath={setGraphLevel} />
<Graph ref={graphRef} setGraphPath={setGraphLevel} onNavigate={setActiveGraphId} />
</Content>
</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 { Module, render } from 'viz.js/full.render.js';
import * as d3 from 'd3';
@@ -8,6 +8,7 @@ import NodeContextMenu from "./NodeContextMenu";
import NodeRenameModal from "./NodeRenameModal";
import { useGraphsStore } from "../stores/GraphsStore";
import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore";
import { useLoadStore } from "../stores/LoadStore";
export class GraphModel {
@@ -45,6 +46,7 @@ export interface NodeContext {
nodeId: string;
nodeName?: string;
coords: { x: number, y: number };
isEmptyArea?: boolean;
}
export interface GraphContext {
@@ -62,14 +64,21 @@ export interface OpenNodeContext {
const viz = new Viz({ Module, render });
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 [graph, setGraph] = useState(defaultGraph());
const [contextMenuOpened, openContextMenu] = useState(false);
const [renameModalOpened, openRenameModal] = useState(false);
const [graphId, selectGraphId] = useState('main');
const graphIdRef = useRef(graphId);
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 = {
graphId: graphId,
selectGraphId: selectGraphId,
@@ -82,6 +91,7 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
const isNodeSelected = useSelectedNodesStore(store => store.has);
const anyNodeSelected = useSelectedNodesStore(store => store.hasAny);
const deselectAllNodes = useSelectedNodesStore(store => store.clear);
const loadCount = useLoadStore(state => state.loadCount);
useEffect(() => {
setGraphPath(graphsPath);
@@ -92,12 +102,50 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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(() => {
if (nodeContext) {
openContextMenu(true);
}
}, [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(() => {
if (nodeContext?.nodeId === graphId) {
graphsPath.push(createPathSegment(nodeContext.nodeId, nodeContext.nodeName));
@@ -109,6 +157,7 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
if (graph) {
setGraph(graph);
}
onNavigate?.(graphId);
}, [graphId]);
async function renderGraph() {
@@ -171,12 +220,26 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
}
event.preventDefault();
event.stopPropagation();
openNodeContext({
nodeId: id,
nodeName: node.label,
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) {
@@ -190,11 +253,7 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
}
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 wordList
.sort(() => Math.random() - 0.5)
.slice(0, count);
return randomWordList(count);
}
function createPathSegment(
@@ -216,6 +275,15 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
} as BreadcrumbItemType;
}
useImperativeHandle(ref, () => ({
navigateTo(nodeId, path) {
const newPath = path.map(p => createPathSegment(p.id, p.name));
openNodeContext(null);
setGraphsPath(newPath);
selectGraphId(nodeId);
}
}));
return (
<div className="flex-1 p-4">
<div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{ minHeight: '600px', overflow: 'auto' }}>
@@ -236,6 +304,13 @@ export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<R
</graphContext.Provider>
</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 {

View File

@@ -1,11 +1,11 @@
import { Dropdown, type MenuProps } from "antd";
import { defaultGraph, graphContext, GraphModel, type EdgeModel, type NodeContext } from "./Graph";
import { defaultGraph, graphContext, GraphModel, randomWordList, type EdgeModel, type NodeContext } from "./Graph";
import { useContext } from "react";
import { cloneDeep } from "lodash";
import { useGraphsStore } from "../stores/GraphsStore";
import { useGraphLayersTreeStore } from "../stores/TreeStore";
const items: MenuProps['items'] = [
const nodeItems: MenuProps['items'] = [
{
key: 'rename',
label: 'Rename',
@@ -23,6 +23,13 @@ const items: MenuProps['items'] = [
}
]
const emptyAreaItems: MenuProps['items'] = [
{
key: 'create',
label: 'Create Node',
}
]
export default function NodeContextMenu({
nodeContext,
contextMenuOpened,
@@ -38,9 +45,11 @@ export default function NodeContextMenu({
return;
}
const items = nodeContext.isEmptyArea ? emptyAreaItems : nodeItems;
const graphContextValue = useContext(graphContext)!;
const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById);
const addTreeNode = useGraphLayersTreeStore(store => store.add);
const removeTreeNode = useGraphLayersTreeStore(store => store.remove);
function contextMenuOpenChange(open: boolean) {
if (!open) {
@@ -56,6 +65,12 @@ export default function NodeContextMenu({
}
case 'remove': {
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;
}
case 'subgraph': {

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,7 @@ export interface TreeStore {
tree: TreeDataNode[];
add: (childNode: NodeContext, parentNodeId: string | undefined) => void;
remove: (nodeId: string) => void;
reset: () => void;
}
export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
@@ -42,11 +43,12 @@ export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
} else {
const nodesFlatById = new Map(state.nodesFlatById);
nodesFlatById.set(childNode.key, childNode);
const newRootNodes = [...state.rootNodes, childNode];
const newState = {
nodesFlatById: nodesFlatById,
parentIdByChildId: state.parentIdByChildId,
rootNodes: [...state.rootNodes, childNode],
tree: createTree([...state.rootNodes], nodesFlatById)
rootNodes: newRootNodes,
tree: createTree(newRootNodes, nodesFlatById)
}
return newState;
}
@@ -87,7 +89,13 @@ export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
parentIdByChildId: state.parentIdByChildId,
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[] {

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);
}