diff --git a/src/flowchart/flowchart-canvas.js b/src/flowchart/flowchart-canvas.js new file mode 100644 index 0000000..2f63bc4 --- /dev/null +++ b/src/flowchart/flowchart-canvas.js @@ -0,0 +1,287 @@ +/** + * SVG canvas for the flow chart editor. + * + * Owns an 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 + //