diff --git a/e2e/cut-paste.spec.ts b/e2e/cut-paste.spec.ts new file mode 100644 index 0000000..08f3b26 --- /dev/null +++ b/e2e/cut-paste.spec.ts @@ -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 }); + }); +}); diff --git a/src/Graphviz.tsx b/src/Graphviz.tsx index f178858..de7ed13 100644 --- a/src/Graphviz.tsx +++ b/src/Graphviz.tsx @@ -1,6 +1,6 @@ import type { GraphModel } from "./components/Graph"; -export function graphToDot(g: GraphModel): string { +export function graphToDot(g: GraphModel, cutNodeIds: Set = new Set()): string { // Directed graph, use neato layout so we can use pos attributes const lines = []; lines.push('digraph G {'); @@ -12,6 +12,9 @@ export function graphToDot(g: GraphModel): string { const attrs = []; attrs.push(`label=\"${n.label}\"`); attrs.push(`id=\"${n.id}\"`) + if (cutNodeIds.has(n.id)) { + attrs.push(`fillcolor="#d9d9d9"`, `style="filled,dashed"`); + } lines.push(` \"${n.id}\" [${attrs.join(', ')}];`); } diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index 5a8c284..1223cb9 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -9,6 +9,7 @@ import NodeRenameModal from "./NodeRenameModal"; import { useGraphsStore } from "../stores/GraphsStore"; import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore"; import { useLoadStore } from "../stores/LoadStore"; +import { useCutStore } from "../stores/CutStore"; export class GraphModel { @@ -92,6 +93,7 @@ const Graph = forwardRef store.hasAny); const deselectAllNodes = useSelectedNodesStore(store => store.clear); const loadCount = useLoadStore(state => state.loadCount); + const cutNodeIds = useCutStore(state => state.cutNodeIds); useEffect(() => { setGraphPath(graphsPath); @@ -100,7 +102,7 @@ const Graph = forwardRef { renderGraph(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [graph]); + }, [graph, cutNodeIds]); // Persist graph edits to the store so they survive breadcrumb navigation. // We intentionally read graphId via ref (not as a dep) so this effect only @@ -142,6 +144,7 @@ const Graph = forwardRef (s as { graphsById: Map }).graphsById); const addTreeNode = useGraphLayersTreeStore(store => store.add); const removeTreeNode = useGraphLayersTreeStore(store => store.remove); + const moveTreeNode = useGraphLayersTreeStore(store => store.move); + const setCut = useCutStore(store => store.setCut); + const clearCut = useCutStore(store => store.clearCut); function contextMenuOpenChange(open: boolean) { if (!open) { @@ -84,11 +99,92 @@ export default function NodeContextMenu({ const parenNodeId = graphContextValue.graphId === 'main' ? undefined : graphContextValue.graphId; addTreeNode(nodeContext, parenNodeId); } - + graphContextValue.setGraph(selectedGraph); 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; + } } }; diff --git a/src/stores/CutStore.ts b/src/stores/CutStore.ts new file mode 100644 index 0000000..0e22327 --- /dev/null +++ b/src/stores/CutStore.ts @@ -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()((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 }), +})); diff --git a/src/stores/TreeStore.tsx b/src/stores/TreeStore.tsx index bd913e8..df2ee17 100644 --- a/src/stores/TreeStore.tsx +++ b/src/stores/TreeStore.tsx @@ -10,6 +10,7 @@ export interface TreeStore { tree: TreeDataNode[]; add: (childNode: NodeContext, parentNodeId: string | undefined) => void; remove: (nodeId: string) => void; + move: (nodeId: string, newParentId: string | undefined) => void; reset: () => void; } @@ -90,6 +91,44 @@ export const useGraphLayersTreeStore = create()((set) => ({ tree: createTree([...state.rootNodes], nodesFlatById) } }), + move: (nodeId, newParentId) => set((state) => { + const node = state.nodesFlatById.get(nodeId); + if (!node) return state; + + const nodesFlatById = new Map(state.nodesFlatById); + const parentIdByChildId = new Map(state.parentIdByChildId); + let rootNodes = [...state.rootNodes]; + + // Remove from old parent + const oldParentId = state.parentIdByChildId.get(nodeId); + if (oldParentId !== undefined) { + const oldParent = nodesFlatById.get(oldParentId); + if (oldParent) { + oldParent.children = oldParent.children?.filter(n => n.key !== nodeId) ?? []; + } + parentIdByChildId.delete(nodeId); + } else { + rootNodes = rootNodes.filter(n => n.key !== nodeId); + } + + // Add to new parent + if (newParentId !== undefined) { + const newParent = nodesFlatById.get(newParentId); + if (newParent) { + newParent.children = newParent.children ? [...newParent.children, node] : [node]; + parentIdByChildId.set(nodeId, newParentId); + } + } else { + rootNodes = [...rootNodes, node]; + } + + return { + nodesFlatById, + parentIdByChildId, + rootNodes, + tree: createTree(rootNodes, nodesFlatById), + }; + }), reset: () => set({ nodesFlatById: new Map(), parentIdByChildId: new Map(),