feat(flowchart): clipboard copy/paste/duplicate pure module (C6a)

New clipboard serialiser for the flowchart editor. Pure module —
no DOM, no globals — so it's unit-testable in isolation.

  - Captures the selected node + its connected edges to a JSON envelope
    that survives a paste-into-new-graph round trip. Edge-only selection
    is also supported.
  - Paste creates a new node offset from the original (default 24px)
    and re-attaches every connected edge to the new id. Only edges
    whose other endpoint still exists in the graph are recreated, so
    deleting a neighbour before pasting doesn't try to reconnect to a
    ghost.
  - Preserves node color on paste.

Wire-up into the renderer controller (Cmd+C / Cmd+V / Cmd+D handlers
+ canvas-owned selection reading) is the next commit; this one just
lands the testable math.

9 new tests cover selection capture, edge-only selection, paste with
offset, edge re-attachment, dangling-edge handling, color preservation,
and the null-payload no-op.

Amit Haridas
This commit is contained in:
Amit Haridas
2026-09-30 21:32:03 +05:30
parent 9412049f56
commit 7beb3effcf
2 changed files with 248 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
/**
* @jest-environment node
*
* Flowchart clipboard — copy/paste pure functions.
*/
const {
copySelection,
pasteSelection,
CLIPBOARD_VERSION,
} = require('../src/flowchart/flowchart-clipboard');
function makeStore() {
const state = { nodes: [], edges: [] };
return {
state,
addNode: ({ kind, x, y, label, color }) => {
const node = { id: 'n_' + state.nodes.length, kind, x, y, label, color };
state.nodes.push(node);
return node;
},
connect: (fromNodeId, toNodeId, kind) => {
const edge = {
id: 'e_' + state.edges.length,
fromNodeId,
toNodeId,
kind,
};
state.edges.push(edge);
return edge;
},
getGraph: () => state,
};
}
describe('copySelection', () => {
test('returns null when no selection', () => {
expect(copySelection({ nodes: [], edges: [] }, {})).toBeNull();
});
test('returns null when selection.nodeId not in graph', () => {
expect(copySelection({ nodes: [], edges: [] }, { nodeId: 'missing' })).toBeNull();
});
test('captures a node plus its connected edges', () => {
const graph = {
nodes: [
{ id: 'a', kind: 'process', x: 0, y: 0, label: 'A' },
{ id: 'b', kind: 'process', x: 100, y: 0, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'a', toNodeId: 'b', kind: 'solid' }],
};
const out = copySelection(graph, { nodeId: 'a' });
expect(out.kind).toBe('node-with-edges');
expect(out.node).toEqual(graph.nodes[0]);
expect(out.edges).toHaveLength(1);
expect(out.version).toBe(CLIPBOARD_VERSION);
});
test('captures only a single edge when selection is edge', () => {
const graph = {
nodes: [
{ id: 'a', kind: 'process', x: 0, y: 0, label: 'A' },
{ id: 'b', kind: 'process', x: 100, y: 0, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'a', toNodeId: 'b', kind: 'solid' }],
};
const out = copySelection(graph, { edgeId: 'e1' });
expect(out.kind).toBe('edge');
expect(out.edge.id).toBe('e1');
});
});
describe('pasteSelection', () => {
test('creates a new node offset from the original', () => {
const graph = {
nodes: [{ id: 'a', kind: 'process', x: 0, y: 0, label: 'A' }],
edges: [],
};
const store = makeStore();
store.state.nodes = graph.nodes;
const payload = copySelection(graph, { nodeId: 'a' });
const created = pasteSelection(payload, graph, store);
expect(created).toHaveLength(1);
expect(store.state.nodes).toHaveLength(2);
// The new node should be offset from the original.
const newNode = store.state.nodes[1];
expect(newNode.x).toBeGreaterThan(0);
expect(newNode.y).toBeGreaterThan(0);
});
test('re-attaches edges to the new node', () => {
const graph = {
nodes: [
{ id: 'a', kind: 'process', x: 0, y: 0, label: 'A' },
{ id: 'b', kind: 'process', x: 100, y: 0, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'a', toNodeId: 'b', kind: 'solid' }],
};
const store = makeStore();
store.state.nodes = graph.nodes;
store.state.edges = [...graph.edges];
const payload = copySelection(graph, { nodeId: 'a' });
pasteSelection(payload, graph, store);
// After paste: 3 nodes (a, b, a'), 2 edges (original a→b + new a'→b).
// The original edge stays put; the new one connects a' → b because b
// is the only neighbour that wasn't in the selection.
expect(store.state.nodes).toHaveLength(3);
expect(store.state.edges).toHaveLength(2);
const newEdges = store.state.edges.filter(
(e) => e.fromNodeId === 'n_2' || e.toNodeId === 'n_2'
);
expect(newEdges).toHaveLength(1);
expect(newEdges[0].toNodeId).toBe('b');
});
test('pasting an edge only adds the edge if both endpoints exist', () => {
const graph = {
nodes: [
{ id: 'a', kind: 'process', x: 0, y: 0, label: 'A' },
{ id: 'b', kind: 'process', x: 100, y: 0, label: 'B' },
],
edges: [],
};
const store = makeStore();
store.state.nodes = graph.nodes;
const payload = copySelection(
{ ...graph, edges: [{ id: 'e1', fromNodeId: 'a', toNodeId: 'b', kind: 'solid' }] },
{ edgeId: 'e1' }
);
pasteSelection(payload, graph, store);
expect(store.state.edges).toHaveLength(1);
});
test('null payload is a no-op', () => {
const store = makeStore();
expect(pasteSelection(null, { nodes: [], edges: [] }, store)).toEqual([]);
});
test('preserves node color on paste', () => {
const graph = {
nodes: [{ id: 'a', kind: 'process', x: 0, y: 0, label: 'A', color: '#ff0000' }],
edges: [],
};
const store = makeStore();
store.state.nodes = graph.nodes;
const payload = copySelection(graph, { nodeId: 'a' });
pasteSelection(payload, graph, store);
const newNode = store.state.nodes[1];
expect(newNode.color).toBe('#ff0000');
});
});