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>
This commit is contained in:
Claude Bot
2026-03-23 15:57:28 +00:00
committed by tymurbaniak
parent ee9a55f4e6
commit 000aad362a
5 changed files with 282 additions and 4 deletions

View File

@@ -1,6 +1,6 @@
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
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(', ')}];`);
}

View File

@@ -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<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
const anyNodeSelected = useSelectedNodesStore(store => 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<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
useEffect(() => {
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<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
setGraphsPath([createPathSegment('main', 'Main')]);
openNodeContext(null);
openContextMenu(false);
useCutStore.getState().clearCut();
onNavigate?.('main');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadCount]);
@@ -161,7 +164,8 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
}, [graphId]);
async function renderGraph() {
const dot = graphToDot(graph);
const currentCutNodeIds = new Set(useCutStore.getState().cutNodeIds);
const dot = graphToDot(graph, currentCutNodeIds);
try {
const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' });

View File

@@ -4,6 +4,8 @@ import { useContext } from "react";
import { cloneDeep } from "lodash";
import { useGraphsStore } from "../stores/GraphsStore";
import { useGraphLayersTreeStore } from "../stores/TreeStore";
import { useCutStore } from "../stores/CutStore";
import { useSelectedNodesStore } from "../stores/ArrayStore";
const nodeItems: MenuProps['items'] = [
{
@@ -20,6 +22,11 @@ const nodeItems: MenuProps['items'] = [
key: 'remove',
label: 'Remove',
extra: 'ctrl + r'
},
{
key: 'cut',
label: 'Cut',
extra: 'ctrl + x',
}
]
@@ -27,6 +34,11 @@ const emptyAreaItems: MenuProps['items'] = [
{
key: 'create',
label: 'Create Node',
},
{
key: 'paste',
label: 'Paste',
extra: 'ctrl + v',
}
]
@@ -50,6 +62,8 @@ export default function NodeContextMenu({
const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById);
const addTreeNode = useGraphLayersTreeStore(store => store.add);
const removeTreeNode = useGraphLayersTreeStore(store => store.remove);
const setCut = useCutStore(store => store.setCut);
const clearCut = useCutStore(store => store.clearCut);
function contextMenuOpenChange(open: boolean) {
if (!open) {
@@ -84,11 +98,72 @@ 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];
// Also include nodes reachable by outgoing edges from base nodes
const baseIdSet = new Set(baseIds);
const linkedIds = new Set<string>();
for (const nodeId of baseIds) {
currentGraph.edges
.filter(e => e.from === nodeId && !baseIdSet.has(e.to))
.forEach(e => linkedIds.add(e.to));
}
const allCutIds = [...baseIds, ...linkedIds];
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)),
});
}
}
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 }),
}));