feat(flowchart): edge geometry at shape boundary + connect preview (C2+C3)

Three UX/DI wins in one canvas pass:

1. Edge endpoints now land on the source/target shape's BOUNDARY
   rather than the centres. Lines no longer cut through nodes. New
   pure helpers boundaryPoint() + edgeEndpoints() do the geometry;
   nodeCenter() is still used internally for direction vectors.

2. Connect-mode preview line. Alt+drag now draws a dashed line from
   the source node's boundary to the live pointer position so the
   user can see where the edge will land before releasing. The original
   code marked this 'visual feedback deferred to v2'.

3. Selection state is now owned by the canvas (selectedNodeId /
   selectedEdgeId variables), not read from .selected DOM classes.
   The controller can ask via api.getSelection(). Fixes the fragile
   pattern in flowchart-controller.js where Delete/Backspace read
   querySelector('.flowchart-node.selected') — that read is now
   redundant and the controller can be cleaned up to use getSelection()
   directly.

5 new tests cover: edge starts past source centre, edge ends before
target centre, vertical edges connect top/bottom, label background
auto-sizes for long labels, canvas getSelection() reflects clicks.

Amit Haridas
This commit is contained in:
Amit Haridas
2026-09-30 21:23:39 +05:30
parent 855dfcd9f5
commit 0bb1091c47
2 changed files with 202 additions and 32 deletions
+85
View File
@@ -183,3 +183,88 @@ describe('flowchart-canvas: destroy', () => {
expect(() => store.addNode({ kind: 'process', x: 0, y: 0, label: 'X' })).not.toThrow();
});
});
describe('flowchart-canvas: edge geometry (v4.13.0)', () => {
test('edge starts at source boundary, not centre', () => {
const store = makeStore();
const from = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const to = store.addNode({ kind: 'process', x: 300, y: 0, label: 'B' });
store.connect(from.id, to.id, 'solid');
const { container, api } = mount(store);
const line = container.querySelector('line[data-edge-id]');
expect(line).not.toBeNull();
// Source centre is at (70, 30); line should start further right (toward B).
const x1 = parseFloat(line.getAttribute('x1'));
const y1 = parseFloat(line.getAttribute('y1'));
expect(x1).toBeGreaterThan(70); // started past the source centre
expect(y1).toBeCloseTo(30, 0); // horizontal edge stays at the centre y
api.destroy();
});
test('edge ends at target boundary, not centre', () => {
const store = makeStore();
const from = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const to = store.addNode({ kind: 'process', x: 300, y: 0, label: 'B' });
store.connect(from.id, to.id, 'solid');
const { container, api } = mount(store);
const line = container.querySelector('line[data-edge-id]');
const x2 = parseFloat(line.getAttribute('x2'));
// Target centre is at (370, 30); line should end before that.
expect(x2).toBeLessThan(370);
api.destroy();
});
test('vertical edge connects at top/bottom boundary', () => {
const store = makeStore();
const from = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const to = store.addNode({ kind: 'process', x: 0, y: 300, label: 'B' });
store.connect(from.id, to.id, 'solid');
const { container, api } = mount(store);
const line = container.querySelector('line[data-edge-id]');
const y1 = parseFloat(line.getAttribute('y1'));
const y2 = parseFloat(line.getAttribute('y2'));
expect(y1).toBeGreaterThan(30); // source's bottom boundary is at y=60
expect(y2).toBeLessThan(330); // target's top boundary is at y=300
api.destroy();
});
test('edge label background auto-sizes for long labels', () => {
const store = makeStore();
const from = store.addNode({ kind: 'process', x: 0, y: 0, label: 'A' });
const to = store.addNode({ kind: 'process', x: 300, y: 0, label: 'B' });
store.connect(from.id, to.id, 'solid');
store.setEdgeLabel(store.getGraph().edges[0].id, 'a very long label');
const { container, api } = mount(store);
const bg = container.querySelector('rect[data-edge-label-bg]');
const longBg = bg.getAttribute('width');
// Set a short label and re-render — bg should be smaller
store.setEdgeLabel(store.getGraph().edges[0].id, 'x');
const bg2 = container.querySelector('rect[data-edge-label-bg]');
const shortBg = bg2.getAttribute('width');
expect(parseFloat(longBg)).toBeGreaterThan(parseFloat(shortBg));
api.destroy();
});
});
describe('flowchart-canvas: selection ownership (v4.13.0)', () => {
test('canvas owns selection state, not DOM', () => {
const store = makeStore();
const { api, container } = mount(store);
expect(api.getSelection()).toEqual({ nodeId: null, edgeId: null });
// Simulate pointerdown via dispatching the event on the SVG
const node = store.addNode({ kind: 'process', x: 10, y: 10, label: 'X' });
store.subscribe(() => {}); // noop; the canvas subscriber re-renders
// Manually call internal logic via a re-render trigger: we set selection
// through the public pointerdown handler.
const svg = container.querySelector('svg.flowchart-canvas');
const nodeEl = container.querySelector('g[data-node-id]');
// Build a synthetic pointerdown on the node element
const ev = new Event('pointerdown', { bubbles: true, cancelable: true });
Object.defineProperty(ev, 'target', { value: nodeEl });
Object.defineProperty(ev, 'clientX', { value: 10 });
Object.defineProperty(ev, 'clientY', { value: 10 });
svg.dispatchEvent(ev);
expect(api.getSelection().nodeId).toBe(node.id);
api.destroy();
});
});