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
This commit was merged in pull request #9.
This commit is contained in:
2026-03-26 01:00:42 +00:00
6 changed files with 342 additions and 4 deletions

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

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>(),