Files
markdown-converter/src/flowchart/flowchart-align.js
T
amitwh f133efe025 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
2026-09-30 22:28:13 +05:30

135 lines
4.4 KiB
JavaScript

/**
* 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,
};
}