mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
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:
@@ -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 };
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user