Files
ComfyAssets-ComfyUI-KikoTools/web/xyz_plot_controller.js
T
Vito Sansevero 0e7747d248 fix: consolidate and fix XYZ Plot Controller JavaScript
- Move JS files to correct web directory location
- Fix import paths for ComfyUI compatibility
- Consolidate all UI functionality into single xyz_plot_controller.js
- Add proper widget enhancement with select buttons
- Fix API calls to use ComfyUI's api object
- Add working multi-select dialogs and validation
2025-08-04 11:56:35 -07:00

622 lines
21 KiB
JavaScript

import { app } from "../scripts/app.js";
import { api } from "../scripts/api.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
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"
}
};
// Helper to get available options for different parameter types
const parameterHelpers = {
model: {
getOptions: async () => {
try {
const resp = await api.fetchApi('/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 api.fetchApi('/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 api.fetchApi('/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
}
};
// Show example usage in a nice tooltip
function showExampleTooltip(paramType, targetElement) {
const info = parameterInfo[paramType];
if (!info || !info.example) return;
// Remove any existing tooltip
const existing = document.querySelector('.xyz-example-tooltip');
if (existing) existing.remove();
const tooltip = document.createElement("div");
tooltip.className = "xyz-example-tooltip";
tooltip.style.cssText = `
position: absolute;
background: #1e1e1e;
border: 2px solid #4a90e2;
border-radius: 8px;
padding: 15px;
z-index: 10000;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.2s ease;
`;
const content = document.createElement("pre");
content.style.cssText = `
margin: 0;
color: #ddd;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
font-family: monospace;
`;
content.textContent = info.example;
tooltip.appendChild(content);
// Position near the target element
const rect = targetElement.getBoundingClientRect();
tooltip.style.left = rect.left + "px";
tooltip.style.top = (rect.bottom + 5) + "px";
document.body.appendChild(tooltip);
// Fade in
requestAnimationFrame(() => {
tooltip.style.opacity = "1";
});
// Auto-close
const closeTooltip = () => {
tooltip.style.opacity = "0";
setTimeout(() => tooltip.remove(), 200);
};
setTimeout(closeTooltip, 5000);
targetElement.addEventListener('blur', closeTooltip);
targetElement.addEventListener('input', closeTooltip);
}
// Create multi-select dialog for file-based parameters
function showMultiSelectDialog(options, widget, helper) {
// Create modal overlay
const overlay = document.createElement("div");
overlay.className = "xyz-multiselect-overlay";
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.className = "xyz-multiselect-dialog";
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 = helper.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.className = "xyz-search-input";
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;
max-height: 400px;
`;
// 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.className = "xyz-checkbox-option";
label.style.cssText = `
display: block;
padding: 5px;
cursor: pointer;
color: #ddd;
margin: 2px 0;
`;
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.className = "xyz-button-group";
buttonContainer.style.cssText = `
margin-top: 15px;
display: flex;
gap: 10px;
justify-content: flex-end;
`;
const selectAllBtn = document.createElement("button");
selectAllBtn.textContent = "Select All";
selectAllBtn.className = "xyz-button-secondary";
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.className = "xyz-button-secondary";
clearBtn.style.cssText = selectAllBtn.style.cssText;
clearBtn.onclick = () => {
checkboxes.forEach(({ checkbox }) => checkbox.checked = false);
};
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.className = "xyz-button-secondary";
cancelBtn.style.cssText = selectAllBtn.style.cssText;
cancelBtn.onclick = () => document.body.removeChild(overlay);
const applyBtn = document.createElement("button");
applyBtn.textContent = "Apply";
applyBtn.className = "xyz-button-primary";
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);
}
// Main extension
app.registerExtension({
name: "ComfyAssets.XYZPlotController",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "XYZPlotController") {
// Add setup method
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Add our UI enhancements
setTimeout(() => {
setupEnhancedUI(this);
}, 100);
};
}
}
});
function setupEnhancedUI(node) {
// Setup for each axis
['x_axis_type', 'y_axis_type', 'z_axis_type'].forEach(axisName => {
const typeWidget = node.widgets.find(w => w.name === axisName);
if (!typeWidget) return;
const axisPrefix = axisName.split('_')[0];
const valueWidgetName = `${axisPrefix}_values`;
// Override callback
const originalCallback = typeWidget.callback;
typeWidget.callback = function(value) {
if (originalCallback) {
originalCallback.call(this, value);
}
// Update value widget based on type
updateValueWidget(node, valueWidgetName, value);
};
// Initial setup
if (typeWidget.value && typeWidget.value !== 'none') {
updateValueWidget(node, valueWidgetName, typeWidget.value);
}
});
// Add total image count display
addImageCountDisplay(node);
}
function updateValueWidget(node, widgetName, paramType) {
const widget = node.widgets.find(w => w.name === widgetName);
if (!widget) return;
const helper = parameterHelpers[paramType];
const info = parameterInfo[paramType];
// Remove previous enhancements
const parent = widget.element?.parentElement;
if (parent) {
const buttons = parent.querySelectorAll('button.xyz-select-btn');
buttons.forEach(btn => btn.remove());
}
if (!info || paramType === 'none') return;
// Update placeholder
widget.element.placeholder = info.example ?
info.example.split('\n')[0] :
`Enter ${paramType} values...`;
// Add select button for file-based parameters
if (helper && helper.getOptions && widget.element) {
const selectBtn = document.createElement("button");
selectBtn.className = "xyz-select-btn";
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;
display: block;
`;
selectBtn.onclick = async () => {
const options = await helper.getOptions();
showMultiSelectDialog(options, widget, helper);
};
widget.element.parentElement.appendChild(selectBtn);
}
// Add example tooltip on focus
if (info.example && widget.element) {
widget.element.addEventListener('focus', () => {
showExampleTooltip(paramType, widget.element);
});
}
// Add validation for numeric types
if (['cfg_scale', 'steps', 'clip_skip', 'seed', 'denoise', 'flux_guidance'].includes(paramType)) {
widget.element.addEventListener('input', () => {
const value = widget.element.value.trim();
let isValid = true;
if (value) {
const rangePattern = /^\d+(\.\d+)?:\d+(\.\d+)?(:\d+(\.\d+)?)?$/;
const listPattern = /^[\d\.\s,]+$/;
isValid = rangePattern.test(value) || listPattern.test(value);
}
widget.element.style.borderColor = isValid ? "#4ecdc4" : "#ff6b6b";
});
}
}
function addImageCountDisplay(node) {
// Find or create display widget
let countWidget = node.widgets.find(w => w.name === "_image_count");
if (!countWidget) {
countWidget = node.addWidget("text", "_image_count", "Total Images: 0", () => {}, {});
countWidget.element.readOnly = true;
countWidget.element.style.textAlign = "center";
countWidget.element.style.fontWeight = "bold";
countWidget.element.style.background = "#2a2a2a";
}
// Monitor value changes
const updateCount = () => {
let xCount = 1, yCount = 1, zCount = 1;
['x', 'y', 'z'].forEach(axis => {
const typeWidget = node.widgets.find(w => w.name === `${axis}_axis_type`);
const valueWidget = node.widgets.find(w => w.name === `${axis}_values`);
if (typeWidget?.value && typeWidget.value !== 'none' && valueWidget?.value) {
const values = valueWidget.value.trim();
if (values.includes(':')) {
// Parse range
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;
const count = Math.floor((stop - start) / step) + 1;
if (axis === 'x') xCount = count;
else if (axis === 'y') yCount = count;
else if (axis === 'z') zCount = count;
}
} else {
// Count comma-separated values
const count = values.split(',').filter(v => v.trim()).length;
if (axis === 'x') xCount = count || 1;
else if (axis === 'y') yCount = count || 1;
else if (axis === 'z') zCount = count || 1;
}
}
});
const total = xCount * yCount * zCount;
countWidget.value = `Total Images: ${total}`;
// Color based on count
if (total > 100) {
countWidget.element.style.color = "#ff6b6b";
countWidget.value += " ⚠️ Large batch!";
} else if (total > 50) {
countWidget.element.style.color = "#ffaa00";
} else {
countWidget.element.style.color = "#4ecdc4";
}
};
// Add listeners
node.widgets.forEach(w => {
if (w.name.includes('_values') || w.name.includes('_axis_type')) {
w.callback = (() => {
const original = w.callback;
return function() {
if (original) original.apply(this, arguments);
updateCount();
};
})();
}
});
// Initial update
updateCount();
}