feat(flowchart): SVG canvas with drag, double-click label edit, context menu hook

- Adds flowchart-canvas.js with createCanvas(container, store, opts) -> { destroy, getSvg }
- Consumes T1 store (subscribe/moveNode/connect/addNode) and T2 shapeSvg for 5 node kinds
- All 3 edge kinds render: solid (default), dotted (stroke-dasharray 4,4), thick (stroke-width 3)
- Pointer events: drag moves nodes; Alt+drag creates solid edges between nodes
- onEdgeClick fires on edge click; onShapeMenu fires on contextmenu
- Double-click opens inline <input> overlay for label editing
- destroy() unsubscribes from store and removes all listeners/SVG

Tests: 9 new (rendering 5, pointer events 2, subscribe 1, destroy 1) @jest-environment jsdom
Full suite: 89 suites / 1083 tests pass; lint clean; prettier clean.

Amit Haridas
This commit is contained in:
2026-09-14 21:41:58 +05:30
parent 216e0ecf51
commit 0331a08207
2 changed files with 472 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
/**
* @jest-environment jsdom
*/
const { create } = require('../src/flowchart/flowchart-store');
const { createCanvas } = require('../src/flowchart/flowchart-canvas');
function makeStore(graph) {
const store = create({
persistencePath: '/tmp/x.json',
readFile: async () => null,
writeFile: async () => undefined,
now: () => 0,
});
if (graph) store.deserialize(JSON.stringify(graph));
return store;
}
function mount(store) {
const container = document.createElement('div');
document.body.appendChild(container);
const api = createCanvas(container, store, {
onEdgeClick: jest.fn(),
onShapeMenu: jest.fn(),
});
return { container, api };
}
describe('flowchart-canvas: rendering', () => {
test('mounts an <svg> inside the container', () => {
const store = makeStore();
const { container } = mount(store);
expect(container.querySelector('svg.flowchart-canvas')).not.toBeNull();
});
test('renders one <g data-node-id> per node', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 10, y: 20, label: 'A' },
{ id: 'n2', kind: 'decision', x: 50, y: 50, label: 'B' },
{ id: 'n3', kind: 'terminator', x: 90, y: 80, label: 'C' },
],
edges: [],
});
const { container } = mount(store);
const groups = container.querySelectorAll('g[data-node-id]');
expect(groups).toHaveLength(3);
});
test('renders edges as <line> elements', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 10, y: 20, label: 'A' },
{ id: 'n2', kind: 'process', x: 100, y: 100, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' }],
});
const { container } = mount(store);
expect(container.querySelectorAll('line[data-edge-id]')).toHaveLength(1);
});
test('thick edges get stroke-width="3"', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 10, y: 20, label: 'A' },
{ id: 'n2', kind: 'process', x: 100, y: 100, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'thick' }],
});
const { container } = mount(store);
const line = container.querySelector('line[data-edge-id]');
expect(line.getAttribute('stroke-width')).toBe('3');
});
test('dotted edges get stroke-dasharray', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 10, y: 20, label: 'A' },
{ id: 'n2', kind: 'process', x: 100, y: 100, label: 'B' },
],
edges: [{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'dotted' }],
});
const { container } = mount(store);
const line = container.querySelector('line[data-edge-id]');
expect(line.getAttribute('stroke-dasharray')).toBe('4,4');
});
});
describe('flowchart-canvas: pointer events', () => {
function stubLayout(container, nodes) {
// Match the SVG viewBox (1000 x 700) so client→svg mapping is 1:1.
container.querySelector('svg').getBoundingClientRect = () => ({
x: 0,
y: 0,
width: 1000,
height: 700,
top: 0,
left: 0,
bottom: 700,
right: 1000,
});
for (const n of nodes) {
const g = container.querySelector(`g[data-node-id="${n.id}"]`);
g.getBoundingClientRect = () => ({
x: n.x,
y: n.y,
width: 80,
height: 40,
top: n.y,
left: n.x,
bottom: n.y + 40,
right: n.x + 80,
});
}
}
function dispatch(target, type, opts) {
const ev = new Event(type, { bubbles: true, cancelable: true });
Object.assign(ev, opts || {});
target.dispatchEvent(ev);
}
test('pointerdown + pointermove + pointerup on a node moves it', () => {
const store = makeStore({
nodes: [{ id: 'n1', kind: 'process', x: 100, y: 100, label: 'A' }],
edges: [],
});
const { container } = mount(store);
const nodeG = container.querySelector('g[data-node-id="n1"]');
stubLayout(container, [{ id: 'n1', x: 100, y: 100 }]);
dispatch(nodeG, 'pointerdown', { clientX: 120, clientY: 110, pointerId: 1 });
dispatch(nodeG, 'pointermove', { clientX: 170, clientY: 160, pointerId: 1 });
dispatch(nodeG, 'pointerup', { clientX: 170, clientY: 160, pointerId: 1 });
const moved = store.getGraph().nodes.find((n) => n.id === 'n1');
expect(moved.x).toBe(150);
expect(moved.y).toBe(150);
});
test('Alt+drag from node A center to node B creates a solid edge', () => {
const store = makeStore({
nodes: [
{ id: 'n1', kind: 'process', x: 100, y: 100, label: 'A' },
{ id: 'n2', kind: 'process', x: 400, y: 100, label: 'B' },
],
edges: [],
});
const { container } = mount(store);
stubLayout(container, [
{ id: 'n1', x: 100, y: 100 },
{ id: 'n2', x: 400, y: 100 },
]);
const a = container.querySelector('g[data-node-id="n1"]');
const b = container.querySelector('g[data-node-id="n2"]');
dispatch(a, 'pointerdown', { clientX: 140, clientY: 120, altKey: true, pointerId: 1 });
// pointermove over node B's center
dispatch(a, 'pointermove', { clientX: 440, clientY: 120, altKey: true, pointerId: 1 });
// pointerup on node B
dispatch(b, 'pointerup', { clientX: 440, clientY: 120, altKey: true, pointerId: 1 });
expect(store.getGraph().edges).toHaveLength(1);
expect(store.getGraph().edges[0]).toMatchObject({
fromNodeId: 'n1',
toNodeId: 'n2',
kind: 'solid',
});
});
});
describe('flowchart-canvas: subscribe re-renders on store change', () => {
test('addNode causes a new <g> to appear', () => {
const store = makeStore();
const { container } = mount(store);
expect(container.querySelectorAll('g[data-node-id]')).toHaveLength(0);
store.addNode({ kind: 'process', x: 0, y: 0, label: 'New' });
expect(container.querySelectorAll('g[data-node-id]')).toHaveLength(1);
});
});
describe('flowchart-canvas: destroy', () => {
test('removes the SVG and detaches subscribers', () => {
const store = makeStore();
const { container, api } = mount(store);
api.destroy();
expect(container.querySelector('svg.flowchart-canvas')).toBeNull();
// After destroy, store changes should not throw inside a detached subscriber.
expect(() => store.addNode({ kind: 'process', x: 0, y: 0, label: 'X' })).not.toThrow();
});
});