From 012c0e698c7cdf0f44c2af2df3e010d370116ba2 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 4 Aug 2025 11:50:12 -0700 Subject: [PATCH] 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 --- web/css/xyz_grid.css | 247 +++++++++++++++++ web/js/xyz_grid_controller.js | 2 + web/js/xyz_grid_ui.js | 304 +++++++++++++++++++++ web/js/xyz_grid_widgets.js | 491 ++++++++++++++++++++++++++++++++++ 4 files changed, 1044 insertions(+) create mode 100644 web/css/xyz_grid.css create mode 100644 web/js/xyz_grid_ui.js create mode 100644 web/js/xyz_grid_widgets.js diff --git a/web/css/xyz_grid.css b/web/css/xyz_grid.css new file mode 100644 index 0000000..cb8e78a --- /dev/null +++ b/web/css/xyz_grid.css @@ -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; +} \ No newline at end of file diff --git a/web/js/xyz_grid_controller.js b/web/js/xyz_grid_controller.js index bf6575f..36348ba 100644 --- a/web/js/xyz_grid_controller.js +++ b/web/js/xyz_grid_controller.js @@ -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) { diff --git a/web/js/xyz_grid_ui.js b/web/js/xyz_grid_ui.js new file mode 100644 index 0000000..5757148 --- /dev/null +++ b/web/js/xyz_grid_ui.js @@ -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 = ` +
+
${paramType.toUpperCase()}
+
${info.description}
+ ${info.connection ? `
↗ ${info.connection}
` : ''} +
+ ${info.range ? ` +
+ Range: ${info.range.min}-${info.range.max}
+ Default: ${info.range.default} +
+ ` : ''} + `; + + 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); + } + }); +} \ No newline at end of file diff --git a/web/js/xyz_grid_widgets.js b/web/js/xyz_grid_widgets.js new file mode 100644 index 0000000..d66542e --- /dev/null +++ b/web/js/xyz_grid_widgets.js @@ -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); + } + }; + }); +} \ No newline at end of file