Files
ComfyAssets-ComfyUI-KikoTools/web/xyz_plot_controller.js
T
Vito Sansevero f484482e2c feat: complete XYZ Plot Controller widget functionality
- Remove unwanted input connection from node
- Fix FlexibleOptionalInputType to not create input slot
- Add callbacks to update image count when any input changes
- Support text widget changes for ranges (e.g., 10:50:5)
- Update count when dropdown selections change
- Update count when widgets are toggled on/off
- Properly calculate total images from all axis combinations
- Verified outputs with Display Any nodes showing correct grid data
2025-08-05 13:59:52 -07:00

1061 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { api } from "../../scripts/api.js";
// Counter for unique widget names
let widgetCounter = 0;
// Helper text for different parameter types
const PARAM_HELP = {
cfg_scale: "Enter values separated by commas: 5, 7.5, 10\nOr use range: 5:15:2.5",
steps: "Enter values separated by commas: 20, 30, 40\nOr use range: 10:50:10",
seed: "Enter values separated by commas: 42, 123, 456\nOr use range: 0:1000:100",
denoise: "Enter values separated by commas: 0.3, 0.5, 0.7\nOr use range: 0.2:1.0:0.2",
clip_skip: "Enter values separated by commas: 1, 2\nCommon values for SDXL",
prompt: "Enter prompts separated by new lines:\nbeautiful sunset\nmystical forest\nfuturistic city"
};
// Helper function to check if we're in low quality mode
function isLowQuality() {
const canvas = app.canvas;
return ((canvas.ds?.scale) || 1) <= 0.5;
}
// Helper function to fit string with ellipsis
function fitString(ctx, str, maxWidth) {
let width = ctx.measureText(str).width;
const ellipsis = "…";
const ellipsisWidth = ctx.measureText(ellipsis).width;
if (width <= maxWidth || width <= ellipsisWidth) {
return str;
}
// Binary search for the right length
let min = 0;
let max = str.length;
while (min <= max) {
let guess = Math.floor((min + max) / 2);
width = ctx.measureText(str.substring(0, guess)).width;
if (width + ellipsisWidth === maxWidth) {
return str.substring(0, guess) + ellipsis;
}
if (width + ellipsisWidth < maxWidth) {
min = guess + 1;
} else {
max = guess - 1;
}
}
return str.substring(0, max) + ellipsis;
}
// Optimized toggle drawing function
function drawTogglePart(ctx, options) {
const lowQuality = isLowQuality();
ctx.save();
const { posX, posY, height, value } = options;
const toggleRadius = height * 0.36;
const toggleBgWidth = height * 1.5;
if (!lowQuality) {
ctx.beginPath();
ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fill();
ctx.globalAlpha = app.canvas.editor_alpha;
}
ctx.fillStyle = value ? "#89B" : "#888";
const toggleX = lowQuality || !value
? posX + height * 0.5
: posX + height;
ctx.beginPath();
ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return [posX, toggleBgWidth];
}
// Draw number widget with arrows
function drawNumberWidgetPart(ctx, options) {
const arrowWidth = 9;
const arrowHeight = 10;
const innerMargin = 3;
const numberWidth = 32;
const xBoundsArrowLess = [0, 0];
const xBoundsNumber = [0, 0];
const xBoundsArrowMore = [0, 0];
ctx.save();
let posX = options.posX;
const { posY, height, value, textColor } = options;
const midY = posY + height / 2;
if (options.direction === -1) {
posX = posX - arrowWidth - innerMargin - numberWidth - innerMargin - arrowWidth;
}
// Draw left arrow (decrease)
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fill(new Path2D(`M ${posX} ${midY} l ${arrowWidth} ${arrowHeight / 2} l 0 -${arrowHeight} L ${posX} ${midY} z`));
xBoundsArrowLess[0] = posX;
xBoundsArrowLess[1] = arrowWidth;
posX += arrowWidth + innerMargin;
// Draw number
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const oldTextcolor = ctx.fillStyle;
if (textColor) {
ctx.fillStyle = textColor;
}
ctx.fillText(fitString(ctx, value.toFixed(2), numberWidth), posX + numberWidth / 2, midY);
ctx.fillStyle = oldTextcolor;
xBoundsNumber[0] = posX;
xBoundsNumber[1] = numberWidth;
posX += numberWidth + innerMargin;
// Draw right arrow (increase)
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fill(new Path2D(`M ${posX} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${arrowHeight / 2} v -${arrowHeight} z`));
xBoundsArrowMore[0] = posX;
xBoundsArrowMore[1] = arrowWidth;
ctx.restore();
return [xBoundsArrowLess, xBoundsNumber, xBoundsArrowMore];
}
drawNumberWidgetPart.WIDTH_TOTAL = 9 + 3 + 32 + 3 + 9;
// Base widget class for XYZ selections
class XYZBaseWidget {
constructor(name) {
this.name = name;
this.type = "custom";
this.last_y = 0;
this.y = 0;
this.hitAreas = {};
}
computeSize(width) {
return [width || 300, LiteGraph.NODE_WIDGET_HEIGHT];
}
// Base draw method
draw(ctx, node, width, posY, height) {
// To be overridden
}
// Base serialization
serializeValue(node, index) {
// Make sure we return a full copy of the value
return this.value ? {...this.value} : null;
}
mouse(event, pos, node) {
if (event.type === "pointerdown") {
const localX = pos[0];
// Check all hit areas
for (const [key, area] of Object.entries(this.hitAreas)) {
if (area.bounds && area.bounds[0] !== undefined && area.bounds[1] !== undefined) {
if (localX >= area.bounds[0] && localX <= area.bounds[0] + area.bounds[1]) {
if (area.onDown) {
return area.onDown.call(this, event, pos, node);
}
}
}
}
}
return false;
}
}
// Widget for displaying selected items with toggle
class XYZSelectionWidget extends XYZBaseWidget {
constructor(name) {
super(name);
this.value = {
on: true,
value: null,
strength: 1.0,
_axis: null,
_type: null
};
this.hitAreas = {
toggle: { bounds: [0, 0], onDown: this.onToggleDown },
value: { bounds: [0, 0], onDown: this.onValueClick },
strengthDec: { bounds: [0, 0], onDown: this.onStrengthDec },
strengthVal: { bounds: [0, 0], onDown: this.onStrengthVal },
strengthInc: { bounds: [0, 0], onDown: this.onStrengthInc }
};
this.lastClickTime = 0;
}
draw(ctx, node, width, posY) {
const margin = 10;
const innerMargin = 3;
const height = LiteGraph.NODE_WIDGET_HEIGHT;
const midY = posY + height / 2;
const lowQuality = isLowQuality();
let posX = margin;
ctx.save();
// Background - skip complex drawing in low quality
if (!lowQuality) {
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.beginPath();
ctx.roundRect(posX, posY + 2, width - margin * 2, height - 4, [height * 0.5]);
ctx.fill();
}
// Draw toggle
const toggleResult = drawTogglePart(ctx, { posX, posY, height, value: this.value.on });
this.hitAreas.toggle.bounds = toggleResult;
posX += toggleResult[1] + innerMargin;
if (lowQuality) {
ctx.restore();
return;
}
// Apply opacity if disabled
if (!this.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
// Calculate space for strength controls if lora
let maxTextWidth = width - posX - margin;
if (this.value._type === "loras") {
maxTextWidth -= drawNumberWidgetPart.WIDTH_TOTAL + innerMargin * 2;
}
// Draw value text
const valueText = String(this.value.value || "None");
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
const displayText = fitString(ctx, valueText, maxTextWidth);
ctx.fillText(displayText, posX, midY);
this.hitAreas.value.bounds = [posX, ctx.measureText(displayText).width];
// Draw strength controls for loras
if (this.value._type === "loras" && this.value.value && this.value.value !== "None") {
const [leftArrow, numberBounds, rightArrow] = drawNumberWidgetPart(ctx, {
posX: width - margin - innerMargin,
posY: posY,
height: height,
value: this.value.strength || 1.0,
direction: -1
});
this.hitAreas.strengthDec.bounds = leftArrow;
this.hitAreas.strengthVal.bounds = numberBounds;
this.hitAreas.strengthInc.bounds = rightArrow;
} else {
// Clear strength hit areas
this.hitAreas.strengthDec.bounds = [0, 0];
this.hitAreas.strengthVal.bounds = [0, 0];
this.hitAreas.strengthInc.bounds = [0, 0];
}
ctx.restore();
}
onToggleDown(event, pos, node) {
this.value.on = !this.value.on;
node.setDirtyCanvas(true, true);
updateNodeTitle(node);
return true;
}
async onValueClick(event, pos, node) {
const type = this.value._type;
const axis = this.value._axis;
// Get options based on type
const options = await getOptionsForType(type);
if (options && options.length > 0) {
new LiteGraph.ContextMenu(
options,
{
event: event,
title: `Choose ${type}`,
callback: (value) => {
this.value.value = value;
node.setDirtyCanvas(true, true);
updateNodeTitle(node);
}
}
);
}
return true;
}
onStrengthDec(event, pos, node) {
if (this.value.strength > 0) {
this.value.strength = Math.max(0, this.value.strength - 0.1);
node.setDirtyCanvas(true, true);
}
return true;
}
onStrengthInc(event, pos, node) {
if (this.value.strength < 2) {
this.value.strength = Math.min(2, this.value.strength + 0.1);
node.setDirtyCanvas(true, true);
}
return true;
}
onStrengthVal(event, pos, node) {
// Check for double-click
const currentTime = Date.now();
if (this.lastClickTime && currentTime - this.lastClickTime < 300) {
// Double-click detected - use LiteGraph's prompt for float input
const currentValue = this.value.strength || 1.0;
app.canvas.prompt("Strength", currentValue.toFixed(2), (value) => {
if (value !== null && value !== undefined) {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
this.value.strength = Math.max(0, Math.min(2, parsed));
node.setDirtyCanvas(true, true);
}
}
}, event);
}
this.lastClickTime = currentTime;
return true;
}
}
// Button widget for adding new selections
class XYZAddButton extends XYZBaseWidget {
constructor(name, type, axis) {
super(name);
this.type = "xyz_button"; // Use custom type to avoid conflicts
this._type = type;
this._axis = axis;
this.value = null;
this.serialize = false; // Prevent serialization
}
serializeValue(node, index) {
// Don't serialize button widgets - return a special marker
return null;
}
draw(ctx, node, width, posY) {
const margin = 10;
const height = LiteGraph.NODE_WIDGET_HEIGHT;
const lowQuality = isLowQuality();
ctx.save();
// Button background
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.strokeStyle = lowQuality ? "transparent" : LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.beginPath();
ctx.roundRect(margin, posY, width - margin * 2, height, lowQuality ? [0] : [4]);
ctx.fill();
if (!lowQuality) {
ctx.stroke();
}
// Button text
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const label = this.name || `➕ Add ${this._type.charAt(0).toUpperCase() + this._type.slice(1, -1)}`;
ctx.fillText(label, width / 2, posY + height / 2);
ctx.restore();
}
mouse(event, pos, node) {
if (event.type === "pointerdown") {
this.onButtonClick(event, pos, node);
return true;
}
return false;
}
async onButtonClick(event, pos, node) {
const options = await getOptionsForType(this._type);
if (!options || options.length === 0) {
alert(`No ${this._type} found`);
return;
}
new LiteGraph.ContextMenu(
options,
{
event: event,
title: `Choose ${this._type}`,
callback: (value) => {
if (value !== "None" && value !== "Automatic") {
// Add new selection widget
const widget = new XYZSelectionWidget(`${this._axis}_${this._type}_${widgetCounter++}`);
// Ensure we properly copy the existing value structure
widget.value = {
on: true,
value: value,
strength: 1.0,
_axis: this._axis,
_type: this._type
};
node.addCustomWidget(widget);
// Store in dynamic widgets
if (!node.dynamicWidgets[this._axis]) {
node.dynamicWidgets[this._axis] = [];
}
node.dynamicWidgets[this._axis].push(widget);
// Move button to end
const buttonIndex = node.widgets.indexOf(this);
if (buttonIndex > -1) {
node.widgets.splice(buttonIndex, 1);
node.widgets.push(this);
}
updateNodeTitle(node);
}
}
}
);
}
}
// Cache for options
const optionsCache = {};
// Get options based on type
async function getOptionsForType(type) {
if (optionsCache[type]) {
return optionsCache[type];
}
let options = [];
if (type === "models") {
options = await getCheckpoints();
} else if (type === "vaes") {
options = await getVAEs();
} else if (type === "loras") {
options = await getLoRAs();
} else if (type === "samplers") {
options = await getSamplers();
} else if (type === "schedulers") {
options = await getSchedulers();
}
optionsCache[type] = options;
return options;
}
app.registerExtension({
name: "ComfyAssets.XYZPlotController",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "XYZPlotController") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const node = this;
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Enable widget serialization
this.serialize_widgets = true;
// Remove the onSerialize override - let ComfyUI handle it with proper serializeValue methods
// Initialize storage
if (!node.dynamicWidgets) {
node.dynamicWidgets = {
x: [],
y: [],
z: []
};
}
if (!node.addButtons) {
node.addButtons = {};
}
if (!node.textWidgets) {
node.textWidgets = {};
}
// Setup axis type callbacks
["x_type", "y_type", "z_type"].forEach(widgetName => {
const widget = node.widgets.find(w => w.name === widgetName);
if (widget) {
const originalCallback = widget.callback;
widget.callback = function(value) {
if (originalCallback) originalCallback.call(this, value);
// Defer widget update to next tick to ensure node is ready
setTimeout(() => {
updateAxisWidgets(node, widgetName.split("_")[0], value);
updateNodeTitle(node);
}, 0);
};
}
});
// Pre-fetch common options and set initial title
setTimeout(() => {
getSamplers();
getSchedulers();
updateNodeTitle(node);
}, 100);
// Override configuration for proper restoration
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
// Remove only custom widgets, keep base widgets
const baseWidgetNames = ['x_type', 'y_type', 'z_type', 'auto_queue'];
const toRemove = [];
for (const w of this.widgets || []) {
if (!baseWidgetNames.includes(w.name)) {
toRemove.push(w);
}
}
for (const w of toRemove) {
const idx = this.widgets.indexOf(w);
if (idx !== -1) {
this.widgets.splice(idx, 1);
}
}
// Reset storage
this.dynamicWidgets = { x: [], y: [], z: [] };
this.addButtons = {};
this.textWidgets = {};
// Call parent configure to restore base widget values
if (onConfigure) {
onConfigure.call(this, info);
}
// Now restore dynamic widgets
if (info.widgets_values) {
// Count base widgets to know where dynamic values start
let baseWidgetCount = 0;
for (const w of this.widgets) {
if (baseWidgetNames.includes(w.name)) {
baseWidgetCount++;
}
}
// Group widgets by axis
const widgetsByAxis = { x: [], y: [], z: [] };
// Collect all widgets first
for (let i = baseWidgetCount; i < info.widgets_values.length; i++) {
const value = info.widgets_values[i];
if (value && typeof value === 'object' && value._axis && value._type) {
widgetsByAxis[value._axis].push(value);
}
}
// Now restore widgets grouped by axis, with buttons after each group
for (const axis of ['x', 'y', 'z']) {
const typeWidget = this.widgets.find(w => w.name === `${axis}_type`);
const axisType = typeWidget ? typeWidget.value : 'none';
if (axisType !== 'none' && needsDropdownWidget(axisType)) {
const axisWidgets = widgetsByAxis[axis];
// Restore all widgets for this axis
for (const widgetData of axisWidgets) {
const widget = new XYZSelectionWidget(`${axis}_${widgetData._type}_${widgetCounter++}`);
widget.value = { ...widgetData };
this.addCustomWidget(widget);
if (!this.dynamicWidgets[axis]) {
this.dynamicWidgets[axis] = [];
}
this.dynamicWidgets[axis].push(widget);
}
// Add button for this axis
if (axisWidgets.length > 0 || axisType) {
const buttonLabel = `➕ Add ${axisType.charAt(0).toUpperCase() + axisType.slice(1, -1)}`;
const button = new XYZAddButton(buttonLabel, axisType, axis);
this.addCustomWidget(button);
this.addButtons[axis] = button;
}
}
}
}
updateNodeTitle(this);
};
// Override getSlotInPosition to return widget as a fake slot
const originalGetSlotInPosition = node.getSlotInPosition;
node.getSlotInPosition = function(canvasX, canvasY) {
// First check the normal slots
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, canvasX, canvasY) : null;
if (slot) {
return slot;
}
// Check if we clicked on a widget
let lastWidget = null;
for (const widget of this.widgets) {
if (!widget.last_y) continue;
if (canvasY > this.pos[1] + widget.last_y) {
lastWidget = widget;
continue;
}
break;
}
// If we found a widget and it's one of ours, return a fake slot
if (lastWidget instanceof XYZSelectionWidget) {
return { widget: lastWidget, output: { type: "XYZ_WIDGET" } };
}
return null;
};
// Override getSlotMenuOptions for right-click menu
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
node.getSlotMenuOptions = function(slot) {
// Check if slot has a widget and it's one of ours
if (slot?.widget instanceof XYZSelectionWidget) {
const widget = slot.widget;
const axis = widget.value._axis;
const array = this.dynamicWidgets[axis];
const currentIndex = array.indexOf(widget);
return [
{
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
callback: () => {
widget.value.on = !widget.value.on;
this.setDirtyCanvas(true, true);
updateNodeTitle(this);
}
},
{
content: `⬆️ Move Up`,
disabled: currentIndex === 0,
callback: () => {
if (currentIndex > 0) {
[array[currentIndex - 1], array[currentIndex]] =
[array[currentIndex], array[currentIndex - 1]];
const widgetIndex = this.widgets.indexOf(widget);
const prevWidget = array[currentIndex];
const prevIndex = this.widgets.indexOf(prevWidget);
if (widgetIndex > -1 && prevIndex > -1) {
[this.widgets[prevIndex], this.widgets[widgetIndex]] =
[this.widgets[widgetIndex], this.widgets[prevIndex]];
}
this.setDirtyCanvas(true, true);
}
}
},
{
content: `⬇️ Move Down`,
disabled: currentIndex === array.length - 1,
callback: () => {
if (currentIndex < array.length - 1) {
[array[currentIndex], array[currentIndex + 1]] =
[array[currentIndex + 1], array[currentIndex]];
const widgetIndex = this.widgets.indexOf(widget);
const nextWidget = array[currentIndex];
const nextIndex = this.widgets.indexOf(nextWidget);
if (widgetIndex > -1 && nextIndex > -1) {
[this.widgets[widgetIndex], this.widgets[nextIndex]] =
[this.widgets[nextIndex], this.widgets[widgetIndex]];
}
this.setDirtyCanvas(true, true);
}
}
},
null, // Separator
{
content: `🗑️ Remove`,
callback: () => {
const index = array.indexOf(widget);
if (index > -1) {
array.splice(index, 1);
}
const wIndex = this.widgets.indexOf(widget);
if (wIndex > -1) {
this.widgets.splice(wIndex, 1);
}
this.setDirtyCanvas(true, true);
updateNodeTitle(this);
}
}
];
}
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
};
};
}
}
});
// Helper function to update widgets based on axis type
function updateAxisWidgets(node, axis, type, skipClear = false) {
console.log(`updateAxisWidgets called: axis=${axis}, type=${type}, skipClear=${skipClear}`);
console.log("Current widgets:", node.widgets.map(w => ({ name: w.name, type: w.type })));
if (!skipClear) {
// Remove ALL widgets for this axis - be very thorough
const widgetsToRemove = [];
// Find all widgets that belong to this axis
for (const widget of node.widgets) {
// Skip base widgets
if (['x_type', 'y_type', 'z_type', 'auto_queue'].includes(widget.name)) {
continue;
}
// Log widget details for debugging
console.log(`Checking widget: name="${widget.name}", type="${widget.type}", axis="${axis}"`);
// Check if it's a dynamic widget for this axis
if (node.dynamicWidgets[axis] && node.dynamicWidgets[axis].includes(widget)) {
console.log(" -> Found as dynamic widget");
widgetsToRemove.push(widget);
}
// Check if it's the add button for this axis
if (node.addButtons[axis] === widget) {
console.log(" -> Found as add button");
widgetsToRemove.push(widget);
}
// Check if it's a text widget for this axis
if (node.textWidgets[axis] === widget) {
console.log(" -> Found as text widget");
widgetsToRemove.push(widget);
}
// Check by name pattern
const axisPatterns = [
`${axis}_prompt`, `${axis}_cfg_scale`, `${axis}_steps`,
`${axis}_seed`, `${axis}_denoise`, `${axis}_clip_skip`
];
if (widget.name && axisPatterns.includes(widget.name)) {
console.log(" -> Found by name pattern");
widgetsToRemove.push(widget);
}
// Check if widget name starts with axis
if (widget.name && widget.name.startsWith(`${axis}_`)) {
console.log(" -> Found by axis prefix");
widgetsToRemove.push(widget);
}
// Special check for text widgets that might not have proper names
if ((widget.type === 'text' || widget.type === 'customtext') && widget.inputEl &&
widget.inputEl.placeholder && widget.inputEl.placeholder.includes("prompt")) {
console.log(" -> Found prompt widget by placeholder text");
widgetsToRemove.push(widget);
}
}
// Remove all found widgets (remove duplicates first)
const uniqueWidgets = [...new Set(widgetsToRemove)];
console.log(`Removing ${uniqueWidgets.length} widgets for axis ${axis}:`, uniqueWidgets.map(w => w.name));
// Remove in reverse order to avoid index issues
for (let i = node.widgets.length - 1; i >= 0; i--) {
const widget = node.widgets[i];
if (uniqueWidgets.includes(widget)) {
// Remove DOM element if it exists
if (widget.inputEl && widget.inputEl.parentNode) {
widget.inputEl.parentNode.removeChild(widget.inputEl);
}
// Call onRemoved callback if it exists
if (widget.onRemoved) {
widget.onRemoved();
}
// Remove from widgets array
node.widgets.splice(i, 1);
}
}
// Clear references
node.dynamicWidgets[axis] = [];
node.addButtons[axis] = null;
node.textWidgets[axis] = null;
// Force canvas redraw and recalculate size
node.setDirtyCanvas(true, true);
const size = node.computeSize();
node.size[1] = size[1];
}
// If type is "none", don't add any widgets
if (type === "none") {
return;
}
// Add widgets based on type
if (needsTextWidget(type)) {
const widgetName = `${axis}_${type}`;
// Check if widget already exists
const existingWidget = node.widgets.find(w => w.name === widgetName);
if (!existingWidget) {
console.log(`Creating text widget: ${widgetName}`);
console.log("Widgets before creation:", node.widgets.length);
// Create text widget with proper configuration
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
default: "",
multiline: true, // Always multiline for better display of hints
dynamicPrompts: false
}], app);
console.log("Widgets after creation:", node.widgets.length);
console.log("All widget names:", node.widgets.map(w => w.name));
// The widget returned by ComfyWidgets.STRING is an object with a 'widget' property
const addedWidget = textWidget.widget;
if (addedWidget) {
node.textWidgets[axis] = addedWidget;
console.log(`Text widget created and stored: ${widgetName}`);
// Set placeholder and styling after widget is created
if (addedWidget.inputEl && PARAM_HELP[type]) {
addedWidget.inputEl.placeholder = PARAM_HELP[type];
// Make the placeholder more visible
addedWidget.inputEl.style.fontFamily = "monospace";
if (type !== "prompt") {
// For non-prompt fields, adjust the height
addedWidget.inputEl.style.minHeight = "60px";
}
}
// Add callback to update node title when text changes
const originalCallback = addedWidget.callback;
addedWidget.callback = function(value) {
if (originalCallback) originalCallback.call(this, value);
updateNodeTitle(node);
};
}
// Resize node to fit the new widget
node.setDirtyCanvas(true, true);
const size = node.computeSize();
node.size[1] = size[1] + 30; // Add padding to prevent overflow
} else {
console.log(`Text widget already exists: ${widgetName}`);
node.textWidgets[axis] = existingWidget;
// Ensure existing widget has placeholder set
if (existingWidget.inputEl && PARAM_HELP[type]) {
existingWidget.inputEl.placeholder = PARAM_HELP[type];
existingWidget.inputEl.style.fontFamily = "monospace";
if (type !== "prompt") {
existingWidget.inputEl.style.minHeight = "60px";
}
}
// Add callback to update node title when text changes
const originalCallback = existingWidget.callback;
existingWidget.callback = function(value) {
if (originalCallback) originalCallback.call(this, value);
updateNodeTitle(node);
};
}
} else if (needsDropdownWidget(type)) {
// Only add button if not already present
if (!node.addButtons[axis]) {
const buttonLabel = `➕ Add ${type.charAt(0).toUpperCase() + type.slice(1, -1)}`;
const button = new XYZAddButton(buttonLabel, type, axis);
// Make sure widget is added at the end
const index = node.widgets.length;
node.addCustomWidget(button);
// Move to proper position if needed
if (node.widgets[index] !== button) {
const actualIndex = node.widgets.indexOf(button);
if (actualIndex > -1) {
node.widgets.splice(actualIndex, 1);
node.widgets.push(button);
}
}
node.addButtons[axis] = button;
// Resize node to fit the new button
node.setDirtyCanvas(true, true);
const size = node.computeSize();
node.size[1] = Math.max(node.size[1], size[1]);
}
}
}
// Helper function to check if type needs text widget
function needsTextWidget(type) {
return ["cfg_scale", "steps", "seed", "denoise", "clip_skip", "prompt"].includes(type);
}
// Helper function to check if type needs dropdown widgets
function needsDropdownWidget(type) {
return ["models", "vaes", "loras", "samplers", "schedulers"].includes(type);
}
// Helper function to update node title with count
function updateNodeTitle(node) {
let xCount = 1, yCount = 1, zCount = 1;
// Count values for each axis
for (const axis of ['x', 'y', 'z']) {
const typeWidget = node.widgets.find(w => w.name === `${axis}_type`);
if (typeWidget && typeWidget.value !== "none") {
const type = typeWidget.value;
if (needsTextWidget(type)) {
const textWidget = node.textWidgets[axis];
if (textWidget && textWidget.value) {
const values = parseAxisValues(textWidget.value, type);
if (axis === 'x') xCount = values.length;
else if (axis === 'y') yCount = values.length;
else if (axis === 'z') zCount = values.length;
}
} else if (needsDropdownWidget(type)) {
const enabledWidgets = (node.dynamicWidgets[axis] || []).filter(w => w.value.on);
if (axis === 'x') xCount = Math.max(1, enabledWidgets.length);
else if (axis === 'y') yCount = Math.max(1, enabledWidgets.length);
else if (axis === 'z') zCount = Math.max(1, enabledWidgets.length);
}
}
}
const total = xCount * yCount * zCount;
node.title = `XYZ Plot Controller (${total} images)`;
}
// Helper function to parse axis values
function parseAxisValues(text, type) {
if (!text) return [];
if (type === "prompt") {
return text.split('\n').filter(line => line.trim());
} else {
// Handle comma-separated and range syntax
const values = [];
const parts = text.split(',').map(s => s.trim());
for (const part of parts) {
if (part.includes(':')) {
// Range syntax: start:end:step
const [start, end, step] = part.split(':').map(s => parseFloat(s));
if (!isNaN(start) && !isNaN(end) && !isNaN(step) && step > 0) {
for (let v = start; v <= end; v += step) {
values.push(v);
}
}
} else {
const val = parseFloat(part);
if (!isNaN(val)) {
values.push(val);
}
}
}
return values;
}
}
// API helper functions
async function getCheckpoints() {
try {
const nodeData = await api.getNodeDefs();
if (nodeData.CheckpointLoaderSimple &&
nodeData.CheckpointLoaderSimple.input.required.ckpt_name) {
return nodeData.CheckpointLoaderSimple.input.required.ckpt_name[0];
}
} catch (e) {
console.error("Error getting checkpoints:", e);
}
return ["None"];
}
async function getVAEs() {
try {
const nodeData = await api.getNodeDefs();
if (nodeData.VAELoader &&
nodeData.VAELoader.input.required.vae_name) {
return ["Automatic", ...nodeData.VAELoader.input.required.vae_name[0]];
}
} catch (e) {
console.error("Error getting VAEs:", e);
}
return ["Automatic"];
}
async function getLoRAs() {
try {
const nodeData = await api.getNodeDefs();
if (nodeData.LoraLoader &&
nodeData.LoraLoader.input.required.lora_name) {
return ["None", ...nodeData.LoraLoader.input.required.lora_name[0]];
}
} catch (e) {
console.error("Error getting LoRAs:", e);
}
return ["None"];
}
async function getSamplers() {
try {
const nodeData = await api.getNodeDefs();
if (nodeData.KSampler && nodeData.KSampler.input.required.sampler_name) {
return nodeData.KSampler.input.required.sampler_name[0];
}
} catch {}
return ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral", "lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral", "dpmpp_sde", "dpmpp_2m", "dpmpp_3m_sde"];
}
async function getSchedulers() {
try {
const nodeData = await api.getNodeDefs();
if (nodeData.KSampler && nodeData.KSampler.input.required.scheduler) {
return nodeData.KSampler.input.required.scheduler[0];
}
} catch {}
return ["normal", "karras", "exponential", "simple", "ddim_uniform"];
}