diff --git a/src/flowchart/flowchart-canvas.js b/src/flowchart/flowchart-canvas.js index b5cf669..cfb4441 100644 --- a/src/flowchart/flowchart-canvas.js +++ b/src/flowchart/flowchart-canvas.js @@ -45,8 +45,12 @@ function edgeStyle(kind) { return { 'stroke-width': 1 }; } +function nodeWidth(node) { + return Number(node.width) || DEFAULT_WIDTH; +} + function nodeCenter(node) { - return { x: node.x + DEFAULT_WIDTH / 2, y: node.y + DEFAULT_HEIGHT / 2 }; + return { x: node.x + nodeWidth(node) / 2, y: node.y + DEFAULT_HEIGHT / 2 }; } /** @@ -58,12 +62,13 @@ function nodeCenter(node) { * is a fine visual trade for the simplicity. */ function boundaryPoint(node, dx, dy) { - const cx = node.x + DEFAULT_WIDTH / 2; + const w = nodeWidth(node); + const cx = node.x + w / 2; const cy = node.y + DEFAULT_HEIGHT / 2; if (dx === 0 && dy === 0) return { x: cx, y: cy }; const absDx = Math.abs(dx); const absDy = Math.abs(dy); - const scaleX = absDx > 0 ? DEFAULT_WIDTH / 2 / absDx : Infinity; + const scaleX = absDx > 0 ? w / 2 / absDx : Infinity; const scaleY = absDy > 0 ? DEFAULT_HEIGHT / 2 / absDy : Infinity; const scale = Math.min(scaleX, scaleY); return { x: cx + dx * scale, y: cy + dy * scale }; @@ -204,6 +209,7 @@ function createCanvas(container, store, opts = {}) { } for (const node of graph.nodes) { + const w = nodeWidth(node); const g = svgEl('g', { 'data-node-id': node.id, transform: `translate(${node.x},${node.y})`, @@ -211,9 +217,9 @@ function createCanvas(container, store, opts = {}) { tabindex: '0', 'aria-label': `${node.kind}: ${node.label || '(no label)'}`, }); - g.innerHTML = shapeSvg(node.kind, 0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT); + g.innerHTML = shapeSvg(node.kind, 0, 0, w, DEFAULT_HEIGHT); const text = svgEl('text', { - x: DEFAULT_WIDTH / 2, + x: w / 2, y: DEFAULT_HEIGHT / 2 + 4, 'text-anchor': 'middle', 'font-size': 13, @@ -222,6 +228,27 @@ function createCanvas(container, store, opts = {}) { }); text.textContent = node.label || ' '; g.appendChild(text); + + // v4.13.0 — bottom-right resize handle on the selected node. A + // single square handle is enough for v1; multi-handle (4 corners + // + 4 edges) is a follow-up. Width is the only mutable dimension. + if (node.id === selectedNodeId) { + const handleSize = 10; + const handle = svgEl('rect', { + x: w - handleSize / 2, + y: DEFAULT_HEIGHT - handleSize / 2, + width: handleSize, + height: handleSize, + fill: '#e5461f', + stroke: '#ffffff', + 'stroke-width': 1, + class: 'flowchart-resize-handle', + 'data-resize-node': node.id, + cursor: 'nwse-resize', + }); + g.appendChild(handle); + } + nodesLayer.appendChild(g); } } @@ -262,6 +289,27 @@ function createCanvas(container, store, opts = {}) { } function onPointerDown(ev) { + // v4.13.0 — resize handle on the selected node. Detected before the + // generic node drag so the bottom-right square doesn't accidentally + // start a move on the node. + const handleEl = ev.target.closest('[data-resize-node]'); + if (handleEl) { + const nodeId = handleEl.getAttribute('data-resize-node'); + const node = store.getGraph().nodes.find((n) => n.id === nodeId); + if (!node) return; + setSelection({ nodeId }); + const startW = nodeWidth(node); + const startX = ev.clientX; + dragState = { + mode: 'resize', + nodeId, + startW, + startClientX: startX, + }; + ev.preventDefault(); + return; + } + const nodeG = ev.target.closest('g[data-node-id]'); if (nodeG) { const nodeId = nodeG.getAttribute('data-node-id'); @@ -375,6 +423,17 @@ function createCanvas(container, store, opts = {}) { return; } if (!dragState) return; + if (dragState.mode === 'resize') { + // Width delta in screen units, then convert to SVG via the + // viewport-aware viewBox-to-screen ratio. + const dxScreen = ev.clientX - dragState.startClientX; + const rect = svg.getBoundingClientRect(); + const vb = svg.viewBox.baseVal; + const scaleX = vb.width / rect.width; + const newW = Math.max(60, Math.min(600, dragState.startW + dxScreen * scaleX)); + store.setNodeWidth(dragState.nodeId, newW); + return; + } if (dragState.mode === 'move') { const p = getSvgPoint(ev.clientX, ev.clientY); const dx = p.x - dragState.pointerX; diff --git a/src/flowchart/flowchart-store.js b/src/flowchart/flowchart-store.js index a4468d8..f79ad75 100644 --- a/src/flowchart/flowchart-store.js +++ b/src/flowchart/flowchart-store.js @@ -163,6 +163,19 @@ function create(io) { emit(); } + /** + * Resize a node by adjusting its width (v4.13.0). Height is derived from + * the shape, so width is the only mutable dimension. Min 60 / max 600. + */ + function setNodeWidth(id, width) { + const idx = findNodeIndex(id); + if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`); + const w = Math.max(60, Math.min(600, Number(width) || 60)); + snapshot(); + graph.nodes[idx] = { ...graph.nodes[idx], width: w }; + emit(); + } + function removeNode(id) { const idx = findNodeIndex(id); if (idx === -1) return; @@ -281,6 +294,7 @@ function create(io) { setNodeLabel, setNodeKind, setNodeColor, + setNodeWidth, removeNode, connect, disconnect, diff --git a/tests/flowchart-store.test.js b/tests/flowchart-store.test.js index 5e1ec45..77d947e 100644 --- a/tests/flowchart-store.test.js +++ b/tests/flowchart-store.test.js @@ -101,6 +101,27 @@ describe('flowchart-store: node operations', () => { expect(() => store.setNodeColor('nope', '#ff0000')).toThrow(/nope/); }); + test('setNodeWidth updates the width of an existing node', () => { + const store = create(makeIO()); + const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' }); + store.setNodeWidth(node.id, 220); + expect(store.getGraph().nodes[0].width).toBe(220); + }); + + test('setNodeWidth clamps to [60, 600]', () => { + const store = create(makeIO()); + const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' }); + store.setNodeWidth(node.id, 10); + expect(store.getGraph().nodes[0].width).toBe(60); + store.setNodeWidth(node.id, 99999); + expect(store.getGraph().nodes[0].width).toBe(600); + }); + + test('setNodeWidth throws on unknown node id', () => { + const store = create(makeIO()); + expect(() => store.setNodeWidth('nope', 200)).toThrow(/nope/); + }); + test('setNodeColor pushes an undo snapshot', () => { const store = create(makeIO()); const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });