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:
Will Miao
2026-01-17 14:19:20 +08:00
parent 07d599810d
commit 88e7f671d2
9 changed files with 1652 additions and 1181 deletions

View File

@@ -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 ## 3. The `addDOMWidget` API

View File

@@ -293,6 +293,57 @@ class AutoComplete {
} }
}; };
document.addEventListener('click', this.onDocumentClick); 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 = '') { handleInput(value = '') {
@@ -638,7 +689,26 @@ class AutoComplete {
} }
return `${trimmed}, `; 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() { destroy() {
if (this.debounceTimer) { if (this.debounceTimer) {
clearTimeout(this.debounceTimer); clearTimeout(this.debounceTimer);

View File

@@ -241,6 +241,27 @@ app.registerExtension({
// Merge the loras data // Merge the loras data
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras); const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
node.lorasWidget.value = mergedLoras; 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);
}
} }
}, },
}); });

View File

@@ -146,12 +146,6 @@ app.registerExtension({
isUpdating = false; isUpdating = false;
} }
}; };
inputWidget.callback = setupInputWidgetWithAutocomplete(
this,
inputWidget,
originalCallback
);
}); });
} }
}, },
@@ -167,6 +161,18 @@ app.registerExtension({
// Merge the loras data // Merge the loras data
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras); const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
node.lorasWidget.value = mergedLoras; 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);
}
} }
}, },
}); });

View File

@@ -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);
}
}
},
}); });

View File

@@ -408,22 +408,55 @@ async function findWidgetInputElement(node, widget) {
const doSearch = () => { const doSearch = () => {
let inputElement = null; 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) { // PRIORITY 2: Fallback - heuristic search using widget containers
const hasInput = !!container.querySelector('input, textarea'); if (!inputElement) {
const textContent = container.textContent.toLowerCase(); const allWidgetContainers = document.querySelectorAll('.lg-node-widget, .dom-widget');
const containsWidgetName = textContent.includes(widgetName.toLowerCase());
const containsNodeTitle = textContent.includes(node.title?.toLowerCase() || '');
if (hasInput && (containsWidgetName || (widgetName === 'text' && container.querySelector('textarea')))) { for (const container of allWidgetContainers) {
inputElement = container.querySelector('input, textarea'); const hasInput = !!container.querySelector('input, textarea');
break; 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) { if (inputElement) {
console.log(`[Lora Manager] Found input element for widget "${widgetName}" on node ${nodeId}`);
resolve(inputElement); resolve(inputElement);
} else if (attempt < maxAttempts) { } else if (attempt < maxAttempts) {
setTimeout(() => searchForInput(attempt + 1).then(resolve), searchInterval); setTimeout(() => searchForInput(attempt + 1).then(resolve), searchInterval);
@@ -449,32 +482,59 @@ async function findWidgetInputElement(node, widget) {
* @returns {Function} Enhanced callback function with autocomplete * @returns {Function} Enhanced callback function with autocomplete
*/ */
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback, modelType = 'loras', autocompleteOptions = {}) { export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback, modelType = 'loras', autocompleteOptions = {}) {
let autocomplete = null;
let isInitializing = false;
const defaultOptions = { const defaultOptions = {
maxItems: 20, maxItems: 20,
minChars: 1, minChars: 1,
debounceDelay: 200, debounceDelay: 200,
}; };
const mergedOptions = { ...defaultOptions, ...autocompleteOptions }; const mergedOptions = { ...defaultOptions, ...autocompleteOptions };
setupAutocompleteCleanup(node);
// Track rendering mode changes per node
let lastVueNodesMode = typeof LiteGraph !== 'undefined' ? LiteGraph.vueNodesMode : false;
const initializeAutocomplete = async () => { const initializeAutocomplete = async () => {
if (autocomplete || isInitializing) return; if (node.autocomplete) {
isInitializing = true; console.log(`[Lora Manager] Autocomplete already initialized for widget "${inputWidget.name}" on node ${node.id}`);
return;
}
try { try {
let inputElement = null; 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; inputElement = inputWidget.inputEl;
console.log(`[Lora Manager] Using widget.inputEl for widget "${inputWidget.name}"`); // If not yet in DOM, wait for it to be added
} else { if (!document.body.contains(inputElement)) {
console.log(`[Lora Manager] Searching DOM for input element for widget "${inputWidget.name}"`); 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); inputElement = await findWidgetInputElement(node, inputWidget);
} }
if (inputElement) { if (inputElement) {
autocomplete = new AutoComplete(inputElement, modelType, mergedOptions); const autocomplete = new AutoComplete(inputElement, modelType, mergedOptions);
node.autocomplete = autocomplete; node.autocomplete = autocomplete;
console.log(`[Lora Manager] Autocomplete initialized for widget "${inputWidget.name}" on node ${node.id}`); console.log(`[Lora Manager] Autocomplete initialized for widget "${inputWidget.name}" on node ${node.id}`);
} else { } else {
@@ -482,23 +542,65 @@ export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCall
} }
} catch (error) { } catch (error) {
console.error('[Lora Manager] Error initializing autocomplete:', 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) => { const enhancedCallback = (value) => {
if (!autocomplete && !isInitializing) { // Check validity and invalidate if needed
checkAndInvalidateAutocomplete();
if (!node.autocomplete) {
initializeAutocomplete(); initializeAutocomplete();
} }
if (typeof originalCallback === "function") { if (typeof originalCallback === "function") {
originalCallback.call(node, value); originalCallback.call(node, value);
} }
}; };
setupAutocompleteCleanup(node);
return enhancedCallback; return enhancedCallback;
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -119,6 +119,18 @@ app.registerExtension({
// Merge the loras data // Merge the loras data
const mergedLoras = mergeLoras(node.widgets[2].value, existingLoras); const mergedLoras = mergeLoras(node.widgets[2].value, existingLoras);
node.lorasWidget.value = mergedLoras; 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);
}
} }
}, },
}); });