Files
ComfyUI-Lora-Manager/web/comfyui/utils.js
id-fa 7f92d09239 fix(ui): make Lora Loader list scrollable in Nodes 2.0 mode
In Nodes 2.0 / Vue node mode the Lora Loader list could not be capped
and the node grew to show every row, unlike classic mode which fixes the
list area to 12 rows. The Vue layout engine measures the rendered DOM, so
CSS variables and computeLayoutSize alone were ignored.

- Physically cap the container via max-height so the rendered element is
  bounded to the 12-row height; extra rows scroll (overflow: auto).
- Report the capped height through computeSize / computeLayoutSize /
  getHeight / getMinHeight so the node background matches the list.
- Add enableListWheelScroll: a window capture-phase wheel hook that scrolls
  the hovered list instead of letting ComfyUI zoom the canvas, which fires
  on the document/canvas in capture and beat a container-level listener.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:29:01 +09:00

915 lines
32 KiB
JavaScript

export const CONVERTED_TYPE = 'converted-widget';
import { app } from "../../scripts/app.js";
import { AutoComplete } from "./autocomplete.js";
const ROOT_GRAPH_ID = "root";
export const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)",
];
export const LORA_STACK_AGGREGATOR_NODE_TYPES = [
"Lora Stack Combiner (LoraManager)",
];
export const LORA_CHAIN_NODE_TYPES = [
...LORA_PROVIDER_NODE_TYPES,
...LORA_STACK_AGGREGATOR_NODE_TYPES,
];
export function isLoraProviderNode(comfyClass) {
return LORA_PROVIDER_NODE_TYPES.includes(comfyClass);
}
export function isLoraStackAggregatorNode(comfyClass) {
return LORA_STACK_AGGREGATOR_NODE_TYPES.includes(comfyClass);
}
export function isLoraChainNode(comfyClass) {
return LORA_CHAIN_NODE_TYPES.includes(comfyClass);
}
function isMapLike(collection) {
return collection && typeof collection.entries === "function" && typeof collection.values === "function";
}
function getChildGraphs(graph) {
if (!graph || !graph._subgraphs) {
return [];
}
const rawSubgraphs = isMapLike(graph._subgraphs)
? Array.from(graph._subgraphs.values())
: Object.values(graph._subgraphs);
return rawSubgraphs
.map((subgraph) => subgraph?.graph || subgraph?._graph || subgraph)
.filter((subgraph) => subgraph && subgraph !== graph);
}
function traverseGraphs(rootGraph, visitor, visited = new Set()) {
const graph = rootGraph || app.graph;
if (!graph) {
return;
}
const graphId = getGraphId(graph);
if (visited.has(graphId)) {
return;
}
visited.add(graphId);
visitor(graph);
for (const subgraph of getChildGraphs(graph)) {
traverseGraphs(subgraph, visitor, visited);
}
}
export function getGraphId(graph) {
return graph?.id ?? ROOT_GRAPH_ID;
}
export function getNodeGraphId(node) {
if (!node) {
return ROOT_GRAPH_ID;
}
return getGraphId(node.graph || app.graph);
}
export function getGraphById(graphId, rootGraph = app.graph) {
if (!graphId) {
return rootGraph;
}
let foundGraph = null;
traverseGraphs(rootGraph, (graph) => {
if (!foundGraph && getGraphId(graph) === graphId) {
foundGraph = graph;
}
});
return foundGraph;
}
export function getNodeFromGraph(graphId, nodeId) {
const graph = getGraphById(graphId) || app.graph;
if (!graph || typeof graph.getNodeById !== "function") {
return null;
}
const numericId = typeof nodeId === "string" ? Number(nodeId) : nodeId;
return graph.getNodeById(Number.isNaN(numericId) ? nodeId : numericId) || null;
}
export function getAllGraphNodes(rootGraph = app.graph) {
const nodes = [];
traverseGraphs(rootGraph, (graph) => {
if (Array.isArray(graph._nodes)) {
for (const node of graph._nodes) {
nodes.push({ graph, node });
}
}
});
return nodes;
}
export function getNodeReference(node) {
if (!node) {
return null;
}
return {
node_id: node.id,
graph_id: getNodeGraphId(node),
};
}
export function getNodeKey(node) {
if (!node) {
return null;
}
return `${getNodeGraphId(node)}:${node.id}`;
}
export function getWidgetByName(node, widgetName) {
if (!node || !Array.isArray(node.widgets)) {
return null;
}
return node.widgets.find((widget) => widget?.name === widgetName) || null;
}
export function getWidgetSerializedValue(node, widgetName) {
if (!node || !Array.isArray(node.widgets) || !Array.isArray(node.widgets_values)) {
return undefined;
}
const widgetIndex = node.widgets.findIndex((widget) => widget?.name === widgetName);
if (widgetIndex === -1) {
return undefined;
}
return node.widgets_values[widgetIndex];
}
export function getLinkFromGraph(graph, linkId) {
if (!graph || graph.links == null) {
return null;
}
if (isMapLike(graph.links)) {
return graph.links.get(linkId) || null;
}
return graph.links[linkId] || null;
}
export function chainCallback(object, property, callback) {
if (object == undefined) {
//This should not happen.
console.error("Tried to add callback to non-existant object")
return;
}
if (property in object) {
const callback_orig = object[property]
object[property] = function () {
const r = callback_orig.apply(this, arguments);
callback.apply(this, arguments);
return r
};
} else {
object[property] = callback;
}
}
/**
* Show a toast notification
* @param {Object|string} options - Toast options object or message string for backward compatibility
* @param {string} [options.severity] - Message severity level (success, info, warn, error, secondary, contrast)
* @param {string} [options.summary] - Short title for the toast
* @param {any} [options.detail] - Detailed message content
* @param {boolean} [options.closable] - Whether user can close the toast (default: true)
* @param {number} [options.life] - Duration in milliseconds before auto-closing
* @param {string} [options.group] - Group identifier for managing related toasts
* @param {any} [options.styleClass] - Style class of the message
* @param {any} [options.contentStyleClass] - Style class of the content
* @param {string} [type] - Deprecated: severity type for backward compatibility
*/
export function showToast(options, type = 'info') {
// Handle backward compatibility: showToast(message, type)
if (typeof options === 'string') {
options = {
detail: options,
severity: type
};
}
// Set defaults
const toastOptions = {
severity: options.severity || 'info',
summary: options.summary,
detail: options.detail,
closable: options.closable !== false, // default to true
life: options.life,
group: options.group,
styleClass: options.styleClass,
contentStyleClass: options.contentStyleClass
};
// Remove undefined properties
Object.keys(toastOptions).forEach(key => {
if (toastOptions[key] === undefined) {
delete toastOptions[key];
}
});
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add(toastOptions);
} else {
const message = toastOptions.detail || toastOptions.summary || 'No message';
const severity = toastOptions.severity.toUpperCase();
console.log(`${severity}: ${message}`);
// Fallback alert for critical errors only
if (toastOptions.severity === 'error') {
alert(message);
}
}
}
export function hideWidgetForGood(node, widget, suffix = "") {
widget.origType = widget.type;
widget.origComputeSize = widget.computeSize;
widget.origSerializeValue = widget.serializeValue;
widget.computeSize = () => [0, -4]; // -4 is due to the gap litegraph adds between widgets automatically
widget.type = CONVERTED_TYPE + suffix;
// widget.serializeValue = () => {
// // Prevent serializing the widget if we have no input linked
// const w = node.inputs?.find((i) => i.widget?.name === widget.name);
// if (w?.link == null) {
// return undefined;
// }
// return widget.origSerializeValue ? widget.origSerializeValue() : widget.value;
// };
// Hide any linked widgets, e.g. seed+seedControl
if (widget.linkedWidgets) {
for (const w of widget.linkedWidgets) {
hideWidgetForGood(node, w, `:${widget.name}`);
}
}
}
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
export const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\d\.]+))?>/g;
function isLoraStackInput(input) {
return input?.type === "LORA_STACK";
}
// Get connected LORA_STACK chain nodes that feed into the current node
export function getConnectedInputLoraChainNodes(node) {
const connectedNodes = [];
if (!node?.inputs) {
return connectedNodes;
}
for (const input of node.inputs) {
if (!isLoraStackInput(input) || !input.link) {
continue;
}
const link = getLinkFromGraph(node.graph, input.link);
if (!link) {
continue;
}
const sourceNode = node.graph?.getNodeById?.(link.origin_id);
if (sourceNode && isLoraChainNode(sourceNode.comfyClass)) {
connectedNodes.push(sourceNode);
}
}
return connectedNodes;
}
// Get connected TriggerWord Toggle nodes that receive output from the current node
export function getConnectedTriggerToggleNodes(node) {
const connectedNodes = [];
if (!node?.outputs) {
return connectedNodes;
}
for (const output of node.outputs) {
if (!output?.links?.length) {
continue;
}
for (const linkId of output.links) {
const link = getLinkFromGraph(node.graph, linkId);
if (!link) {
continue;
}
const targetNode = node.graph?.getNodeById?.(link.target_id);
if (targetNode && targetNode.comfyClass === "TriggerWord Toggle (LoraManager)") {
connectedNodes.push(targetNode);
}
}
}
return connectedNodes;
}
// Extract active lora names from a node's widgets
export function getActiveLorasFromNode(node) {
const activeLoraNames = new Set();
// Handle Lora Cycler (single LoRA from cycler_config widget)
if (node.comfyClass === "Lora Cycler (LoraManager)") {
const cyclerWidget = node.widgets?.find(w => w.name === 'cycler_config');
if (cyclerWidget?.value?.current_lora_filename) {
activeLoraNames.add(cyclerWidget.value.current_lora_filename);
}
return activeLoraNames;
}
// Aggregator nodes do not own LoRA state directly; they only forward upstream stacks.
if (isLoraStackAggregatorNode(node.comfyClass)) {
return activeLoraNames;
}
// Handle Lora Stacker and Lora Randomizer (lorasWidget)
let lorasWidget = node.lorasWidget;
if (!lorasWidget && node.widgets) {
lorasWidget = node.widgets.find(w => w.name === 'loras');
}
if (lorasWidget && lorasWidget.value) {
lorasWidget.value.forEach(lora => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
});
}
return activeLoraNames;
}
// Recursively collect all active loras from a node and its input chain
// A node is considered active only if its mode is 0 (Always) or 3 (On Trigger)
export function collectActiveLorasFromChain(node, visited = new Set()) {
// Prevent infinite loops from circular references
const nodeKey = getNodeKey(node);
if (!nodeKey) {
return new Set();
}
if (visited.has(nodeKey)) {
return new Set();
}
visited.add(nodeKey);
// Check if node is active (mode 0 for Always, mode 3 for On Trigger)
// Mode 2 is Never, Mode 4 is Bypass
const isNodeActive = node.mode === undefined || node.mode === 0 || node.mode === 3;
if (!isNodeActive) {
return new Set();
}
// Get active loras from current node only if node is active
const allActiveLoraNames = getActiveLorasFromNode(node);
// Get connected input LORA_STACK chain nodes and collect their active loras
const inputChainNodes = getConnectedInputLoraChainNodes(node);
for (const chainNode of inputChainNodes) {
const upstreamLoras = collectActiveLorasFromChain(chainNode, visited);
upstreamLoras.forEach(name => allActiveLoraNames.add(name));
}
return allActiveLoraNames;
}
// Update trigger words for connected toggle nodes
export function updateConnectedTriggerWords(node, loraNames) {
const connectedNodes = getConnectedTriggerToggleNodes(node);
if (connectedNodes.length > 0) {
const nodeIds = connectedNodes
.map((connectedNode) => getNodeReference(connectedNode))
.filter((reference) => reference !== null);
if (nodeIds.length === 0) {
return;
}
fetch("/api/lm/loras/get_trigger_words", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lora_names: Array.from(loraNames),
node_ids: nodeIds
})
}).catch(err => console.error("Error fetching trigger words:", err));
}
}
export function mergeLoras(lorasText, lorasArr) {
// Parse lorasText into a map: name -> {strength, clipStrength}
const parsedLoras = {};
let match;
LORA_PATTERN.lastIndex = 0;
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const modelStrength = Number(match[2]);
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
parsedLoras[name] = { strength: modelStrength, clipStrength };
}
// Build result array in the order of lorasArr
const result = [];
const usedNames = new Set();
for (const lora of lorasArr) {
if (parsedLoras[lora.name]) {
result.push({
name: lora.name,
strength: lora.strength !== undefined ? lora.strength : parsedLoras[lora.name].strength,
active: lora.active !== undefined ? lora.active : true,
expanded: lora.expanded !== undefined ? lora.expanded : false,
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
});
usedNames.add(lora.name);
}
}
// Add any new loras from lorasText that are not in lorasArr, in their text order
for (const name in parsedLoras) {
if (!usedNames.has(name)) {
result.push({
name,
strength: parsedLoras[name].strength,
active: true,
clipStrength: parsedLoras[name].clipStrength,
});
}
}
return result;
}
/**
* Find the actual input element for a widget
* @param {Object} node - The node instance
* @param {Object} widget - The widget to find input element for
* @returns {Promise<HTMLElement|null>} The input element or null
*/
async function findWidgetInputElement(node, widget) {
if (widget.inputEl && document.body.contains(widget.inputEl)) {
return widget.inputEl;
}
const nodeId = node.id;
const widgetName = widget.name;
const maxAttempts = 20;
const searchInterval = 50;
const searchForInput = (attempt = 0) => {
return new Promise((resolve) => {
const doSearch = () => {
let inputElement = null;
// 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;
}
}
}
}
// PRIORITY 2: Fallback - heuristic search using widget containers
if (!inputElement) {
const allWidgetContainers = document.querySelectorAll('.lg-node-widget, .dom-widget');
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) {
resolve(inputElement);
} else if (attempt < maxAttempts) {
setTimeout(() => searchForInput(attempt + 1).then(resolve), searchInterval);
} else {
console.warn(`[Lora Manager] Could not find input element for widget "${widgetName}" on node ${nodeId} after ${maxAttempts} attempts`);
resolve(null);
}
};
doSearch();
});
};
return searchForInput();
}
/**
* Initialize autocomplete for an input widget and setup cleanup
* @param {Object} node - The node instance
* @param {Object} inputWidget - The input widget to add autocomplete to
* @param {Function} originalCallback - The original callback function
* @param {string} [modelType='loras'] - The model type used by the autocomplete API
* @param {Object} [autocompleteOptions] - Additional options for the autocomplete instance
* @returns {Function} Enhanced callback function with autocomplete
*/
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback, modelType = 'loras', autocompleteOptions = {}) {
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 (node.autocomplete) {
console.log(`[Lora Manager] Autocomplete already initialized for widget "${inputWidget.name}" on node ${node.id}`);
return;
}
try {
let inputElement = null;
// 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;
// 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) {
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 {
console.warn(`[Lora Manager] Could not find input element for widget "${inputWidget.name}" on node ${node.id}`);
}
} catch (error) {
console.error('[Lora Manager] Error initializing autocomplete:', error);
}
};
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) => {
// Check validity and invalidate if needed
checkAndInvalidateAutocomplete();
if (!node.autocomplete) {
initializeAutocomplete();
}
if (typeof originalCallback === "function") {
originalCallback.call(node, value);
}
};
return enhancedCallback;
}
/**
* Setup autocomplete cleanup when node is removed
* @param {Object} node - The node instance
*/
export function setupAutocompleteCleanup(node) {
// Override onRemoved to cleanup autocomplete
const originalOnRemoved = node.onRemoved;
node.onRemoved = function() {
if (this.autocomplete) {
this.autocomplete.destroy();
this.autocomplete = null;
}
if (originalOnRemoved) {
originalOnRemoved.call(this);
}
};
}
/**
* Forward middle mouse (button 1) pointer events from a container to the ComfyUI canvas,
* so that workflow panning works even when the pointer is over a DOM widget.
* @param {HTMLElement} container - The root DOM element of the widget
*/
export function forwardMiddleMouseToCanvas(container) {
if (!container) return;
// Forward pointerdown
container.addEventListener('pointerdown', (event) => {
if (event.button === 1) {
app.canvas.processMouseDown(event);
}
});
// Forward pointermove
container.addEventListener('pointermove', (event) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event);
}
});
// Forward pointerup
container.addEventListener('pointerup', (event) => {
if (event.button === 1) {
app.canvas.processMouseUp(event);
}
});
}
/**
* Forward wheel events from a container to the ComfyUI canvas for zooming,
* unless the container has scrollable content.
* This allows canvas zoom to work when hovering over DOM widgets.
* @param {HTMLElement} container - The root DOM element of the widget
* @param {Object} options - Configuration options
* @param {boolean} options.captureWheel - If true, always capture wheel events (default: false)
*/
export function forwardWheelToCanvas(container, options = {}) {
if (!container) return;
const { captureWheel = false } = options;
container.addEventListener('wheel', (event) => {
// If explicitly capturing wheel (for internal scrolling), stop here
if (captureWheel) {
event.stopPropagation();
return;
}
// Access ComfyUI app from global window
const comfyApp = window.app;
if (!comfyApp || !comfyApp.canvas || typeof comfyApp.canvas.processMouseWheel !== 'function') {
return;
}
const deltaX = event.deltaX;
const deltaY = event.deltaY;
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY);
// 1. Handle pinch-to-zoom (ctrlKey is true for pinch-to-zoom on most browsers)
if (event.ctrlKey) {
event.preventDefault();
event.stopPropagation();
comfyApp.canvas.processMouseWheel(event);
return;
}
// 2. Horizontal scroll: pass to canvas (widgets usually don't scroll horizontally)
if (isHorizontal) {
event.preventDefault();
event.stopPropagation();
comfyApp.canvas.processMouseWheel(event);
return;
}
// 3. Vertical scrolling: check if container is scrollable
const canScrollY = container.scrollHeight > container.clientHeight;
if (canScrollY) {
// Container is scrollable, let it handle the wheel event but stop propagation
// to prevent the canvas from zooming while the user is trying to scroll
event.stopPropagation();
} else {
// Container is NOT scrollable, forward the wheel event to the canvas
// so it can trigger zoom in/out
event.preventDefault();
event.stopPropagation();
comfyApp.canvas.processMouseWheel(event);
}
}, { passive: false });
}
// Marks elements whose wheel scrolling must win over the canvas zoom.
const LIST_WHEEL_SCROLL_CLASS = 'lm-wheel-scrollable';
let listWheelScrollHookInstalled = false;
/**
* Keep vertical wheel scrolling inside a scrollable widget container instead of
* letting ComfyUI zoom the canvas.
*
* In Nodes 2.0 / Vue mode ComfyUI's wheel→zoom handler runs on the document /
* canvas in the capture phase, which is *outer* than the widget, so a listener
* on the container (even in capture) fires too late. The reliable place to win
* is a single hook on `window` in the capture phase — the very first step of
* event dispatch. When the wheel is over a marked, scrollable element we scroll
* it manually and fully consume the event; otherwise we leave it alone so canvas
* zoom keeps working.
* @param {HTMLElement} container - The scrollable element (overflow: auto)
*/
export function enableListWheelScroll(container) {
if (!container) return;
container.classList.add(LIST_WHEEL_SCROLL_CLASS);
if (listWheelScrollHookInstalled) return;
listWheelScrollHookInstalled = true;
window.addEventListener('wheel', (event) => {
// Let pinch/zoom and horizontal gestures fall through to the canvas.
if (event.ctrlKey) return;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
const target = event.target;
if (!target || typeof target.closest !== 'function') return;
const el = target.closest(`.${LIST_WHEEL_SCROLL_CLASS}`);
if (!el) return;
const canScrollY = el.scrollHeight > el.clientHeight + 1;
if (!canScrollY) return; // Nothing to scroll → allow canvas zoom.
// Translate the wheel delta to pixels (line / page modes → approx px).
const unit = event.deltaMode === 1
? 16
: event.deltaMode === 2
? el.clientHeight
: 1;
el.scrollTop += event.deltaY * unit;
// Consume the event so neither ComfyUI's zoom nor forwardWheelToCanvas react.
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}, { capture: true, passive: false });
}
// Get connected Lora Pool node from pool_config input
export function getConnectedPoolConfigNode(node) {
if (!node?.inputs) {
return null;
}
for (const input of node.inputs) {
if (input.name !== "pool_config" || !input.link) {
continue;
}
const link = getLinkFromGraph(node.graph, input.link);
if (!link) {
continue;
}
const sourceNode = node.graph?.getNodeById?.(link.origin_id);
if (sourceNode && sourceNode.comfyClass === "Lora Pool (LoraManager)") {
return sourceNode;
}
}
return null;
}
// Get pool config widget value from connected Lora Pool node
export function getPoolConfigFromConnectedNode(node) {
const poolNode = getConnectedPoolConfigNode(node);
if (!poolNode) {
return null;
}
const isNodeActive = poolNode.mode === undefined || poolNode.mode === 0 || poolNode.mode === 3;
if (!isNodeActive) {
return null;
}
const poolWidget = poolNode.widgets?.find(w => w.name === "pool_config");
return poolWidget?.value || null;
}
// Helper function to find and update downstream Lora Loader nodes
export function updateDownstreamLoaders(startNode, visited = new Set()) {
const nodeKey = getNodeKey(startNode);
if (!nodeKey || visited.has(nodeKey)) return;
visited.add(nodeKey);
// Check each output link
if (startNode.outputs) {
for (const output of startNode.outputs) {
if (output.links) {
for (const linkId of output.links) {
const link = getLinkFromGraph(startNode.graph, linkId);
if (link) {
const targetNode = startNode.graph?.getNodeById?.(link.target_id);
// If target is a Lora Loader, collect all active loras in the chain and update
if (
targetNode &&
targetNode.comfyClass === "Lora Loader (LoraManager)"
) {
const allActiveLoraNames =
collectActiveLorasFromChain(targetNode);
updateConnectedTriggerWords(targetNode, allActiveLoraNames);
}
// If target is another LORA_STACK chain node, recursively check its outputs
else if (targetNode && isLoraChainNode(targetNode.comfyClass)) {
updateDownstreamLoaders(targetNode, visited);
}
}
}
}
}
}
}