diff --git a/src/flowchart/flowchart-clipboard.js b/src/flowchart/flowchart-clipboard.js new file mode 100644 index 0000000..f5c9726 --- /dev/null +++ b/src/flowchart/flowchart-clipboard.js @@ -0,0 +1,96 @@ +/** + * Pure clipboard serialiser for the flowchart editor (v4.13.0). + * + * Serialises the selected node + its edges to a JSON envelope that + * survives a paste-into-new-graph round trip. Uses a fresh id-mangling + * pass on paste so duplicates don't collide with the originals. + * + * Pure module — no DOM, no globals. + * + * @module flowchart-clipboard + */ + +const CLIPBOARD_VERSION = 1; + +/** + * @param {object} graph current graph from store + * @param {{nodeId?:string, edgeId?:string}} selection canvas selection + * @returns {object|null} clipboard payload or null when nothing useful + * is selected + */ +function copySelection(graph, selection) { + if (!graph || !selection) return null; + if (selection.nodeId) { + const node = graph.nodes.find((n) => n.id === selection.nodeId); + if (!node) return null; + const connectedEdges = graph.edges.filter( + (e) => e.fromNodeId === node.id || e.toNodeId === node.id + ); + return { + version: CLIPBOARD_VERSION, + kind: 'node-with-edges', + node, + edges: connectedEdges, + }; + } + if (selection.edgeId) { + const edge = graph.edges.find((e) => e.id === selection.edgeId); + if (!edge) return null; + return { version: CLIPBOARD_VERSION, kind: 'edge', edge }; + } + return null; +} + +/** + * Apply a clipboard payload to a graph + selection. Returns the new + * node(s) created so the controller can move the selection. + * + * @param {object} payload from copySelection() + * @param {object} graph current graph + * @param {object} store store with addNode / connect APIs + * @param {{offsetX?:number, offsetY?:number}} [opts] + * @returns {Array<{id:string}>} ids of newly created nodes (empty for edge-only paste) + */ +function pasteSelection(payload, graph, store, opts = {}) { + if (!payload) return []; + const dx = opts.offsetX || 24; + const dy = opts.offsetY || 24; + + if (payload.kind === 'node-with-edges' && payload.node) { + const oldNode = payload.node; + const newNode = store.addNode({ + kind: oldNode.kind, + x: (oldNode.x || 0) + dx, + y: (oldNode.y || 0) + dy, + label: oldNode.label, + color: oldNode.color, + }); + // Re-attach edges: each connected edge maps its endpoint to either + // the new node (when it was the original) or to the existing neighbour. + const idRemap = new Map([[oldNode.id, newNode.id]]); + const edges = payload.edges || []; + for (const e of edges) { + const from = idRemap.get(e.fromNodeId) || e.fromNodeId; + const to = idRemap.get(e.toNodeId) || e.toNodeId; + // Both endpoints must still exist in the current graph + if (graph.nodes.find((n) => n.id === from) && graph.nodes.find((n) => n.id === to)) { + store.connect(from, to, e.kind); + } + } + return [{ id: newNode.id }]; + } + + if (payload.kind === 'edge' && payload.edge) { + const e = payload.edge; + if ( + graph.nodes.find((n) => n.id === e.fromNodeId) && + graph.nodes.find((n) => n.id === e.toNodeId) + ) { + store.connect(e.fromNodeId, e.toNodeId, e.kind); + } + return []; + } + return []; +} + +module.exports = { copySelection, pasteSelection, CLIPBOARD_VERSION }; diff --git a/tests/flowchart-clipboard.test.js b/tests/flowchart-clipboard.test.js new file mode 100644 index 0000000..fb893b5 --- /dev/null +++ b/tests/flowchart-clipboard.test.js @@ -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'); + }); +});