Files
ComfyAssets-ComfyUI-KikoTools/web/xyz_plot_controller.js
T
Vito Sansevero 40bfccc952 feat: implement XYZ Plot Controller with RGThree-style widget framework
- Created XYZ Plot Controller node with dynamic widget management
- Implemented full widget persistence across page refreshes
- Added RGThree-style UI with toggles and strength controls
- Fixed text widget serialization issues
- Implemented hide/show pattern for widget management
- Added comprehensive right-click context menus
- Created detailed documentation of the widget framework
- Removed all debug console.log statements for production
2025-08-04 19:17:54 -07:00

1155 lines
50 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";
// 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"
};
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;
// Track widget visibility
this.hiddenWidgets = new Set();
// Override widget value methods to handle our custom widgets
const origComputeSize = this.computeSize;
this.computeSize = function() {
const size = origComputeSize ? origComputeSize.call(this) : [200, 100];
// Don't auto-resize based on widgets
return [this.size[0] || size[0], this.size[1] || size[1]];
};
// Override setWidgetValue to protect our custom widgets
const origSetWidgetValue = this.setWidgetValue;
this.setWidgetValue = function(index, value) {
const widget = this.widgets[index];
if (widget && widget.type === "xyz_dynamic_widget") {
// Only set if value is not null/undefined
if (value !== null && value !== undefined) {
widget.value = value;
}
return;
}
if (origSetWidgetValue) {
origSetWidgetValue.call(this, index, value);
}
};
// Override getWidgetValue
const origGetWidgetValue = this.getWidgetValue;
this.getWidgetValue = function(index) {
const widget = this.widgets[index];
if (widget && widget.type === "xyz_dynamic_widget") {
return widget.value;
}
return origGetWidgetValue ? origGetWidgetValue.call(this, index) : undefined;
};
// Initialize storage for dynamic widgets if not already done
if (!node.dynamicWidgets) {
node.dynamicWidgets = {
x: [],
y: [],
z: []
};
}
// Store references to add buttons
if (!node.addButtons) {
node.addButtons = {};
}
// Store references to text widgets
if (!node.textWidgets) {
node.textWidgets = {};
}
// Store last mouse event for context menu positioning
node.lastMouseEvent = null;
// Setup axis type callbacks
["x_type", "y_type", "z_type"].forEach(widgetName => {
const widget = node.widgets.find(w => w.name === widgetName);
if (widget) {
// Set default value to 'none'
widget.value = "none";
const originalCallback = widget.callback;
widget.callback = function(value) {
if (originalCallback) originalCallback.call(this, value);
updateAxisWidgets(node, widgetName.split("_")[0], value);
updateNodeTitle(node);
};
}
});
// Add header widget for each axis
node.headerWidgets = {};
// Initial setup - only run if not already configured
setTimeout(() => {
// Check if we're in initial creation (not restoration)
if (!node._configured) {
["x", "y", "z"].forEach(axis => {
const typeWidget = node.widgets.find(w => w.name === `${axis}_type`);
if (typeWidget) {
updateAxisWidgets(node, axis, typeWidget.value);
}
});
updateNodeTitle(node);
} else {
}
}, 100);
};
// Override onSerialize to ensure our custom widgets are saved
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(info) {
name: w.name,
type: w.type,
value: w.value
})));
// Let original serialization run first
if (origOnSerialize) {
origOnSerialize.call(this, info);
}
// Log what ComfyUI serialized
// Fix empty text widget values by getting them directly from the widgets
if (info.widgets_values && this.widgets) {
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
const widget = this.widgets[i];
const serializedValue = info.widgets_values[i];
// If the serialized value is empty/null but the widget has a value, use the widget's value
if ((serializedValue === '' || serializedValue === null || serializedValue === undefined) &&
widget && widget.value !== '' && widget.value !== null && widget.value !== undefined) {
info.widgets_values[i] = widget.value;
}
// Also handle the case where the widget has an inputEl with a value
if (widget && widget.inputEl && widget.inputEl.value &&
(serializedValue === '' || serializedValue === null || serializedValue === undefined)) {
info.widgets_values[i] = widget.inputEl.value;
}
}
}
};
// Override mouse events to track position
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function(e, localPos, graphCanvas) {
this.lastMouseEvent = e;
return onMouseDown ? onMouseDown.apply(this, arguments) : undefined;
};
const onMouseUp = nodeType.prototype.onMouseUp;
nodeType.prototype.onMouseUp = function(e, localPos, graphCanvas) {
if (e.button === 2) { // Right click
this.lastMouseEvent = e;
}
return onMouseUp ? onMouseUp.apply(this, arguments) : undefined;
};
// Override configuration to restore dynamic widgets
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
// Mark as configured to prevent initial setup from running
this._configured = true;
// Store the widgets_values before anything modifies them
const savedWidgetValues = [...(info.widgets_values || [])];
// Clear dynamic widget tracking for fresh restoration
if (!this.hiddenWidgets) {
this.hiddenWidgets = new Set();
}
this.dynamicWidgets = { x: [], y: [], z: [] };
this.addButtons = {};
this.textWidgets = {};
// Let ComfyUI restore base widgets first
if (onConfigure) {
onConfigure.call(this, info);
}
name: w.name,
type: w.type,
value: w.value
})));
// Remove any existing dynamic widgets that might have been restored incorrectly
if (this.widgets) {
// Remove widgets in reverse order to avoid index issues
for (let i = this.widgets.length - 1; i >= 0; i--) {
const widget = this.widgets[i];
if (widget && (widget.type === "xyz_dynamic_widget" ||
widget.name?.includes("Add ") ||
widget.name?.includes("_numeric") ||
widget.name?.includes("_prompt"))) {
this.widgets.splice(i, 1);
if (widget.onRemove) {
widget.onRemove();
}
}
}
}
// Restore dynamic widgets from saved values
// Use our saved copy instead of info.widgets_values which might have been modified
const widgetValues = savedWidgetValues;
// Log each widget value for debugging
widgetValues.forEach((val, idx) => {
});
// Group saved widget values by axis (using the stored _axis property)
const widgetsByAxis = { x: [], y: [], z: [] };
for (const widgetValue of widgetValues) {
if (widgetValue && typeof widgetValue === 'object' &&
'on' in widgetValue && 'value' in widgetValue && widgetValue.value) {
// Use the stored axis info if available
if (widgetValue._axis && widgetsByAxis[widgetValue._axis]) {
widgetsByAxis[widgetValue._axis].push(widgetValue);
} else {
// Fallback: try to guess which axis based on current configuration
for (const axis of ['x', 'y', 'z']) {
const typeWidget = this.widgets.find(w => w.name === `${axis}_type`);
if (typeWidget && canAddDynamicWidget(typeWidget.value)) {
widgetsByAxis[axis].push(widgetValue);
break;
}
}
}
}
}
// First setup axis type callbacks again since we removed all widgets
["x_type", "y_type", "z_type"].forEach(widgetName => {
const widget = this.widgets.find(w => w.name === widgetName);
if (widget) {
const originalCallback = widget.callback;
widget.callback = function(value) {
if (originalCallback) originalCallback.call(this, value);
updateAxisWidgets(node, widgetName.split("_")[0], value);
updateNodeTitle(node);
};
}
});
// Now create widgets for each axis based on restored type values
for (const axis of ['x', 'y', 'z']) {
const typeWidget = this.widgets.find(w => w.name === `${axis}_type`);
const axisType = typeWidget?.value;
if (axisType && axisType !== "none") {
// First update axis widgets to create add buttons and text widgets
// Pass true to skip clearing dynamic widgets during restoration
updateAxisWidgets(this, axis, axisType, true);
// Then restore dynamic widgets if applicable
if (canAddDynamicWidget(axisType)) {
const savedWidgets = widgetsByAxis[axis];
for (const savedValue of savedWidgets) {
// Pass the item name, not the full value object
const itemName = savedValue.value || "";
const widget = createDynamicWidget(this, axis, axisType, itemName);
if (widget) {
// Now restore the full value including on/off state and strength
// Use Object.assign to ensure the value is properly set
const fullValue = Object.assign({}, savedValue, {
_axis: axis,
_type: axisType
});
widget.value = fullValue;
}
}
}
}
}
// Manually restore all widget values from saved widget values
// This is needed because ComfyUI creates the widgets but doesn't always restore their values properly
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
const widget = this.widgets[i];
const savedValue = savedWidgetValues[i];
// Skip if no saved value
if (savedValue === null || savedValue === undefined) {
continue;
}
// For string values (text widgets)
if (widget && typeof savedValue === 'string' && savedValue !== '') {
// Check various widget types that might contain text
if (widget.type === "text" || widget.type === "string" || widget.type === "customtext" ||
widget.name?.includes("numeric") || widget.name?.includes("prompt")) {
widget.value = savedValue;
// Also update the input element if it exists
if (widget.inputEl) {
widget.inputEl.value = savedValue;
}
// Check for callback to trigger updates
if (widget.callback) {
widget.callback(savedValue);
}
}
}
}
updateNodeTitle(this);
// Log final state
// Add a slight delay to check if values are being cleared after configure
setTimeout(() => {
name: w.name,
type: w.type,
value: w.type === "xyz_dynamic_widget" ? w.value : w.value
})));
// Final attempt to restore text widget values if they're still empty
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
const widget = this.widgets[i];
const savedValue = savedWidgetValues[i];
if (widget && typeof savedValue === 'string' && savedValue !== '' &&
(!widget.value || widget.value === '')) {
widget.value = savedValue;
if (widget.inputEl) {
widget.inputEl.value = savedValue;
}
}
}
}, 100);
};
// Override onExecuted to see if something is clearing values there
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
if (onExecuted) {
onExecuted.call(this, message);
}
};
}
}
});
// Update widgets for an axis based on selected type
function updateAxisWidgets(node, axis, type, skipClearDynamic = false) {
// During restoration, we don't want to remove anything
if (!skipClearDynamic) {
// Hide all widgets for this axis first
const widgetPrefixes = [`${axis}_numeric`, `${axis}_prompt`, `➕ Add`];
// Hide text widgets and buttons for this axis
node.widgets?.forEach(widget => {
if (widget.name && widgetPrefixes.some(prefix => widget.name.includes(prefix) ||
(widget.name.includes(axis) && (widget.name.includes('numeric') || widget.name.includes('prompt'))))) {
widget.hidden = true;
widget.computeSize = () => [0, 0];
node.hiddenWidgets?.add(widget.name);
}
});
// Remove existing add button
if (node.addButtons[axis]) {
const index = node.widgets.indexOf(node.addButtons[axis]);
if (index > -1) {
node.widgets.splice(index, 1);
}
node.addButtons[axis] = null;
}
// Clear dynamic widgets for this axis
if (node.dynamicWidgets[axis]) {
while (node.dynamicWidgets[axis].length > 0) {
const widget = node.dynamicWidgets[axis].pop();
const index = node.widgets.indexOf(widget);
if (index > -1) {
node.widgets.splice(index, 1);
}
}
}
}
if (type === "none") {
// Don't resize - respect user's manual sizing
return;
}
// Add widgets based on type
if (canAddDynamicWidget(type)) {
// Add button for dynamic widget types if it doesn't exist
if (!node.addButtons[axis]) {
const buttonLabel = `➕ Add ${type.charAt(0).toUpperCase() + type.slice(1, -1)}`;
const button = node.addWidget("button", buttonLabel, null, (value, canvas, node, pos, event) => {
showResourceChooser(node, axis, type, event || window.event);
return true;
});
node.addButtons[axis] = button;
}
} else if (["cfg_scale", "steps", "seed", "denoise", "clip_skip"].includes(type)) {
// Check if text widget already exists
const widgetName = `${axis}_numeric`;
let existingWidget = node.widgets?.find(w => w.name === widgetName);
if (!existingWidget) {
// Only create new widget if it doesn't exist
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
default: "",
multiline: true,
placeholder: PARAM_HELP[type] || "Enter values"
}]);
textWidget.widget.inputEl.style.fontFamily = "monospace";
textWidget.widget.inputEl.rows = 3;
node.textWidgets[axis] = textWidget.widget;
// Add input listener for live updates
textWidget.widget.inputEl.addEventListener("input", () => {
updateNodeTitle(node);
});
} else {
// Widget already exists, just track it and unhide it
node.textWidgets[axis] = existingWidget;
existingWidget.hidden = false;
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
node.hiddenWidgets?.delete(existingWidget.name);
// Re-add input listener in case it was lost
if (existingWidget.inputEl && !existingWidget._hasUpdateListener) {
existingWidget.inputEl.addEventListener("input", () => {
updateNodeTitle(node);
});
existingWidget._hasUpdateListener = true;
}
}
} else if (type === "prompt") {
// Check if prompt widget already exists
const widgetName = `${axis}_prompt`;
let existingWidget = node.widgets?.find(w => w.name === widgetName);
if (!existingWidget) {
// Only create new widget if it doesn't exist
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
default: "",
multiline: true,
placeholder: PARAM_HELP.prompt
}]);
textWidget.widget.inputEl.style.fontFamily = "monospace";
textWidget.widget.inputEl.rows = 4;
node.textWidgets[axis] = textWidget.widget;
// Add input listener for live updates
textWidget.widget.inputEl.addEventListener("input", () => {
updateNodeTitle(node);
});
} else {
// Widget already exists, just track it and unhide it
node.textWidgets[axis] = existingWidget;
existingWidget.hidden = false;
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
node.hiddenWidgets?.delete(existingWidget.name);
// Re-add input listener in case it was lost
if (existingWidget.inputEl && !existingWidget._hasUpdateListener) {
existingWidget.inputEl.addEventListener("input", () => {
updateNodeTitle(node);
});
existingWidget._hasUpdateListener = true;
}
}
}
// Don't auto-resize - respect user's manual sizing
}
// Check if a type can have dynamic widgets
function canAddDynamicWidget(type) {
return ["models", "vaes", "loras", "samplers", "schedulers"].includes(type);
}
// Show resource chooser based on type
function showResourceChooser(node, axis, type, event) {
// Get available options based on type
let options = [];
let title = "Choose a " + type.slice(0, -1); // Remove 's' for singular
// Try to get options from existing nodes or use defaults
if (type === "models") {
// Try to find a checkpoint loader node to get model list
const checkpointNode = app.graph._nodes.find(n => n.type === "CheckpointLoaderSimple");
if (checkpointNode && checkpointNode.widgets) {
const modelWidget = checkpointNode.widgets.find(w => w.type === "combo");
if (modelWidget && modelWidget.options && modelWidget.options.values) {
options = [...modelWidget.options.values];
}
}
if (options.length === 0) {
options = ["No models found"];
}
} else if (type === "vaes") {
options = [];
// Try to find a VAE loader node
const vaeNode = app.graph._nodes.find(n => n.type === "VAELoader");
if (vaeNode && vaeNode.widgets) {
const vaeWidget = vaeNode.widgets.find(w => w.type === "combo");
if (vaeWidget && vaeWidget.options && vaeWidget.options.values) {
options = [...vaeWidget.options.values];
}
}
if (options.length === 0) {
options = ["Automatic"];
}
} else if (type === "loras") {
options = [];
// Try multiple ways to find LoRA options
// Method 1: Look for any LoraLoader node
const loraNodes = app.graph._nodes.filter(n =>
n.type === "LoraLoader" ||
n.type === "Power Lora Loader (rgthree)" ||
n.type?.includes("Lora") ||
n.type?.includes("lora")
);
for (const node of loraNodes) {
if (node.widgets) {
const loraWidget = node.widgets.find(w =>
(w.type === "combo" && (w.name === "lora_name" || w.name?.includes("lora"))) ||
(w.options?.values && Array.isArray(w.options.values) && w.options.values.length > 0)
);
if (loraWidget && loraWidget.options && loraWidget.options.values) {
options = [...loraWidget.options.values];
break;
}
}
}
// Method 2: Check if we can get from node definitions
if (options.length === 0) {
try {
const nodeData = LiteGraph.registered_node_types["LoraLoader"];
if (nodeData && nodeData.nodeData && nodeData.nodeData.input) {
const loraInput = nodeData.nodeData.input.required?.lora_name;
if (loraInput && Array.isArray(loraInput[0])) {
options = [...loraInput[0]];
}
}
} catch (e) {
}
}
// If still no options, show a message
if (options.length === 0) {
options = ["No LoRAs found - Add a LoraLoader node first"];
} else {
}
} else if (type === "samplers") {
// Try to find a KSampler node
const samplerNode = app.graph._nodes.find(n => n.type === "KSampler");
if (samplerNode && samplerNode.widgets) {
const samplerWidget = samplerNode.widgets.find(w => w.name === "sampler");
if (samplerWidget && samplerWidget.options && samplerWidget.options.values) {
options = [...samplerWidget.options.values];
} else {
options = ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc"];
}
} else {
options = ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc"];
}
} else if (type === "schedulers") {
// Try to find a KSampler node
const samplerNode = app.graph._nodes.find(n => n.type === "KSampler");
if (samplerNode && samplerNode.widgets) {
const schedulerWidget = samplerNode.widgets.find(w => w.name === "scheduler");
if (schedulerWidget && schedulerWidget.options && schedulerWidget.options.values) {
options = [...schedulerWidget.options.values];
} else {
options = ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"];
}
} else {
options = ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"];
}
}
// Show context menu for selection
const canvas = app.canvas;
if (!event) {
event = canvas.last_mouse_event || new MouseEvent('click', {
clientX: canvas.mouse[0],
clientY: canvas.mouse[1]
});
}
new LiteGraph.ContextMenu(options, {
event: event,
title: title,
scale: Math.max(1, canvas.ds?.scale || 1),
callback: (value) => {
if (value && value !== "None" && !value.includes("No") && !value.includes("found")) {
createDynamicWidget(node, axis, type, value);
updateNodeTitle(node);
}
}
});
}
// Create a dynamic widget for the given axis and type with selected value
function createDynamicWidget(node, axis, type, selectedValue) {
widgetCounter++;
const widgetName = `${axis}_${type}_${widgetCounter}`;
// Create a custom widget that ComfyUI will properly serialize
class XYZDynamicWidget {
constructor(name, value) {
this.name = name;
this._value = value;
this.type = "xyz_dynamic_widget";
this.y = 0;
this.options = {};
}
// Use getter/setter to ensure value persistence
get value() {
return this._value;
}
set value(v) {
this._value = v;
}
draw(ctx, node, width, y) {
// Will be replaced
}
mouse(event, pos, node) {
// Will be replaced
return false;
}
computeSize() {
return [node.size[0], LiteGraph.NODE_WIDGET_HEIGHT];
}
serialize() {
return this._value;
}
serializeValue(node, index) {
// Return a deep copy to prevent modification
return this._value ? { ...this._value } : null;
}
onRemove() {}
}
const widget = new XYZDynamicWidget(widgetName, null);
// Add widget to node
if (!node.widgets) {
node.widgets = [];
}
node.widgets.push(widget);
// Store value with on/off state and strength for loras
// Add axis and type info to help with restoration
if (type === "loras") {
widget.value = {
on: true,
value: selectedValue,
strength: 1.0,
_axis: axis, // Store axis info
_type: type // Store type info
};
} else {
widget.value = {
on: true,
value: selectedValue,
_axis: axis, // Store axis info
_type: type // Store type info
};
}
// Widget is already added to node in the constructor above
// Track mouse movement for strength adjustment
widget.mouseState = {
dragging: false,
startX: 0,
startValue: 0,
lastClickTime: 0
};
// Custom draw function to show toggle and value with strength for loras
widget.draw = function(ctx, node, width, y) {
const margin = 10;
const innerMargin = 3;
const height = LiteGraph.NODE_WIDGET_HEIGHT;
const midY = y + height / 2;
let posX = margin;
ctx.save();
// Draw background rounded rectangle
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.beginPath();
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
ctx.fill();
// Draw toggle - Power Lora Loader style
const toggleRadius = height * 0.36;
const toggleBgWidth = height * 1.5;
// Toggle background
ctx.beginPath();
ctx.roundRect(posX + 4, y + 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;
// Toggle circle
const toggleX = widget.value.on ? posX + height : posX + height * 0.5;
ctx.fillStyle = widget.value.on ? "#89B" : "#888";
ctx.beginPath();
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
ctx.fill();
widget.toggleBounds = [posX, toggleBgWidth];
posX += toggleBgWidth + innerMargin;
// Apply opacity if disabled
if (!widget.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
// Draw strength controls for loras
let strengthX = width - margin - innerMargin;
if (type === "loras" && widget.value.strength !== undefined) {
// Draw strength value and controls
const arrowWidth = 9;
const numberWidth = 32;
// Right arrow ▶
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.beginPath();
const rightArrowX = strengthX - arrowWidth;
ctx.moveTo(rightArrowX + arrowWidth, midY);
ctx.lineTo(rightArrowX, midY - 5);
ctx.lineTo(rightArrowX, midY + 5);
ctx.closePath();
ctx.fill();
widget.incBounds = [rightArrowX, arrowWidth];
// Strength value
strengthX = rightArrowX - innerMargin - numberWidth;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(widget.value.strength.toFixed(2), strengthX + numberWidth/2, midY);
widget.valBounds = [strengthX, numberWidth];
// Left arrow ◀
strengthX = strengthX - innerMargin - arrowWidth;
ctx.beginPath();
ctx.moveTo(strengthX, midY);
ctx.lineTo(strengthX + arrowWidth, midY - 5);
ctx.lineTo(strengthX + arrowWidth, midY + 5);
ctx.closePath();
ctx.fill();
widget.decBounds = [strengthX, arrowWidth];
strengthX -= innerMargin;
}
// Draw the value text and track its bounds for right-click
const textWidth = strengthX - posX - innerMargin;
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
// Fit text to available space
let text = widget.value.value || "";
ctx.font = "12px Arial";
const metrics = ctx.measureText(text);
if (metrics.width > textWidth) {
// Truncate with ellipsis
while (text.length > 0 && ctx.measureText(text + "...").width > textWidth) {
text = text.substring(0, text.length - 1);
}
text += "...";
}
ctx.fillText(text, posX, midY);
// Store the lora name bounds for right-click detection
widget.loraBounds = [posX, textWidth];
ctx.restore();
};
// Handle mouse events - pos is relative to widget
widget.mouse = function(event, pos, node) {
const height = LiteGraph.NODE_WIDGET_HEIGHT;
if (event.type === "mousedown" || event.type === "pointerdown") {
// Check toggle bounds
if (widget.toggleBounds && pos[0] >= widget.toggleBounds[0] &&
pos[0] <= widget.toggleBounds[0] + widget.toggleBounds[1]) {
widget.value.on = !widget.value.on;
updateNodeTitle(node);
node.setDirtyCanvas(true, true);
return true;
}
// Check strength controls for loras
if (type === "loras" && widget.value.strength !== undefined) {
// Decrease button
if (widget.decBounds && pos[0] >= widget.decBounds[0] &&
pos[0] <= widget.decBounds[0] + widget.decBounds[1]) {
widget.value.strength = Math.max(-3, widget.value.strength - 0.1);
widget.value.strength = Math.round(widget.value.strength * 10) / 10;
updateNodeTitle(node);
node.setDirtyCanvas(true, true);
return true;
}
// Increase button
if (widget.incBounds && pos[0] >= widget.incBounds[0] &&
pos[0] <= widget.incBounds[0] + widget.incBounds[1]) {
widget.value.strength = Math.min(3, widget.value.strength + 0.1);
widget.value.strength = Math.round(widget.value.strength * 10) / 10;
updateNodeTitle(node);
node.setDirtyCanvas(true, true);
return true;
}
// Double-click or drag on value
if (widget.valBounds && pos[0] >= widget.valBounds[0] &&
pos[0] <= widget.valBounds[0] + widget.valBounds[1]) {
// Check for double-click
const currentTime = Date.now();
if (currentTime - widget.mouseState.lastClickTime < 300) {
// Double-click detected - show input dialog
const currentValue = widget.value.strength;
app.canvas.prompt("Value", currentValue.toFixed(2), (v) => {
const value = Number(v);
if (!isNaN(value)) {
widget.value.strength = Math.max(-3, Math.min(3, value));
updateNodeTitle(node);
node.setDirtyCanvas(true, true);
}
}, event);
widget.mouseState.lastClickTime = 0; // Reset to prevent triple-click
} else {
// Single click - start dragging
widget.mouseState.dragging = true;
widget.mouseState.startX = pos[0];
widget.mouseState.startValue = widget.value.strength;
widget.mouseState.lastClickTime = currentTime;
}
return true;
}
}
} else if ((event.type === "mousemove" || event.type === "pointermove") && widget.mouseState.dragging) {
// Drag to adjust strength
const delta = (pos[0] - widget.mouseState.startX) / 50;
widget.value.strength = Math.max(-3, Math.min(3, widget.mouseState.startValue + delta));
widget.value.strength = Math.round(widget.value.strength * 100) / 100;
updateNodeTitle(node);
node.setDirtyCanvas(true, true);
return true;
} else if (event.type === "mouseup" || event.type === "pointerup") {
widget.mouseState.dragging = false;
}
return false;
};
// Override getSlotInPosition to handle widget clicks only on lora name area
const originalGetSlotInPosition = node.getSlotInPosition;
if (!node._xyzGetSlotInPositionPatched) {
node._xyzGetSlotInPositionPatched = true;
node.getSlotInPosition = function(x, y) {
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
if (!slot) {
// Check if we clicked on a dynamic widget's lora name area
const localX = x - this.pos[0];
const localY = y - this.pos[1];
for (const w of this.widgets || []) {
if (w.type === "xyz_dynamic_widget" && w.y && localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
// Check if click is within lora name bounds
if (w.loraBounds && localX >= w.loraBounds[0] && localX <= w.loraBounds[0] + w.loraBounds[1]) {
return { widget: w, output: { type: "XYZ_DYNAMIC_WIDGET" } };
}
}
}
}
return slot;
};
}
// Override getSlotMenuOptions for right-click menu
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
if (!node._xyzGetSlotMenuOptionsPatched) {
node._xyzGetSlotMenuOptionsPatched = true;
node.getSlotMenuOptions = function(slot) {
if (slot?.output?.type === "XYZ_DYNAMIC_WIDGET" && slot.widget?.type === "xyz_dynamic_widget") {
const widget = slot.widget;
const parts = widget.name.split("_");
const axis = parts[0];
const type = parts[1];
// Find widget in dynamicWidgets array
const widgetIndex = this.dynamicWidgets[axis] ? this.dynamicWidgets[axis].indexOf(widget) : -1;
if (widgetIndex === -1) {
console.warn(`[XYZ Plot] Widget not found in dynamicWidgets[${axis}]`);
}
// Check if we can move up/down
const canMoveUp = widgetIndex > 0;
const canMoveDown = widgetIndex >= 0 && this.dynamicWidgets[axis] && widgetIndex < this.dynamicWidgets[axis].length - 1;
const menuItems = [];
// Show Info for loras
if (type === "loras") {
menuItems.push({
content: `ℹ️ Show Info`,
disabled: true, // For now, until we implement lora info dialog
callback: () => {
// TODO: Implement lora info dialog
}
});
menuItems.push(null); // separator
}
// Toggle on/off
menuItems.push({
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
callback: () => {
widget.value.on = !widget.value.on;
updateNodeTitle(node);
node.setDirtyCanvas(true, true);
}
});
// Move up
menuItems.push({
content: `⬆️ Move Up`,
disabled: !canMoveUp,
callback: () => {
if (canMoveUp) {
// Swap in dynamic widgets array
const temp = this.dynamicWidgets[axis][widgetIndex];
this.dynamicWidgets[axis][widgetIndex] = this.dynamicWidgets[axis][widgetIndex - 1];
this.dynamicWidgets[axis][widgetIndex - 1] = temp;
// Find positions in main widgets array
const currentIndex = this.widgets.indexOf(widget);
const prevWidget = this.dynamicWidgets[axis][widgetIndex];
const prevIndex = this.widgets.indexOf(prevWidget);
// Swap in main widgets array
if (currentIndex > -1 && prevIndex > -1) {
this.widgets.splice(currentIndex, 1);
this.widgets.splice(prevIndex, 0, widget);
}
this.setDirtyCanvas(true, true);
}
}
});
// Move down
menuItems.push({
content: `⬇️ Move Down`,
disabled: !canMoveDown,
callback: () => {
if (canMoveDown) {
// Swap in dynamic widgets array
const temp = this.dynamicWidgets[axis][widgetIndex];
this.dynamicWidgets[axis][widgetIndex] = this.dynamicWidgets[axis][widgetIndex + 1];
this.dynamicWidgets[axis][widgetIndex + 1] = temp;
// Find positions in main widgets array
const currentIndex = this.widgets.indexOf(widget);
const nextWidget = this.dynamicWidgets[axis][widgetIndex];
const nextIndex = this.widgets.indexOf(nextWidget);
// Swap in main widgets array
if (currentIndex > -1 && nextIndex > -1) {
this.widgets.splice(currentIndex, 1);
this.widgets.splice(nextIndex + 1, 0, widget);
}
this.setDirtyCanvas(true, true);
}
}
});
// Remove
menuItems.push({
content: `🗑️ Remove`,
callback: () => {
// Remove from dynamic widgets
const idx = this.dynamicWidgets[axis] ? this.dynamicWidgets[axis].indexOf(widget) : -1;
if (idx > -1) {
this.dynamicWidgets[axis].splice(idx, 1);
}
// Remove from main widgets
const widgetIdx = this.widgets.indexOf(widget);
if (widgetIdx > -1) {
this.widgets.splice(widgetIdx, 1);
}
updateNodeTitle(this);
this.setDirtyCanvas(true, true);
}
});
// Show the context menu
// We need to use the canvas mouse event that has proper coordinates
const canvasEvent = app.canvas.last_mouse_event || window.event;
if (!canvasEvent) {
// Create a synthetic event at the node position if we don't have a real event
const nodePos = node.pos || [100, 100];
const widgetY = slot.widget.y || 0;
canvasEvent = new MouseEvent('contextmenu', {
clientX: nodePos[0] + 100,
clientY: nodePos[1] + widgetY + 20,
bubbles: true
});
}
new LiteGraph.ContextMenu(menuItems, {
title: type === "loras" ? "LORA WIDGET" : type.toUpperCase().slice(0, -1) + " WIDGET",
event: canvasEvent,
parentMenu: null
});
return null; // Prevent default menu
}
// Call original if not our widget
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
};
}
// Add to dynamic widgets array - IMPORTANT for tracking
if (!node.dynamicWidgets) {
console.warn(`[XYZ Plot] dynamicWidgets was null, reinitializing`);
node.dynamicWidgets = { x: [], y: [], z: [] };
}
if (!node.dynamicWidgets[axis]) {
node.dynamicWidgets[axis] = [];
}
node.dynamicWidgets[axis].push(widget);
// Position widget after add button
if (node.addButtons[axis]) {
const buttonIndex = node.widgets.indexOf(node.addButtons[axis]);
if (buttonIndex > -1) {
node.widgets.splice(node.widgets.indexOf(widget), 1);
node.widgets.splice(buttonIndex + 1, 0, widget);
}
}
// Don't auto-resize - respect user's manual sizing
return widget;
}
// Update node title with image count
function updateNodeTitle(node) {
const counts = { x: 0, y: 0, z: 0 };
["x", "y", "z"].forEach(axis => {
const typeWidget = node.widgets.find(w => w.name === `${axis}_type`);
if (!typeWidget || typeWidget.value === "none") {
counts[axis] = 1;
return;
}
const type = typeWidget.value;
if (canAddDynamicWidget(type)) {
// Count enabled dynamic widgets
counts[axis] = node.dynamicWidgets[axis]
.filter(w => {
if (typeof w.value === 'object' && w.value) {
return w.value.on;
}
return false;
})
.length;
} else if (["cfg_scale", "steps", "seed", "denoise", "clip_skip"].includes(type)) {
// Count numeric values
const textWidget = node.textWidgets[axis];
if (textWidget && textWidget.value) {
const values = textWidget.value.trim();
if (values.includes(':')) {
const parts = values.split(':');
if (parts.length >= 2) {
const start = parseFloat(parts[0]);
const stop = parseFloat(parts[1]);
const step = parts[2] ? parseFloat(parts[2]) : 1;
counts[axis] = Math.floor((stop - start) / step) + 1;
}
} else {
counts[axis] = values.split(',').filter(v => v.trim()).length;
}
}
} else if (type === "prompt") {
// Count prompts
const textWidget = node.textWidgets[axis];
if (textWidget && textWidget.value) {
counts[axis] = textWidget.value.split('\n').filter(p => p.trim()).length;
}
}
counts[axis] = Math.max(1, counts[axis]);
});
const totalImages = counts.x * counts.y * counts.z;
node.title = `XYZ Plot Controller (${totalImages} images)`;
if (totalImages > 100) {
node.title += " ⚠️";
}
}