feat: add intelligent UI for XYZ Plot Controller

- Dynamic value selection widgets for models, VAEs, LoRAs, samplers
- Multi-select dialogs with search functionality
- Context-sensitive examples and usage hints for each parameter type
- Real-time validation for numeric inputs with range syntax support
- Visual feedback with proper styling and animations
- Connection hints showing where to connect outputs
- Parameter-specific placeholders and tooltips
This commit is contained in:
Vito Sansevero
2025-08-04 11:50:12 -07:00
parent b18f7cad38
commit 012c0e698c
4 changed files with 1044 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
/* XYZ Grid Styles */
.xyz-value-selector {
position: relative;
margin: 5px 0;
}
.xyz-value-selector button {
transition: all 0.2s ease;
}
.xyz-value-selector button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(74, 144, 226, 0.3);
}
.xyz-value-selector button:active {
transform: translateY(0);
}
/* Multi-select dialog animations */
.xyz-multiselect-overlay {
animation: fadeIn 0.2s ease;
}
.xyz-multiselect-dialog {
animation: slideIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Example helper styles */
.xyz-example-helper {
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
border: 1px solid #444;
border-radius: 4px;
padding: 8px;
margin: 5px 0;
font-size: 11px;
color: #aaa;
position: relative;
overflow: hidden;
}
.xyz-example-helper::before {
content: "Example";
position: absolute;
top: 4px;
right: 8px;
font-size: 9px;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
.xyz-example-helper pre {
margin: 0;
font-family: 'Consolas', 'Monaco', monospace;
white-space: pre-wrap;
line-height: 1.4;
}
/* Value input validation styles */
.xyz-value-input {
transition: border-color 0.2s ease;
}
.xyz-value-input.valid {
border-color: #4ecdc4 !important;
}
.xyz-value-input.invalid {
border-color: #ff6b6b !important;
}
/* Progress indicator */
.xyz-progress-bar {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, #4ecdc4 0%, #44a3aa 100%);
transition: width 0.3s ease;
border-radius: 0 0 4px 4px;
}
/* Grid info display */
.xyz-grid-info {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
padding: 8px 12px;
margin: 5px 0;
font-size: 12px;
color: #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.xyz-grid-info .dimensions {
color: #4ecdc4;
font-weight: bold;
}
.xyz-grid-info .warning {
color: #ff6b6b;
font-size: 11px;
}
/* Checkbox styling in multi-select */
.xyz-checkbox-option {
display: flex;
align-items: center;
padding: 6px 8px;
margin: 2px 0;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.xyz-checkbox-option:hover {
background-color: rgba(74, 144, 226, 0.1);
}
.xyz-checkbox-option input[type="checkbox"] {
margin-right: 10px;
width: 16px;
height: 16px;
cursor: pointer;
}
.xyz-checkbox-option label {
flex: 1;
cursor: pointer;
user-select: none;
}
/* Search input styling */
.xyz-search-input {
width: 100%;
padding: 10px 12px;
margin-bottom: 12px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 14px;
transition: all 0.2s ease;
}
.xyz-search-input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.xyz-search-input::placeholder {
color: #666;
}
/* Button group styling */
.xyz-button-group {
display: flex;
gap: 8px;
margin-top: 12px;
}
.xyz-button-group button {
flex: 1;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.xyz-button-primary {
background: #4a90e2;
color: white;
}
.xyz-button-primary:hover {
background: #357abd;
}
.xyz-button-secondary {
background: #666;
color: white;
}
.xyz-button-secondary:hover {
background: #555;
}
/* Tooltip for parameter info */
.xyz-param-tooltip {
position: absolute;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 4px;
padding: 8px 12px;
font-size: 11px;
color: #ddd;
z-index: 10001;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.xyz-param-tooltip.visible {
opacity: 1;
}
.xyz-param-tooltip::before {
content: '';
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid #444;
}
+2
View File
@@ -1,5 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import "./xyz_grid_widgets.js";
import "./xyz_grid_ui.js";
// Helper function to trigger re-execution
async function queueNextExecution(nodeId, batchId) {
+304
View File
@@ -0,0 +1,304 @@
import { app } from "../../scripts/app.js";
// Load CSS
const link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = "extensions/ComfyUI-KikoTools/css/xyz_grid.css";
document.head.appendChild(link);
// Parameter type information and examples
export const parameterInfo = {
none: {
description: "No parameter selected",
example: null
},
model: {
description: "Checkpoint/Model files",
example: "Select from available models using the button below",
connection: "Connect to CheckpointLoaderSimple → ckpt_name",
outputType: "STRING"
},
vae: {
description: "VAE (Variational Autoencoder) models",
example: "Select from available VAEs or use 'Automatic'",
connection: "Connect to VAELoader → vae_name",
outputType: "STRING"
},
lora: {
description: "LoRA (Low-Rank Adaptation) models",
example: "Select LoRAs to apply (use 'None' for no LoRA)",
connection: "Connect to LoraLoader → lora_name",
outputType: "STRING"
},
sampler: {
description: "Sampling algorithms",
example: "Select from available samplers",
connection: "Connect to KSampler → sampler_name",
outputType: "STRING"
},
scheduler: {
description: "Noise schedulers",
example: "Select scheduler types",
connection: "Connect to KSampler → scheduler",
outputType: "STRING"
},
cfg_scale: {
description: "Classifier-Free Guidance scale",
example: "Examples:\n• Single values: 5, 7.5, 10, 12.5\n• Range: 5:15:2.5 (from 5 to 15, step 2.5)\n• Mixed: 1, 3, 5:10:1",
connection: "Connect y_float or x_float to KSampler → cfg",
outputType: "FLOAT",
range: { min: 0, max: 30, default: 7.5 }
},
steps: {
description: "Number of denoising steps",
example: "Examples:\n• List: 20, 30, 40, 50\n• Range: 10:50:10 (from 10 to 50, step 10)\n• Quick test: 10, 20, 50",
connection: "Connect y_int or x_int to KSampler → steps",
outputType: "INT",
range: { min: 1, max: 150, default: 20 }
},
clip_skip: {
description: "CLIP layers to skip from the end",
example: "Examples:\n• Common values: 1, 2\n• Full test: 1, 2, 3, 4\n• SDXL typically uses 1 or 2",
connection: "Connect to CLIPSetLastLayer → stop_at_clip_layer",
outputType: "INT",
range: { min: 1, max: 12, default: 1 }
},
seed: {
description: "Random seed for generation",
example: "Examples:\n• Specific seeds: 42, 123, 456, 789\n• Range: 0:1000:100\n• Random set: 12345, 67890, 11111",
connection: "Connect y_int or x_int to KSampler → seed",
outputType: "INT",
range: { min: 0, max: "0xffffffffffffffff", default: 0 }
},
denoise: {
description: "Denoising strength (0=no change, 1=full denoise)",
example: "Examples:\n• Light: 0.3, 0.5, 0.7\n• Full range: 0.2:1.0:0.2\n• img2img: 0.4, 0.6, 0.8, 1.0",
connection: "Connect y_float or x_float to KSampler → denoise",
outputType: "FLOAT",
range: { min: 0, max: 1, default: 1 }
},
flux_guidance: {
description: "Flux model guidance strength",
example: "Examples:\n• Low to high: 1.0, 2.0, 3.5, 5.0\n• Fine steps: 1:5:0.5\n• Recommended: 2.5, 3.5, 4.5",
connection: "Connect to FluxGuidance → guidance",
outputType: "FLOAT",
range: { min: 0, max: 10, default: 3.5 }
},
prompt: {
description: "Text prompts (one per line)",
example: "Enter different prompts, one per line:\n\na serene lake at sunset\na bustling city street\na mystical forest path\n\nTip: Keep prompts similar length for better grid layout",
connection: "Connect y_string or x_string to CLIPTextEncode → text",
outputType: "STRING"
}
};
// Create info panel for parameter type
function createInfoPanel(node, paramType) {
const info = parameterInfo[paramType];
if (!info || paramType === 'none') return null;
const panel = document.createElement("div");
panel.className = "xyz-grid-info";
panel.innerHTML = `
<div style="flex: 1;">
<div style="font-weight: bold; margin-bottom: 4px;">${paramType.toUpperCase()}</div>
<div style="font-size: 11px; color: #999; margin-bottom: 4px;">${info.description}</div>
${info.connection ? `<div style="font-size: 10px; color: #4ecdc4;">↗ ${info.connection}</div>` : ''}
</div>
${info.range ? `
<div style="text-align: right; font-size: 10px; color: #888;">
Range: ${info.range.min}-${info.range.max}<br>
Default: ${info.range.default}
</div>
` : ''}
`;
return panel;
}
// Show example usage in a nice overlay
function showExampleOverlay(paramType, targetElement) {
const info = parameterInfo[paramType];
if (!info || !info.example) return;
// Remove any existing overlay
const existing = document.querySelector('.xyz-example-overlay');
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.className = "xyz-example-overlay";
overlay.style.cssText = `
position: absolute;
background: #1e1e1e;
border: 2px solid #4a90e2;
border-radius: 8px;
padding: 15px;
z-index: 1000;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
`;
const title = document.createElement("h4");
title.style.cssText = "margin: 0 0 10px 0; color: #4a90e2;";
title.textContent = `${paramType.toUpperCase()} - Usage Examples`;
const content = document.createElement("pre");
content.style.cssText = `
margin: 0;
color: #ddd;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
`;
content.textContent = info.example;
const closeBtn = document.createElement("button");
closeBtn.textContent = "×";
closeBtn.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: #999;
font-size: 20px;
cursor: pointer;
`;
closeBtn.onclick = () => overlay.remove();
overlay.appendChild(title);
overlay.appendChild(content);
overlay.appendChild(closeBtn);
// Position near the target element
const rect = targetElement.getBoundingClientRect();
overlay.style.left = rect.left + "px";
overlay.style.top = (rect.bottom + 5) + "px";
document.body.appendChild(overlay);
// Auto-close on click outside
setTimeout(() => {
const closeOnClickOutside = (e) => {
if (!overlay.contains(e.target)) {
overlay.remove();
document.removeEventListener('click', closeOnClickOutside);
}
};
document.addEventListener('click', closeOnClickOutside);
}, 100);
}
// Enhance the value input widget based on parameter type
export function enhanceValueWidget(node, widgetName, paramType) {
const widget = node.widgets.find(w => w.name === widgetName);
if (!widget || !widget.inputEl) return;
const info = parameterInfo[paramType];
if (!info) return;
// Add info panel above the input
const infoPanel = createInfoPanel(node, paramType);
if (infoPanel && widget.inputEl.parentNode) {
widget.inputEl.parentNode.insertBefore(infoPanel, widget.inputEl);
}
// Add example button if there's an example
if (info.example) {
const exampleBtn = document.createElement("button");
exampleBtn.textContent = "Show Examples";
exampleBtn.style.cssText = `
margin: 5px 0;
padding: 4px 10px;
background: #3a3a3a;
color: #aaa;
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
`;
exampleBtn.onclick = () => showExampleOverlay(paramType, widget.inputEl);
if (widget.inputEl.parentNode) {
widget.inputEl.parentNode.insertBefore(exampleBtn, widget.inputEl.nextSibling);
}
}
// Add validation for numeric types
if (['cfg_scale', 'steps', 'clip_skip', 'seed', 'denoise', 'flux_guidance'].includes(paramType)) {
widget.inputEl.addEventListener('input', () => {
const value = widget.inputEl.value.trim();
let isValid = true;
if (value) {
// Check for range syntax or comma-separated numbers
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
const listPattern = /^[\d\.\s,]+$/;
isValid = rangePattern.test(value) || listPattern.test(value);
}
widget.inputEl.classList.toggle('xyz-value-input', true);
widget.inputEl.classList.toggle('valid', isValid);
widget.inputEl.classList.toggle('invalid', !isValid);
});
}
}
// Register UI enhancements
app.registerExtension({
name: "ComfyAssets.XYZGridUI",
async nodeCreated(node) {
if (node.comfyClass === "XYZPlotController") {
setupUIEnhancements(node);
}
}
});
function setupUIEnhancements(node) {
// Monitor all axis type widgets
['x_axis_type', 'y_axis_type', 'z_axis_type'].forEach(axisName => {
const widget = node.widgets.find(w => w.name === axisName);
if (!widget) return;
const axisPrefix = axisName.split('_')[0];
const valueWidgetName = `${axisPrefix}_values`;
// Enhance callback to update UI
const originalCallback = widget.callback;
widget.callback = function(value) {
if (originalCallback) {
originalCallback.call(this, value);
}
// Clear previous enhancements
const valueWidget = node.widgets.find(w => w.name === valueWidgetName);
if (valueWidget && valueWidget.inputEl) {
// Remove any info panels or buttons
const parent = valueWidget.inputEl.parentNode;
const infoPanels = parent.querySelectorAll('.xyz-grid-info');
const buttons = parent.querySelectorAll('button');
infoPanels.forEach(el => el.remove());
buttons.forEach(el => {
if (el.textContent === 'Show Examples') el.remove();
});
}
// Add new enhancements
if (value && value !== 'none') {
setTimeout(() => {
enhanceValueWidget(node, valueWidgetName, value);
}, 50);
}
};
// Trigger for initial values
if (widget.value && widget.value !== 'none') {
setTimeout(() => {
enhanceValueWidget(node, valueWidgetName, widget.value);
}, 100);
}
});
}
+491
View File
@@ -0,0 +1,491 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
// Helper to get available options for different parameter types
const parameterHelpers = {
model: {
getOptions: async () => {
try {
const resp = await fetch('/api/models');
const data = await resp.json();
return data.checkpoints || [];
} catch (e) {
console.error("Failed to fetch models:", e);
return [];
}
},
placeholder: "Select models...",
multiselect: true
},
vae: {
getOptions: async () => {
try {
const resp = await fetch('/api/models');
const data = await resp.json();
return ["Automatic", ...(data.vae || [])];
} catch (e) {
return ["Automatic"];
}
},
placeholder: "Select VAEs...",
multiselect: true
},
lora: {
getOptions: async () => {
try {
const resp = await fetch('/api/models');
const data = await resp.json();
return ["None", ...(data.loras || [])];
} catch (e) {
return ["None"];
}
},
placeholder: "Select LoRAs...",
multiselect: true
},
sampler: {
getOptions: async () => {
// Get samplers from KSampler node definition
const nodeData = LiteGraph.registered_node_types["KSampler"];
if (nodeData && nodeData.nodeData && nodeData.nodeData.input) {
const samplerInput = nodeData.nodeData.input.required.sampler_name;
if (samplerInput && samplerInput[0]) {
return samplerInput[0];
}
}
// Fallback list
return ["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"];
},
placeholder: "Select samplers...",
multiselect: true
},
scheduler: {
getOptions: async () => {
const nodeData = LiteGraph.registered_node_types["KSampler"];
if (nodeData && nodeData.nodeData && nodeData.nodeData.input) {
const schedulerInput = nodeData.nodeData.input.required.scheduler;
if (schedulerInput && schedulerInput[0]) {
return schedulerInput[0];
}
}
return ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"];
},
placeholder: "Select schedulers...",
multiselect: true
},
cfg_scale: {
example: "5, 7.5, 10, 12.5\n-- or --\n5:15:2.5",
placeholder: "Enter CFG values or range (start:stop:step)",
validate: (value) => {
// Check if it's a valid number list or range
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
const listPattern = /^[\d\.\s,]+$/;
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
}
},
steps: {
example: "20, 30, 40, 50\n-- or --\n10:50:10",
placeholder: "Enter step counts or range",
validate: (value) => {
const rangePattern = /^\d+:\d+(:\d+)?$/;
const listPattern = /^[\d\s,]+$/;
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
}
},
clip_skip: {
example: "1, 2, 3, 4",
placeholder: "Enter clip skip values (typically 1-4)",
validate: (value) => {
const listPattern = /^[\d\s,]+$/;
return listPattern.test(value.trim());
}
},
seed: {
example: "42, 123, 456, 789\n-- or --\n0:1000:100",
placeholder: "Enter seed values or range",
validate: (value) => {
const rangePattern = /^\d+:\d+(:\d+)?$/;
const listPattern = /^[\d\s,]+$/;
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
}
},
denoise: {
example: "0.5, 0.75, 1.0\n-- or --\n0.4:1.0:0.2",
placeholder: "Enter denoise values (0.0-1.0)",
validate: (value) => {
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
const listPattern = /^[\d\.\s,]+$/;
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
}
},
flux_guidance: {
example: "1.0, 2.0, 3.5, 5.0\n-- or --\n1:5:0.5",
placeholder: "Enter Flux guidance values",
validate: (value) => {
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
const listPattern = /^[\d\.\s,]+$/;
return rangePattern.test(value.trim()) || listPattern.test(value.trim());
}
},
prompt: {
example: "a beautiful sunset over the ocean\na mystical forest at dawn\na futuristic city skyline\na cozy cottage in the mountains",
placeholder: "Enter prompts (one per line)",
multiline: true,
validate: (value) => value.trim().length > 0
}
};
// Create a custom widget for value selection
function createValueSelectorWidget(node, widgetName, axisType) {
const widget = node.widgets.find(w => w.name === widgetName);
if (!widget) return;
const helper = parameterHelpers[axisType];
if (!helper) return;
// Store original widget
const originalWidget = widget;
// Create container for enhanced UI
const container = document.createElement("div");
container.className = "xyz-value-selector";
container.style.cssText = `
position: relative;
margin: 5px 0;
`;
if (helper.getOptions) {
// Create multi-select for file-based options
createMultiSelect(container, helper, originalWidget);
} else if (helper.example) {
// Show example for numeric/text inputs
createExampleHelper(container, helper, originalWidget);
}
// Replace widget element
if (widget.inputEl && widget.inputEl.parentNode) {
widget.inputEl.parentNode.insertBefore(container, widget.inputEl);
container.appendChild(widget.inputEl);
}
}
function createMultiSelect(container, helper, widget) {
// Create select button
const selectBtn = document.createElement("button");
selectBtn.textContent = "Select Values...";
selectBtn.style.cssText = `
margin: 5px 0;
padding: 5px 10px;
background: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
`;
selectBtn.onclick = async () => {
const options = await helper.getOptions();
showMultiSelectDialog(options, widget, helper.placeholder);
};
container.appendChild(selectBtn);
}
function createExampleHelper(container, helper, widget) {
// Create example display
const exampleDiv = document.createElement("div");
exampleDiv.style.cssText = `
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
padding: 8px;
margin: 5px 0;
font-size: 11px;
color: #aaa;
display: none;
`;
const exampleText = document.createElement("pre");
exampleText.style.cssText = `
margin: 0;
font-family: monospace;
white-space: pre-wrap;
`;
exampleText.textContent = `Example:\n${helper.example}`;
exampleDiv.appendChild(exampleText);
// Show/hide example based on focus
widget.inputEl.addEventListener("focus", () => {
exampleDiv.style.display = "block";
});
widget.inputEl.addEventListener("blur", () => {
setTimeout(() => {
exampleDiv.style.display = "none";
}, 200);
});
// Update placeholder
if (helper.placeholder) {
widget.inputEl.placeholder = helper.placeholder;
}
// Add validation
if (helper.validate) {
widget.inputEl.addEventListener("input", () => {
const isValid = helper.validate(widget.inputEl.value);
widget.inputEl.style.borderColor = isValid ? "" : "#ff6b6b";
});
}
container.appendChild(exampleDiv);
}
function showMultiSelectDialog(options, widget, placeholder) {
// Create modal overlay
const overlay = document.createElement("div");
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
`;
// Create dialog
const dialog = document.createElement("div");
dialog.style.cssText = `
background: #1e1e1e;
border: 2px solid #444;
border-radius: 8px;
padding: 20px;
max-width: 500px;
max-height: 70vh;
overflow: hidden;
display: flex;
flex-direction: column;
`;
// Title
const title = document.createElement("h3");
title.textContent = placeholder || "Select Values";
title.style.cssText = "margin: 0 0 15px 0; color: #fff;";
dialog.appendChild(title);
// Search input
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.placeholder = "Search...";
searchInput.style.cssText = `
width: 100%;
padding: 8px;
margin-bottom: 10px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
`;
dialog.appendChild(searchInput);
// Options container
const optionsContainer = document.createElement("div");
optionsContainer.style.cssText = `
flex: 1;
overflow-y: auto;
border: 1px solid #444;
border-radius: 4px;
padding: 10px;
background: #2a2a2a;
`;
// Get current values
const currentValues = widget.value.split(',').map(v => v.trim()).filter(v => v);
// Create checkboxes
const checkboxes = [];
options.forEach(option => {
const label = document.createElement("label");
label.style.cssText = `
display: block;
padding: 5px;
cursor: pointer;
color: #ddd;
`;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = option;
checkbox.checked = currentValues.includes(option);
checkbox.style.marginRight = "8px";
label.appendChild(checkbox);
label.appendChild(document.createTextNode(option));
checkboxes.push({ checkbox, label, option });
optionsContainer.appendChild(label);
});
dialog.appendChild(optionsContainer);
// Search functionality
searchInput.addEventListener("input", () => {
const search = searchInput.value.toLowerCase();
checkboxes.forEach(({ label, option }) => {
label.style.display = option.toLowerCase().includes(search) ? "block" : "none";
});
});
// Buttons
const buttonContainer = document.createElement("div");
buttonContainer.style.cssText = `
margin-top: 15px;
display: flex;
gap: 10px;
justify-content: flex-end;
`;
const selectAllBtn = document.createElement("button");
selectAllBtn.textContent = "Select All";
selectAllBtn.style.cssText = `
padding: 8px 15px;
background: #666;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
`;
selectAllBtn.onclick = () => {
checkboxes.forEach(({ checkbox, label }) => {
if (label.style.display !== "none") {
checkbox.checked = true;
}
});
};
const clearBtn = document.createElement("button");
clearBtn.textContent = "Clear";
clearBtn.style.cssText = selectAllBtn.style.cssText;
clearBtn.onclick = () => {
checkboxes.forEach(({ checkbox }) => checkbox.checked = false);
};
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.style.cssText = selectAllBtn.style.cssText;
cancelBtn.onclick = () => document.body.removeChild(overlay);
const applyBtn = document.createElement("button");
applyBtn.textContent = "Apply";
applyBtn.style.cssText = `
padding: 8px 15px;
background: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
`;
applyBtn.onclick = () => {
const selected = checkboxes
.filter(({ checkbox }) => checkbox.checked)
.map(({ checkbox }) => checkbox.value);
widget.value = selected.join(", ");
widget.callback?.(widget.value);
document.body.removeChild(overlay);
};
buttonContainer.appendChild(selectAllBtn);
buttonContainer.appendChild(clearBtn);
buttonContainer.appendChild(cancelBtn);
buttonContainer.appendChild(applyBtn);
dialog.appendChild(buttonContainer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Focus search
searchInput.focus();
// Close on escape
const handleEscape = (e) => {
if (e.key === "Escape") {
document.body.removeChild(overlay);
document.removeEventListener("keydown", handleEscape);
}
};
document.addEventListener("keydown", handleEscape);
}
// Register the extension
app.registerExtension({
name: "ComfyAssets.XYZGridWidgets",
async nodeCreated(node) {
if (node.comfyClass === "XYZPlotController") {
// Wait for widgets to be created
setTimeout(() => {
setupEnhancedWidgets(node);
}, 100);
}
}
});
function setupEnhancedWidgets(node) {
const axisTypes = ['x_axis_type', 'y_axis_type', 'z_axis_type'];
axisTypes.forEach(axisType => {
const typeWidget = node.widgets.find(w => w.name === axisType);
if (!typeWidget) return;
const axisPrefix = axisType.split('_')[0]; // 'x', 'y', or 'z'
const valueWidgetName = `${axisPrefix}_values`;
// Monitor axis type changes
const originalCallback = typeWidget.callback;
typeWidget.callback = function(value) {
if (originalCallback) {
originalCallback.call(this, value);
}
// Clear any existing enhancements
const valueWidget = node.widgets.find(w => w.name === valueWidgetName);
if (valueWidget && valueWidget.inputEl) {
const container = valueWidget.inputEl.parentNode;
if (container && container.className === "xyz-value-selector") {
// Remove enhancements
while (container.firstChild !== valueWidget.inputEl) {
container.removeChild(container.firstChild);
}
while (container.lastChild !== valueWidget.inputEl) {
container.removeChild(container.lastChild);
}
}
}
// Add new enhancements based on type
if (value && value !== "none") {
setTimeout(() => {
createValueSelectorWidget(node, valueWidgetName, value);
}, 50);
}
};
});
}