Compare commits

..

10 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 22:57:48 +00:00
8 changed files with 489 additions and 72 deletions

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

@@ -102,5 +102,92 @@ test.describe('Breadcrumb navigation', () => {
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('End'); await expect(page.locator('.ant-breadcrumb')).toContainText('End');
await expect(page.locator('.ant-breadcrumb')).not.toContainText('Start'); await expect(page.locator('.ant-breadcrumb')).not.toContainText('Start');
}); });
});
// Regression tests for issue #2: stale closure caused breadcrumbs to disappear on back-navigation.
//
// The bug: createPathSegment's onClick closed over `graphsPath` from the render in which
// the segment was created. After further navigation the closure was stale, so findIndex
// returned -1 and splice(0) wiped the entire breadcrumb array.
//
// The fix: use the functional updater form of setGraphsPath so findIndex always runs
// against the *current* state rather than a captured snapshot.
test.describe('Stale-closure regression (issue #2)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForGraph(page);
});
test('root breadcrumb remains functional after navigating three levels deep', async ({ page }) => {
// Navigate 3 levels deep — each additional level re-renders breadcrumbs with new
// closures; before the fix, clicking the root would hit a stale closure from level 1
// and wipe everything.
await openSubgraph(page, 'Start'); // level 1
await openSubgraph(page, 'Start'); // level 2
await openSubgraph(page, 'Start'); // level 3
await expect(breadcrumbLinks(page)).toHaveCount(4);
// Click the root ("Main") breadcrumb
await breadcrumbLinks(page).first().click();
// Breadcrumbs must NOT disappear — exactly one "Main" segment remains
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
await waitForGraph(page);
// The root graph is rendered correctly
await expect(nodeByLabel(page, 'Start')).toBeVisible();
await expect(nodeByLabel(page, 'End')).toBeVisible();
});
test('level-1 breadcrumb remains functional when clicked from level 3', async ({ page }) => {
// This is the core regression scenario: a breadcrumb segment created at level 1
// must still resolve correctly after two more navigations have updated the path state.
await openSubgraph(page, 'Start'); // level 1
await openSubgraph(page, 'Start'); // level 2
await openSubgraph(page, 'Start'); // level 3
await expect(breadcrumbLinks(page)).toHaveCount(4);
// Click the level-1 breadcrumb (second item)
await breadcrumbLinks(page).nth(1).click();
// Should trim to exactly 2 items — NOT clear everything
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
await waitForGraph(page);
// The graph at level 1 is shown
await expect(nodeByLabel(page, 'Start')).toBeVisible();
});
test('successive back-clicks each trim exactly one breadcrumb level', async ({ page }) => {
await openSubgraph(page, 'Start'); // level 1
await openSubgraph(page, 'Start'); // level 2
await openSubgraph(page, 'Start'); // level 3
await expect(breadcrumbLinks(page)).toHaveCount(4);
// Click level-2 breadcrumb
await breadcrumbLinks(page).nth(2).click();
await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 });
// Then click level-1 breadcrumb
await breadcrumbLinks(page).nth(1).click();
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
// Then click root breadcrumb
await breadcrumbLinks(page).first().click();
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
});
test('clicking the active (last) breadcrumb does not alter the path', async ({ page }) => {
// Clicking the segment you are already on should be a no-op — it must not clear
// breadcrumbs by accidentally slicing at index -1.
await openSubgraph(page, 'Start'); // level 1
await openSubgraph(page, 'Start'); // level 2
await expect(breadcrumbLinks(page)).toHaveCount(3);
// The last breadcrumb link is the currently active level
await breadcrumbLinks(page).last().click();
await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 });
});
}); });

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

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

View File

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

View File

@@ -9,6 +9,7 @@ import NodeRenameModal from "./NodeRenameModal";
import { useGraphsStore } from "../stores/GraphsStore"; import { useGraphsStore } from "../stores/GraphsStore";
import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore"; import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore";
import { useLoadStore } from "../stores/LoadStore"; import { useLoadStore } from "../stores/LoadStore";
import { useCutStore } from "../stores/CutStore";
export class GraphModel { export class GraphModel {
@@ -92,6 +93,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
const anyNodeSelected = useSelectedNodesStore(store => store.hasAny); const anyNodeSelected = useSelectedNodesStore(store => store.hasAny);
const deselectAllNodes = useSelectedNodesStore(store => store.clear); const deselectAllNodes = useSelectedNodesStore(store => store.clear);
const loadCount = useLoadStore(state => state.loadCount); const loadCount = useLoadStore(state => state.loadCount);
const cutNodeIds = useCutStore(state => state.cutNodeIds);
useEffect(() => { useEffect(() => {
setGraphPath(graphsPath); setGraphPath(graphsPath);
@@ -100,7 +102,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
useEffect(() => { useEffect(() => {
renderGraph(); renderGraph();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [graph]); }, [graph, cutNodeIds]);
// Persist graph edits to the store so they survive breadcrumb navigation. // 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 // We intentionally read graphId via ref (not as a dep) so this effect only
@@ -142,6 +144,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
setGraphsPath([createPathSegment('main', 'Main')]); setGraphsPath([createPathSegment('main', 'Main')]);
openNodeContext(null); openNodeContext(null);
openContextMenu(false); openContextMenu(false);
useCutStore.getState().clearCut();
onNavigate?.('main'); onNavigate?.('main');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadCount]); }, [loadCount]);
@@ -161,7 +164,8 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
}, [graphId]); }, [graphId]);
async function renderGraph() { async function renderGraph() {
const dot = graphToDot(graph); const currentCutNodeIds = new Set(useCutStore.getState().cutNodeIds);
const dot = graphToDot(graph, currentCutNodeIds);
try { try {
const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' }); const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' });
@@ -264,12 +268,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
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;

View File

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

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

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

View File

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