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
+287
View File
@@ -0,0 +1,287 @@
/**
* SVG canvas for the flow chart editor.
*
* Owns an <svg class="flowchart-canvas"> mounted into the supplied container.
* Subscribes to the store; re-renders on every change. Listens for pointer
* events on nodes/edges to drive drag, label-edit, and context-menu actions.
*
* Hand-rolled SVG — no D3, no Konva. Hit-testing via `data-node-id` /
* `data-edge-id` attributes. Pure DOM module: no globals.
*
* @module flowchart-canvas
*/
'use strict';
const { DEFAULT_WIDTH, DEFAULT_HEIGHT, shapeSvg, SHAPE_KINDS } = require('./flowchart-shapes');
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs = {}) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) {
if (v === null || v === undefined) continue;
el.setAttribute(k, String(v));
}
return el;
}
function edgeStyle(kind) {
if (kind === 'dotted') return { 'stroke-dasharray': '4,4', 'stroke-width': 1 };
if (kind === 'thick') return { 'stroke-width': 3 };
return { 'stroke-width': 1 };
}
function nodeCenter(node) {
return { x: node.x + DEFAULT_WIDTH / 2, y: node.y + DEFAULT_HEIGHT / 2 };
}
function createCanvas(container, store, opts = {}) {
const svg = svgEl('svg', {
class: 'flowchart-canvas',
width: '100%',
height: '100%',
viewBox: '0 0 1000 700',
role: 'img',
'aria-label': 'Flow chart canvas',
});
container.appendChild(svg);
// Layer order: edges first (under nodes), then nodes.
const edgesLayer = svgEl('g', { class: 'flowchart-edges' });
const nodesLayer = svgEl('g', { class: 'flowchart-nodes' });
svg.appendChild(edgesLayer);
svg.appendChild(nodesLayer);
let selectedNodeId = null;
let selectedEdgeId = null;
let unsubscribe = null;
let destroyed = false;
function render() {
if (destroyed) return;
const graph = store.getGraph();
edgesLayer.replaceChildren();
nodesLayer.replaceChildren();
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
for (const edge of graph.edges) {
const from = nodeById.get(edge.fromNodeId);
const to = nodeById.get(edge.toNodeId);
if (!from || !to) continue;
const fc = nodeCenter(from);
const tc = nodeCenter(to);
const line = svgEl('line', {
x1: fc.x,
y1: fc.y,
x2: tc.x,
y2: tc.y,
stroke: 'currentColor',
'data-edge-id': edge.id,
...edgeStyle(edge.kind),
class: 'flowchart-edge' + (edge.id === selectedEdgeId ? ' selected' : ''),
});
edgesLayer.appendChild(line);
if (edge.label) {
const mx = (fc.x + tc.x) / 2;
const my = (fc.y + tc.y) / 2;
const bg = svgEl('rect', {
x: mx - 20,
y: my - 8,
width: 40,
height: 16,
fill: 'var(--bg-primary, #fff)',
'data-edge-label-bg': edge.id,
});
edgesLayer.appendChild(bg);
const t = svgEl('text', {
x: mx,
y: my + 4,
'text-anchor': 'middle',
'font-size': 11,
fill: 'currentColor',
'data-edge-label': edge.id,
});
t.textContent = edge.label;
edgesLayer.appendChild(t);
}
}
for (const node of graph.nodes) {
const g = svgEl('g', {
'data-node-id': node.id,
transform: `translate(${node.x},${node.y})`,
class: 'flowchart-node' + (node.id === selectedNodeId ? ' selected' : ''),
tabindex: '0',
'aria-label': `${node.kind}: ${node.label || '(no label)'}`,
});
g.innerHTML = shapeSvg(node.kind, 0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT);
const text = svgEl('text', {
x: DEFAULT_WIDTH / 2,
y: DEFAULT_HEIGHT / 2 + 4,
'text-anchor': 'middle',
'font-size': 13,
fill: 'currentColor',
'pointer-events': 'none',
});
text.textContent = node.label || ' ';
g.appendChild(text);
nodesLayer.appendChild(g);
}
}
// ----- pointer events -----
let dragState = null;
function getSvgPoint(clientX, clientY) {
const rect = svg.getBoundingClientRect();
// Naive linear mapping into the viewBox (works in jsdom and roughly in
// production for the bounded viewport; v2 can add proper screenCTM).
const vb = svg.viewBox.baseVal;
const scaleX = vb.width / rect.width;
const scaleY = vb.height / rect.height;
return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY };
}
function onPointerDown(ev) {
const nodeG = ev.target.closest('g[data-node-id]');
if (nodeG) {
const nodeId = nodeG.getAttribute('data-node-id');
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
if (!node) return;
selectedNodeId = nodeId;
selectedEdgeId = null;
const start = getSvgPoint(ev.clientX, ev.clientY);
if (ev.altKey) {
// Alt+drag = create a new edge from this node to wherever the pointer
// is released. Track source node only; movement does not move nodes.
dragState = { mode: 'connect', sourceNodeId: nodeId };
} else {
dragState = {
mode: 'move',
nodeId,
startX: node.x,
startY: node.y,
pointerX: start.x,
pointerY: start.y,
};
}
ev.preventDefault();
return;
}
const edgeLine = ev.target.closest('line[data-edge-id]');
if (edgeLine) {
selectedEdgeId = edgeLine.getAttribute('data-edge-id');
selectedNodeId = null;
if (typeof opts.onEdgeClick === 'function') {
opts.onEdgeClick(selectedEdgeId, ev);
}
ev.preventDefault();
return;
}
// Click on empty canvas: add a process node at the click point.
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
const p = getSvgPoint(ev.clientX, ev.clientY);
const x = Math.max(0, p.x - DEFAULT_WIDTH / 2);
const y = Math.max(0, p.y - DEFAULT_HEIGHT / 2);
store.addNode({ kind: 'process', x, y, label: 'Node' });
ev.preventDefault();
}
}
function onPointerMove(ev) {
if (!dragState) return;
if (dragState.mode === 'move') {
const p = getSvgPoint(ev.clientX, ev.clientY);
const dx = p.x - dragState.pointerX;
const dy = p.y - dragState.pointerY;
store.moveNode(dragState.nodeId, dragState.startX + dx, dragState.startY + dy);
}
// connect-mode: visual feedback deferred to v2 (no preview line yet).
}
function onPointerUp(ev) {
if (dragState && dragState.mode === 'connect') {
const targetG = ev.target && ev.target.closest && ev.target.closest('g[data-node-id]');
if (targetG) {
const targetId = targetG.getAttribute('data-node-id');
if (targetId && targetId !== dragState.sourceNodeId) {
try {
store.connect(dragState.sourceNodeId, targetId, 'solid');
} catch {
// Connect throws on self-loop; canvas silently ignores.
}
}
}
}
dragState = null;
}
function onDblClick(ev) {
const nodeG = ev.target.closest('g[data-node-id]');
if (!nodeG) return;
const nodeId = nodeG.getAttribute('data-node-id');
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
if (!node) return;
// Inline-edit overlay: foreignObject-free — just use a positioned HTML
// <input> overlaid on top of the node, in the canvas's parent.
const input = document.createElement('input');
input.type = 'text';
input.value = node.label;
input.className = 'flowchart-label-input';
const rect = nodeG.getBoundingClientRect();
input.style.position = 'fixed';
input.style.left = `${rect.left}px`;
input.style.top = `${rect.top}px`;
input.style.width = `${rect.width}px`;
input.style.height = `${rect.height}px`;
container.appendChild(input);
input.focus();
input.select();
const finish = (commit) => {
if (commit) store.setNodeLabel(nodeId, input.value);
input.remove();
};
input.addEventListener('blur', () => finish(true));
input.addEventListener('keydown', (kev) => {
if (kev.key === 'Enter') finish(true);
else if (kev.key === 'Escape') finish(false);
});
ev.preventDefault();
}
function onContextMenu(ev) {
const nodeG = ev.target.closest('g[data-node-id]');
if (nodeG) {
const nodeId = nodeG.getAttribute('data-node-id');
ev.preventDefault();
if (typeof opts.onShapeMenu === 'function') opts.onShapeMenu(nodeId, ev);
}
}
svg.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
svg.addEventListener('dblclick', onDblClick);
svg.addEventListener('contextmenu', onContextMenu);
unsubscribe = store.subscribe(render);
render();
function destroy() {
if (destroyed) return;
destroyed = true;
if (typeof unsubscribe === 'function') unsubscribe();
svg.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
svg.removeEventListener('dblclick', onDblClick);
svg.removeEventListener('contextmenu', onContextMenu);
svg.remove();
}
return { destroy, getSvg: () => svg };
}
module.exports = { createCanvas, SHAPE_KINDS };
+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();
});
});