Merge branch 'feature/group-tokens'
This commit is contained in:
@@ -57,6 +57,7 @@ Use docs-mcp server for ComfyUI API reference.
|
||||
8. `groups.ts` - Resize groups to fit members
|
||||
9. `layout-utils.ts` - Shared functions for internal edge detection and member layer assignment
|
||||
10. `selected-groups.ts` - Organize only selected groups independently
|
||||
11. `title-tokens.ts` - Parse group title tokens and arrange nodes by mode
|
||||
|
||||
**Configuration** in `types.ts`:
|
||||
- `maxColumns`: 0=auto, 1=vertical stack, 2+=fixed columns
|
||||
@@ -91,6 +92,29 @@ Use docs-mcp server for ComfyUI API reference.
|
||||
- Duck-typing via `isLGraphGroup()` checks for pos/size/title/id
|
||||
- Handles Float64Array (ComfyUI runtime uses typed arrays for pos/size)
|
||||
|
||||
## Group Title Tokens
|
||||
|
||||
`parseLayoutToken(title)` and `arrangeByMode()` in `src/layout/title-tokens.ts`:
|
||||
|
||||
**Supported tokens** (case-insensitive):
|
||||
- `[HORIZONTAL]` - Single horizontal row
|
||||
- `[VERTICAL]` - Single vertical column
|
||||
- `[1-9ROW]` - Distribute into N rows (round-robin)
|
||||
- `[1-9COL]` - Distribute into N columns (round-robin)
|
||||
|
||||
**Behavior**:
|
||||
- Tokens override default layout for direct group members
|
||||
- Ignores DAG structure when token present (treats all as disconnected)
|
||||
- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]`
|
||||
- Nested groups each respect their own tokens
|
||||
- Groups without tokens use default behavior
|
||||
- Nodes sorted by original position (left-to-right, top-to-bottom) to preserve user intent
|
||||
|
||||
**Integration points**:
|
||||
- `selected-groups.ts:layoutGroupContents()` - Token layout for "Organize Group"
|
||||
- `positioning.ts:positionGroupContents()` - Token layout for "Organize Workflow"
|
||||
- Both handle nested groups: process bottom-up first, then include as items in parent's token layout
|
||||
|
||||
## Testing
|
||||
|
||||
**Vitest-based regression testing** in `tests/`:
|
||||
@@ -105,11 +129,12 @@ Use docs-mcp server for ComfyUI API reference.
|
||||
- `assertIdempotent` - stable after multiple runs
|
||||
- `assertGroupMembershipPreserved` - nodes originally in groups remain in groups
|
||||
- `tests/integration/layout.test.ts` - Main regression tests
|
||||
- `tests/integration/selected-groups.test.ts` - Selected groups layout tests (17 cases)
|
||||
- `tests/integration/selected-groups.test.ts` - Selected groups layout tests (34 cases)
|
||||
- `tests/integration/token-layout.test.ts` - Token-based layout regression tests (8 cases)
|
||||
|
||||
**Coverage**: 70% line threshold, excludes `src/index.ts` (ComfyUI runtime) and `src/layout/reroute-collapse.ts` (no fixtures)
|
||||
|
||||
All fixtures pass all invariants including idempotency. 50 total tests.
|
||||
All fixtures pass all invariants including idempotency. 75 total tests.
|
||||
|
||||
## CI/CD
|
||||
|
||||
|
||||
@@ -9,12 +9,36 @@ Automatically organizes nodes in your workflow using a compact hierarchical layo
|
||||
- **Disconnected Node Handling**: Annotations, bookmarks placed in separate left margin
|
||||
- **Reroute Optimization**: Reroute chains collapsed and restored along edges
|
||||
- **Group Support**: Nested groups with proper hierarchy
|
||||
- **Group Layout Tokens**: Control layout with title tokens (see below)
|
||||
- **Subgraph Support**: Works inside subgraphs with I/O node handling
|
||||
|
||||
## Usage
|
||||
|
||||
Right-click on the canvas and select **Organize Workflow**.
|
||||
|
||||
To organize specific groups, select them and choose **Organize Group** or **Organize N Groups**.
|
||||
|
||||
## Group Layout Tokens
|
||||
|
||||
Add tokens to group titles to control how nodes are arranged:
|
||||
|
||||
| Token | Effect |
|
||||
|-------|--------|
|
||||
| `[HORIZONTAL]` | Single horizontal row |
|
||||
| `[VERTICAL]` | Single vertical column |
|
||||
| `[2ROW]`...`[9ROW]` | Distribute into N rows |
|
||||
| `[2COL]`...`[9COL]` | Distribute into N columns |
|
||||
|
||||
**Examples:**
|
||||
- `"My Loaders [HORIZONTAL]"` - arranges all nodes in a single row
|
||||
- `"Processing [3COL]"` - distributes nodes into 3 columns
|
||||
|
||||
**Notes:**
|
||||
- Tokens are case-insensitive (`[horizontal]` works)
|
||||
- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]`
|
||||
- Nested groups each respect their own tokens
|
||||
- Groups without tokens use default DAG-based layout
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -22,6 +22,7 @@ import { packNodesIntoRows, getPackedWidth } from "./bin-pack";
|
||||
import { restoreReroutePositions } from "./reroute-collapse";
|
||||
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
|
||||
import { debugLog } from "../debug";
|
||||
import { parseLayoutToken, arrangeByMode } from "./title-tokens";
|
||||
|
||||
/** Title bar height for groups */
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
@@ -374,14 +375,32 @@ function positionGroupContents(
|
||||
return startY;
|
||||
}
|
||||
|
||||
debugLog(`layoutGroupContents "${group.group.title}": startX=${startX}, members=${members.length}, childGroups=${group.childGroups.length}`);
|
||||
// Parse layout token from group title
|
||||
const layoutMode = parseLayoutToken(group.group.title);
|
||||
|
||||
// Get internal edges between members
|
||||
const edges = getInternalEdges(group.memberIds, allNodes);
|
||||
debugLog(`layoutGroupContents "${group.group.title}": startX=${startX}, members=${members.length}, childGroups=${group.childGroups.length}, mode=${layoutMode.type}`);
|
||||
|
||||
let finalY = startY;
|
||||
|
||||
if (members.length > 0) {
|
||||
// If layout mode is specified, use token-based layout (ignores internal edges)
|
||||
if (layoutMode.type !== "default" && members.length > 0) {
|
||||
const arranged = arrangeByMode(members, layoutMode, startX, startY, config);
|
||||
|
||||
for (const pos of arranged) {
|
||||
const node = allNodes.get(pos.id);
|
||||
if (node?.pos) {
|
||||
node.pos[0] = pos.x;
|
||||
node.pos[1] = pos.y;
|
||||
debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`);
|
||||
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
finalY = Math.max(finalY, pos.y + nh);
|
||||
}
|
||||
}
|
||||
} else if (members.length > 0) {
|
||||
// Default behavior: check internal edges for layer-based or bin pack layout
|
||||
const edges = getInternalEdges(group.memberIds, allNodes);
|
||||
|
||||
if (edges.length > 0) {
|
||||
// Has internal connections - use layer-based layout
|
||||
const layers = assignMemberLayers(group.memberIds, edges, allNodes);
|
||||
|
||||
@@ -9,10 +9,85 @@ import { DEFAULT_CONFIG } from "./types";
|
||||
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
|
||||
import { packNodesIntoRows } from "./bin-pack";
|
||||
import { debugLog } from "../debug";
|
||||
import { parseLayoutToken, arrangeByMode } from "./title-tokens";
|
||||
|
||||
/** Title bar height for groups */
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
|
||||
/**
|
||||
* Resize a group to fit its member nodes
|
||||
*/
|
||||
function resizeGroupToFitMembers(
|
||||
group: LGraphGroup,
|
||||
memberIds: Set<number>,
|
||||
allNodes: Map<number, LGraphNode>,
|
||||
config: LayoutConfig
|
||||
): void {
|
||||
if (memberIds.size === 0) return;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const nodeId of memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (!node?.pos) continue;
|
||||
const nw = node.size?.[0] ?? 200;
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
minX = Math.min(minX, node.pos[0]);
|
||||
minY = Math.min(minY, node.pos[1]);
|
||||
maxX = Math.max(maxX, node.pos[0] + nw);
|
||||
maxY = Math.max(maxY, node.pos[1] + nh);
|
||||
}
|
||||
|
||||
if (minX === Infinity) return;
|
||||
|
||||
// Update group position and size with padding
|
||||
group.pos[0] = minX - config.groupPadding;
|
||||
group.pos[1] = minY - config.groupPadding - GROUP_TITLE_HEIGHT;
|
||||
group.size[0] = maxX - minX + config.groupPadding * 2;
|
||||
group.size[1] = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a group and its tracked members by a delta
|
||||
*/
|
||||
function translateGroupWithMembers(
|
||||
group: LGraphGroup,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
memberIds: Set<number>,
|
||||
allNodes: Map<number, LGraphNode>,
|
||||
allGroups: LGraphGroup[]
|
||||
): void {
|
||||
// Move the group itself
|
||||
if (Array.isArray(group.pos)) {
|
||||
group.pos[0] += deltaX;
|
||||
group.pos[1] += deltaY;
|
||||
}
|
||||
|
||||
// Translate tracked member nodes
|
||||
for (const nodeId of memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node?.pos) {
|
||||
node.pos[0] += deltaX;
|
||||
node.pos[1] += deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
// Find and translate nested groups inside this group
|
||||
for (const nestedGroup of allGroups) {
|
||||
if (nestedGroup === group) continue;
|
||||
if (groupContainsGroup(group, nestedGroup)) {
|
||||
if (Array.isArray(nestedGroup.pos)) {
|
||||
nestedGroup.pos[0] += deltaX;
|
||||
nestedGroup.pos[1] += deltaY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if outer group's bounds fully contain inner group's bounds
|
||||
*/
|
||||
@@ -185,8 +260,11 @@ function layoutGroupContents(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse layout token from group title
|
||||
const layoutMode = parseLayoutToken(group.title);
|
||||
|
||||
debugLog(
|
||||
`layoutGroupContents "${group.title}": members=${members.length}, nestedGroups=${nestedGroups.length}`
|
||||
`layoutGroupContents "${group.title}": members=${members.length}, nestedGroups=${nestedGroups.length}, mode=${layoutMode.type}`
|
||||
);
|
||||
|
||||
// Get the group's current content area start position
|
||||
@@ -199,12 +277,139 @@ function layoutGroupContents(
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
// Get internal edges between members
|
||||
const edges = getInternalEdges(memberIds, allNodes);
|
||||
|
||||
let contentEndY = startY;
|
||||
|
||||
if (members.length > 0) {
|
||||
// If layout mode is specified, use token-based layout (ignores internal edges)
|
||||
// This handles both direct member nodes AND nested groups as top-level items
|
||||
if (layoutMode.type !== "default" && (members.length > 0 || nestedGroups.length > 0)) {
|
||||
// First, recursively process nested groups (bottom-up) so their sizes are final
|
||||
// Sort by containment depth (deepest first)
|
||||
const sortedNested = [...nestedGroups].sort((a, b) => {
|
||||
let aDepth = 0,
|
||||
bDepth = 0;
|
||||
for (const other of nestedGroups) {
|
||||
if (other !== a && groupContainsGroup(other, a)) aDepth++;
|
||||
if (other !== b && groupContainsGroup(other, b)) bDepth++;
|
||||
}
|
||||
return bDepth - aDepth; // Deepest first
|
||||
});
|
||||
|
||||
// Track member IDs for each nested group (needed for translation later)
|
||||
const nestedGroupMembers = new Map<number, Set<number>>();
|
||||
|
||||
for (const nested of sortedNested) {
|
||||
if (processedGroups.has(nested)) continue;
|
||||
|
||||
const nestedMemberIds = new Set<number>();
|
||||
for (const [nodeId, node] of allNodes) {
|
||||
if (nodeInsideGroup(node, nested)) {
|
||||
// Check not in deeper nested group
|
||||
let inDeeperGroup = false;
|
||||
for (const other of sortedNested) {
|
||||
if (other !== nested && groupContainsGroup(nested, other) && nodeInsideGroup(node, other)) {
|
||||
inDeeperGroup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inDeeperGroup) {
|
||||
nestedMemberIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
nestedGroupMembers.set(nested.id, nestedMemberIds);
|
||||
|
||||
const deeperNested = sortedNested.filter(
|
||||
(g) => g !== nested && groupContainsGroup(nested, g)
|
||||
);
|
||||
|
||||
layoutGroupContents(
|
||||
nested,
|
||||
nestedMemberIds,
|
||||
deeperNested,
|
||||
allNodes,
|
||||
config,
|
||||
processedGroups
|
||||
);
|
||||
|
||||
// Resize nested group to fit its (possibly rearranged) contents
|
||||
resizeGroupToFitMembers(nested, nestedMemberIds, allNodes, config);
|
||||
}
|
||||
|
||||
// Now create combined items list: direct member nodes + direct nested groups
|
||||
// Direct nested groups are those not contained by other nested groups
|
||||
const directNestedGroups = nestedGroups.filter((g) => {
|
||||
for (const other of nestedGroups) {
|
||||
if (other !== g && groupContainsGroup(other, g)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Create fake LGraphNode-like objects for nested groups so we can use arrangeByMode
|
||||
const groupItems: LGraphNode[] = directNestedGroups.map((g) => ({
|
||||
id: -g.id, // Negative ID to distinguish from real nodes
|
||||
type: "__group__",
|
||||
title: g.title,
|
||||
pos: [g.pos[0], g.pos[1]] as [number, number],
|
||||
size: [g.size[0], g.size[1]] as [number, number],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
}));
|
||||
|
||||
// Combine members and group items
|
||||
const allItems = [...members, ...groupItems];
|
||||
|
||||
if (allItems.length > 0) {
|
||||
const arranged = arrangeByMode(allItems, layoutMode, startX, startY, config);
|
||||
|
||||
for (const pos of arranged) {
|
||||
if (pos.id < 0) {
|
||||
// This is a nested group (negative ID)
|
||||
const groupId = -pos.id;
|
||||
const nestedGroup = directNestedGroups.find((g) => g.id === groupId);
|
||||
if (nestedGroup) {
|
||||
const oldX = nestedGroup.pos[0];
|
||||
const oldY = nestedGroup.pos[1];
|
||||
const deltaX = pos.x - oldX;
|
||||
const deltaY = pos.y - oldY;
|
||||
|
||||
// Use tracked members for accurate translation after token layout
|
||||
const trackedMembers = nestedGroupMembers.get(groupId) ?? new Set();
|
||||
translateGroupWithMembers(nestedGroup, deltaX, deltaY, trackedMembers, allNodes, nestedGroups);
|
||||
debugLog(` Nested group ${groupId} (${nestedGroup.title}): token layout [${pos.x}, ${pos.y}]`);
|
||||
|
||||
const gw = nestedGroup.size[0];
|
||||
const gh = nestedGroup.size[1];
|
||||
|
||||
minX = Math.min(minX, pos.x);
|
||||
minY = Math.min(minY, pos.y);
|
||||
maxX = Math.max(maxX, pos.x + gw);
|
||||
maxY = Math.max(maxY, pos.y + gh);
|
||||
contentEndY = Math.max(contentEndY, pos.y + gh);
|
||||
}
|
||||
} else {
|
||||
// This is a regular node
|
||||
const node = allNodes.get(pos.id);
|
||||
if (node?.pos) {
|
||||
node.pos[0] = pos.x;
|
||||
node.pos[1] = pos.y;
|
||||
debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`);
|
||||
|
||||
const nw = node.size?.[0] ?? 200;
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
|
||||
minX = Math.min(minX, pos.x);
|
||||
minY = Math.min(minY, pos.y);
|
||||
maxX = Math.max(maxX, pos.x + nw);
|
||||
maxY = Math.max(maxY, pos.y + nh);
|
||||
contentEndY = Math.max(contentEndY, pos.y + nh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (members.length > 0) {
|
||||
// Default behavior: check internal edges for layer-based or vertical stack layout
|
||||
const edges = getInternalEdges(memberIds, allNodes);
|
||||
|
||||
if (edges.length > 0) {
|
||||
// Has internal connections - use layer-based layout
|
||||
const layers = assignMemberLayers(memberIds, edges, allNodes);
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { LGraphNode, LayoutConfig } from "./types";
|
||||
|
||||
/**
|
||||
* Layout mode parsed from group title tokens
|
||||
*/
|
||||
export type LayoutMode =
|
||||
| { type: "default" }
|
||||
| { type: "horizontal" }
|
||||
| { type: "vertical" }
|
||||
| { type: "rows"; count: number }
|
||||
| { type: "columns"; count: number };
|
||||
|
||||
/**
|
||||
* Parse layout token from group title (case-insensitive)
|
||||
* Supports: [HORIZONTAL], [VERTICAL], [1-9ROW], [1-9COL]
|
||||
*/
|
||||
export function parseLayoutToken(title: string): LayoutMode {
|
||||
const upper = title.toUpperCase();
|
||||
|
||||
// Check for [HORIZONTAL]
|
||||
if (upper.includes("[HORIZONTAL]")) {
|
||||
return { type: "horizontal" };
|
||||
}
|
||||
|
||||
// Check for [VERTICAL]
|
||||
if (upper.includes("[VERTICAL]")) {
|
||||
return { type: "vertical" };
|
||||
}
|
||||
|
||||
// Check for [xROW] pattern (1-9)
|
||||
const rowMatch = upper.match(/\[([1-9])ROW\]/);
|
||||
if (rowMatch) {
|
||||
const count = parseInt(rowMatch[1], 10);
|
||||
// 1ROW is same as horizontal
|
||||
if (count === 1) {
|
||||
return { type: "horizontal" };
|
||||
}
|
||||
return { type: "rows", count };
|
||||
}
|
||||
|
||||
// Check for [xCOL] pattern (1-9)
|
||||
const colMatch = upper.match(/\[([1-9])COL\]/);
|
||||
if (colMatch) {
|
||||
const count = parseInt(colMatch[1], 10);
|
||||
// 1COL is same as vertical
|
||||
if (count === 1) {
|
||||
return { type: "vertical" };
|
||||
}
|
||||
return { type: "columns", count };
|
||||
}
|
||||
|
||||
return { type: "default" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Node info for layout calculations
|
||||
*/
|
||||
interface NodeInfo {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
originalX: number;
|
||||
originalY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of arranging nodes by mode
|
||||
*/
|
||||
export interface ArrangedNode {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort nodes by original position (left-to-right, top-to-bottom)
|
||||
*/
|
||||
function sortByOriginalPosition(nodes: NodeInfo[]): NodeInfo[] {
|
||||
return [...nodes].sort((a, b) => {
|
||||
// Primary: sort by X (left to right)
|
||||
if (a.originalX !== b.originalX) {
|
||||
return a.originalX - b.originalX;
|
||||
}
|
||||
// Secondary: sort by Y (top to bottom)
|
||||
return a.originalY - b.originalY;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes in a single horizontal row
|
||||
*/
|
||||
function arrangeHorizontal(
|
||||
nodes: NodeInfo[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
gap: number
|
||||
): ArrangedNode[] {
|
||||
// Sort by original position (left to right)
|
||||
const sorted = sortByOriginalPosition(nodes);
|
||||
|
||||
const result: ArrangedNode[] = [];
|
||||
let x = startX;
|
||||
|
||||
for (const node of sorted) {
|
||||
result.push({ id: node.id, x, y: startY });
|
||||
x += node.width + gap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes in a single vertical column
|
||||
*/
|
||||
function arrangeVertical(
|
||||
nodes: NodeInfo[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
gap: number
|
||||
): ArrangedNode[] {
|
||||
// Sort by original position (top to bottom, then left to right)
|
||||
const sorted = [...nodes].sort((a, b) => {
|
||||
if (a.originalY !== b.originalY) {
|
||||
return a.originalY - b.originalY;
|
||||
}
|
||||
return a.originalX - b.originalX;
|
||||
});
|
||||
|
||||
const result: ArrangedNode[] = [];
|
||||
let y = startY;
|
||||
|
||||
for (const node of sorted) {
|
||||
result.push({ id: node.id, x: startX, y });
|
||||
y += node.height + gap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes into N rows (round-robin distribution)
|
||||
* Each row is horizontal, rows stacked vertically
|
||||
*/
|
||||
function arrangeRows(
|
||||
nodes: NodeInfo[],
|
||||
rowCount: number,
|
||||
startX: number,
|
||||
startY: number,
|
||||
horizontalGap: number,
|
||||
verticalGap: number
|
||||
): ArrangedNode[] {
|
||||
// Sort by original position first
|
||||
const sorted = sortByOriginalPosition(nodes);
|
||||
|
||||
// Distribute round-robin into rows
|
||||
const rows: NodeInfo[][] = Array.from({ length: rowCount }, () => []);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
rows[i % rowCount].push(sorted[i]);
|
||||
}
|
||||
|
||||
const result: ArrangedNode[] = [];
|
||||
let y = startY;
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.length === 0) continue;
|
||||
|
||||
let x = startX;
|
||||
let rowMaxHeight = 0;
|
||||
|
||||
for (const node of row) {
|
||||
result.push({ id: node.id, x, y });
|
||||
x += node.width + horizontalGap;
|
||||
rowMaxHeight = Math.max(rowMaxHeight, node.height);
|
||||
}
|
||||
|
||||
y += rowMaxHeight + verticalGap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes into N columns (round-robin distribution)
|
||||
* Each column is vertical, columns side-by-side
|
||||
*/
|
||||
function arrangeColumns(
|
||||
nodes: NodeInfo[],
|
||||
colCount: number,
|
||||
startX: number,
|
||||
startY: number,
|
||||
horizontalGap: number,
|
||||
verticalGap: number
|
||||
): ArrangedNode[] {
|
||||
// Sort by original position first
|
||||
const sorted = sortByOriginalPosition(nodes);
|
||||
|
||||
// Distribute round-robin into columns
|
||||
const cols: NodeInfo[][] = Array.from({ length: colCount }, () => []);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
cols[i % colCount].push(sorted[i]);
|
||||
}
|
||||
|
||||
const result: ArrangedNode[] = [];
|
||||
let x = startX;
|
||||
|
||||
for (const col of cols) {
|
||||
if (col.length === 0) continue;
|
||||
|
||||
let y = startY;
|
||||
let colMaxWidth = 0;
|
||||
|
||||
for (const node of col) {
|
||||
result.push({ id: node.id, x, y });
|
||||
y += node.height + verticalGap;
|
||||
colMaxWidth = Math.max(colMaxWidth, node.width);
|
||||
}
|
||||
|
||||
x += colMaxWidth + horizontalGap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes according to layout mode
|
||||
*/
|
||||
export function arrangeByMode(
|
||||
members: LGraphNode[],
|
||||
mode: LayoutMode,
|
||||
startX: number,
|
||||
startY: number,
|
||||
config: LayoutConfig
|
||||
): ArrangedNode[] {
|
||||
// Build node info list with original positions
|
||||
const nodes: NodeInfo[] = members.map((n) => ({
|
||||
id: n.id,
|
||||
width: n.size?.[0] ?? 200,
|
||||
height: n.size?.[1] ?? 100,
|
||||
originalX: n.pos?.[0] ?? 0,
|
||||
originalY: n.pos?.[1] ?? 0,
|
||||
}));
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (mode.type) {
|
||||
case "horizontal":
|
||||
return arrangeHorizontal(nodes, startX, startY, config.horizontalGap);
|
||||
|
||||
case "vertical":
|
||||
return arrangeVertical(nodes, startX, startY, config.verticalGap);
|
||||
|
||||
case "rows":
|
||||
return arrangeRows(
|
||||
nodes,
|
||||
mode.count,
|
||||
startX,
|
||||
startY,
|
||||
config.horizontalGap,
|
||||
config.verticalGap
|
||||
);
|
||||
|
||||
case "columns":
|
||||
return arrangeColumns(
|
||||
nodes,
|
||||
mode.count,
|
||||
startX,
|
||||
startY,
|
||||
config.horizontalGap,
|
||||
config.verticalGap
|
||||
);
|
||||
|
||||
default:
|
||||
// Default mode - caller should use existing behavior
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Vendored
+1744
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { describe, it, beforeEach, expect } from "vitest";
|
||||
import { loadFixture, capturePositions } from "../helpers";
|
||||
import { layoutSelectedGroups } from "../../src/layout/selected-groups";
|
||||
import { parseLayoutToken } from "../../src/layout/title-tokens";
|
||||
import {
|
||||
assertNodesInsideGroups,
|
||||
assertFiniteCoordinates,
|
||||
@@ -406,4 +407,314 @@ describe("layoutSelectedGroups", () => {
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLayoutToken", () => {
|
||||
it("parses [HORIZONTAL] token", () => {
|
||||
expect(parseLayoutToken("My Group [HORIZONTAL]")).toEqual({
|
||||
type: "horizontal",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses [VERTICAL] token", () => {
|
||||
expect(parseLayoutToken("[VERTICAL] Group")).toEqual({ type: "vertical" });
|
||||
});
|
||||
|
||||
it("parses [xROW] tokens", () => {
|
||||
expect(parseLayoutToken("Group [2ROW]")).toEqual({ type: "rows", count: 2 });
|
||||
expect(parseLayoutToken("[3ROW] Test")).toEqual({ type: "rows", count: 3 });
|
||||
});
|
||||
|
||||
it("parses [xCOL] tokens", () => {
|
||||
expect(parseLayoutToken("Group [2COL]")).toEqual({
|
||||
type: "columns",
|
||||
count: 2,
|
||||
});
|
||||
expect(parseLayoutToken("[4COL] Test")).toEqual({
|
||||
type: "columns",
|
||||
count: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats [1ROW] as horizontal", () => {
|
||||
expect(parseLayoutToken("Group [1ROW]")).toEqual({ type: "horizontal" });
|
||||
});
|
||||
|
||||
it("treats [1COL] as vertical", () => {
|
||||
expect(parseLayoutToken("Group [1COL]")).toEqual({ type: "vertical" });
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(parseLayoutToken("[horizontal]")).toEqual({ type: "horizontal" });
|
||||
expect(parseLayoutToken("[Vertical]")).toEqual({ type: "vertical" });
|
||||
expect(parseLayoutToken("[2row]")).toEqual({ type: "rows", count: 2 });
|
||||
expect(parseLayoutToken("[3Col]")).toEqual({ type: "columns", count: 3 });
|
||||
});
|
||||
|
||||
it("returns default for no token", () => {
|
||||
expect(parseLayoutToken("Normal Group")).toEqual({ type: "default" });
|
||||
expect(parseLayoutToken("")).toEqual({ type: "default" });
|
||||
});
|
||||
|
||||
it("returns first matching token", () => {
|
||||
// HORIZONTAL comes before xROW in parsing order
|
||||
expect(parseLayoutToken("[HORIZONTAL] [2ROW]")).toEqual({
|
||||
type: "horizontal",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("groups with layout tokens", () => {
|
||||
function createTestGraph(groupTitle: string): LGraph {
|
||||
return {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Node 1",
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Node 2",
|
||||
pos: [100, 250],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "Note",
|
||||
title: "Node 3",
|
||||
pos: [100, 400],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "Note",
|
||||
title: "Node 4",
|
||||
pos: [100, 550],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: groupTitle,
|
||||
pos: [50, 50],
|
||||
size: [500, 700],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
it("[HORIZONTAL] arranges nodes in single row", () => {
|
||||
const graph = createTestGraph("Test [HORIZONTAL]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// All nodes should have same Y (single row)
|
||||
const ys = graph._nodes.map((n) => n.pos[1]);
|
||||
expect(new Set(ys).size).toBe(1);
|
||||
|
||||
// Nodes should be spread horizontally
|
||||
const xs = graph._nodes.map((n) => n.pos[0]).sort((a, b) => a - b);
|
||||
expect(xs[3]).toBeGreaterThan(xs[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("[VERTICAL] arranges nodes in single column", () => {
|
||||
const graph = createTestGraph("Test [VERTICAL]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// All nodes should have same X (single column)
|
||||
const xs = graph._nodes.map((n) => n.pos[0]);
|
||||
expect(new Set(xs).size).toBe(1);
|
||||
|
||||
// Nodes should be spread vertically
|
||||
const ys = graph._nodes.map((n) => n.pos[1]).sort((a, b) => a - b);
|
||||
expect(ys[3]).toBeGreaterThan(ys[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("[2ROW] distributes nodes into 2 rows", () => {
|
||||
const graph = createTestGraph("Test [2ROW]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Should have exactly 2 distinct Y values (2 rows)
|
||||
const ys = new Set(graph._nodes.map((n) => n.pos[1]));
|
||||
expect(ys.size).toBe(2);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("[3COL] distributes nodes into 3 columns", () => {
|
||||
const graph = createTestGraph("Test [3COL]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Should have 3 distinct X values (4 nodes into 3 cols = 2,1,1 distribution)
|
||||
const xs = new Set(graph._nodes.map((n) => n.pos[0]));
|
||||
expect(xs.size).toBe(3);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("lowercase token works", () => {
|
||||
const graph = createTestGraph("Test [horizontal]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// All nodes should have same Y (single row)
|
||||
const ys = graph._nodes.map((n) => n.pos[1]);
|
||||
expect(new Set(ys).size).toBe(1);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("ignores DAG structure with token", () => {
|
||||
// Create graph with connected nodes
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "NodeA",
|
||||
title: "Node A",
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [{ name: "out", type: "FLOAT", links: [1] }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "NodeB",
|
||||
title: "Node B",
|
||||
pos: [100, 300],
|
||||
size: [200, 100],
|
||||
inputs: [{ name: "in", type: "FLOAT", link: 1 }],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Test [HORIZONTAL]",
|
||||
pos: [50, 50],
|
||||
size: [500, 400],
|
||||
},
|
||||
],
|
||||
links: new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: "FLOAT",
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// With [HORIZONTAL], both nodes should have same Y regardless of connection
|
||||
const node1 = graph._nodes.find((n) => n.id === 1)!;
|
||||
const node2 = graph._nodes.find((n) => n.id === 2)!;
|
||||
expect(node1.pos[1]).toBe(node2.pos[1]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("nested groups respect their own tokens", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
// Nodes in outer group (not in inner)
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Outer 1",
|
||||
pos: [100, 100],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Outer 2",
|
||||
pos: [100, 250],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
// Nodes in inner group
|
||||
{
|
||||
id: 3,
|
||||
type: "Note",
|
||||
title: "Inner 1",
|
||||
pos: [350, 150],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "Note",
|
||||
title: "Inner 2",
|
||||
pos: [350, 300],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Outer [HORIZONTAL]",
|
||||
pos: [50, 50],
|
||||
size: [600, 500],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Inner [VERTICAL]",
|
||||
pos: [300, 100],
|
||||
size: [200, 350],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Inner group nodes should have same X (vertical)
|
||||
const inner1 = graph._nodes.find((n) => n.id === 3)!;
|
||||
const inner2 = graph._nodes.find((n) => n.id === 4)!;
|
||||
expect(inner1.pos[0]).toBe(inner2.pos[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("resizes group to fit token-arranged contents", () => {
|
||||
const graph = createTestGraph("Test [HORIZONTAL]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
const group = graph._groups.find((g) => g.id === 1)!;
|
||||
|
||||
// Group should be wider than tall (horizontal layout)
|
||||
// With 4 nodes of 200px width + gaps, width should be significant
|
||||
expect(group.size[0]).toBeGreaterThan(600);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { loadFixture } from "../helpers";
|
||||
import { layoutSelectedGroups } from "../../src/layout/selected-groups";
|
||||
import { layoutGraph } from "../../src/layout/index";
|
||||
import { assertFiniteCoordinates } from "../invariants";
|
||||
import type { LGraph } from "../../src/layout/types";
|
||||
|
||||
/**
|
||||
* Regression tests for token-based layout issues
|
||||
* Based on real workflow: token-testing.json
|
||||
*/
|
||||
describe("token layout regression", () => {
|
||||
let graph: LGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = loadFixture("token-testing.json");
|
||||
});
|
||||
|
||||
describe("[1ROW] ordering", () => {
|
||||
// Group 8 "[1ROW]" contains nodes 41, 42 (GetNode), 43, 44 (PreviewImage), 47 (Note)
|
||||
// GetNodes are originally at X=3290, PreviewImages at X=3600
|
||||
|
||||
it("preserves left-to-right order (sources before targets)", () => {
|
||||
layoutSelectedGroups(graph, new Set([8]));
|
||||
|
||||
const node41 = graph._nodes.find((n) => n.id === 41)!; // GetNode
|
||||
const node42 = graph._nodes.find((n) => n.id === 42)!; // GetNode
|
||||
const node43 = graph._nodes.find((n) => n.id === 43)!; // PreviewImage
|
||||
const node44 = graph._nodes.find((n) => n.id === 44)!; // PreviewImage
|
||||
|
||||
// GetNodes (originally left at X=3290) should remain left of PreviewImages (X=3600)
|
||||
expect(node41.pos[0]).toBeLessThan(node43.pos[0]);
|
||||
expect(node42.pos[0]).toBeLessThan(node44.pos[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("places all nodes at same Y in horizontal row", () => {
|
||||
layoutSelectedGroups(graph, new Set([8]));
|
||||
|
||||
const groupNodes = graph._nodes.filter((n) =>
|
||||
[41, 42, 43, 44, 47].includes(n.id)
|
||||
);
|
||||
const ys = new Set(groupNodes.map((n) => n.pos[1]));
|
||||
|
||||
// All nodes should have same Y (single row)
|
||||
expect(ys.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("[3COL] with nested groups", () => {
|
||||
// Group 9 "Full Workflow Group [3COL]" contains nested group 10 "[HORIZONTAL]"
|
||||
// Group 10 contains nodes 55, 56, 57 (isolated notes)
|
||||
|
||||
it("includes nested groups in column arrangement", () => {
|
||||
layoutSelectedGroups(graph, new Set([9]));
|
||||
|
||||
const group10 = graph._groups.find((g) => g.id === 10)!;
|
||||
|
||||
// Nested group should be positioned
|
||||
expect(group10.pos[0]).toBeGreaterThan(0);
|
||||
expect(group10.pos[1]).toBeGreaterThan(0);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("nested group respects its own token when selected directly", () => {
|
||||
// Test group 10 "[HORIZONTAL]" directly
|
||||
layoutSelectedGroups(graph, new Set([10]));
|
||||
|
||||
const innerNodes = graph._nodes.filter((n) =>
|
||||
[55, 56, 57].includes(n.id)
|
||||
);
|
||||
const ys = new Set(innerNodes.map((n) => n.pos[1]));
|
||||
|
||||
// Inner nodes should all be at same Y (horizontal)
|
||||
expect(ys.size).toBe(1);
|
||||
});
|
||||
|
||||
it("does not overlap nested groups with other content", () => {
|
||||
layoutSelectedGroups(graph, new Set([9]));
|
||||
|
||||
const group10 = graph._groups.find((g) => g.id === 10)!;
|
||||
|
||||
// Group 10 should have valid bounds
|
||||
expect(group10.size[0]).toBeGreaterThan(0);
|
||||
expect(group10.size[1]).toBeGreaterThan(0);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organize Workflow with tokens", () => {
|
||||
it("respects group tokens in full workflow layout", () => {
|
||||
layoutGraph(graph);
|
||||
|
||||
// Group 8 "[1ROW]" - nodes should be at same Y
|
||||
const group8Nodes = graph._nodes.filter((n) =>
|
||||
[41, 42, 43, 44, 47].includes(n.id)
|
||||
);
|
||||
const g8Ys = new Set(group8Nodes.map((n) => n.pos[1]));
|
||||
expect(g8Ys.size).toBe(1);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("preserves ordering in workflow layout", () => {
|
||||
layoutGraph(graph);
|
||||
|
||||
// In "[1ROW]" group, GetNodes should be left of PreviewImages
|
||||
const node41 = graph._nodes.find((n) => n.id === 41)!;
|
||||
const node43 = graph._nodes.find((n) => n.id === 43)!;
|
||||
|
||||
expect(node41.pos[0]).toBeLessThan(node43.pos[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("[HORIZONTAL] token", () => {
|
||||
// Group 7 "[HORIZONTAL]" contains nodes 32, 33, 34, 35 (PreviewImage nodes)
|
||||
|
||||
it("arranges nodes horizontally", () => {
|
||||
layoutSelectedGroups(graph, new Set([7]));
|
||||
|
||||
const nodes = graph._nodes.filter((n) =>
|
||||
[32, 33, 34, 35].includes(n.id)
|
||||
);
|
||||
const ys = new Set(nodes.map((n) => n.pos[1]));
|
||||
|
||||
// All nodes should have same Y
|
||||
expect(ys.size).toBe(1);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user