Add Subgraph-friendly UI nodes (Heading/Spacer/Divider) + docs

- Added BV Subgraph Heading with optional divider + dynamic sizing/clipping
- Added BV Spacer (height/scale) and BV Divider nodes
- Added JS rendering/patching for Subgraph compatibility and stable widget mapping
- Updated README with new UI nodes and usage details
- Updated project description in pyproject.toml to mention Subgraph UI nodes
- Minor UI improvements (dynamic redraw/resize on widget changes)
This commit is contained in:
BlackVortexAI
2025-12-21 18:44:21 +01:00
parent 70aaf538f7
commit 0bdeebc439
8 changed files with 548 additions and 5 deletions
+83 -3
View File
@@ -72,6 +72,87 @@ A config-driven carrier node that forwards one pipe connection while exposing na
---
### BV Subgraph Heading
A UI node that renders a bold header text inside the node.
Designed to work well inside **Subgraphs/Subflows**: when exposed, the header is also rendered on the Subgraph node in the main graph.
![BV Subgraph Heading](docs/screenshots/bv_subgraph_heading.png)
![BV Subgraph Heading](docs/screenshots/bv_subgraph_heading_in_subgraph.png)
![BV Subgraph Heading](docs/screenshots/bv_subgraph_heading_subgraph.png)
**Inputs**
- `value` (STRING) — the header text
- `font_size` (INT) — font size used for rendering
- `show_divider` (BOOLEAN) — optional divider line below the heading
**What it does**
- Displays a clean header label for visual separation
- Dynamically adjusts its node height to avoid text overlap/clipping
- Optional divider line for stronger section separation
![BV Subgraph Heading](docs/screenshots/bv_subgraph_heading_divider.png)
**How to use**
1. Add **BV Subgraph Heading**
2. Set the title via `value`
3. (Optional) increase `font_size`
4. (Optional) enable `show_divider`
**Subgraph usage**
- Place it inside a Subgraph/Subflow and expose the widget input to show the same heading on the Subgraph node.
---
### BV Spacer
A UI-only spacer node to add vertical whitespace in your graph or inside Subgraphs/Subflows.
![BV Spacer](docs/screenshots/bv_spacer.png)
![BV Spacer](docs/screenshots/bv_spacer_in_subgraph.png)
![BV Spacer](docs/screenshots/bv_spacer_subgraph.png)
**Inputs**
- `height` (INT) — spacer height in pixels
- `scale` (FLOAT, optional) — multiplier for the height (useful for quick global spacing adjustments)
**What it does**
- Reserves vertical space without rendering text
- Updates its node height dynamically (also in Subgraphs/Subflows)
**How to use**
1. Add **BV Spacer**
2. Adjust `height` to the desired spacing
3. (Optional) use `scale` for fine-tuning
---
### BV Divider
A UI-only divider node that draws a horizontal line to visually separate sections.
![BV Divider](docs/screenshots/bv_divider.png)
![BV Divider](docs/screenshots/bv_divider_in_subgraph.png)
![BV Divider](docs/screenshots/bv_divider_subgrpah.png)
**Inputs**
- `thickness` (INT) — line thickness
- `padding` (INT) — left/right padding
- `alpha` (FLOAT) — line opacity
**What it does**
- Draws a horizontal separator line
- Dynamically updates when inputs change (also in Subgraphs/Subflows)
**How to use**
1. Add **BV Divider**
2. Adjust `thickness`, `padding`, and `alpha` to match your layout
---
## Quick Start (Pipe)
1. Add **BV Pipe Config** and enter names, e.g.:
@@ -93,7 +174,6 @@ prompt
## Notes
- Pipes update dynamically (including Subgraphs/Subflows).
- Slot socket IDs stay stable internally (`v_001…v_100`, `out_001…out_100`) while labels come from your config.
- UI nodes (**Heading / Spacer / Divider**) use JavaScript rendering and support being exposed through Subgraphs/Subflows.
## Known issues
If a pipe config is within a subgraph and this generated pipe is connected to a pipe node in the maingraph or another subgraph, the values are only applied when the config is edited.
---
+126
View File
@@ -0,0 +1,126 @@
import {app} from "../../scripts/app.js";
function getWidget(node, name) {
return (node?.widgets || []).find((w) => w?.name === name) || null;
}
function clamp(n, a, b) {
n = Number(n);
if (!Number.isFinite(n)) n = a;
return Math.max(a, Math.min(b, n));
}
function patchDivider(node) {
const thickW = getWidget(node, "thickness");
const padW = getWidget(node, "padding");
const alphaW = getWidget(node, "alpha");
if (!node || !thickW || thickW.__bvDividerPatched) return;
thickW.__bvDividerPatched = true;
thickW.__bvNode = node;
thickW.__bvPadWidget = padW;
thickW.__bvAlphaWidget = alphaW;
thickW.type = "BV_DIVIDER";
thickW.options = thickW.options || {};
thickW.options.serialize = true;
thickW.draw = function (ctx, _node, width, posY, height) {
const thickness = clamp(thickW.value ?? 2, 1, 10);
const pad = clamp(this.__bvPadWidget?.value ?? 10, 0, 60);
const alpha = clamp(this.__bvAlphaWidget?.value ?? 0.35, 0.05, 1.0);
const y = posY + height * 0.5;
ctx.save();
ctx.globalAlpha = alpha;
ctx.lineWidth = thickness;
ctx.strokeStyle = "#ddd";
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(width - pad, y);
ctx.stroke();
ctx.restore();
};
thickW.computeSize = function (width) {
const thickness = clamp(thickW.value ?? 2, 1, 10);
const padY = 10;
const h = Math.max(18, thickness + padY);
return [Math.max(220, width || 220), h];
};
const hookRedraw = (w) => {
if (!w || w.__bvDividerHooked) return;
w.__bvDividerHooked = true;
const oldCb = w.callback;
w.callback = function () {
const n = thickW.__bvNode || node;
n?.setSize?.(n.computeSize());
n?.setDirtyCanvas(true, true);
n?.graph?.setDirtyCanvas(true, true);
return oldCb?.apply(this, arguments);
};
};
hookRedraw(thickW);
hookRedraw(padW);
hookRedraw(alphaW);
}
function hideAllOutputs(node) {
if (!node?.outputs?.length) return;
node.outputs.length = 0; // removes output slots visually
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
}
app.registerExtension({
name: "bv_nodepack.bv_divider",
getCustomWidgets() {
return {
BV_DIVIDER: () => ({widget: null}),
};
},
async beforeRegisterNodeDef(nodeType, nodeData) {
const comfyClass = nodeType?.ComfyClass || nodeType?.comfyClass;
const nodeName = nodeData?.name;
const isDivider = comfyClass === "BVSubgraphDivider" || nodeName === "BV Subgraph Divider";
if (!isDivider) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
patchDivider(this);
hideAllOutputs(this);
this.setSize?.(this.computeSize());
this.graph?.setDirtyCanvas(true, true);
this.serialize_widgets = true;
this.setDirtyCanvas(true, true);
return r;
};
const oldConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = oldConfigure?.apply(this, arguments);
// patch + hide outputs after graph loads/restores node
patchDivider(this);
hideAllOutputs(this);
this.setSize?.(this.computeSize());
return r;
};
},
});
+155
View File
@@ -0,0 +1,155 @@
import {app} from "../../scripts/app.js";
function getWidget(node, name) {
return (node?.widgets || []).find((w) => w?.name === name) || null;
}
function clamp(n, a, b) {
n = Number(n);
if (!Number.isFinite(n)) n = a;
return Math.max(a, Math.min(b, n));
}
function patchHeading(node) {
const valueW =
(node.widgets || []).find((w) => w?.name === "value") ||
(node.widgets || []).find((w) => w?.config?.[0] === "STRING");
if (!node || !valueW || valueW.__bvHeadingPatched) return;
valueW.__bvHeadingPatched = true;
valueW.__bvNode = node;
valueW.__bvFontSizeWidget = getWidget(node, "font_size");
valueW.__bvShowDividerWidget = getWidget(node, "show_divider");
valueW.type = "BV_HEADING";
valueW.options = valueW.options || {};
valueW.options.serialize = true;
valueW.draw = function (ctx, _node, width, posY, height) {
const text = String(this.value ?? "");
const fontSize = clamp(this.__bvFontSizeWidget?.value ?? 20, 10, 80);
const showDivider = !!this.__bvShowDividerWidget?.value;
const padX = 10;
const padY = 10;
const textH = fontSize * 1.35;
const y = posY + Math.max(0, (height - (textH + (showDivider ? 10 : 0))) * 0.5);
ctx.save();
// Clip only the text area
ctx.beginPath();
ctx.rect(padX, posY + padY, width - padX * 2, textH + padY);
ctx.clip();
ctx.font = `700 ${fontSize}px Inter, system-ui, sans-serif`;
ctx.fillStyle = "#ddd";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText(text, padX, y + padY);
ctx.restore();
if (showDivider) {
const lineY = posY + textH + 5;
ctx.save();
ctx.globalAlpha = 0.35;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padX, lineY);
ctx.lineTo(width - padX, lineY);
ctx.strokeStyle = "#ddd";
ctx.stroke();
ctx.restore();
}
};
valueW.computeSize = function (width) {
const fontSize = clamp(this.__bvFontSizeWidget?.value ?? 20, 10, 80);
const showDivider = !!this.__bvShowDividerWidget?.value;
const padY = 10;
const textH = Math.ceil(fontSize * 1.35);
const dividerExtra = showDivider ? 12 : 0;
const h = Math.max(34, textH + padY + dividerExtra);
return [Math.max(220, width || 220), h];
};
// Keep widget->input conversion compatibility
valueW.config = valueW.config || ["STRING", {default: valueW.value ?? "", multiline: false}];
const hookRedraw = (w) => {
if (!w || w.__bvHeadingHooked) return;
w.__bvHeadingHooked = true;
const oldCb = w.callback;
w.callback = function () {
const n = valueW.__bvNode || node;
n?.setSize?.(n.computeSize());
n?.setDirtyCanvas(true, true);
n?.graph?.setDirtyCanvas(true, true);
return oldCb?.apply(this, arguments);
};
};
hookRedraw(valueW.__bvFontSizeWidget);
hookRedraw(valueW.__bvShowDividerWidget);
}
function hideAllOutputs(node) {
if (!node?.outputs?.length) return;
node.outputs.length = 0; // removes output slots visually
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
}
app.registerExtension({
name: "bv_nodepack.bv_subgraph_heading",
getCustomWidgets() {
return {
BV_HEADING: () => ({widget: null}),
};
},
async beforeRegisterNodeDef(nodeType, nodeData) {
const comfyClass = nodeType?.ComfyClass || nodeType?.comfyClass;
const nodeName = nodeData?.name;
const isHeading =
comfyClass === "BVSubgraphHeading" || nodeName === "BV Subgraph Heading";
if (!isHeading) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
patchHeading(this);
hideAllOutputs(this);
this.setSize?.(this.computeSize());
this.graph?.setDirtyCanvas(true, true);
this.serialize_widgets = true;
this.setDirtyCanvas(true, true);
return r;
};
const oldConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = oldConfigure?.apply(this, arguments);
// patch + hide outputs after graph loads/restores node
patchHeading(this);
hideAllOutputs(this);
this.setSize?.(this.computeSize());
return r;
};
},
});
+109
View File
@@ -0,0 +1,109 @@
import {app} from "../../scripts/app.js";
function getWidget(node, name) {
return (node?.widgets || []).find((w) => w?.name === name) || null;
}
function clamp(n, a, b) {
n = Number(n);
if (!Number.isFinite(n)) n = a;
return Math.max(a, Math.min(b, n));
}
function patchSpacer(node) {
const hW = getWidget(node, "height");
if (!node || !hW || hW.__bvSpacerPatched) return;
hW.__bvSpacerPatched = true;
hW.__bvNode = node;
// NEW: cache scale widget
hW.__bvScaleWidget = getWidget(node, "scale");
hW.type = "BV_SPACER";
hW.options = hW.options || {};
hW.options.serialize = true;
hW.draw = function () {
};
hW.computeSize = function (width) {
const base = clamp(hW.value ?? 24, 0, 400);
const scale = clamp(this.__bvScaleWidget?.value ?? 1.0, 0.0, 5.0);
const h = Math.max(8, Math.round(base * scale));
return [Math.max(220, width || 220), h];
};
// NEW: hookRedraw helper + hook both widgets
const hookRedraw = (w) => {
if (!w || w.__bvSpacerHooked) return;
w.__bvSpacerHooked = true;
const oldCb = w.callback;
w.callback = function () {
const n = hW.__bvNode || node;
n?.setSize?.(n.computeSize());
n?.setDirtyCanvas(true, true);
n?.graph?.setDirtyCanvas(true, true);
return oldCb?.apply(this, arguments);
};
};
hookRedraw(hW);
hookRedraw(hW.__bvScaleWidget);
}
function hideAllOutputs(node) {
if (!node?.outputs?.length) return;
node.outputs.length = 0; // removes output slots visually
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
}
app.registerExtension({
name: "bv_nodepack.bv_spacer",
getCustomWidgets() {
return {
BV_SPACER: () => ({widget: null}),
};
},
async beforeRegisterNodeDef(nodeType, nodeData) {
const comfyClass = nodeType?.ComfyClass || nodeType?.comfyClass;
const nodeName = nodeData?.name;
const isSpacer = comfyClass === "BVSubgraphSpacer" || nodeName === "BV Subgraph Spacer";
if (!isSpacer) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
patchSpacer(this);
hideAllOutputs(this);
this.setSize?.(this.computeSize());
this.graph?.setDirtyCanvas(true, true);
this.serialize_widgets = true;
this.setDirtyCanvas(true, true);
return r;
};
const oldConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = oldConfigure?.apply(this, arguments);
// patch + hide outputs after graph loads/restores node
patchSpacer(this);
hideAllOutputs(this);
this.setSize?.(this.computeSize());
return r;
};
},
});
+23
View File
@@ -0,0 +1,23 @@
class BVSubgraphDivider:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"thickness": ("INT", {"default": 2, "min": 1, "max": 10, "step": 1}),
"padding": ("INT", {"default": 10, "min": 0, "max": 60, "step": 1}),
"alpha": ("FLOAT", {"default": 0.35, "min": 0.05, "max": 1.0, "step": 0.05}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("_",)
FUNCTION = "run"
CATEGORY = "🌀 BV Node Pack/subgraph"
def run(self, thickness, padding, alpha):
return ("",)
NODE_CLASS_MAPPINGS = {"BV Subgraph Divider": BVSubgraphDivider}
NODE_DISPLAY_NAME_MAPPINGS = {"BV Subgraph Divider": "🌀 BV Subgraph Divider"}
+28
View File
@@ -0,0 +1,28 @@
# py/nodes/bv_subgraph_heading.py
from __future__ import annotations
class BVSubgraphHeading:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# This is the value you edit
"value": ("STRING", {"default": "Heading"}),
"font_size": ("INT", {"default": 20, "min": 10, "max": 60, "step": 1}),
"show_divider": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("_",)
FUNCTION = "run"
CATEGORY = "🌀 BV Node Pack/subgraph"
def run(self, value):
return (value,)
NODE_CLASS_MAPPINGS = {"BV Subgraph Heading": BVSubgraphHeading}
NODE_DISPLAY_NAME_MAPPINGS = {"BV Subgraph Heading": "🌀 BV Subgraph Heading"}
+22
View File
@@ -0,0 +1,22 @@
class BVSubgraphSpacer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"height": ("INT", {"default": 24, "min": 0, "max": 400, "step": 1}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("_",)
FUNCTION = "run"
CATEGORY = "🌀 BV Node Pack/subgraph"
def run(self, height):
return (height,)
NODE_CLASS_MAPPINGS = {"BV Subgraph Spacer": BVSubgraphSpacer}
NODE_DISPLAY_NAME_MAPPINGS = {"BV Subgraph Spacer": "🌀 BV Subgraph Spacer"}
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "bv_nodepack"
version = "0.0.5"
description = "A ComfyUI node pack providing a growing set of utility and workflow nodes, including a flexible config-driven Pipe system."
version = "0.0.6"
description = "A ComfyUI node pack with utility and workflow nodes: a flexible config-driven Pipe system plus Subgraph-friendly UI nodes (Heading, Spacer, Divider) with dynamic JavaScript rendering."
authors = [
{name = "BlackVortex", email = "blackvortexx@proton.me"}
]