- 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
1061 lines
41 KiB
JavaScript
1061 lines
41 KiB
JavaScript
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"];
|
||
} |