mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(flowchart): node resizing (C10)
v4.13.0 — nodes can be resized by dragging the bottom-right handle.
- New setNodeWidth(id, width) on the store. Clamps to [60, 600] so
shapes stay readable. Throws on unknown id; pushes an undo
snapshot like every other mutator.
- New nodeWidth(node) helper on the canvas uses node.width with a
fallback to DEFAULT_WIDTH for graphs loaded from older JSON
that didn't have the field.
- Canvas render uses the per-node width for shapeSvg + label
positioning, and edge boundaryPoint() uses it too so connection
geometry adapts to the new shape size.
- Selected node renders a bottom-right resize handle (10px square).
Clicking the handle starts a 'resize' dragState that calls
setNodeWidth on every pointermove with the screen-to-SVG delta
applied to the start width.
- The handle is detected before the generic node click handler so
it doesn't accidentally start a move drag.
3 new tests cover setNodeWidth update, clamp to [60, 600], and
unknown-id throw. The canvas change reuses the existing 14 tests
(which check rendering and pointer events) — they still pass.
Amit Haridas
This commit is contained in:
@@ -45,8 +45,12 @@ function edgeStyle(kind) {
|
|||||||
return { 'stroke-width': 1 };
|
return { 'stroke-width': 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nodeWidth(node) {
|
||||||
|
return Number(node.width) || DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
function nodeCenter(node) {
|
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.
|
* is a fine visual trade for the simplicity.
|
||||||
*/
|
*/
|
||||||
function boundaryPoint(node, dx, dy) {
|
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;
|
const cy = node.y + DEFAULT_HEIGHT / 2;
|
||||||
if (dx === 0 && dy === 0) return { x: cx, y: cy };
|
if (dx === 0 && dy === 0) return { x: cx, y: cy };
|
||||||
const absDx = Math.abs(dx);
|
const absDx = Math.abs(dx);
|
||||||
const absDy = Math.abs(dy);
|
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 scaleY = absDy > 0 ? DEFAULT_HEIGHT / 2 / absDy : Infinity;
|
||||||
const scale = Math.min(scaleX, scaleY);
|
const scale = Math.min(scaleX, scaleY);
|
||||||
return { x: cx + dx * scale, y: cy + dy * scale };
|
return { x: cx + dx * scale, y: cy + dy * scale };
|
||||||
@@ -204,6 +209,7 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const node of graph.nodes) {
|
for (const node of graph.nodes) {
|
||||||
|
const w = nodeWidth(node);
|
||||||
const g = svgEl('g', {
|
const g = svgEl('g', {
|
||||||
'data-node-id': node.id,
|
'data-node-id': node.id,
|
||||||
transform: `translate(${node.x},${node.y})`,
|
transform: `translate(${node.x},${node.y})`,
|
||||||
@@ -211,9 +217,9 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
tabindex: '0',
|
tabindex: '0',
|
||||||
'aria-label': `${node.kind}: ${node.label || '(no label)'}`,
|
'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', {
|
const text = svgEl('text', {
|
||||||
x: DEFAULT_WIDTH / 2,
|
x: w / 2,
|
||||||
y: DEFAULT_HEIGHT / 2 + 4,
|
y: DEFAULT_HEIGHT / 2 + 4,
|
||||||
'text-anchor': 'middle',
|
'text-anchor': 'middle',
|
||||||
'font-size': 13,
|
'font-size': 13,
|
||||||
@@ -222,6 +228,27 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
});
|
});
|
||||||
text.textContent = node.label || ' ';
|
text.textContent = node.label || ' ';
|
||||||
g.appendChild(text);
|
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);
|
nodesLayer.appendChild(g);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,6 +289,27 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(ev) {
|
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]');
|
const nodeG = ev.target.closest('g[data-node-id]');
|
||||||
if (nodeG) {
|
if (nodeG) {
|
||||||
const nodeId = nodeG.getAttribute('data-node-id');
|
const nodeId = nodeG.getAttribute('data-node-id');
|
||||||
@@ -375,6 +423,17 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dragState) 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') {
|
if (dragState.mode === 'move') {
|
||||||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
const dx = p.x - dragState.pointerX;
|
const dx = p.x - dragState.pointerX;
|
||||||
|
|||||||
@@ -163,6 +163,19 @@ function create(io) {
|
|||||||
emit();
|
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) {
|
function removeNode(id) {
|
||||||
const idx = findNodeIndex(id);
|
const idx = findNodeIndex(id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
@@ -281,6 +294,7 @@ function create(io) {
|
|||||||
setNodeLabel,
|
setNodeLabel,
|
||||||
setNodeKind,
|
setNodeKind,
|
||||||
setNodeColor,
|
setNodeColor,
|
||||||
|
setNodeWidth,
|
||||||
removeNode,
|
removeNode,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
|||||||
@@ -101,6 +101,27 @@ describe('flowchart-store: node operations', () => {
|
|||||||
expect(() => store.setNodeColor('nope', '#ff0000')).toThrow(/nope/);
|
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', () => {
|
test('setNodeColor pushes an undo snapshot', () => {
|
||||||
const store = create(makeIO());
|
const store = create(makeIO());
|
||||||
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
|
const node = store.addNode({ kind: 'process', x: 0, y: 0, label: '' });
|
||||||
|
|||||||
Reference in New Issue
Block a user