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:
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**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
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**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.
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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"}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user