feat(flowchart): alignment + distribution tools (C15)

8 new toolbar buttons (Align L/R/T/B, Center H/V, Distribute H/V)
plus a Select All button. Each operation takes the current
selection and applies a pure-function transform to the (x, y)
positions, then commits each result via store.moveNode().

Architecture:

  - src/flowchart/flowchart-align.js: pure module with 8 helpers
    + nodeWidth / nodeHeight utility. UMD wrapper for browser
    global + CommonJS, same pattern as the other pure modules.
  - src/renderer/flowchart-bundle.js: pure module inlined so the
    standalone window doesn't need a separate bundle build; the
    inlined functions are exposed as window.FlowchartAlign for
    jsdom tests.
  - Bundle's _selectedNodeIds Set tracks the alignment selection.
    Empty by default; populated by Select All or by future
    canvas-side shift+click (item 1: multi-select).
  - Each align button no-ops with a status hint if fewer than 2
    nodes are selected. Distribute needs 3+.
  - Each moveNode call creates its own undo snapshot, which is
    fine for typical 2-10 node selections and lets the user undo
    a misalignment one node at a time.

Tests: 14 new (1376 total) covering all 8 functions plus
immutability, no-op for insufficient nodes, and unsorted-input
sorting for distribute.

Amit Haridas
This commit is contained in:
2026-09-30 22:28:13 +05:30
parent 7bb062a464
commit f133efe025
4 changed files with 513 additions and 0 deletions
+68
View File
@@ -395,6 +395,74 @@
Reset All
</button>
</div>
<h3 class="fc-section-title">Align (multi-select)</h3>
<div class="fc-toolbar-row">
<button
class="fc-btn"
id="fc-btn-select-all"
title="Select every node so the alignment buttons work"
>
Select All
</button>
</div>
<div class="fc-toolbar-row">
<button
class="fc-btn"
id="fc-btn-align-left"
title="Align selected nodes to the left edge"
>
Align Left
</button>
<button
class="fc-btn"
id="fc-btn-align-right"
title="Align selected nodes to the right edge"
>
Align Right
</button>
<button
class="fc-btn"
id="fc-btn-align-top"
title="Align selected nodes to the top edge"
>
Align Top
</button>
<button
class="fc-btn"
id="fc-btn-align-bottom"
title="Align selected nodes to the bottom edge"
>
Align Bottom
</button>
<button
class="fc-btn"
id="fc-btn-align-center-h"
title="Align selected nodes to a common horizontal center"
>
Center H
</button>
<button
class="fc-btn"
id="fc-btn-align-center-v"
title="Align selected nodes to a common vertical center"
>
Center V
</button>
<button
class="fc-btn"
id="fc-btn-distribute-h"
title="Distribute selected nodes evenly between leftmost and rightmost"
>
Distribute H
</button>
<button
class="fc-btn"
id="fc-btn-distribute-v"
title="Distribute selected nodes evenly between topmost and bottommost"
>
Distribute V
</button>
</div>
<h3 class="fc-section-title">Export Image</h3>
<div class="fc-toolbar-row">
<button class="fc-btn" id="fc-btn-export-svg" title="Export the canvas as a .svg file">
+134
View File
@@ -0,0 +1,134 @@
/**
* Pure alignment + distribution helpers for the flowchart editor (v4.13.0).
*
* Each function takes an array of node objects and returns a new array
* with the same nodes but their (x, y) adjusted per the operation.
* Pure module — no DOM, no globals, no store mutation — so it's
* unit-testable in isolation and the caller decides how to apply the
* result (one moveNode per node, or a future batch API).
*
* All operations are no-ops when given fewer than two nodes (nothing
* to align against). Distribute needs three or more to have a meaningful
* "even space" between extremes.
*
* Coordinates are SVG units. A node's height is the canvas default
* (60px) since the editor doesn't store height — only width.
*
* @module flowchart-align
*/
'use strict';
const DEFAULT_HEIGHT = 60;
function nodeWidth(node) {
return Number(node.width) > 0 ? Number(node.width) : 120;
}
function nodeHeight(_node) {
return DEFAULT_HEIGHT;
}
function clone(node) {
return { ...node };
}
/** All nodes line up at the leftmost x. */
function alignLeft(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const minX = Math.min(...nodes.map((n) => n.x));
return nodes.map((n) => Object.assign(clone(n), { x: minX }));
}
/** All nodes line up at the rightmost (x + width). */
function alignRight(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const maxRight = Math.max(...nodes.map((n) => n.x + nodeWidth(n)));
return nodes.map((n) => Object.assign(clone(n), { x: maxRight - nodeWidth(n) }));
}
/** All nodes line up at the topmost y. */
function alignTop(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const minY = Math.min(...nodes.map((n) => n.y));
return nodes.map((n) => Object.assign(clone(n), { y: minY }));
}
/** All nodes line up at the bottommost (y + height). */
function alignBottom(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const maxBottom = Math.max(...nodes.map((n) => n.y + nodeHeight(n)));
return nodes.map((n) => Object.assign(clone(n), { y: maxBottom - nodeHeight(n) }));
}
/** All nodes share the same horizontal center (mean of centers). */
function alignCenterHorizontal(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const centers = nodes.map((n) => n.x + nodeWidth(n) / 2);
const avg = centers.reduce((a, b) => a + b, 0) / centers.length;
return nodes.map((n) => Object.assign(clone(n), { x: avg - nodeWidth(n) / 2 }));
}
/** All nodes share the same vertical center (mean of centers). */
function alignCenterVertical(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const centers = nodes.map((n) => n.y + nodeHeight(n) / 2);
const avg = centers.reduce((a, b) => a + b, 0) / centers.length;
return nodes.map((n) => Object.assign(clone(n), { y: avg - nodeHeight(n) / 2 }));
}
/**
* Distribute horizontally — equal gap between consecutive node left edges.
* The first and last nodes keep their positions; everything in between
* is spaced evenly.
*/
function distributeHorizontally(nodes) {
if (!Array.isArray(nodes) || nodes.length < 3) return nodes;
const sorted = [...nodes].sort((a, b) => a.x - b.x);
const leftmost = sorted[0].x;
const rightmost = sorted[sorted.length - 1].x;
const gap = (rightmost - leftmost) / (sorted.length - 1);
return sorted.map((n, i) => Object.assign(clone(n), { x: leftmost + gap * i }));
}
/**
* Distribute vertically — equal gap between consecutive node top edges.
* Same first/last-anchored semantics as the horizontal version.
*/
function distributeVertically(nodes) {
if (!Array.isArray(nodes) || nodes.length < 3) return nodes;
const sorted = [...nodes].sort((a, b) => a.y - b.y);
const topmost = sorted[0].y;
const bottommost = sorted[sorted.length - 1].y;
const gap = (bottommost - topmost) / (sorted.length - 1);
return sorted.map((n, i) => Object.assign(clone(n), { y: topmost + gap * i }));
}
if (typeof module === 'object' && module.exports) {
module.exports = {
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontally,
distributeVertically,
nodeWidth,
nodeHeight,
};
}
if (typeof window !== 'undefined') {
window.FlowchartAlign = {
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontally,
distributeVertically,
nodeWidth,
nodeHeight,
};
}
+190
View File
@@ -920,8 +920,112 @@
return { destroy, getSvg: () => svg };
}
// ========== flowchart-align (inlined v4.13.0) ==========
// Pure alignment + distribution helpers. The full module lives in
// src/flowchart/flowchart-align.js (14 tests); this inlining keeps the
// standalone window functional without a rebuild step.
const ALIGN_DEFAULT_HEIGHT = 60;
function _alignNodeWidth(node) {
return Number(node.width) > 0 ? Number(node.width) : 120;
}
function _alignClone(node) {
return Object.assign({}, node);
}
function alignLeft(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const minX = Math.min.apply(
null,
nodes.map(function (n) {
return n.x;
})
);
return nodes.map(function (n) {
return Object.assign(_alignClone(n), { x: minX });
});
}
function alignRight(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
let maxRight = -Infinity;
for (const n of nodes) maxRight = Math.max(maxRight, n.x + _alignNodeWidth(n));
return nodes.map(function (n) {
return Object.assign(_alignClone(n), { x: maxRight - _alignNodeWidth(n) });
});
}
function alignTop(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const minY = Math.min.apply(
null,
nodes.map(function (n) {
return n.y;
})
);
return nodes.map(function (n) {
return Object.assign(_alignClone(n), { y: minY });
});
}
function alignBottom(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
let maxBottom = -Infinity;
for (const n of nodes) maxBottom = Math.max(maxBottom, n.y + ALIGN_DEFAULT_HEIGHT);
return nodes.map(function (n) {
return Object.assign(_alignClone(n), { y: maxBottom - ALIGN_DEFAULT_HEIGHT });
});
}
function alignCenterHorizontal(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
let sum = 0;
for (const n of nodes) sum += n.x + _alignNodeWidth(n) / 2;
const avg = sum / nodes.length;
return nodes.map(function (n) {
return Object.assign(_alignClone(n), { x: avg - _alignNodeWidth(n) / 2 });
});
}
function alignCenterVertical(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
let sum = 0;
for (const n of nodes) sum += n.y + ALIGN_DEFAULT_HEIGHT / 2;
const avg = sum / nodes.length;
return nodes.map(function (n) {
return Object.assign(_alignClone(n), { y: avg - ALIGN_DEFAULT_HEIGHT / 2 });
});
}
function distributeHorizontally(nodes) {
if (!Array.isArray(nodes) || nodes.length < 3) return nodes;
const sorted = nodes.slice().sort(function (a, b) {
return a.x - b.x;
});
const leftmost = sorted[0].x;
const rightmost = sorted[sorted.length - 1].x;
const gap = (rightmost - leftmost) / (sorted.length - 1);
return sorted.map(function (n, i) {
return Object.assign(_alignClone(n), { x: leftmost + gap * i });
});
}
function distributeVertically(nodes) {
if (!Array.isArray(nodes) || nodes.length < 3) return nodes;
const sorted = nodes.slice().sort(function (a, b) {
return a.y - b.y;
});
const topmost = sorted[0].y;
const bottommost = sorted[sorted.length - 1].y;
const gap = (bottommost - topmost) / (sorted.length - 1);
return sorted.map(function (n, i) {
return Object.assign(_alignClone(n), { y: topmost + gap * i });
});
}
// ========== Globals — set BEFORE the controller boots ==========
window.FlowchartCanvas = { createCanvas, SHAPE_KINDS };
window.FlowchartAlign = {
alignLeft: alignLeft,
alignRight: alignRight,
alignTop: alignTop,
alignBottom: alignBottom,
alignCenterHorizontal: alignCenterHorizontal,
alignCenterVertical: alignCenterVertical,
distributeHorizontally: distributeHorizontally,
distributeVertically: distributeVertically,
};
// ========== Controller bootstrap (inline) ==========
const api =
@@ -942,6 +1046,18 @@
btnExportPng: document.getElementById('fc-btn-export-png'),
btnExportJpg: document.getElementById('fc-btn-export-jpg'),
btnExportVsdx: document.getElementById('fc-btn-export-vsdx'),
// v4.13.0 — alignment + distribution buttons. Multi-select is
// required for these to be useful; the Set is empty by default
// and the user can click "Select All" to operate on every node.
btnAlignLeft: document.getElementById('fc-btn-align-left'),
btnAlignRight: document.getElementById('fc-btn-align-right'),
btnAlignTop: document.getElementById('fc-btn-align-top'),
btnAlignBottom: document.getElementById('fc-btn-align-bottom'),
btnAlignCenterH: document.getElementById('fc-btn-align-center-h'),
btnAlignCenterV: document.getElementById('fc-btn-align-center-v'),
btnDistributeH: document.getElementById('fc-btn-distribute-h'),
btnDistributeV: document.getElementById('fc-btn-distribute-v'),
btnSelectAll: document.getElementById('fc-btn-select-all'),
btnUndo: document.getElementById('fc-btn-undo'),
btnRedo: document.getElementById('fc-btn-redo'),
historyCount: document.getElementById('fc-history-count'),
@@ -1018,6 +1134,11 @@
let _persistTimer = null;
let _store = null;
let _canvas = null;
// v4.13.0 — selection state for multi-select + alignment. The canvas
// still owns single-click visual selection; this Set tracks the set
// the alignment / distribute buttons operate on. Empty by default;
// populated by "Select All" or canvas shift+click (item 1 below).
let _selectedNodeIds = new Set();
// v4.11.0 — no more selection state. Every mutation is initiated from
// a button in the #fc-nodelist panel; the canvas is purely visual.
@@ -1713,6 +1834,75 @@
});
}
// v4.13.0 — Alignment + distribution buttons. Each operates on the
// current selection (_selectedNodeIds). If the selection is empty,
// the buttons no-op with a status hint — otherwise they mutate the
// graph via _store.moveNode() once per affected node. Each call
// creates its own undo snapshot, which is fine for the typical
// 2-10 node selection.
function getSelectedNodes() {
if (!_store) return [];
const graph = _store.getGraph();
return graph.nodes.filter((node) => _selectedNodeIds.has(node.id));
}
function applyAlignment(transform) {
if (!_store) return;
const targets = getSelectedNodes();
if (targets.length < 2) {
setStatus('Select 2+ nodes first (try Select All)');
return;
}
const updates = transform(targets);
for (const updated of updates) {
_store.moveNode(updated.id, updated.x, updated.y);
}
setStatus('Aligned ' + updates.length + ' nodes');
}
const ALIGN = window.FlowchartAlign;
if (els.btnAlignLeft && ALIGN) {
els.btnAlignLeft.addEventListener('click', () => applyAlignment(ALIGN.alignLeft));
}
if (els.btnAlignRight && ALIGN) {
els.btnAlignRight.addEventListener('click', () => applyAlignment(ALIGN.alignRight));
}
if (els.btnAlignTop && ALIGN) {
els.btnAlignTop.addEventListener('click', () => applyAlignment(ALIGN.alignTop));
}
if (els.btnAlignBottom && ALIGN) {
els.btnAlignBottom.addEventListener('click', () => applyAlignment(ALIGN.alignBottom));
}
if (els.btnAlignCenterH && ALIGN) {
els.btnAlignCenterH.addEventListener('click', () =>
applyAlignment(ALIGN.alignCenterHorizontal)
);
}
if (els.btnAlignCenterV && ALIGN) {
els.btnAlignCenterV.addEventListener('click', () =>
applyAlignment(ALIGN.alignCenterVertical)
);
}
if (els.btnDistributeH && ALIGN) {
els.btnDistributeH.addEventListener('click', () =>
applyAlignment(ALIGN.distributeHorizontally)
);
}
if (els.btnDistributeV && ALIGN) {
els.btnDistributeV.addEventListener('click', () =>
applyAlignment(ALIGN.distributeVertically)
);
}
if (els.btnSelectAll) {
els.btnSelectAll.addEventListener('click', () => {
if (!_store) return;
const graph = _store.getGraph();
_selectedNodeIds = new Set(graph.nodes.map((node) => node.id));
setStatus('Selected ' + _selectedNodeIds.size + ' nodes');
rerenderNodeList();
});
}
// v4.13.0 — Open from .mmd/.md file. Pops a system Open dialog,
// strips the ```mermaid fence (if any), parses via fromMermaid()
// (inlined above) and replaces the current graph. Confirms
+121
View File
@@ -0,0 +1,121 @@
/**
* @jest-environment node
*
* Pure tests for the alignment + distribution helpers.
* Each function takes nodes and returns a new array with positions
* adjusted — no store interaction, no DOM.
*/
const {
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontally,
distributeVertically,
} = require('../src/flowchart/flowchart-align');
const n = (id, x, y, w = 120) => ({ id, x, y, width: w, label: id });
describe('flowchart-align: alignLeft', () => {
test('aligns all nodes to the leftmost x', () => {
const out = alignLeft([n('a', 100, 50), n('b', 200, 80), n('c', 300, 30)]);
expect(out.map((node) => node.x)).toEqual([100, 100, 100]);
});
test('preserves y positions and other fields', () => {
const out = alignLeft([n('a', 100, 50), n('b', 200, 80)]);
expect(out[0]).toMatchObject({ id: 'a', x: 100, y: 50 });
expect(out[1]).toMatchObject({ id: 'b', x: 100, y: 80 });
});
test('no-op for 0 or 1 nodes', () => {
expect(alignLeft([])).toEqual([]);
expect(alignLeft([n('a', 100, 50)])).toEqual([n('a', 100, 50)]);
});
test('returns a new array — does not mutate inputs', () => {
const inputs = [n('a', 100, 50), n('b', 200, 80)];
const snapshot = JSON.parse(JSON.stringify(inputs));
alignLeft(inputs);
expect(inputs).toEqual(snapshot);
});
});
describe('flowchart-align: alignRight', () => {
test('aligns all nodes to the rightmost (x + width)', () => {
const out = alignRight([n('a', 100, 50), n('b', 200, 80), n('c', 250, 30, 200)]);
// maxRight = 250 + 200 = 450
expect(out.map((node) => node.x + node.width)).toEqual([450, 450, 450]);
});
test('no-op for single node', () => {
expect(alignRight([n('a', 100, 50)])).toEqual([n('a', 100, 50)]);
});
});
describe('flowchart-align: alignTop / alignBottom', () => {
test('alignTop pins to the topmost y', () => {
const out = alignTop([n('a', 50, 100), n('b', 80, 200), n('c', 30, 50)]);
expect(out.map((node) => node.y)).toEqual([50, 50, 50]);
});
test('alignBottom pins to the bottommost y + 60', () => {
const out = alignBottom([n('a', 50, 100), n('b', 80, 200), n('c', 30, 150)]);
expect(out.map((node) => node.y)).toEqual([200, 200, 200]);
});
});
describe('flowchart-align: alignCenterHorizontal / alignCenterVertical', () => {
test('alignCenterHorizontal averages centers', () => {
const out = alignCenterHorizontal([
n('a', 0, 0, 100),
n('b', 100, 0, 100),
n('c', 200, 0, 100),
]);
// centers: 50, 150, 250 → avg = 150
expect(out.map((node) => node.x + node.width / 2)).toEqual([150, 150, 150]);
});
test('alignCenterVertical averages centers', () => {
const out = alignCenterVertical([n('a', 0, 0), n('b', 0, 60), n('c', 0, 120)]);
// centers: 30, 90, 150 → avg = 90
expect(out.map((node) => node.y + 30)).toEqual([90, 90, 90]);
});
});
describe('flowchart-align: distributeHorizontally', () => {
test('spaces 4 nodes evenly between leftmost and rightmost', () => {
const out = distributeHorizontally([
n('a', 0, 0),
n('b', 100, 0),
n('c', 500, 0),
n('d', 900, 0),
]);
// gap = (900 - 0) / 3 = 300
expect(out.map((node) => node.x)).toEqual([0, 300, 600, 900]);
});
test('handles unsorted input by sorting first', () => {
const out = distributeHorizontally([
n('a', 500, 0),
n('b', 0, 0),
n('c', 900, 0),
]);
// sorted: 0, 500, 900 → gap = 450 → x = [0, 450, 900]
expect(out.map((node) => node.x)).toEqual([0, 450, 900]);
});
test('no-op for fewer than 3 nodes', () => {
expect(distributeHorizontally([n('a', 0, 0), n('b', 100, 0)])).toEqual([
n('a', 0, 0),
n('b', 100, 0),
]);
});
});
describe('flowchart-align: distributeVertically', () => {
test('spaces 4 nodes evenly between topmost and bottommost', () => {
const out = distributeVertically([
n('a', 0, 0),
n('b', 0, 60),
n('c', 0, 300),
n('d', 0, 900),
]);
// gap = 900 / 3 = 300
expect(out.map((node) => node.y)).toEqual([0, 300, 600, 900]);
});
});