refactor: centralize core api helpers

This commit is contained in:
pbanddev
2026-03-24 11:43:53 -05:00
parent a91b0b0a4c
commit ceff7d2480
27 changed files with 276 additions and 158 deletions
+1
View File
@@ -13,6 +13,7 @@ Use `.changeset/plans/` only when prior design context is needed.
pnpm install
pnpm build
pnpm build:lib
pnpm test:lib
pnpm typecheck
pnpm test
pnpm setup:e2e
+1
View File
@@ -53,6 +53,7 @@ Add tokens to group titles to control how nodes are arranged:
- `pnpm test`
- `pnpm build`
- `pnpm build:lib` emits the pure library entrypoint to `lib/`
- `pnpm test:lib` smoke-tests the built `comfyui-node-organizer/core` export
- `pnpm setup:e2e` provisions a dedicated ComfyUI instance for browser tests and installs the exact workflow-template package required by the pinned ComfyUI checkout
- `pnpm test:e2e` runs the full Playwright suite against that instance
- Broad installed-template coverage is discovered live at E2E runtime from the pinned test ComfyUI environment, with one correctness-invariant test per installed workflow template
+1
View File
@@ -16,6 +16,7 @@
"dev": "vite",
"build": "tsc && vite build",
"build:lib": "tsc -p tsconfig.lib.json",
"test:lib": "pnpm build:lib && tsx scripts/smoke-core-package.ts",
"watch": "tsc && vite build --watch",
"typecheck": "tsc --noEmit",
"prepare": "pnpm build:lib",
+7
View File
@@ -0,0 +1,7 @@
import assert from "node:assert/strict";
const coreModule = await import("comfyui-node-organizer/core");
assert.equal(typeof coreModule.inferGroupMembership, "function");
assert.equal(typeof coreModule.normalizeWorkflowGeometry, "function");
assert.equal(typeof coreModule.DEFAULT_FRAMEWORK_CONFIG, "object");
+5 -11
View File
@@ -21,6 +21,10 @@ import {
inferGroupMembership,
type Rect as MembershipRect,
} from "./group-membership";
import {
fromNumericGroupLayoutId,
toGroupLayoutId,
} from "./layout/group-ids";
// ---------------------------------------------------------------------------
// Structural types for ComfyUI runtime objects
@@ -66,16 +70,6 @@ export interface GraphLike {
setDirtyCanvas?(fg: boolean, bg: boolean): void;
}
function toGroupLayoutId(groupId: number): string {
return `group:${groupId}`;
}
function fromGroupLayoutId(groupId: string): number | null {
if (!groupId.startsWith("group:")) return null;
const parsed = Number(groupId.slice("group:".length));
return Number.isFinite(parsed) ? parsed : null;
}
// ---------------------------------------------------------------------------
// Extract layout input from a ComfyUI graph
// ---------------------------------------------------------------------------
@@ -354,7 +348,7 @@ export function anchorSelectedGroupLayoutResult(
);
for (const rootGroupId of selectedRootIds) {
const originalGraphGroupId = fromGroupLayoutId(rootGroupId);
const originalGraphGroupId = fromNumericGroupLayoutId(rootGroupId);
if (originalGraphGroupId === null) continue;
const originalGroup = graphGroupById.get(originalGraphGroupId);
+38 -55
View File
@@ -1,43 +1,44 @@
import { createHorizontalAlgorithm } from "./layout/algorithms/horizontal";
import { createSugiyamaAlgorithm } from "./layout/algorithms/sugiyama";
import { createVerticalAlgorithm } from "./layout/algorithms/vertical";
import { layoutWithGroups } from "./layout/framework";
import { parseLayoutToken } from "./layout/tokens";
import {
createLayoutAlgorithm,
type LayoutAlgorithmName,
} from "./layout/algorithm-factory.js";
import { layoutWithGroups } from "./layout/framework.js";
import { toGroupLayoutId } from "./layout/group-ids.js";
import { parseLayoutToken } from "./layout/tokens.js";
import {
DEFAULT_FRAMEWORK_CONFIG,
type FrameworkConfig,
type LayoutAlgorithm,
type LayoutEdge as InternalLayoutEdge,
type LayoutGroup as InternalLayoutGroup,
type LayoutNode as InternalLayoutNode,
} from "./layout/types";
} from "./layout/types.js";
export {
inferGroupMembership,
type GroupMembership,
type Rect,
} from "./group-membership";
} from "./group-membership.js";
export interface LayoutNode {
export interface WorkflowNode {
readonly id: string;
readonly width: number;
readonly height: number;
readonly kind?: "node" | "subgraph-input" | "subgraph-output";
}
export interface LayoutEdge {
export interface WorkflowEdge {
readonly source: string;
readonly target: string;
}
export interface LayoutGroup {
export interface WorkflowGroup {
readonly id: string;
readonly title: string;
readonly memberIds: ReadonlyArray<string>;
readonly childGroupIds: ReadonlyArray<string>;
}
export interface PositionedRect {
export interface WorkflowRect {
readonly id: string;
readonly x: number;
readonly y: number;
@@ -45,38 +46,41 @@ export interface PositionedRect {
readonly height: number;
}
export interface NormalizeResult {
readonly nodes: PositionedRect[];
readonly groups: PositionedRect[];
readonly memberships: Array<{
readonly groupId: string;
readonly nodeIds: string[];
readonly childGroupIds: string[];
}>;
export interface WorkflowMembership {
readonly groupId: string;
readonly nodeIds: string[];
readonly childGroupIds: string[];
}
export interface NormalizeOptions {
readonly algorithm?: "sugiyama" | "horizontal" | "vertical";
export interface NormalizeWorkflowResult {
readonly nodes: WorkflowRect[];
readonly groups: WorkflowRect[];
readonly memberships: WorkflowMembership[];
}
export interface NormalizeWorkflowOptions {
readonly algorithm?: LayoutAlgorithmName;
readonly config?: Partial<FrameworkConfig>;
}
export { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig } from "./layout/types";
type AlgorithmName = NonNullable<NormalizeOptions["algorithm"]>;
export { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig } from "./layout/types.js";
export function normalizeWorkflowGeometry(
input: {
readonly nodes: ReadonlyArray<LayoutNode>;
readonly edges: ReadonlyArray<LayoutEdge>;
readonly groups: ReadonlyArray<LayoutGroup>;
readonly nodes: ReadonlyArray<WorkflowNode>;
readonly edges: ReadonlyArray<WorkflowEdge>;
readonly groups: ReadonlyArray<WorkflowGroup>;
},
options?: NormalizeOptions,
): NormalizeResult {
options?: NormalizeWorkflowOptions,
): NormalizeWorkflowResult {
const config: FrameworkConfig = {
...DEFAULT_FRAMEWORK_CONFIG,
...options?.config,
};
const algorithm = createAlgorithm(options?.algorithm ?? "sugiyama", config);
const algorithm = createLayoutAlgorithm(
options?.algorithm ?? "sugiyama",
config,
);
const nodes: InternalLayoutNode[] = input.nodes.map((node) => {
if (node.kind === "subgraph-input") {
@@ -115,10 +119,10 @@ export function normalizeWorkflowGeometry(
const token = parseLayoutToken(group.title);
return {
id: toInternalGroupId(group.id),
id: toGroupLayoutId(group.id),
title: group.title,
memberIds: [...group.memberIds],
childGroupIds: group.childGroupIds.map(toInternalGroupId),
childGroupIds: group.childGroupIds.map(toGroupLayoutId),
...(token ? { token } : {}),
};
});
@@ -141,7 +145,7 @@ export function normalizeWorkflowGeometry(
: [];
}),
groups: input.groups.flatMap((group) => {
const bounds = result.groupBounds.get(toInternalGroupId(group.id));
const bounds = result.groupBounds.get(toGroupLayoutId(group.id));
return bounds
? [
{
@@ -161,24 +165,3 @@ export function normalizeWorkflowGeometry(
})),
};
}
function createAlgorithm(
algorithmName: AlgorithmName,
config: FrameworkConfig,
): LayoutAlgorithm {
switch (algorithmName) {
case "horizontal":
return createHorizontalAlgorithm(config.horizontalGap);
case "vertical":
return createVerticalAlgorithm(config.verticalGap);
case "sugiyama":
return createSugiyamaAlgorithm({
horizontalGap: config.horizontalGap,
verticalGap: config.verticalGap,
});
}
}
function toInternalGroupId(groupId: string): string {
return `group:${groupId}`;
}
+42 -24
View File
@@ -3,41 +3,59 @@ export interface PositionedItem {
readonly size: ArrayLike<number>;
}
export interface RectGeometry {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export function isGroupInsideGroup(
inner: PositionedItem,
outer: PositionedItem,
): boolean {
const ix = Number(inner.pos[0]);
const iy = Number(inner.pos[1]);
const iw = Number(inner.size[0]);
const ih = Number(inner.size[1]);
const ox = Number(outer.pos[0]);
const oy = Number(outer.pos[1]);
const ow = Number(outer.size[0]);
const oh = Number(outer.size[1]);
return (
ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh
);
return isRectInsideRect(toRectGeometry(inner), toRectGeometry(outer));
}
export function isNodeCenterInsideGroup(
node: PositionedItem,
group: PositionedItem,
): boolean {
const centerX = Number(node.pos[0]) + Number(node.size[0]) / 2;
const centerY = Number(node.pos[1]) + Number(node.size[1]) / 2;
const gx = Number(group.pos[0]);
const gy = Number(group.pos[1]);
const gw = Number(group.size[0]);
const gh = Number(group.size[1]);
return isCenterInsideRect(toRectGeometry(node), toRectGeometry(group));
}
export function isRectInsideRect(
inner: RectGeometry,
outer: RectGeometry,
): boolean {
return (
centerX >= gx &&
centerX <= gx + gw &&
centerY >= gy &&
centerY <= gy + gh
inner.x >= outer.x &&
inner.y >= outer.y &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y + inner.height <= outer.y + outer.height
);
}
export function isCenterInsideRect(
item: RectGeometry,
container: RectGeometry,
): boolean {
const centerX = item.x + item.width / 2;
const centerY = item.y + item.height / 2;
return (
centerX >= container.x &&
centerX <= container.x + container.width &&
centerY >= container.y &&
centerY <= container.y + container.height
);
}
function toRectGeometry(item: PositionedItem): RectGeometry {
return {
x: Number(item.pos[0]),
y: Number(item.pos[1]),
width: Number(item.size[0]),
height: Number(item.size[1]),
};
}
+5 -21
View File
@@ -1,3 +1,8 @@
import {
isCenterInsideRect,
isRectInsideRect,
} from "./group-geometry.js";
export interface Rect {
readonly id: string;
readonly x: number;
@@ -81,24 +86,3 @@ export function inferGroupMembership(
};
});
}
function isRectInsideRect(inner: Rect, outer: Rect): boolean {
return (
inner.x >= outer.x &&
inner.y >= outer.y &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y + inner.height <= outer.y + outer.height
);
}
function isCenterInsideRect(item: Rect, container: Rect): boolean {
const centerX = item.x + item.width / 2;
const centerY = item.y + item.height / 2;
return (
centerX >= container.x &&
centerX <= container.x + container.width &&
centerY >= container.y &&
centerY <= container.y + container.height
);
}
+10 -16
View File
@@ -6,6 +6,10 @@
*/
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
import {
createLayoutAlgorithm,
type LayoutAlgorithmName,
} from "./layout/algorithm-factory";
import {
SETTING_IDS,
EXTENSION_NAME,
@@ -24,9 +28,6 @@ import {
} from "./adapter";
import type { GraphLike } from "./adapter";
import { layoutWithGroups } from "./layout/framework";
import { createSugiyamaAlgorithm } from "./layout/algorithms/sugiyama";
import { createHorizontalAlgorithm } from "./layout/algorithms/horizontal";
import { createVerticalAlgorithm } from "./layout/algorithms/vertical";
import type { LayoutAlgorithm, FrameworkConfig } from "./layout/types";
import { computeGraphBounds } from "./bounds";
import { DEFAULT_FRAMEWORK_CONFIG } from "./layout/types";
@@ -39,18 +40,6 @@ declare global {
}
const DEFAULT_ALGORITHM_NAME: DefaultAlgorithmName = "sugiyama";
const algorithmFactories: Record<
DefaultAlgorithmName,
(config: Partial<FrameworkConfig>) => LayoutAlgorithm
> = {
sugiyama: (config) =>
createSugiyamaAlgorithm({
horizontalGap: config.horizontalGap,
verticalGap: config.verticalGap,
}),
horizontal: (config) => createHorizontalAlgorithm(config.horizontalGap),
vertical: (config) => createVerticalAlgorithm(config.verticalGap),
};
// ---------------------------------------------------------------------------
// Helpers
@@ -70,7 +59,12 @@ export function createConfiguredDefaultAlgorithm(
algorithmName: DefaultAlgorithmName,
config: Partial<FrameworkConfig>,
): LayoutAlgorithm {
return algorithmFactories[algorithmName](config);
return createLayoutAlgorithm(algorithmName as LayoutAlgorithmName, {
horizontalGap:
config.horizontalGap ?? DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
verticalGap:
config.verticalGap ?? DEFAULT_FRAMEWORK_CONFIG.verticalGap,
});
}
function getDefaultAlgorithm(config: Partial<FrameworkConfig>): LayoutAlgorithm {
+23
View File
@@ -0,0 +1,23 @@
import { createHorizontalAlgorithm } from "./algorithms/horizontal.js";
import { createSugiyamaAlgorithm } from "./algorithms/sugiyama.js";
import { createVerticalAlgorithm } from "./algorithms/vertical.js";
import type { FrameworkConfig, LayoutAlgorithm } from "./types.js";
export type LayoutAlgorithmName = "sugiyama" | "horizontal" | "vertical";
export function createLayoutAlgorithm(
algorithmName: LayoutAlgorithmName,
config: Pick<FrameworkConfig, "horizontalGap" | "verticalGap">,
): LayoutAlgorithm {
switch (algorithmName) {
case "horizontal":
return createHorizontalAlgorithm(config.horizontalGap);
case "vertical":
return createVerticalAlgorithm(config.verticalGap);
case "sugiyama":
return createSugiyamaAlgorithm({
horizontalGap: config.horizontalGap,
verticalGap: config.verticalGap,
});
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
* Ignores edges (token layouts ignore DAG structure).
*/
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js";
const DEFAULT_GAP = 40;
+1 -1
View File
@@ -5,7 +5,7 @@
* Nodes are placed in input order; vertically centered by midpoint.
*/
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js";
const DEFAULT_GAP = 40;
+5 -5
View File
@@ -15,11 +15,11 @@ import type {
LayoutInput,
LayoutOutput,
LayoutNode,
} from "../types";
import { breakCycles } from "./sugiyama/cycle-break";
import { assignLayers } from "./sugiyama/layer-assign";
import { minimizeCrossings } from "./sugiyama/crossing-minimize";
import { assignCoordinates } from "./sugiyama/coordinate-assign";
} from "../types.js";
import { breakCycles } from "./sugiyama/cycle-break.js";
import { assignLayers } from "./sugiyama/layer-assign.js";
import { minimizeCrossings } from "./sugiyama/crossing-minimize.js";
import { assignCoordinates } from "./sugiyama/coordinate-assign.js";
export interface SugiyamaConfig {
readonly horizontalGap: number;
@@ -5,7 +5,7 @@
* and Y coordinates by stacking nodes top-to-bottom within each layer.
*/
import type { LayoutNode, Position } from "../../types";
import type { LayoutNode, Position } from "../../types.js";
interface CoordinateConfig {
readonly horizontalGap: number;
@@ -6,7 +6,7 @@
* their neighbors in the adjacent fixed layer.
*/
import type { LayoutNode, LayoutEdge } from "../../types";
import type { LayoutNode, LayoutEdge } from "../../types.js";
/**
* Reorder nodes within each layer to minimize edge crossings.
@@ -6,7 +6,7 @@
* repeatedly extract sinks, sources, then the node with max (out - in).
*/
import type { LayoutNode, LayoutEdge } from "../../types";
import type { LayoutNode, LayoutEdge } from "../../types.js";
export interface CycleBreakResult {
readonly edges: ReadonlyArray<LayoutEdge>;
+5 -5
View File
@@ -2,11 +2,11 @@
* Sugiyama sub-module barrel export.
*/
export { breakCycles } from "./cycle-break";
export type { CycleBreakResult } from "./cycle-break";
export { breakCycles } from "./cycle-break.js";
export type { CycleBreakResult } from "./cycle-break.js";
export { assignLayers } from "./layer-assign";
export { assignLayers } from "./layer-assign.js";
export { minimizeCrossings } from "./crossing-minimize";
export { minimizeCrossings } from "./crossing-minimize.js";
export { assignCoordinates } from "./coordinate-assign";
export { assignCoordinates } from "./coordinate-assign.js";
@@ -5,7 +5,7 @@
* path from any source to that node. Sources (in-degree 0) get layer 0.
*/
import type { LayoutNode, LayoutEdge } from "../../types";
import type { LayoutNode, LayoutEdge } from "../../types.js";
/**
* Assign layer indices to each node using longest-path from sources.
+1 -1
View File
@@ -5,7 +5,7 @@
* Nodes are placed in input order; horizontally centered by midpoint.
*/
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js";
const DEFAULT_GAP = 40;
+1 -1
View File
@@ -3,7 +3,7 @@
* All functions are pure: they return new maps, never mutate inputs.
*/
import type { Position, LayoutNode } from "./types";
import type { Position, LayoutNode } from "./types.js";
/**
* Remove vertical gaps between nodes by shifting them up.
+3 -3
View File
@@ -17,9 +17,9 @@ import type {
FrameworkResult,
Position,
GroupBounds,
} from "./types";
import { DEFAULT_FRAMEWORK_CONFIG } from "./types";
import { tokenToAlgorithm } from "./tokens";
} from "./types.js";
import { DEFAULT_FRAMEWORK_CONFIG } from "./types.js";
import { tokenToAlgorithm } from "./tokens.js";
// ---------------------------------------------------------------------------
// Public API
+23
View File
@@ -0,0 +1,23 @@
const GROUP_LAYOUT_ID_PREFIX = "group:";
export function toGroupLayoutId(groupId: string | number): string {
return `${GROUP_LAYOUT_ID_PREFIX}${groupId}`;
}
export function fromGroupLayoutId(groupId: string): string | null {
if (!groupId.startsWith(GROUP_LAYOUT_ID_PREFIX)) {
return null;
}
return groupId.slice(GROUP_LAYOUT_ID_PREFIX.length);
}
export function fromNumericGroupLayoutId(groupId: string): number | null {
const rawGroupId = fromGroupLayoutId(groupId);
if (rawGroupId === null) {
return null;
}
const parsed = Number(rawGroupId);
return Number.isFinite(parsed) ? parsed : null;
}
+4 -4
View File
@@ -8,9 +8,9 @@ export {
splitDisconnected,
placeDisconnected,
translatePositions,
} from "./framework";
} from "./framework.js";
export { compactVertically, compactHorizontally } from "./compact";
export { compactVertically, compactHorizontally } from "./compact.js";
export type {
Position,
@@ -24,6 +24,6 @@ export type {
GroupBounds,
FrameworkResult,
FrameworkConfig,
} from "./types";
} from "./types.js";
export { DEFAULT_FRAMEWORK_CONFIG } from "./types";
export { DEFAULT_FRAMEWORK_CONFIG } from "./types.js";
+4 -4
View File
@@ -5,10 +5,10 @@
* and creates the corresponding LayoutAlgorithm instances.
*/
import type { LayoutToken, LayoutAlgorithm } from "./types";
import { createHorizontalAlgorithm } from "./algorithms/horizontal";
import { createVerticalAlgorithm } from "./algorithms/vertical";
import { createGridAlgorithm } from "./algorithms/grid";
import type { LayoutToken, LayoutAlgorithm } from "./types.js";
import { createHorizontalAlgorithm } from "./algorithms/horizontal.js";
import { createVerticalAlgorithm } from "./algorithms/vertical.js";
import { createGridAlgorithm } from "./algorithms/grid.js";
/**
* Regex to match layout tokens in group titles.
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { createLayoutAlgorithm } from "../../src/layout/algorithm-factory";
import type { LayoutInput, LayoutNode } from "../../src/layout/types";
describe("createLayoutAlgorithm", () => {
it("creates a horizontal algorithm using horizontalGap", () => {
const algorithm = createLayoutAlgorithm("horizontal", {
horizontalGap: 123,
verticalGap: 77,
});
const positions = algorithm.layout({
nodes: [
{ id: "a", width: 10, height: 10 },
{ id: "b", width: 10, height: 10 },
],
edges: [],
}).positions;
expect(algorithm.name).toBe("horizontal");
expect(positions.get("b")?.x).toBe(133);
});
it("creates a vertical algorithm using verticalGap", () => {
const algorithm = createLayoutAlgorithm("vertical", {
horizontalGap: 123,
verticalGap: 77,
});
const positions = algorithm.layout({
nodes: [
{ id: "a", width: 10, height: 10 },
{ id: "b", width: 10, height: 10 },
],
edges: [],
}).positions;
expect(algorithm.name).toBe("vertical");
expect(positions.get("b")?.y).toBe(87);
});
it("creates a sugiyama algorithm using both spacing values", () => {
const algorithm = createLayoutAlgorithm("sugiyama", {
horizontalGap: 123,
verticalGap: 77,
});
const input: LayoutInput = {
nodes: [
{ id: "left-a", width: 10, height: 10 },
{ id: "left-b", width: 10, height: 10 },
{ id: "right", width: 10, height: 10 },
] satisfies LayoutNode[],
edges: [
{ source: "left-a", target: "right" },
{ source: "left-b", target: "right" },
],
};
const positions = algorithm.layout(input).positions;
expect(algorithm.name).toBe("sugiyama");
expect(positions.get("right")?.x).toBe(133);
expect(positions.get("left-b")?.y).toBe(87);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
fromGroupLayoutId,
fromNumericGroupLayoutId,
toGroupLayoutId,
} from "../../src/layout/group-ids";
describe("group layout ids", () => {
it("namespaces string and numeric ids consistently", () => {
expect(toGroupLayoutId("abc")).toBe("group:abc");
expect(toGroupLayoutId(42)).toBe("group:42");
});
it("parses raw ids back out of namespaced ids", () => {
expect(fromGroupLayoutId("group:abc")).toBe("abc");
expect(fromGroupLayoutId("node:abc")).toBeNull();
});
it("parses numeric group ids when they are valid numbers", () => {
expect(fromNumericGroupLayoutId("group:42")).toBe(42);
expect(fromNumericGroupLayoutId("group:abc")).toBeNull();
});
});
+2 -2
View File
@@ -2,8 +2,8 @@
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,