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:
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user