mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
- 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
288 lines
9.2 KiB
JavaScript
288 lines
9.2 KiB
JavaScript
/**
|
|
* 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 };
|