mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
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:
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user