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
|
Reset All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<h3 class="fc-section-title">Export Image</h3>
|
||||||
<div class="fc-toolbar-row">
|
<div class="fc-toolbar-row">
|
||||||
<button class="fc-btn" id="fc-btn-export-svg" title="Export the canvas as a .svg file">
|
<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 };
|
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 ==========
|
// ========== Globals — set BEFORE the controller boots ==========
|
||||||
window.FlowchartCanvas = { createCanvas, SHAPE_KINDS };
|
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) ==========
|
// ========== Controller bootstrap (inline) ==========
|
||||||
const api =
|
const api =
|
||||||
@@ -942,6 +1046,18 @@
|
|||||||
btnExportPng: document.getElementById('fc-btn-export-png'),
|
btnExportPng: document.getElementById('fc-btn-export-png'),
|
||||||
btnExportJpg: document.getElementById('fc-btn-export-jpg'),
|
btnExportJpg: document.getElementById('fc-btn-export-jpg'),
|
||||||
btnExportVsdx: document.getElementById('fc-btn-export-vsdx'),
|
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'),
|
btnUndo: document.getElementById('fc-btn-undo'),
|
||||||
btnRedo: document.getElementById('fc-btn-redo'),
|
btnRedo: document.getElementById('fc-btn-redo'),
|
||||||
historyCount: document.getElementById('fc-history-count'),
|
historyCount: document.getElementById('fc-history-count'),
|
||||||
@@ -1018,6 +1134,11 @@
|
|||||||
let _persistTimer = null;
|
let _persistTimer = null;
|
||||||
let _store = null;
|
let _store = null;
|
||||||
let _canvas = 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
|
// v4.11.0 — no more selection state. Every mutation is initiated from
|
||||||
// a button in the #fc-nodelist panel; the canvas is purely visual.
|
// 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,
|
// v4.13.0 — Open from .mmd/.md file. Pops a system Open dialog,
|
||||||
// strips the ```mermaid fence (if any), parses via fromMermaid()
|
// strips the ```mermaid fence (if any), parses via fromMermaid()
|
||||||
// (inlined above) and replaces the current graph. Confirms
|
// (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