mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
fix(autocomplete): resolve instability in Vue DOM mode and fix WanVideo node binding
- Fix infinite reinitialization loop by only validating stale widget.inputEl when it's actually in DOM - Improve findWidgetInputElement to specifically search for textarea for text widgets, avoiding mismatches with checkbox inputs on nodes like WanVideo Lora Select that have toggle switches - Add data-node-id based element search as primary strategy for better reliability across rendering modes - Fix autocomplete initialization to properly handle element DOM state transitions Fixes autocomplete failing after Canvas ↔ Vue DOM mode switches and WanVideo node always failing to trigger autocomplete. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,46 @@ app.registerExtension({
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ComfyUI Dual Rendering Modes
|
||||
|
||||
ComfyUI frontend supports two rendering modes:
|
||||
|
||||
| Mode | Description | DOM Structure |
|
||||
| :--- | :--- | :--- |
|
||||
| **Canvas Mode** | Traditional rendering where widgets are rendered on top of canvas using absolute positioning | Uses `.dom-widget` class on containers |
|
||||
| **Vue DOM Mode** | New rendering mode where nodes and widgets are rendered as Vue components | Uses `.lg-node-widget` class on containers with dynamic IDs (e.g., `v-1-0`) |
|
||||
|
||||
### Mode Switching
|
||||
|
||||
The frontend switches between modes via `LiteGraph.vueNodesMode` boolean:
|
||||
- `LiteGraph.vueNodesMode = true` → Vue DOM Mode
|
||||
- `LiteGraph.vueNodesMode = false` → Canvas Mode
|
||||
|
||||
**Key Behavior**: Mode switching triggers DOM re-rendering WITHOUT page reload. Widget elements are destroyed and recreated, so any event listeners or references to old DOM elements become invalid.
|
||||
|
||||
### Testing Mode Switches via Chrome DevTools MCP
|
||||
|
||||
```javascript
|
||||
// Trigger render mode change
|
||||
LiteGraph.vueNodesMode = !LiteGraph.vueNodesMode;
|
||||
|
||||
// Force canvas redraw (optional but helps trigger re-render)
|
||||
if (app.canvas) {
|
||||
app.canvas.draw(true, true);
|
||||
}
|
||||
```
|
||||
|
||||
### Development Notes
|
||||
|
||||
When implementing widgets that attach event listeners or maintain external references:
|
||||
1. **Use `node.onRemoved`** to clean up when node is deleted
|
||||
2. **Detect DOM changes** by checking if widget input element is still in document: `document.body.contains(inputElement)`
|
||||
3. **Poll for mode changes** by watching `LiteGraph.vueNodesMode` and re-initializing when it changes
|
||||
4. **Use `loadedGraphNode` hook** for initial setup (guarantees DOM is fully rendered)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. The `addDOMWidget` API
|
||||
|
||||
@@ -293,6 +293,57 @@ class AutoComplete {
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', this.onDocumentClick);
|
||||
|
||||
// Mark this element as having autocomplete events bound
|
||||
this.inputElement._autocompleteEventsBound = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the autocomplete is valid (input element is in DOM and events are bound)
|
||||
*/
|
||||
isValid() {
|
||||
return this.inputElement &&
|
||||
document.body.contains(this.inputElement) &&
|
||||
this.inputElement._autocompleteEventsBound === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if events need to be rebound (element exists but events not bound)
|
||||
*/
|
||||
needsRebind() {
|
||||
return this.inputElement &&
|
||||
document.body.contains(this.inputElement) &&
|
||||
this.inputElement._autocompleteEventsBound !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebind events to the input element (useful after Vue moves the element)
|
||||
*/
|
||||
rebindEvents() {
|
||||
// Remove old listeners if they exist
|
||||
if (this.onInput) {
|
||||
this.inputElement.removeEventListener('input', this.onInput);
|
||||
}
|
||||
if (this.onKeyDown) {
|
||||
this.inputElement.removeEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
if (this.onBlur) {
|
||||
this.inputElement.removeEventListener('blur', this.onBlur);
|
||||
}
|
||||
|
||||
// Rebind all events
|
||||
this.bindEvents();
|
||||
|
||||
console.log('[Lora Manager] Autocomplete events rebound');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the TextAreaCaretHelper (useful after element properties change)
|
||||
*/
|
||||
refreshHelper() {
|
||||
if (this.inputElement && document.body.contains(this.inputElement)) {
|
||||
this.helper = new TextAreaCaretHelper(this.inputElement, () => app.canvas.ds.scale);
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(value = '') {
|
||||
@@ -638,7 +689,26 @@ class AutoComplete {
|
||||
}
|
||||
return `${trimmed}, `;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the autocomplete instance is still valid
|
||||
* (input element exists and is in the DOM)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValid() {
|
||||
return this.inputElement && document.body.contains(this.inputElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the TextAreaCaretHelper to update cached measurements
|
||||
* Useful after element is moved in DOM (e.g., Vue mode switch)
|
||||
*/
|
||||
refreshCaretHelper() {
|
||||
if (this.inputElement && document.body.contains(this.inputElement)) {
|
||||
this.helper = new TextAreaCaretHelper(this.inputElement, () => app.canvas.ds.scale);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
|
||||
@@ -241,6 +241,27 @@ app.registerExtension({
|
||||
// Merge the loras data
|
||||
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
|
||||
node.lorasWidget.value = mergedLoras;
|
||||
|
||||
// Initialize autocomplete after DOM is fully rendered
|
||||
const inputWidget = node.inputWidget || node.widgets[0];
|
||||
if (inputWidget && !node.autocomplete) {
|
||||
const { setupInputWidgetWithAutocomplete } = await import("./utils.js");
|
||||
const modelType = "loras";
|
||||
const autocompleteOptions = {
|
||||
maxItems: 20,
|
||||
minChars: 1,
|
||||
debounceDelay: 200,
|
||||
};
|
||||
// Fix: Assign the enhanced callback to replace the original
|
||||
inputWidget.callback = setupInputWidgetWithAutocomplete(node, inputWidget, inputWidget.callback, modelType, autocompleteOptions);
|
||||
|
||||
// Eager initialization: trigger callback after short delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
if (!node.autocomplete && inputWidget.callback) {
|
||||
inputWidget.callback(inputWidget.value);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -146,12 +146,6 @@ app.registerExtension({
|
||||
isUpdating = false;
|
||||
}
|
||||
};
|
||||
|
||||
inputWidget.callback = setupInputWidgetWithAutocomplete(
|
||||
this,
|
||||
inputWidget,
|
||||
originalCallback
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -167,6 +161,18 @@ app.registerExtension({
|
||||
// Merge the loras data
|
||||
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
|
||||
node.lorasWidget.value = mergedLoras;
|
||||
|
||||
const inputWidget = node.inputWidget || node.widgets[0];
|
||||
if (inputWidget && !node.autocomplete) {
|
||||
const { setupInputWidgetWithAutocomplete } = await import("./utils.js");
|
||||
const modelType = "loras";
|
||||
const autocompleteOptions = {
|
||||
maxItems: 20,
|
||||
minChars: 1,
|
||||
debounceDelay: 200,
|
||||
};
|
||||
inputWidget.callback = setupInputWidgetWithAutocomplete(node, inputWidget, inputWidget.callback, modelType, autocompleteOptions);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,4 +26,19 @@ app.registerExtension({
|
||||
});
|
||||
}
|
||||
},
|
||||
async loadedGraphNode(node) {
|
||||
if (node.comfyClass == "Prompt (LoraManager)") {
|
||||
const textWidget = node.widgets?.[0];
|
||||
if (textWidget && !node.autocomplete) {
|
||||
const { setupInputWidgetWithAutocomplete } = await import("./utils.js");
|
||||
const modelType = "embeddings";
|
||||
const autocompleteOptions = {
|
||||
maxItems: 20,
|
||||
minChars: 1,
|
||||
debounceDelay: 200,
|
||||
};
|
||||
textWidget.callback = setupInputWidgetWithAutocomplete(node, textWidget, textWidget.callback, modelType, autocompleteOptions);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -408,22 +408,55 @@ async function findWidgetInputElement(node, widget) {
|
||||
const doSearch = () => {
|
||||
let inputElement = null;
|
||||
|
||||
const allWidgetContainers = document.querySelectorAll('.lg-node-widget');
|
||||
// PRIORITY 1: Use data-node-id attribute (most reliable)
|
||||
// Always try this first, regardless of mode - Vue elements may still exist after mode switch
|
||||
const nodeContainer = document.querySelector(`[data-node-id="${nodeId}"]`);
|
||||
if (nodeContainer) {
|
||||
// For text widgets, specifically look for textarea (not checkbox/toggle inputs)
|
||||
if (widgetName === 'text') {
|
||||
const textarea = nodeContainer.querySelector('textarea');
|
||||
if (textarea) {
|
||||
inputElement = textarea;
|
||||
console.log(`[Lora Manager] Found textarea for widget "${widgetName}" on node ${nodeId} via data-node-id`);
|
||||
}
|
||||
} else {
|
||||
// For other widgets, find input within widget containers
|
||||
const widgetContainers = nodeContainer.querySelectorAll('.lg-node-widget');
|
||||
for (const container of widgetContainers) {
|
||||
const input = container.querySelector('input:not([type="checkbox"]), textarea');
|
||||
if (input) {
|
||||
inputElement = input;
|
||||
console.log(`[Lora Manager] Found input element for widget "${widgetName}" on node ${nodeId} via data-node-id`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const container of allWidgetContainers) {
|
||||
const hasInput = !!container.querySelector('input, textarea');
|
||||
const textContent = container.textContent.toLowerCase();
|
||||
const containsWidgetName = textContent.includes(widgetName.toLowerCase());
|
||||
const containsNodeTitle = textContent.includes(node.title?.toLowerCase() || '');
|
||||
// PRIORITY 2: Fallback - heuristic search using widget containers
|
||||
if (!inputElement) {
|
||||
const allWidgetContainers = document.querySelectorAll('.lg-node-widget, .dom-widget');
|
||||
|
||||
if (hasInput && (containsWidgetName || (widgetName === 'text' && container.querySelector('textarea')))) {
|
||||
inputElement = container.querySelector('input, textarea');
|
||||
break;
|
||||
for (const container of allWidgetContainers) {
|
||||
const hasInput = !!container.querySelector('input, textarea');
|
||||
if (!hasInput) continue;
|
||||
|
||||
const textContent = container.textContent.toLowerCase();
|
||||
const containsWidgetName = textContent.includes(widgetName.toLowerCase());
|
||||
const containsNodeTitle = textContent.includes(node.title?.toLowerCase() || '');
|
||||
|
||||
// For text widgets, check if it's a textarea
|
||||
const isTextareaWidget = widgetName === 'text' && container.querySelector('textarea');
|
||||
|
||||
if (containsWidgetName || containsNodeTitle || isTextareaWidget) {
|
||||
inputElement = container.querySelector('input, textarea');
|
||||
console.log(`[Lora Manager] Found input element for widget "${widgetName}" on node ${nodeId} via heuristic`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputElement) {
|
||||
console.log(`[Lora Manager] Found input element for widget "${widgetName}" on node ${nodeId}`);
|
||||
resolve(inputElement);
|
||||
} else if (attempt < maxAttempts) {
|
||||
setTimeout(() => searchForInput(attempt + 1).then(resolve), searchInterval);
|
||||
@@ -449,32 +482,59 @@ async function findWidgetInputElement(node, widget) {
|
||||
* @returns {Function} Enhanced callback function with autocomplete
|
||||
*/
|
||||
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback, modelType = 'loras', autocompleteOptions = {}) {
|
||||
let autocomplete = null;
|
||||
let isInitializing = false;
|
||||
const defaultOptions = {
|
||||
maxItems: 20,
|
||||
minChars: 1,
|
||||
debounceDelay: 200,
|
||||
};
|
||||
const mergedOptions = { ...defaultOptions, ...autocompleteOptions };
|
||||
|
||||
|
||||
setupAutocompleteCleanup(node);
|
||||
|
||||
// Track rendering mode changes per node
|
||||
let lastVueNodesMode = typeof LiteGraph !== 'undefined' ? LiteGraph.vueNodesMode : false;
|
||||
|
||||
const initializeAutocomplete = async () => {
|
||||
if (autocomplete || isInitializing) return;
|
||||
isInitializing = true;
|
||||
if (node.autocomplete) {
|
||||
console.log(`[Lora Manager] Autocomplete already initialized for widget "${inputWidget.name}" on node ${node.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let inputElement = null;
|
||||
|
||||
if (inputWidget.inputEl && document.body.contains(inputWidget.inputEl)) {
|
||||
// PRIORITY 1: Always prefer widget.inputEl if it exists (even if not yet in DOM)
|
||||
// This is the authoritative element created by ComfyUI
|
||||
if (inputWidget.inputEl) {
|
||||
inputElement = inputWidget.inputEl;
|
||||
console.log(`[Lora Manager] Using widget.inputEl for widget "${inputWidget.name}"`);
|
||||
} else {
|
||||
console.log(`[Lora Manager] Searching DOM for input element for widget "${inputWidget.name}"`);
|
||||
// If not yet in DOM, wait for it to be added
|
||||
if (!document.body.contains(inputElement)) {
|
||||
console.log(`[Lora Manager] widget.inputEl exists but not in DOM yet, waiting for node ${node.id}`);
|
||||
const maxWait = 1000; // 1 second max
|
||||
const checkInterval = 50;
|
||||
let waited = 0;
|
||||
while (!document.body.contains(inputElement) && waited < maxWait) {
|
||||
await new Promise(r => setTimeout(r, checkInterval));
|
||||
waited += checkInterval;
|
||||
}
|
||||
if (!document.body.contains(inputElement)) {
|
||||
console.warn(`[Lora Manager] widget.inputEl still not in DOM after ${maxWait}ms for node ${node.id}`);
|
||||
inputElement = null; // Fall through to DOM search
|
||||
}
|
||||
}
|
||||
if (inputElement) {
|
||||
console.log(`[Lora Manager] Using widget.inputEl for widget "${inputWidget.name}" on node ${node.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: DOM search only if widget.inputEl doesn't exist
|
||||
if (!inputElement) {
|
||||
console.log(`[Lora Manager] Searching DOM for input element for widget "${inputWidget.name}" on node ${node.id}`);
|
||||
inputElement = await findWidgetInputElement(node, inputWidget);
|
||||
}
|
||||
|
||||
if (inputElement) {
|
||||
autocomplete = new AutoComplete(inputElement, modelType, mergedOptions);
|
||||
const autocomplete = new AutoComplete(inputElement, modelType, mergedOptions);
|
||||
node.autocomplete = autocomplete;
|
||||
console.log(`[Lora Manager] Autocomplete initialized for widget "${inputWidget.name}" on node ${node.id}`);
|
||||
} else {
|
||||
@@ -482,23 +542,65 @@ export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCall
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Lora Manager] Error initializing autocomplete:', error);
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const checkAndInvalidateAutocomplete = () => {
|
||||
// Check for rendering mode change
|
||||
const currentMode = typeof LiteGraph !== 'undefined' ? LiteGraph.vueNodesMode : false;
|
||||
if (currentMode !== lastVueNodesMode) {
|
||||
lastVueNodesMode = currentMode;
|
||||
if (node.autocomplete) {
|
||||
console.log(`[Lora Manager] Rendering mode changed, reinitializing autocomplete for node ${node.id}`);
|
||||
node.autocomplete.destroy();
|
||||
node.autocomplete = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if existing autocomplete's input element is still valid
|
||||
if (node.autocomplete) {
|
||||
const currentInputEl = node.autocomplete.inputElement;
|
||||
if (!currentInputEl || !document.body.contains(currentInputEl)) {
|
||||
console.log(`[Lora Manager] Autocomplete element detached, reinitializing for node ${node.id}`);
|
||||
node.autocomplete.destroy();
|
||||
node.autocomplete = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if autocomplete is bound to wrong element (different from widget.inputEl)
|
||||
// Only do this check if widget.inputEl is actually in the DOM - it may be stale
|
||||
if (inputWidget.inputEl && document.body.contains(inputWidget.inputEl) && currentInputEl !== inputWidget.inputEl) {
|
||||
console.log(`[Lora Manager] Autocomplete bound to wrong element, rebinding for node ${node.id}`);
|
||||
node.autocomplete.destroy();
|
||||
node.autocomplete = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if events need rebinding (element exists but events not bound)
|
||||
// This can happen when Vue moves the element in the DOM
|
||||
if (node.autocomplete.needsRebind()) {
|
||||
console.log(`[Lora Manager] Autocomplete events need rebinding for node ${node.id}`);
|
||||
node.autocomplete.rebindEvents();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const enhancedCallback = (value) => {
|
||||
if (!autocomplete && !isInitializing) {
|
||||
// Check validity and invalidate if needed
|
||||
checkAndInvalidateAutocomplete();
|
||||
|
||||
if (!node.autocomplete) {
|
||||
initializeAutocomplete();
|
||||
}
|
||||
|
||||
|
||||
if (typeof originalCallback === "function") {
|
||||
originalCallback.call(node, value);
|
||||
}
|
||||
};
|
||||
|
||||
setupAutocompleteCleanup(node);
|
||||
|
||||
|
||||
return enhancedCallback;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -119,6 +119,18 @@ app.registerExtension({
|
||||
// Merge the loras data
|
||||
const mergedLoras = mergeLoras(node.widgets[2].value, existingLoras);
|
||||
node.lorasWidget.value = mergedLoras;
|
||||
|
||||
const inputWidget = node.inputWidget || node.widgets[2];
|
||||
if (inputWidget && !node.autocomplete) {
|
||||
const { setupInputWidgetWithAutocomplete } = await import("./utils.js");
|
||||
const modelType = "loras";
|
||||
const autocompleteOptions = {
|
||||
maxItems: 20,
|
||||
minChars: 1,
|
||||
debounceDelay: 200,
|
||||
};
|
||||
inputWidget.callback = setupInputWidgetWithAutocomplete(node, inputWidget, inputWidget.callback, modelType, autocompleteOptions);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user