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