From 90684e9e570d8944d95957ea6fb8cd5ed70ccbb9 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Mon, 2 Jun 2025 22:57:05 +0200 Subject: [PATCH 1/4] Try to make it work as intended --- web/js/dynamicnode.js | 614 ++++++++++++++++++++++++++++++------------ 1 file changed, 440 insertions(+), 174 deletions(-) diff --git a/web/js/dynamicnode.js b/web/js/dynamicnode.js index 7db19b2..fa8d893 100644 --- a/web/js/dynamicnode.js +++ b/web/js/dynamicnode.js @@ -55,6 +55,7 @@ app.registerExtension({ /** Array of groups of (generic) dynamic inputs. */ const dynamicInputGroups = []; for (const name of combinedInputDataOrder) { + const widgetType = combinedInputData[name][1]?.widgetType; const dynamic = combinedInputData[name][1]?._dynamic; const dynamicGroup = combinedInputData[name][1]?._dynamicGroup ?? 0; @@ -81,7 +82,7 @@ app.registerExtension({ dynamicInputGroups[dynamicGroup].push( dynamicInputs.length ) - dynamicInputs.push({name, baseName, matcher, dynamic, dynamicType, dynamicGroup}); + dynamicInputs.push({name, baseName, matcher, dynamic, dynamicType, dynamicGroup, widgetType}); } } if (dynamicInputs.length === 0) { @@ -89,16 +90,13 @@ app.registerExtension({ } /** - * Utility: Check if an input is dynamic. - * @param {string} inputName - Name of the input to check. + * Utility functions for dynamic input operations */ + // Check if an input is dynamic const isDynamicInput = (inputName) => - dynamicInputs.some((di) => di.matcher.test(inputName)); + dynamicInputs.some((di) => di.matcher.test(inputName)); - /** - * Utility: Update inputs' slot indices after reordering. - * @param {ComfyNode} node - The node to update. - */ + // Update inputs' slot indices after reordering const updateSlotIndices = (node) => { node.inputs.forEach((input, index) => { input.slot_index = index; @@ -113,21 +111,88 @@ app.registerExtension({ }); }; + // Get default value for widget based on its type + const getWidgetDefaultValue = (widget) => { + switch (widget.type) { + case 'number': + return 0; + case 'combo': + return widget.options?.[0] || ''; + case 'text': + case 'string': + default: + return ''; + } + }; + + // Check if a dynamic input is empty (not connected and widget has default value) + const isDynamicInputEmpty = (node, inputIndex) => { + const input = node.inputs[inputIndex]; + if (input.isConnected) return false; + + if (input.widget) { + const widget = node.widgets.find(w => w.name === input.widget.name); + return widget?.value === getWidgetDefaultValue(widget); + } + return true; + }; + + // Find if input is the last of its base name group + const isLastDynamicInput = (node, idx, baseName) => { + let isLast = true; + for (let i = idx + 1; i < node.inputs.length; i++) { + isLast &&= !node.inputs[i].name.startsWith(baseName); + } + return isLast; + }; + + // Remove widget associated with an input + const removeWidgetForInput = (node, inputIdx) => { + if (node.inputs[inputIdx].widget !== undefined) { + const widgetIdx = node.widgets.findIndex((w) => w.name === node.inputs[inputIdx].widget.name); + node.widgets.splice(widgetIdx, 1); + node.widgets_values?.splice(widgetIdx, 1); + } + }; + + // Add helper method to get dynamic group for an input name + nodeType.prototype.getDynamicGroup = function(inputName) { + // Find the dynamicGroup by matching the baseName with input name + for (const di of dynamicInputs) { + if (inputName.startsWith(di.baseName)) { + return di.dynamicGroup; + } + } + return undefined; + }; + + /** + * Add a widget with standard configuration. + * @param {string} name - The name of the widget. + * @param {string} widget_type - The type of widget. + * @returns {object} The created widget. + */ + const addStandardWidget = function(name, widget_type) { + return this.addWidget(widget_type, name, '', () => {}, {}); + }; // Add helper method to insert input at a specific position - nodeType.prototype.addInputAtPosition = function (name, type, position, isWidget, shape) { + nodeType.prototype.addInputAtPosition = function (name, input_type, widget_type, position, isWidget, shape) { + // Add widget if needed if (isWidget) { - this.addWidget(type, name, '', ()=>{}, {}); + addStandardWidget.call(this, name, widget_type); const GET_CONFIG = Symbol(); - const input = this.addInput(name, type, { + this.addInput(name, input_type, { shape, widget: {name, [GET_CONFIG]: () =>{}} - }) + }); } else { - this.addInput(name, type, {shape}); // Add new input + this.addInput(name, input_type, {shape}); // Add new input without widget } - const newInput = this.inputs.pop(); // Fetch the newly added input (last item) + + // Position the input at the desired location + const newInput = this.inputs.pop(); // Get the newly added input (last item) this.inputs.splice(position, 0, newInput); // Place it at the desired position updateSlotIndices(this); // Update indices return newInput; @@ -137,155 +202,141 @@ app.registerExtension({ // running in parallel. let isProcessingConnection = false; + /** + * Utility: Handle when a dynamic input becomes empty (disconnected or empty widget value). + * @param {ComfyNode} node - The node with the empty input. + */ + const handleEmptyDynamicInput = function() { + // Process each input to check for empty dynamic inputs + for (let idx = 0; idx < this.inputs.length; idx++) { + const input = this.inputs[idx]; + + // Skip if not a dynamic input + if (!isDynamicInput(input.name)) { + continue; + } + + // Check if this input is empty + if (!isDynamicInputEmpty(this, idx)) { + continue; + } + + // Get information about this dynamic input group + const dynamicGroup = this.getDynamicGroup(input.name); + const baseName = dynamicInputs[dynamicInputGroups[dynamicGroup][0]].baseName; + + // Don't remove if it's the last dynamic input of its type + if (isLastDynamicInput(this, idx, baseName)) { + continue; + } + + // Move the empty input to the end + for (let i = idx + 1; i < this.inputs.length; i++) { + this.swapInputs(i - 1, i); + } + + // Remove the input that's now at the end + const lastIdx = this.inputs.length - 1; + removeWidgetForInput(this, lastIdx); + this.removeInput(lastIdx); + + // Adjust idx to check the current position again (which now has a new input) + idx--; + } + + // Renumber all dynamic inputs to ensure proper ordering + for (const groupIdx in dynamicInputGroups) { + for (const memberIdx of dynamicInputGroups[groupIdx]) { + const baseName = dynamicInputs[memberIdx].baseName; + const dynamic = dynamicInputs[memberIdx].dynamic; + this.renumberDynamicInputs(baseName, dynamicInputs, dynamic); + } + } + }; + + /** + * Utility: Handle when a dynamic input becomes active (connected or non-empty widget value). + * @param {ComfyNode} node - The node with the activated input. + * @param {number} dynamicGroup - The dynamic group to handle. + */ + const handleDynamicInputActivation = function(dynamicGroup) { + // Get information about dynamic inputs + const { + slots: dynamicSlots, + groupConnected: dynamicGroupConnected + } = this.getDynamicSlots(); + + // Ensure all widget-based inputs have actual widgets + // This is important when loading workflows + for (const slot of dynamicSlots) { + if (slot.isWidget && this.widgets && + !this.widgets.some((w) => w.name === slot.name)) { + this.addWidget( + this.inputs[slot.index].type, + slot.name, + '', + ()=>{}, + {} + ); + } + } + + // If all inputs in this group are active, we need to add a new empty one + const hasEmptyInput = dynamicGroupConnected[dynamicGroup]?.some(isActive => !isActive); + + if (!hasEmptyInput) { + // Find position for the new input (after the last one in this group) + const groupSlots = dynamicSlots.filter(slot => slot.dynamicGroup === dynamicGroup); + const lastDynamicIdx = groupSlots.length > 0 + ? Math.max(...groupSlots.map(slot => slot.index)) + : -1; + + // Add a new empty input for this group + this.addNewDynamicInputForGroup(dynamicGroup, lastDynamicIdx); + } + + // Ensure the canvas is updated + + this.setDirtyCanvas(true, true); + }; + // Override onConnectionsChange: Handle connections for dynamic inputs const onConnectionsChange = nodeType.prototype.onConnectionsChange; nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, link, ioSlot) { + // Call the original method first const result = onConnectionsChange?.apply(this, arguments); - if (type !== TypeSlot.Input || isProcessingConnection || !isDynamicInput(this.inputs[slotIndex].name)) { - return result; - } + // Only process input connections for dynamic inputs + const isInput = type === TypeSlot.Input; + const isDynamic = isInput && isDynamicInput(this.inputs[slotIndex].name); - function getDynamicGroup(inputName) { - // Find the dynamicGroup by matching the baseName with input name - for (const di of dynamicInputs) { - if (inputName.startsWith(di.baseName)) { - return di.dynamicGroup; - } - } - return undefined; + // Skip if not an input, already processing, or not a dynamic input + if (!isInput || isProcessingConnection || !isDynamic) { + return result; } + // Prevent recursive processing isProcessingConnection = true; - try { - // Get dynamic input slots - const dynamicSlots = []; - const dynamicGroupCount = []; - const dynamicGroupConnected = []; - for (const [index, input] of this.inputs.entries()) { - const isDynamic = isDynamicInput(input.name); - if (isDynamic) { - const connected = input.isConnected; - const dynamicGroup = getDynamicGroup(input.name); - if (dynamicGroup in dynamicGroupCount) { - if (input.name.startsWith(dynamicInputs[dynamicInputGroups[dynamicGroup][0]].baseName)) { - dynamicGroupConnected[dynamicGroup][dynamicGroupCount[dynamicGroup]] ||= connected; - dynamicGroupCount[dynamicGroup]++; - } - } else { - dynamicGroupCount[dynamicGroup] = 1; - dynamicGroupConnected[dynamicGroup] = [connected]; - } - dynamicSlots.push({ - index, - name: input.name, - isWidget: input.widget !== undefined, - shape: input.shape, - connected, - isDynamic, - dynamicGroup, - dynamicGroupCount: dynamicGroupCount[dynamicGroup] - }); - - // sanity check to make sure every widget is in reality a widget. When loading a workflow this - // isn't the case so we must fix it ourselves. - if (this.widgets && !this.widgets.some((w) => w.name === input.name)) { - this.addWidget(input.type, input.name, '', ()=>{}, {}); - } - } - } - - // Handle connection event - if (isConnected === TypeSlotEvent.Connect) { - const hasEmptyDynamic = dynamicGroupConnected[0].some(dgc => !dgc); - - if (!hasEmptyDynamic) { - // No empty slot - add a new one after the last dynamic input - const lastDynamicIdx = Math.max(...dynamicSlots.map((slot) => slot.index), -1); - let insertPosition = lastDynamicIdx + 1; - let inputInRange = true; - - for (const groupMember of dynamicInputGroups[dynamicInputs[0].dynamicGroup]) { - const baseName = dynamicInputs[groupMember].baseName; - const dynamicType = dynamicInputs[groupMember].dynamicType; - let newName; - if (dynamicInputs[0].dynamic === 'letter') { - if (dynamicSlots.length >= 26) { - inputInRange = false; - } - // For letter type, use the next letter in sequence - newName = String.fromCharCode(97 + dynamicSlots.length); // 97 is ASCII for 'a' - } else { - // For number type, use baseName + index as before - newName = `${baseName}${dynamicSlots.length}`; - } - - if (inputInRange) { - // Insert the new empty input at the correct position - this.addInputAtPosition(newName, dynamicType, insertPosition++, dynamicSlots[groupMember].isWidget, dynamicSlots[groupMember].shape); - // Renumber inputs after addition - this.renumberDynamicInputs(baseName, dynamicInputs, dynamicInputs[0].dynamic); - } - } - } - } else if (isConnected === TypeSlotEvent.Disconnect) { - let foundEmptyIndex = -1; - - for (let idx = 0; idx < this.inputs.length; idx++) { - const input = this.inputs[idx]; - - if (!isDynamicInput(input.name)) { - continue; - } - - if (!input.isConnected) { // Check if the input is empty - // remove empty input - but only when it's not the last one - const dynamicGroup = getDynamicGroup(input.name); - let isLast = true; - for (let i = idx + 1; i < this.inputs.length; i++) { - isLast &&= !this.inputs[i].name.startsWith(dynamicInputs[dynamicInputGroups[dynamicGroup][0]].baseName); - } - if (isLast) { - continue; - } - - for (let i = idx + 1; i < this.inputs.length; i++) { - this.swapInputs(i - 1, i); - } - const lastIdx = this.inputs.length - 1; - if (this.inputs[lastIdx].widget !== undefined) { - const widgetIdx = this.widgets.findIndex((w) => w.name === this.inputs[lastIdx].widget.name) - this.widgets.splice(widgetIdx, 1); - this.widgets_values?.splice(widgetIdx, 1); - } - this.removeInput(lastIdx); - } - } + // Get the dynamic group for this input + const dynamicGroup = this.getDynamicGroup(this.inputs[slotIndex].name); - // Renumber dynamic inputs to ensure proper ordering - for (const groupMember of dynamicInputGroups[dynamicInputs[0].dynamicGroup]) { - const baseName = dynamicInputs[groupMember].baseName; - this.renumberDynamicInputs(baseName, dynamicInputs, dynamicInputs[0].dynamic); - } - - } - - this.setDirtyCanvas(true, true); - } catch (e) { - console.error(e); - debugger; - alert(e); - } finally { - isProcessingConnection = false; + // Handle connection or disconnection event + if (isConnected === TypeSlotEvent.Connect) { + // Input was connected + handleDynamicInputActivation.call(this, dynamicGroup); + } else if (isConnected === TypeSlotEvent.Disconnect) { + // Input was disconnected + handleEmptyDynamicInput.call(this); } + isProcessingConnection = false; return result; }; const onConnectInput = nodeType.prototype.onConnectInput; nodeType.prototype.onConnectInput = function(inputIndex, outputType, outputSlot, outputNode, outputIndex) { - const result = onRemoved?.apply(this, arguments) ?? true; + const result = onConnectInput?.apply(this, arguments) ?? true; if (this.inputs[inputIndex].isConnected) { const pre_isProcessingConnection = isProcessingConnection; @@ -312,6 +363,67 @@ app.registerExtension({ return result; } + /** + * Utility: Find input index for a widget by name. + * @param {ComfyNode} node - The node containing the widget. + * @param {string} widgetName - Name of the widget to find. + * @returns {number} Index of the input associated with the widget, or -1 if not found. + */ + const findInputIndexForWidget = (node, widgetName) => { + for (let i = 0; i < node.inputs.length; i++) { + if (node.inputs[i].widget && node.inputs[i].widget.name === widgetName) { + return i; + } + } + return -1; + }; + + const onWidgetChanged = nodeType.prototype.onWidgetChanged; + nodeType.prototype.onWidgetChanged = function () { + const result = onWidgetChanged?.apply(this, arguments); + + // Extract arguments + const widget_name = arguments[0]; + const new_val = arguments[1]; + const old_val = arguments[2]; + const widget = arguments[3]; + + // Skip if not a dynamic input widget or already processing connections + if (!isDynamicInput(widget_name) || isProcessingConnection) { + return result; + } + + // Find the dynamic group for this widget + const dynamicGroup = this.getDynamicGroup(widget_name); + if (dynamicGroup === undefined) { + return result; + } + + // Check if widget value changed between default and non-default + const default_val = getWidgetDefaultValue(widget); + const wasEmpty = old_val === default_val; + const isNowEmpty = new_val === default_val; + + // Only process if the empty state changed + if (wasEmpty === isNowEmpty) { + return result; + } + + // Prevent recursive processing + isProcessingConnection = true; + + if (wasEmpty && !isNowEmpty) { + // Widget changed from empty to non-empty (like connecting an input) + handleDynamicInputActivation.call(this, dynamicGroup); + } else if (!wasEmpty && isNowEmpty) { + // Widget changed to empty (like disconnecting an input) + handleEmptyDynamicInput.call(this); + } + + isProcessingConnection = false; + return result; + } + // Method to swap two inputs in the "this.inputs" array by their indices nodeType.prototype.swapInputs = function(indexA, indexB) { // Validate indices @@ -325,64 +437,218 @@ app.registerExtension({ return; } - // reflect the swap with the widgets - if (this.inputs[indexA].widget !== undefined) { - if (this.inputs[indexB].widget === undefined) { - console.error("Bad swap: input A is a widget but input B is not", indexA, indexB); - } - const widgetIdxA = this.widgets.findIndex((w) => w.name === this.inputs[indexA].widget.name); - const widgetIdxB = this.widgets.findIndex((w) => w.name === this.inputs[indexB].widget.name); - [this.widgets[widgetIdxA].y, this.widgets[widgetIdxB].y] = [this.widgets[widgetIdxB].y, this.widgets[widgetIdxA].y]; - [this.widgets[widgetIdxA].last_y, this.widgets[widgetIdxB].last_y] = [this.widgets[widgetIdxB].last_y, this.widgets[widgetIdxA].last_y]; - [this.widgets[widgetIdxA], this.widgets[widgetIdxB]] = [this.widgets[widgetIdxB], this.widgets[widgetIdxA]]; + // Handle widgets if both inputs have them + const hasWidgetA = this.inputs[indexA].widget !== undefined; + const hasWidgetB = this.inputs[indexB].widget !== undefined; + + if (hasWidgetA && hasWidgetB) { + // Find widget indices + const widgetIdxA = this.widgets.findIndex( + (w) => w.name === this.inputs[indexA].widget.name + ); + const widgetIdxB = this.widgets.findIndex( + (w) => w.name === this.inputs[indexB].widget.name + ); + + // Swap widget positions + [this.widgets[widgetIdxA].y, this.widgets[widgetIdxB].y] = + [this.widgets[widgetIdxB].y, this.widgets[widgetIdxA].y]; + [this.widgets[widgetIdxA].last_y, this.widgets[widgetIdxB].last_y] = + [this.widgets[widgetIdxB].last_y, this.widgets[widgetIdxA].last_y]; + + // Swap the widgets themselves + [this.widgets[widgetIdxA], this.widgets[widgetIdxB]] = + [this.widgets[widgetIdxB], this.widgets[widgetIdxA]]; + + // Swap widget values if they exist if (this.widgets_values) { - [this.widgets_values[widgetIdxA], this.widgets_values[widgetIdxB]] = [this.widgets_values[widgetIdxB], this.widgets_values[widgetIdxA]]; + [this.widgets_values[widgetIdxA], this.widgets_values[widgetIdxB]] = + [this.widgets_values[widgetIdxB], this.widgets_values[widgetIdxA]]; } + } else if (hasWidgetA || hasWidgetB) { + console.error("Bad swap: one input has a widget but the other doesn't", indexA, indexB); } - // Swap the inputs in the array - [this.inputs[indexA].boundingRect, this.inputs[indexB].boundingRect] = [this.inputs[indexB].boundingRect, this.inputs[indexA].boundingRect]; - [this.inputs[indexA].pos, this.inputs[indexB].pos] = [this.inputs[indexB].pos, this.inputs[indexA].pos]; - [this.inputs[indexA], this.inputs[indexB]] = [this.inputs[indexB], this.inputs[indexA]]; - updateSlotIndices(this); // Refresh indices + // Swap input properties + [this.inputs[indexA].boundingRect, this.inputs[indexB].boundingRect] = + [this.inputs[indexB].boundingRect, this.inputs[indexA].boundingRect]; + [this.inputs[indexA].pos, this.inputs[indexB].pos] = + [this.inputs[indexB].pos, this.inputs[indexA].pos]; - // Redraw the node to ensure the graph updates properly - // -> not needed as the calling method must do it! - this.setDirtyCanvas(true, true); + // Swap the inputs themselves + [this.inputs[indexA], this.inputs[indexB]] = + [this.inputs[indexB], this.inputs[indexA]]; + + // Update indices to maintain connections + updateSlotIndices(this); + + // The calling method is responsible for redrawing the canvas if needed + }; + + // Add helper method to get dynamic slots info + nodeType.prototype.getDynamicSlots = function(dynamicGroup = null) { + const dynamicSlots = []; + const dynamicGroupCount = {}; + const dynamicGroupConnected = {}; + + // Process each input to gather information about dynamic inputs + for (const [index, input] of this.inputs.entries()) { + // Skip non-dynamic inputs + if (!isDynamicInput(input.name)) { + continue; + } + + // Get the dynamic group for this input + const currentDynamicGroup = this.getDynamicGroup(input.name); + + // Skip if filtering by group and this doesn't match + if (dynamicGroup !== null && currentDynamicGroup !== dynamicGroup) { + continue; + } + + // Determine if this input is active (connected or has non-default widget value) + const isActive = !isDynamicInputEmpty(this, index); + + // Initialize group tracking if this is the first input for this group + if (!(currentDynamicGroup in dynamicGroupCount)) { + dynamicGroupCount[currentDynamicGroup] = 0; + dynamicGroupConnected[currentDynamicGroup] = []; + } + + // Get the base name for this dynamic input + const baseNameInfo = dynamicInputs[dynamicInputGroups[currentDynamicGroup][0]]; + + // Track connection status for this input in its group + if (input.name.startsWith(baseNameInfo.baseName)) { + const groupIndex = dynamicGroupCount[currentDynamicGroup]; + // Use OR assignment to preserve 'true' values + dynamicGroupConnected[currentDynamicGroup][groupIndex] = + dynamicGroupConnected[currentDynamicGroup][groupIndex] || isActive; + dynamicGroupCount[currentDynamicGroup]++; + } + + // Store detailed information about this dynamic input + dynamicSlots.push({ + index, + name: input.name, + isWidget: input.widget !== undefined, + shape: input.shape, + connected: isActive, + isDynamic: true, + dynamicGroup: currentDynamicGroup, + dynamicGroupCount: dynamicGroupCount[currentDynamicGroup] + }); + } + + return { + slots: dynamicSlots, + groupCount: dynamicGroupCount, + groupConnected: dynamicGroupConnected + }; + }; + + /** + * Generate a new dynamic input name based on type and count. + * @param {string} dynamic - The dynamic type ('number' or 'letter'). + * @param {string} baseName - The base name for the input. + * @param {number} count - The count/position for the new input. + * @returns {string} The generated input name. + */ + const generateDynamicInputName = (dynamic, baseName, count) => { + if (dynamic === 'letter') { + // For letter type, use the next letter in sequence + return String.fromCharCode(97 + count); // 97 is ASCII for 'a' + } else { + // For number type, use baseName + index + return `${baseName}${count}`; + } + }; + + // Add helper method to add new dynamic input for a group + nodeType.prototype.addNewDynamicInputForGroup = function(dynamicGroup, lastDynamicIdx) { + let insertPosition = lastDynamicIdx + 1; + let inputInRange = true; + + // Add new inputs for each member of the dynamic group + for (const groupMember of dynamicInputGroups[dynamicGroup]) { + const dynamicInput = dynamicInputs[groupMember]; + const baseName = dynamicInput.baseName; + const dynamicType = dynamicInput.dynamicType; + const widgetType = dynamicInput.widgetType ?? dynamicType; + const dynamic = dynamicInput.dynamic; + + // Get current slots for this group + const { slots } = this.getDynamicSlots(dynamicGroup); + const groupSlots = slots.filter(s => s.name.startsWith(baseName)); + + // Check if we've reached the limit for letter inputs (a-z) + if (dynamic === 'letter' && groupSlots.length >= 26) { + inputInRange = false; + continue; + } + + // Generate the new input name based on current count + const newName = generateDynamicInputName(dynamic, baseName, groupSlots.length); + + // Find a reference slot to copy properties from + const referenceSlot = groupSlots[0] || slots.find(s => + s.name.startsWith(dynamicInput.baseName) + ); + + // Create the new input at the correct position + this.addInputAtPosition( + newName, + dynamicType, + widgetType, + insertPosition++, + referenceSlot?.isWidget ?? false, + referenceSlot?.shape + ); + + // Ensure inputs are numbered correctly + this.renumberDynamicInputs(baseName, dynamicInputs, dynamic); + } + + return inputInRange; }; // Add method to safely renumber dynamic inputs without breaking connections nodeType.prototype.renumberDynamicInputs = function(baseName, dynamicInputs, dynamic) { - // Get current dynamic inputs info + // Collect information about dynamic inputs with this base name const dynamicInputInfo = []; + // Find all inputs that match this base name for (let i = 0; i < this.inputs.length; i++) { const input = this.inputs[i]; - const isDynamic = isDynamicInput(input.name); - if (isDynamic && input.name.startsWith(baseName)) { + if (isDynamicInput(input.name) && input.name.startsWith(baseName)) { + // Store info about this input dynamicInputInfo.push({ index: i, - widgetIdx: input.widget !== undefined ? this.widgets.findIndex((w) => w.name === input.widget.name) : undefined, + widgetIdx: input.widget !== undefined + ? this.widgets.findIndex((w) => w.name === input.widget.name) + : undefined, name: input.name, connected: input.isConnected }); } } - // Just rename the inputs in place - don't remove/add to keep connections intact + // Rename inputs in place to maintain connections for (let i = 0; i < dynamicInputInfo.length; i++) { const info = dynamicInputInfo[i]; const input = this.inputs[info.index]; - const newName = dynamic === "number" ? `${baseName}${i}` : String.fromCharCode(97 + i); // 97 is ASCII for 'a' + const newName = generateDynamicInputName(dynamic, baseName, i); - if (input.widget !== undefined) { - const widgetIdx = info.widgetIdx; + // Update widget name if this input has a widget + if (input.widget !== undefined && info.widgetIdx !== undefined) { + const widget = this.widgets[info.widgetIdx]; + widget.name = newName; + widget.label = newName; input.widget.name = newName; - this.widgets[widgetIdx].name = newName; - this.widgets[widgetIdx].label = newName; } + // Update the input name if it's different if (input.name !== newName) { input.name = newName; input.localized_name = newName; From 43b8665a36724d6f615b07d706faba14eb7fc49b Mon Sep 17 00:00:00 2001 From: StableLlama Date: Tue, 3 Jun 2025 00:25:03 +0200 Subject: [PATCH 2/4] Version 0.3.5 Try to make it work as intended --- pyproject.toml | 2 +- src/basic_data_handling/data_list_nodes.py | 25 +- src/basic_data_handling/dict_nodes.py | 30 +- src/basic_data_handling/list_nodes.py | 23 +- src/basic_data_handling/set_nodes.py | 25 +- web/js/dynamicnode.js | 951 +++++++++++---------- 6 files changed, 556 insertions(+), 500 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0a9b282..ff70645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "basic_data_handling" -version = "0.3.4" +version = "0.3.5" description = """NOTE: Still in development! Expect breaking changes! Basic Python functions for manipulating data that every programmer is used to. Currently supported ComfyUI data types: BOOLEAN, FLOAT, INT, STRING and data lists. diff --git a/src/basic_data_handling/data_list_nodes.py b/src/basic_data_handling/data_list_nodes.py index 583f76d..58de2b6 100644 --- a/src/basic_data_handling/data_list_nodes.py +++ b/src/basic_data_handling/data_list_nodes.py @@ -29,7 +29,7 @@ class DataListCreate(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.ANY, {"_dynamic": "number"}), + "item_0": (IO.ANY, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -41,7 +41,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list]: - return (list(kwargs.values()),) + values = list(kwargs.values()) + return (values[:-1],) class DataListCreateFromBoolean(ComfyNodeABC): @@ -56,7 +57,7 @@ class DataListCreateFromBoolean(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.BOOLEAN, {"_dynamic": "number"}), + "item_0": (IO.BOOLEAN, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -68,7 +69,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list]: - return ([bool(value) for value in kwargs.values()],) + values = [bool(value) for value in kwargs.values()] + return (values[:-1],) class DataListCreateFromFloat(ComfyNodeABC): @@ -83,7 +85,7 @@ class DataListCreateFromFloat(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.FLOAT, {"_dynamic": "number"}), + "item_0": (IO.FLOAT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -95,7 +97,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list]: - return ([float(value) for value in kwargs.values()],) + values = [float(value) for value in kwargs.values()] + return (values[:-1],) class DataListCreateFromInt(ComfyNodeABC): @@ -110,7 +113,7 @@ class DataListCreateFromInt(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.INT, {"_dynamic": "number"}), + "item_0": (IO.INT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -122,7 +125,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list]: - return ([int(value) for value in kwargs.values()],) + values = [int(value) for value in kwargs.values()] + return (values[:-1],) class DataListCreateFromString(ComfyNodeABC): @@ -137,7 +141,7 @@ class DataListCreateFromString(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.STRING, {"_dynamic": "number"}), + "item_0": (IO.STRING, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -149,7 +153,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([str(value) for value in kwargs.values()],) + values = [str(value) for value in kwargs.values()] + return (values[:-1],) class DataListAppend(ComfyNodeABC): diff --git a/src/basic_data_handling/dict_nodes.py b/src/basic_data_handling/dict_nodes.py index c2f0871..1b4a8f9 100644 --- a/src/basic_data_handling/dict_nodes.py +++ b/src/basic_data_handling/dict_nodes.py @@ -27,8 +27,8 @@ class DictCreate(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.ANY, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.ANY, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -40,7 +40,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: @@ -59,8 +59,8 @@ class DictCreateFromBoolean(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.BOOLEAN, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.BOOLEAN, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -72,7 +72,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: @@ -91,8 +91,8 @@ class DictCreateFromFloat(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.FLOAT, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.FLOAT, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -104,7 +104,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: @@ -123,8 +123,8 @@ class DictCreateFromInt(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.INT, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.INT, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -136,7 +136,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: @@ -155,8 +155,8 @@ class DictCreateFromString(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -168,7 +168,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: diff --git a/src/basic_data_handling/list_nodes.py b/src/basic_data_handling/list_nodes.py index 49fe20f..fd32d25 100644 --- a/src/basic_data_handling/list_nodes.py +++ b/src/basic_data_handling/list_nodes.py @@ -29,7 +29,7 @@ class ListCreate(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.ANY, {"_dynamic": "number"}), + "item_0": (IO.ANY, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -39,7 +39,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return (list(kwargs.values()),) + values = list(kwargs.values()) + return (values[:-1],) class ListCreateFromBoolean(ComfyNodeABC): @@ -54,7 +55,7 @@ class ListCreateFromBoolean(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.BOOLEAN, {"_dynamic": "number"}), + "item_0": (IO.BOOLEAN, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -64,7 +65,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([bool(value) for value in kwargs.values()],) + values = [bool(value) for value in kwargs.values()] + return (values[:-1],) class ListCreateFromFloat(ComfyNodeABC): @@ -79,7 +81,7 @@ class ListCreateFromFloat(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.FLOAT, {"_dynamic": "number"}), + "item_0": (IO.FLOAT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -89,7 +91,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([float(value) for value in kwargs.values()],) + values = [float(value) for value in kwargs.values()] + return (values[:-1],) class ListCreateFromInt(ComfyNodeABC): @@ -104,7 +107,7 @@ class ListCreateFromInt(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.INT, {"_dynamic": "number"}), + "item_0": (IO.INT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -114,7 +117,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([int(value) for value in kwargs.values()],) + values = [int(value) for value in kwargs.values()] + return (values[:-1],) class ListCreateFromString(ComfyNodeABC): @@ -139,7 +143,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([str(value) for value in kwargs.values()],) + values = [str(value) for value in kwargs.values()] + return (values[:-1],) class ListAppend(ComfyNodeABC): diff --git a/src/basic_data_handling/set_nodes.py b/src/basic_data_handling/set_nodes.py index 7bebdd9..33909c6 100644 --- a/src/basic_data_handling/set_nodes.py +++ b/src/basic_data_handling/set_nodes.py @@ -27,7 +27,7 @@ class SetCreate(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.ANY, {"_dynamic": "number"}), + "item_0": (IO.ANY, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -37,7 +37,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set(kwargs.values()),) + values = kwargs.values() + return (set(values[:-1]),) class SetCreateFromBoolean(ComfyNodeABC): @@ -52,7 +53,7 @@ class SetCreateFromBoolean(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.BOOLEAN, {"_dynamic": "number"}), + "item_0": (IO.BOOLEAN, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -62,7 +63,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set([bool(value) for value in kwargs.values()]),) + values = [bool(value) for value in kwargs.values()] + return (set(values[:-1]),) class SetCreateFromFloat(ComfyNodeABC): @@ -77,7 +79,7 @@ class SetCreateFromFloat(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.FLOAT, {"_dynamic": "number"}), + "item_0": (IO.FLOAT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -87,7 +89,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set([float(value) for value in kwargs.values()]),) + values = [float(value) for value in kwargs.values()] + return (set(values[:-1]),) class SetCreateFromInt(ComfyNodeABC): @@ -102,7 +105,7 @@ class SetCreateFromInt(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.INT, {"_dynamic": "number"}), + "item_0": (IO.INT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -112,7 +115,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set([int(value) for value in kwargs.values()]),) + values = [int(value) for value in kwargs.values()] + return (set(values[:-1]),) class SetCreateFromString(ComfyNodeABC): @@ -127,7 +131,7 @@ class SetCreateFromString(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.STRING, {"_dynamic": "number"}), + "item_0": (IO.STRING, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -137,7 +141,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set([str(value) for value in kwargs.values()]),) + values = [str(value) for value in kwargs.values()] + return (set(values[:-1]),) class SetAdd(ComfyNodeABC): diff --git a/web/js/dynamicnode.js b/web/js/dynamicnode.js index fa8d893..db620fe 100644 --- a/web/js/dynamicnode.js +++ b/web/js/dynamicnode.js @@ -33,312 +33,489 @@ const TypeSlotEvent = { Disconnect: false }; +// Helper for escaping strings to be used in RegExp +const escapeRegExp = (string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +}; + + app.registerExtension({ name: 'Basic data handling: dynamic input', - async beforeRegisterNodeDef(nodeType, nodeData, app) { - // Filter: Only process nodes with class names starting with "Basic data handling:" + async beforeRegisterNodeDef(nodeType, nodeData, appInstance) { if (!nodeType.comfyClass.startsWith('Basic data handling:')) { return; } const combinedInputData = { - ...nodeData?.input?.required ?? {}, - ...nodeData?.input?.optional ?? {} + ...(nodeData?.input?.required ?? {}), + ...(nodeData?.input?.optional ?? {}) + }; + + let combinedInputDataOrder = []; + if (nodeData.input_order) { + if (nodeData.input_order.required) combinedInputDataOrder.push(...nodeData.input_order.required); + if (nodeData.input_order.optional) combinedInputDataOrder.push(...nodeData.input_order.optional); + } else if (nodeData.input) { + if (nodeData.input.required) combinedInputDataOrder.push(...Object.keys(nodeData.input.required)); + if (nodeData.input.optional) combinedInputDataOrder.push(...Object.keys(nodeData.input.optional)); } - const combinedInputDataOrder = [ - ...nodeData?.input_order?.required ?? [], - ...nodeData?.input_order?.optional ?? [] - ]; - /** Array of (generic) dynamic inputs. */ const dynamicInputs = []; - /** Array of groups of (generic) dynamic inputs. */ const dynamicInputGroups = []; + for (const name of combinedInputDataOrder) { - const widgetType = combinedInputData[name][1]?.widgetType; - const dynamic = combinedInputData[name][1]?._dynamic; + if (!combinedInputData[name]) continue; + + const dynamicSetting = combinedInputData[name][1]?._dynamic; const dynamicGroup = combinedInputData[name][1]?._dynamicGroup ?? 0; - if (dynamic) { - let matcher; + if (dynamicSetting) { // Only process for inputs marked with _dynamic + const inputOptions = combinedInputData[name][1] || {}; + const dynamicComfyType = combinedInputData[name][0]; - switch (dynamic) { - case 'number': - matcher = new RegExp(`^(${name.replace(/\d*$/, '')})(\\d+)$`); - break; + let isActuallyWidget = false; + let effectiveWidgetGuiType; - case 'letter': - matcher = new RegExp(`^()([a-zA-Z])$`); - break; + if (inputOptions.widget?.type) { + isActuallyWidget = true; + effectiveWidgetGuiType = inputOptions.widget.type; + } else if (inputOptions.widget && typeof inputOptions.widget === 'string') { + isActuallyWidget = true; + effectiveWidgetGuiType = inputOptions.widget; + } + else if (inputOptions._widgetType) { + isActuallyWidget = true; + effectiveWidgetGuiType = inputOptions._widgetType; + } else if (inputOptions.widgetType) { + isActuallyWidget = true; + effectiveWidgetGuiType = inputOptions.widgetType; + } + else { + const comfyTypeUpper = String(dynamicComfyType).toUpperCase(); + const implicitWidgetGuiTypesMap = { + "STRING": "text", "INT": "number", "FLOAT": "number", + "NUMBER": "number", "BOOLEAN": "toggle" + }; + + if (implicitWidgetGuiTypesMap[comfyTypeUpper] && inputOptions.forceInput !== true && dynamicComfyType !== '*') { + isActuallyWidget = true; + effectiveWidgetGuiType = implicitWidgetGuiTypesMap[comfyTypeUpper]; + } else if (comfyTypeUpper === "COMBO" && inputOptions.values) { + isActuallyWidget = true; + effectiveWidgetGuiType = "combo"; + } else { + isActuallyWidget = false; + effectiveWidgetGuiType = dynamicComfyType; + } + } - default: - continue; + let determinedBaseName; + let suffixExtractionMatcher; + + if (dynamicSetting === 'number') { + const r = /^(.*?)(\d+)$/; + const m = name.match(r); + determinedBaseName = (m && m[1] !== undefined) ? m[1] : name; + suffixExtractionMatcher = new RegExp(`^(${escapeRegExp(determinedBaseName)})(\\d*)$`); + } else if (dynamicSetting === 'letter') { + const r = /^(.*?)([a-zA-Z])$/; + const m = name.match(r); + determinedBaseName = (m && m[1] !== undefined) ? m[1] : name; + suffixExtractionMatcher = new RegExp(`^(${escapeRegExp(determinedBaseName)})([a-zA-Z])$`); + } else { + console.warn(`[Basic Data Handling] Unknown dynamic type: ${dynamicSetting} for input ${name} on node ${nodeType.comfyClass}`); + continue; } - const baseName = name.match(matcher)?.[1] ?? name; - const dynamicType = combinedInputData[name][0]; + if (dynamicInputGroups[dynamicGroup] === undefined) { dynamicInputGroups[dynamicGroup] = []; } - dynamicInputGroups[dynamicGroup].push( - dynamicInputs.length - ) - dynamicInputs.push({name, baseName, matcher, dynamic, dynamicType, dynamicGroup, widgetType}); + dynamicInputGroups[dynamicGroup].push(dynamicInputs.length); + + dynamicInputs.push({ + name, + baseName: determinedBaseName, + matcher: suffixExtractionMatcher, + dynamic: dynamicSetting, + dynamicComfyType, + dynamicGroup, + isWidget: isActuallyWidget, + actualWidgetGuiType: effectiveWidgetGuiType, + originalOptions: inputOptions + }); } } + if (dynamicInputs.length === 0) { return; } - /** - * Utility functions for dynamic input operations - */ - // Check if an input is dynamic - const isDynamicInput = (inputName) => - dynamicInputs.some((di) => di.matcher.test(inputName)); + const getDynamicInputDefinition = (inputName) => { + for (const di of dynamicInputs) { + const m = inputName.match(di.matcher); + if (m && m[1] === di.baseName) { + return { definition: di, suffix: m[2] || "" }; + } + } + return null; + }; + + const isDynamicInput = (inputName) => !!getDynamicInputDefinition(inputName); - // Update inputs' slot indices after reordering const updateSlotIndices = (node) => { node.inputs.forEach((input, index) => { input.slot_index = index; - if (input.isConnected) { - const link = node.graph._links.get(input.link); - if (link) { - link.target_slot = index; - } else { - console.error(`Input ${index} has an invalid link.`); + if (input.link !== null && input.link !== undefined) { + const linkInfo = node.graph.links[input.link]; + if (linkInfo) { + linkInfo.target_slot = index; } } }); }; - // Get default value for widget based on its type - const getWidgetDefaultValue = (widget) => { - switch (widget.type) { + const getWidgetDefaultValue = (widgetDef) => { // widgetDef = { type: actualWidgetGuiType, options: originalOptions } + const type = widgetDef.type; + const opts = widgetDef.options || {}; + + switch (String(type).toLowerCase()) { case 'number': - return 0; + return opts.default ?? 0; case 'combo': - return widget.options?.[0] || ''; + return opts.default ?? (opts.values && opts.values.length > 0 ? opts.values[0] : ''); case 'text': case 'string': + return opts.default ?? ''; + case 'toggle': + case 'boolean': + return opts.default ?? false; default: - return ''; + return opts.default ?? ''; } }; - // Check if a dynamic input is empty (not connected and widget has default value) const isDynamicInputEmpty = (node, inputIndex) => { const input = node.inputs[inputIndex]; - if (input.isConnected) return false; - - if (input.widget) { - const widget = node.widgets.find(w => w.name === input.widget.name); - return widget?.value === getWidgetDefaultValue(widget); + if (input.link !== null && input.link !== undefined) return false; + + const diData = getDynamicInputDefinition(input.name); + if (diData && diData.definition.isWidget) { + if (input.widget && input.widget.name) { + const widget = node.widgets.find(w => w.name === input.widget.name); + if (widget) { + return widget.value === getWidgetDefaultValue({ + type: diData.definition.actualWidgetGuiType, + options: diData.definition.originalOptions + }); + } else { + return true; + } + } else { + return true; + } } return true; }; - // Find if input is the last of its base name group - const isLastDynamicInput = (node, idx, baseName) => { - let isLast = true; - for (let i = idx + 1; i < node.inputs.length; i++) { - isLast &&= !node.inputs[i].name.startsWith(baseName); - } - return isLast; - }; - - // Remove widget associated with an input const removeWidgetForInput = (node, inputIdx) => { - if (node.inputs[inputIdx].widget !== undefined) { - const widgetIdx = node.widgets.findIndex((w) => w.name === node.inputs[inputIdx].widget.name); - node.widgets.splice(widgetIdx, 1); - node.widgets_values?.splice(widgetIdx, 1); + if (node.inputs[inputIdx].widget && node.inputs[inputIdx].widget.name) { + const widgetName = node.inputs[inputIdx].widget.name; + const widgetIdx = node.widgets.findIndex((w) => w.name === widgetName); + if (widgetIdx !== -1) { + node.widgets.splice(widgetIdx, 1); + } } }; - // Add helper method to get dynamic group for an input name nodeType.prototype.getDynamicGroup = function(inputName) { - // Find the dynamicGroup by matching the baseName with input name - for (const di of dynamicInputs) { - if (inputName.startsWith(di.baseName)) { - return di.dynamicGroup; - } - } - return undefined; + const diData = getDynamicInputDefinition(inputName); + return diData ? diData.definition.dynamicGroup : undefined; }; - /** - * Add a widget with standard configuration. - * @param {string} name - The name of the widget. - * @param {string} widget_type - The type of widget. - * @returns {object} The created widget. - */ - const addStandardWidget = function(name, widget_type) { - return this.addWidget(widget_type, name, '', () => {}, {}); - }; + const addStandardWidget = function(name, widgetGuiType, defaultValue, fullConfigOptions = {}) { + let currentVal = defaultValue; + const widgetSpecificOpts = { ...fullConfigOptions }; + + if (String(widgetGuiType).toLowerCase() === "combo") { + if (!widgetSpecificOpts.values && Array.isArray(fullConfigOptions.default)) { + widgetSpecificOpts.values = fullConfigOptions.default; + } else if (!widgetSpecificOpts.values && fullConfigOptions.options?.values) { + widgetSpecificOpts.values = fullConfigOptions.options.values; + } - // Add helper method to insert input at a specific position - nodeType.prototype.addInputAtPosition = function (name, input_type, widget_type, position, isWidget, shape) { - // Add widget if needed - if (isWidget) { - addStandardWidget.call(this, name, widget_type); + if (!widgetSpecificOpts.values) { + widgetSpecificOpts.values = [currentVal]; + } - const GET_CONFIG = Symbol(); - this.addInput(name, input_type, { - shape, - widget: {name, [GET_CONFIG]: () =>{}} - }); + if (widgetSpecificOpts.values && !widgetSpecificOpts.values.includes(currentVal) && widgetSpecificOpts.values.length > 0) { + currentVal = widgetSpecificOpts.values[0]; + } + } + return this.addWidget(widgetGuiType, name, currentVal, () => {}, widgetSpecificOpts); + }; + + nodeType.prototype.addInputAtPosition = function (name, comfyInputType, widgetGuiType, position, isWidgetFlag, shape, widgetDefaultVal, widgetConfOpts) { + if (isWidgetFlag) { + addStandardWidget.call(this, name, widgetGuiType, widgetDefaultVal, widgetConfOpts); + this.addInput(name, comfyInputType, { shape, widget: { name } }); } else { - this.addInput(name, input_type, {shape}); // Add new input without widget + this.addInput(name, comfyInputType, { shape }); } - // Position the input at the desired location - const newInput = this.inputs.pop(); // Get the newly added input (last item) - this.inputs.splice(position, 0, newInput); // Place it at the desired position - updateSlotIndices(this); // Update indices - return newInput; + const newInputIndex = this.inputs.length - 1; + if (position < newInputIndex && position < this.inputs.length -1 ) { + const newInput = this.inputs.pop(); + this.inputs.splice(position, 0, newInput); + } + updateSlotIndices(this); + return this.inputs[position]; }; - // flag to prevent loops. It is ok to be "global" as the code is not - // running in parallel. let isProcessingConnection = false; - /** - * Utility: Handle when a dynamic input becomes empty (disconnected or empty widget value). - * @param {ComfyNode} node - The node with the empty input. - */ - const handleEmptyDynamicInput = function() { - // Process each input to check for empty dynamic inputs - for (let idx = 0; idx < this.inputs.length; idx++) { - const input = this.inputs[idx]; - - // Skip if not a dynamic input - if (!isDynamicInput(input.name)) { - continue; - } + const generateDynamicInputName = (dynamicBehavior, baseName, count) => { + if (dynamicBehavior === 'letter') { + return `${baseName}${String.fromCharCode(97 + count)}`; + } else { // 'number' + return `${baseName}${count}`; + } + }; - // Check if this input is empty - if (!isDynamicInputEmpty(this, idx)) { - continue; + nodeType.prototype.renumberDynamicInputs = function(baseNameToRenumber, dynamicBehavior) { + const inputsToRenumber = []; + for (let i = 0; i < this.inputs.length; i++) { + const input = this.inputs[i]; + const diData = getDynamicInputDefinition(input.name); + if (diData && diData.definition.baseName === baseNameToRenumber && diData.definition.dynamic === dynamicBehavior) { + inputsToRenumber.push({ + inputRef: input, + widgetName: input.widget ? input.widget.name : null + }); } + } - // Get information about this dynamic input group - const dynamicGroup = this.getDynamicGroup(input.name); - const baseName = dynamicInputs[dynamicInputGroups[dynamicGroup][0]].baseName; + for (let i = 0; i < inputsToRenumber.length; i++) { + const { inputRef, widgetName } = inputsToRenumber[i]; + const newName = generateDynamicInputName(dynamicBehavior, baseNameToRenumber, i); - // Don't remove if it's the last dynamic input of its type - if (isLastDynamicInput(this, idx, baseName)) { - continue; + if (inputRef.name === newName) continue; + + if (widgetName) { + const widget = this.widgets.find(w => w.name === widgetName); + if (widget) { + widget.name = newName; + } + if (inputRef.widget) inputRef.widget.name = newName; } + inputRef.name = newName; + } + updateSlotIndices(this); + }; + + const handleEmptyDynamicInput = function() { // (Content largely unchanged from previous good version, ensure it uses new DI props if needed) + let overallChangesMade = false; + let scanAgainPass = true; - // Move the empty input to the end - for (let i = idx + 1; i < this.inputs.length; i++) { - this.swapInputs(i - 1, i); + while (scanAgainPass) { + scanAgainPass = false; + const dynamicGroupInfo = new Map(); + + for (let i = 0; i < this.inputs.length; i++) { + const currentInput = this.inputs[i]; + const diData = getDynamicInputDefinition(currentInput.name); + + if (!diData) continue; + const { definition: di, suffix } = diData; + const group = di.dynamicGroup; + + if (!dynamicGroupInfo.has(group)) { + dynamicGroupInfo.set(group, { items: new Map() }); + } + const groupData = dynamicGroupInfo.get(group); + if (!groupData.items.has(suffix)) { + groupData.items.set(suffix, { inputsInfo: [], allEmpty: true }); + } + const itemData = groupData.items.get(suffix); + itemData.inputsInfo.push({ diDefinition: di, originalInput: currentInput, originalIndex: i }); + if (!isDynamicInputEmpty(this, i)) { // isDynamicInputEmpty uses new DI props + itemData.allEmpty = false; + } } - // Remove the input that's now at the end - const lastIdx = this.inputs.length - 1; - removeWidgetForInput(this, lastIdx); - this.removeInput(lastIdx); + for (const [groupId, groupData] of dynamicGroupInfo) { + const { items } = groupData; + if (items.size === 0) continue; + + const sortedItemSuffixes = Array.from(items.keys()).sort((a, b) => { + const numA = parseInt(a, 10); + const numB = parseInt(b, 10); + if (!isNaN(numA) && !isNaN(numB)) return numA - numB; + if (a.length !== b.length) return a.length - b.length; + return String(a).localeCompare(String(b)); + }); + + let activeItemCount = 0; + const emptyItemSuffixes = []; + for (const suffix of sortedItemSuffixes) { + if (items.get(suffix).allEmpty) { + emptyItemSuffixes.push(suffix); + } else { + activeItemCount++; + } + } + + if (emptyItemSuffixes.length === 0) continue; - // Adjust idx to check the current position again (which now has a new input) - idx--; + let placeholderSuffix = null; + if (activeItemCount === 0 && sortedItemSuffixes.length > 0) { + placeholderSuffix = sortedItemSuffixes[0]; + } else if (activeItemCount > 0 && emptyItemSuffixes.length > 0) { + placeholderSuffix = emptyItemSuffixes[emptyItemSuffixes.length - 1]; + } + + const inputsToRemoveDetails = []; + for (const suffix of emptyItemSuffixes) { + if (suffix !== placeholderSuffix) { + const itemData = items.get(suffix); + for (const inputDetail of itemData.inputsInfo) { + inputsToRemoveDetails.push(inputDetail); + } + } + } + + if (inputsToRemoveDetails.length > 0) { + inputsToRemoveDetails.sort((a, b) => b.originalIndex - a.originalIndex); + + for (const { originalInput, originalIndex } of inputsToRemoveDetails) { + if (this.inputs[originalIndex] === originalInput) { + removeWidgetForInput(this, originalIndex); + this.removeInput(originalIndex); + scanAgainPass = true; + overallChangesMade = true; + } else { + scanAgainPass = true; + overallChangesMade = true; + } + } + if (scanAgainPass) break; + } + } + if (scanAgainPass) continue; } - // Renumber all dynamic inputs to ensure proper ordering - for (const groupIdx in dynamicInputGroups) { - for (const memberIdx of dynamicInputGroups[groupIdx]) { - const baseName = dynamicInputs[memberIdx].baseName; - const dynamic = dynamicInputs[memberIdx].dynamic; - this.renumberDynamicInputs(baseName, dynamicInputs, dynamic); + if (overallChangesMade) { + const renumberedBaseNames = new Set(); + for (const groupIdx_str in dynamicInputGroups) { + const groupIdx = parseInt(groupIdx_str, 10); + if (dynamicInputGroups[groupIdx]) { + for (const memberIdx of dynamicInputGroups[groupIdx]) { + const { baseName, dynamic } = dynamicInputs[memberIdx]; // 'dynamic' here is behavior ('number'/'letter') + if (!renumberedBaseNames.has(baseName + dynamic)) { // Ensure unique combination + this.renumberDynamicInputs(baseName, dynamic); + renumberedBaseNames.add(baseName + dynamic); + } + } + } } + this.setDirtyCanvas(true, true); } }; - /** - * Utility: Handle when a dynamic input becomes active (connected or non-empty widget value). - * @param {ComfyNode} node - The node with the activated input. - * @param {number} dynamicGroup - The dynamic group to handle. - */ const handleDynamicInputActivation = function(dynamicGroup) { - // Get information about dynamic inputs - const { - slots: dynamicSlots, - groupConnected: dynamicGroupConnected - } = this.getDynamicSlots(); - - // Ensure all widget-based inputs have actual widgets - // This is important when loading workflows - for (const slot of dynamicSlots) { - if (slot.isWidget && this.widgets && + const { slots: dynamicSlotsFromGetter } = this.getDynamicSlots(); + + for (const slot of dynamicSlotsFromGetter) { + if (slot.dynamicGroup === dynamicGroup && this.widgets && !this.widgets.some((w) => w.name === slot.name)) { - this.addWidget( - this.inputs[slot.index].type, - slot.name, - '', - ()=>{}, - {} - ); + + const diData = getDynamicInputDefinition(slot.name); + if (diData && diData.definition.isWidget) { // Use our enhanced definition + const originalInputDef = diData.definition; + + addStandardWidget.call(this, slot.name, + originalInputDef.actualWidgetGuiType, + getWidgetDefaultValue({ type: originalInputDef.actualWidgetGuiType, options: originalInputDef.originalOptions }), + originalInputDef.originalOptions + ); + } } } - // If all inputs in this group are active, we need to add a new empty one - const hasEmptyInput = dynamicGroupConnected[dynamicGroup]?.some(isActive => !isActive); + let allExistingItemsActive = true; + const itemSuffixesInGroup = new Set(); + const groupSlots = dynamicSlotsFromGetter.filter(s => s.dynamicGroup === dynamicGroup); - if (!hasEmptyInput) { - // Find position for the new input (after the last one in this group) - const groupSlots = dynamicSlots.filter(slot => slot.dynamicGroup === dynamicGroup); - const lastDynamicIdx = groupSlots.length > 0 - ? Math.max(...groupSlots.map(slot => slot.index)) - : -1; + groupSlots.forEach(s => { + const diData = getDynamicInputDefinition(s.name); + if (diData) itemSuffixesInGroup.add(diData.suffix); + }); - // Add a new empty input for this group - this.addNewDynamicInputForGroup(dynamicGroup, lastDynamicIdx); + if (itemSuffixesInGroup.size === 0 && dynamicInputGroups[dynamicGroup]?.length > 0) { + allExistingItemsActive = true; + } else { + let hasCompletelyEmptyItem = false; + for (const suffix of itemSuffixesInGroup) { + const inputsOfThisItem = groupSlots.filter(s => { + const diData = getDynamicInputDefinition(s.name); + return diData && diData.suffix === suffix; + }); + if (inputsOfThisItem.length > 0 && inputsOfThisItem.every(s => !s.connected)) { // s.connected from getDynamicSlots + hasCompletelyEmptyItem = true; + break; + } + } + allExistingItemsActive = !hasCompletelyEmptyItem; } - // Ensure the canvas is updated + + if (allExistingItemsActive) { + let lastDynamicIdx = -1; + if (groupSlots.length > 0) { + lastDynamicIdx = Math.max(...groupSlots.map(slot => slot.index)); + } else { + let maxIdx = -1; + for(const di of dynamicInputs){ // di is a full definition object + if(di.dynamicGroup < dynamicGroup) { + for(let i=this.inputs.length-1; i>=0; --i){ + if(this.inputs[i].name.startsWith(di.baseName)){ + maxIdx = Math.max(maxIdx, i); + break; + } + } + } + } + lastDynamicIdx = (maxIdx === -1) ? (this.inputs.length -1) : maxIdx; + } + this.addNewDynamicInputForGroup(dynamicGroup, lastDynamicIdx); + } this.setDirtyCanvas(true, true); }; - // Override onConnectionsChange: Handle connections for dynamic inputs + // --- Event Handlers (onConnectionsChange, onConnectInput, onRemoved, onWidgetChanged) --- const onConnectionsChange = nodeType.prototype.onConnectionsChange; nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, link, ioSlot) { - // Call the original method first - const result = onConnectionsChange?.apply(this, arguments); - - // Only process input connections for dynamic inputs - const isInput = type === TypeSlot.Input; - const isDynamic = isInput && isDynamicInput(this.inputs[slotIndex].name); - - // Skip if not an input, already processing, or not a dynamic input - if (!isInput || isProcessingConnection || !isDynamic) { - return result; - } - - // Prevent recursive processing - isProcessingConnection = true; - - // Get the dynamic group for this input - const dynamicGroup = this.getDynamicGroup(this.inputs[slotIndex].name); - - // Handle connection or disconnection event - if (isConnected === TypeSlotEvent.Connect) { - // Input was connected - handleDynamicInputActivation.call(this, dynamicGroup); - } else if (isConnected === TypeSlotEvent.Disconnect) { - // Input was disconnected - handleEmptyDynamicInput.call(this); + const originalReturn = onConnectionsChange?.apply(this, arguments); + if (type === TypeSlot.Input && slotIndex < this.inputs.length && this.inputs[slotIndex] && isDynamicInput(this.inputs[slotIndex].name)) { + if (isProcessingConnection) return originalReturn; + isProcessingConnection = true; + const dynamicGroup = this.getDynamicGroup(this.inputs[slotIndex].name); + if (dynamicGroup !== undefined) { + if (isConnected === TypeSlotEvent.Connect) { + handleDynamicInputActivation.call(this, dynamicGroup); + } else if (isConnected === TypeSlotEvent.Disconnect) { + handleEmptyDynamicInput.call(this); + } + } + isProcessingConnection = false; } - - isProcessingConnection = false; - return result; + return originalReturn; }; const onConnectInput = nodeType.prototype.onConnectInput; nodeType.prototype.onConnectInput = function(inputIndex, outputType, outputSlot, outputNode, outputIndex) { const result = onConnectInput?.apply(this, arguments) ?? true; - - if (this.inputs[inputIndex].isConnected) { + if (this.inputs[inputIndex].link !== null && this.inputs[inputIndex].link !== undefined ) { const pre_isProcessingConnection = isProcessingConnection; isProcessingConnection = true; this.disconnectInput(inputIndex, true); @@ -349,311 +526,175 @@ app.registerExtension({ const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { - const result = onRemoved?.apply(this, arguments); - - // When this is called, the input links are already removed - but - // due to the implementation of the remove() method it might not - // have worked with the dynamic inputs. So we need to fix it here. - for (let i = this.inputs.length-1; i >= 0; i--) { - if ( this.inputs[i].isConnected) { - this.disconnectInput(i, true); + if(!isProcessingConnection){ + isProcessingConnection = true; + for (let i = this.inputs.length - 1; i >= 0; i--) { + if (this.inputs[i].link !== null && this.inputs[i].link !== undefined) { + this.disconnectInput(i, true); + } } + isProcessingConnection = false; } - - return result; + return onRemoved?.apply(this, arguments); } - /** - * Utility: Find input index for a widget by name. - * @param {ComfyNode} node - The node containing the widget. - * @param {string} widgetName - Name of the widget to find. - * @returns {number} Index of the input associated with the widget, or -1 if not found. - */ - const findInputIndexForWidget = (node, widgetName) => { - for (let i = 0; i < node.inputs.length; i++) { - if (node.inputs[i].widget && node.inputs[i].widget.name === widgetName) { - return i; - } - } - return -1; - }; - const onWidgetChanged = nodeType.prototype.onWidgetChanged; - nodeType.prototype.onWidgetChanged = function () { - const result = onWidgetChanged?.apply(this, arguments); - - // Extract arguments - const widget_name = arguments[0]; - const new_val = arguments[1]; - const old_val = arguments[2]; - const widget = arguments[3]; - - // Skip if not a dynamic input widget or already processing connections - if (!isDynamicInput(widget_name) || isProcessingConnection) { - return result; + nodeType.prototype.onWidgetChanged = function (widgetName, newValue, oldValue, widgetObject) { + const originalReturn = onWidgetChanged?.apply(this, arguments); + if (isProcessingConnection || !isDynamicInput(widgetName)) { + return originalReturn; } + const dynamicGroup = this.getDynamicGroup(widgetName); + if (dynamicGroup === undefined) return originalReturn; - // Find the dynamic group for this widget - const dynamicGroup = this.getDynamicGroup(widget_name); - if (dynamicGroup === undefined) { - return result; - } + const diData = getDynamicInputDefinition(widgetName); + const defaultValue = diData ? getWidgetDefaultValue({type: diData.definition.actualWidgetGuiType, options: diData.definition.originalOptions}) : getWidgetDefaultValue(widgetObject) /*fallback*/; - // Check if widget value changed between default and non-default - const default_val = getWidgetDefaultValue(widget); - const wasEmpty = old_val === default_val; - const isNowEmpty = new_val === default_val; + const wasEffectivelyEmpty = oldValue === defaultValue; + const isNowEffectivelyEmpty = newValue === defaultValue; - // Only process if the empty state changed - if (wasEmpty === isNowEmpty) { - return result; - } + if (wasEffectivelyEmpty === isNowEffectivelyEmpty) return originalReturn; - // Prevent recursive processing isProcessingConnection = true; - - if (wasEmpty && !isNowEmpty) { - // Widget changed from empty to non-empty (like connecting an input) + if (wasEffectivelyEmpty && !isNowEffectivelyEmpty) { handleDynamicInputActivation.call(this, dynamicGroup); - } else if (!wasEmpty && isNowEmpty) { - // Widget changed to empty (like disconnecting an input) + } else if (!wasEffectivelyEmpty && isNowEffectivelyEmpty) { handleEmptyDynamicInput.call(this); } - isProcessingConnection = false; - return result; - } + return originalReturn; + }; - // Method to swap two inputs in the "this.inputs" array by their indices - nodeType.prototype.swapInputs = function(indexA, indexB) { - // Validate indices - if ( - indexA < 0 || indexB < 0 || - indexA >= this.inputs.length || - indexB >= this.inputs.length || - indexA === indexB - ) { - console.error("Invalid input indices for swapping:", indexA, indexB); - return; + // --- Core Dynamic Methods (swapInputs, getDynamicSlots, addNewDynamicInputForGroup) --- + nodeType.prototype.swapInputs = function(indexA, indexB) { // (Content unchanged) + if (indexA < 0 || indexB < 0 || indexA >= this.inputs.length || indexB >= this.inputs.length || indexA === indexB) { + console.error("[Basic Data Handling] Invalid input indices for swapping:", indexA, indexB); return; } - - // Handle widgets if both inputs have them const hasWidgetA = this.inputs[indexA].widget !== undefined; const hasWidgetB = this.inputs[indexB].widget !== undefined; - if (hasWidgetA && hasWidgetB) { - // Find widget indices - const widgetIdxA = this.widgets.findIndex( - (w) => w.name === this.inputs[indexA].widget.name - ); - const widgetIdxB = this.widgets.findIndex( - (w) => w.name === this.inputs[indexB].widget.name - ); - - // Swap widget positions - [this.widgets[widgetIdxA].y, this.widgets[widgetIdxB].y] = - [this.widgets[widgetIdxB].y, this.widgets[widgetIdxA].y]; - [this.widgets[widgetIdxA].last_y, this.widgets[widgetIdxB].last_y] = - [this.widgets[widgetIdxB].last_y, this.widgets[widgetIdxA].last_y]; - - // Swap the widgets themselves - [this.widgets[widgetIdxA], this.widgets[widgetIdxB]] = - [this.widgets[widgetIdxB], this.widgets[widgetIdxA]]; - - // Swap widget values if they exist - if (this.widgets_values) { - [this.widgets_values[widgetIdxA], this.widgets_values[widgetIdxB]] = - [this.widgets_values[widgetIdxB], this.widgets_values[widgetIdxA]]; + const widgetIdxA = this.widgets.findIndex((w) => w.name === this.inputs[indexA].widget.name); + const widgetIdxB = this.widgets.findIndex((w) => w.name === this.inputs[indexB].widget.name); + if(widgetIdxA !== -1 && widgetIdxB !== -1) { + [this.widgets[widgetIdxA].y, this.widgets[widgetIdxB].y] = [this.widgets[widgetIdxB].y, this.widgets[widgetIdxA].y]; + [this.widgets[widgetIdxA].last_y, this.widgets[widgetIdxB].last_y] = [this.widgets[widgetIdxB].last_y, this.widgets[widgetIdxA].last_y]; + [this.widgets[widgetIdxA], this.widgets[widgetIdxB]] = [this.widgets[widgetIdxB], this.widgets[widgetIdxA]]; + if (this.widgets_values) { + [this.widgets_values[widgetIdxA], this.widgets_values[widgetIdxB]] = [this.widgets_values[widgetIdxB], this.widgets_values[widgetIdxA]]; + } } } else if (hasWidgetA || hasWidgetB) { - console.error("Bad swap: one input has a widget but the other doesn't", indexA, indexB); + console.error("[Basic Data Handling] Bad swap: one input has a widget but the other doesn't", indexA, indexB); } - - // Swap input properties - [this.inputs[indexA].boundingRect, this.inputs[indexB].boundingRect] = - [this.inputs[indexB].boundingRect, this.inputs[indexA].boundingRect]; - [this.inputs[indexA].pos, this.inputs[indexB].pos] = - [this.inputs[indexB].pos, this.inputs[indexA].pos]; - - // Swap the inputs themselves - [this.inputs[indexA], this.inputs[indexB]] = - [this.inputs[indexB], this.inputs[indexA]]; - - // Update indices to maintain connections + [this.inputs[indexA].boundingRect, this.inputs[indexB].boundingRect] = [this.inputs[indexB].boundingRect, this.inputs[indexA].boundingRect]; + [this.inputs[indexA].pos, this.inputs[indexB].pos] = [this.inputs[indexB].pos, this.inputs[indexA].pos]; + [this.inputs[indexA], this.inputs[indexB]] = [this.inputs[indexB], this.inputs[indexA]]; updateSlotIndices(this); - - // The calling method is responsible for redrawing the canvas if needed }; - // Add helper method to get dynamic slots info - nodeType.prototype.getDynamicSlots = function(dynamicGroup = null) { - const dynamicSlots = []; + nodeType.prototype.getDynamicSlots = function(filterDynamicGroup = null) { // (Content largely unchanged, relies on isDynamicInputEmpty which is updated) + const dynamicSlotsResult = []; const dynamicGroupCount = {}; const dynamicGroupConnected = {}; - // Process each input to gather information about dynamic inputs - for (const [index, input] of this.inputs.entries()) { - // Skip non-dynamic inputs - if (!isDynamicInput(input.name)) { - continue; - } + const itemsState = new Map(); - // Get the dynamic group for this input - const currentDynamicGroup = this.getDynamicGroup(input.name); + for (const [index, input] of this.inputs.entries()) { + const diData = getDynamicInputDefinition(input.name); + if (!diData) continue; - // Skip if filtering by group and this doesn't match - if (dynamicGroup !== null && currentDynamicGroup !== dynamicGroup) { - continue; - } + const { definition: di, suffix } = diData; + const currentDynamicGroup = di.dynamicGroup; - // Determine if this input is active (connected or has non-default widget value) - const isActive = !isDynamicInputEmpty(this, index); + if (filterDynamicGroup !== null && currentDynamicGroup !== filterDynamicGroup) continue; - // Initialize group tracking if this is the first input for this group - if (!(currentDynamicGroup in dynamicGroupCount)) { - dynamicGroupCount[currentDynamicGroup] = 0; - dynamicGroupConnected[currentDynamicGroup] = []; - } + if (!itemsState.has(currentDynamicGroup)) itemsState.set(currentDynamicGroup, new Map()); + const groupItems = itemsState.get(currentDynamicGroup); + if (!groupItems.has(suffix)) groupItems.set(suffix, { isActive: false, inputCount: 0, activeInputCount: 0 }); - // Get the base name for this dynamic input - const baseNameInfo = dynamicInputs[dynamicInputGroups[currentDynamicGroup][0]]; + const itemState = groupItems.get(suffix); + itemState.inputCount++; + const isInputActive = !isDynamicInputEmpty(this, index); // Uses updated isDynamicInputEmpty + if (isInputActive) itemState.activeInputCount++; - // Track connection status for this input in its group - if (input.name.startsWith(baseNameInfo.baseName)) { - const groupIndex = dynamicGroupCount[currentDynamicGroup]; - // Use OR assignment to preserve 'true' values - dynamicGroupConnected[currentDynamicGroup][groupIndex] = - dynamicGroupConnected[currentDynamicGroup][groupIndex] || isActive; - dynamicGroupCount[currentDynamicGroup]++; - } - - // Store detailed information about this dynamic input - dynamicSlots.push({ - index, - name: input.name, - isWidget: input.widget !== undefined, - shape: input.shape, - connected: isActive, - isDynamic: true, - dynamicGroup: currentDynamicGroup, - dynamicGroupCount: dynamicGroupCount[currentDynamicGroup] + dynamicSlotsResult.push({ + index, name: input.name, isWidget: input.widget !== undefined, shape: input.shape, + connected: isInputActive, + isDynamic: true, dynamicGroup: currentDynamicGroup, }); } - return { - slots: dynamicSlots, - groupCount: dynamicGroupCount, - groupConnected: dynamicGroupConnected - }; - }; + for(const [groupId, groupItems] of itemsState) { + dynamicGroupCount[groupId] = groupItems.size; + dynamicGroupConnected[groupId] = []; + const sortedSuffixes = Array.from(groupItems.keys()).sort((a,b) => { + const numA = parseInt(a,10); const numB = parseInt(b,10); + if(!isNaN(numA) && !isNaN(numB)) return numA-numB; + if(a.length !== b.length) return a.length - b.length; + return String(a).localeCompare(String(b)); + }); - /** - * Generate a new dynamic input name based on type and count. - * @param {string} dynamic - The dynamic type ('number' or 'letter'). - * @param {string} baseName - The base name for the input. - * @param {number} count - The count/position for the new input. - * @returns {string} The generated input name. - */ - const generateDynamicInputName = (dynamic, baseName, count) => { - if (dynamic === 'letter') { - // For letter type, use the next letter in sequence - return String.fromCharCode(97 + count); // 97 is ASCII for 'a' - } else { - // For number type, use baseName + index - return `${baseName}${count}`; + for(const suffix of sortedSuffixes){ + const item = groupItems.get(suffix); + dynamicGroupConnected[groupId].push(item.activeInputCount > 0); + } } + return { slots: dynamicSlotsResult, groupCount: dynamicGroupCount, groupConnected: dynamicGroupConnected }; }; - // Add helper method to add new dynamic input for a group - nodeType.prototype.addNewDynamicInputForGroup = function(dynamicGroup, lastDynamicIdx) { - let insertPosition = lastDynamicIdx + 1; + nodeType.prototype.addNewDynamicInputForGroup = function(dynamicGroup, lastKnownInputIndexInGroup) { + let insertPosition = lastKnownInputIndexInGroup + 1; let inputInRange = true; - // Add new inputs for each member of the dynamic group - for (const groupMember of dynamicInputGroups[dynamicGroup]) { - const dynamicInput = dynamicInputs[groupMember]; - const baseName = dynamicInput.baseName; - const dynamicType = dynamicInput.dynamicType; - const widgetType = dynamicInput.widgetType ?? dynamicType; - const dynamic = dynamicInput.dynamic; - - // Get current slots for this group - const { slots } = this.getDynamicSlots(dynamicGroup); - const groupSlots = slots.filter(s => s.name.startsWith(baseName)); - - // Check if we've reached the limit for letter inputs (a-z) - if (dynamic === 'letter' && groupSlots.length >= 26) { - inputInRange = false; - continue; - } + const groupMemberDefinitions = dynamicInputGroups[dynamicGroup].map(idx => dynamicInputs[idx]); - // Generate the new input name based on current count - const newName = generateDynamicInputName(dynamic, baseName, groupSlots.length); - - // Find a reference slot to copy properties from - const referenceSlot = groupSlots[0] || slots.find(s => - s.name.startsWith(dynamicInput.baseName) - ); - - // Create the new input at the correct position - this.addInputAtPosition( - newName, - dynamicType, - widgetType, - insertPosition++, - referenceSlot?.isWidget ?? false, - referenceSlot?.shape - ); + const { slots: currentGroupSlots } = this.getDynamicSlots(dynamicGroup); + const existingSuffixes = new Set(); + currentGroupSlots.forEach(s => { + const diData = getDynamicInputDefinition(s.name); + if(diData) existingSuffixes.add(diData.suffix); + }); - // Ensure inputs are numbered correctly - this.renumberDynamicInputs(baseName, dynamicInputs, dynamic); + let newItemNumericSuffix = 0; + // Find the smallest non-negative integer not in existingSuffixes (if they are numbers) + // or just use existingSuffixes.size if complex/letter based for simplicity (renumbering will fix) + // For simplicity here, using size, assuming renumbering will handle actual ordering. + // A more robust suffix generation might be needed if strict ordering before renumbering is critical. + const isNumericSuffix = groupMemberDefinitions.length > 0 && groupMemberDefinitions[0].dynamic === 'number'; + if (isNumericSuffix) { + while (existingSuffixes.has(String(newItemNumericSuffix))) { + newItemNumericSuffix++; + } + } else { // letter or complex + newItemNumericSuffix = existingSuffixes.size; // This is the 'count' for letter generation } - return inputInRange; - }; - // Add method to safely renumber dynamic inputs without breaking connections - nodeType.prototype.renumberDynamicInputs = function(baseName, dynamicInputs, dynamic) { - // Collect information about dynamic inputs with this base name - const dynamicInputInfo = []; - - // Find all inputs that match this base name - for (let i = 0; i < this.inputs.length; i++) { - const input = this.inputs[i]; + for (const diDefinition of groupMemberDefinitions) { + // Use the new properties from diDefinition + const { baseName, dynamic, dynamicComfyType, isWidget, actualWidgetGuiType, originalOptions } = diDefinition; - if (isDynamicInput(input.name) && input.name.startsWith(baseName)) { - // Store info about this input - dynamicInputInfo.push({ - index: i, - widgetIdx: input.widget !== undefined - ? this.widgets.findIndex((w) => w.name === input.widget.name) - : undefined, - name: input.name, - connected: input.isConnected - }); + if (dynamic === 'letter' && newItemNumericSuffix >= 26) { + inputInRange = false; continue; } - } + const newName = generateDynamicInputName(dynamic, baseName, newItemNumericSuffix); - // Rename inputs in place to maintain connections - for (let i = 0; i < dynamicInputInfo.length; i++) { - const info = dynamicInputInfo[i]; - const input = this.inputs[info.index]; - const newName = generateDynamicInputName(dynamic, baseName, i); - - // Update widget name if this input has a widget - if (input.widget !== undefined && info.widgetIdx !== undefined) { - const widget = this.widgets[info.widgetIdx]; - widget.name = newName; - widget.label = newName; - input.widget.name = newName; - } + const refSlot = currentGroupSlots.find(s => s.name.startsWith(baseName)) || currentGroupSlots[0]; - // Update the input name if it's different - if (input.name !== newName) { - input.name = newName; - input.localized_name = newName; - } + const widgetDefault = getWidgetDefaultValue({ type: actualWidgetGuiType, options: originalOptions }); + + this.addInputAtPosition( + newName, + dynamicComfyType, // ComfyUI input type (e.g., "STRING", "*") + actualWidgetGuiType, // GUI widget type (e.g., "text", "number", "combo") + insertPosition++, + isWidget, // The crucial boolean + refSlot?.shape, + widgetDefault, + originalOptions // Full original options for widget config + ); } + return inputInRange; }; } }); From 597f56cef6ebea8240a43a420577d115626e886b Mon Sep 17 00:00:00 2001 From: StableLlama Date: Tue, 3 Jun 2025 17:47:40 +0200 Subject: [PATCH 3/4] Add STRING escape and unescape nodes. Fix test cases for containers and add those for the new string nodes --- src/basic_data_handling/data_list_nodes.py | 1 - src/basic_data_handling/list_nodes.py | 12 ++-- src/basic_data_handling/set_nodes.py | 20 +++--- src/basic_data_handling/string_nodes.py | 72 ++++++++++++++++++++++ tests/test_dict_nodes.py | 8 +-- tests/test_list_nodes.py | 20 +++--- tests/test_set_nodes.py | 28 ++++----- tests/test_string_nodes.py | 34 ++++++++++ 8 files changed, 150 insertions(+), 45 deletions(-) diff --git a/src/basic_data_handling/data_list_nodes.py b/src/basic_data_handling/data_list_nodes.py index 58de2b6..c3ffa58 100644 --- a/src/basic_data_handling/data_list_nodes.py +++ b/src/basic_data_handling/data_list_nodes.py @@ -811,7 +811,6 @@ def slice(self, **kwargs: list[Any]) -> tuple[list[Any]]: start = kwargs.get('start', [0])[0] stop = kwargs.get('stop', [INT_MAX])[0] step = kwargs.get('step', [1])[0] - print(f"start: {start}, stop: {stop}, step: {step}; input_list: {input_list}") return (input_list[start:stop:step],) diff --git a/src/basic_data_handling/list_nodes.py b/src/basic_data_handling/list_nodes.py index fd32d25..cb35268 100644 --- a/src/basic_data_handling/list_nodes.py +++ b/src/basic_data_handling/list_nodes.py @@ -91,8 +91,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - values = [float(value) for value in kwargs.values()] - return (values[:-1],) + values = [float(value) for value in list(kwargs.values())[:-1]] + return (values,) class ListCreateFromInt(ComfyNodeABC): @@ -117,8 +117,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - values = [int(value) for value in kwargs.values()] - return (values[:-1],) + values = [int(value) for value in list(kwargs.values())[:-1]] + return (values,) class ListCreateFromString(ComfyNodeABC): @@ -143,8 +143,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - values = [str(value) for value in kwargs.values()] - return (values[:-1],) + values = [str(value) for value in list(kwargs.values())[:-1]] + return (values,) class ListAppend(ComfyNodeABC): diff --git a/src/basic_data_handling/set_nodes.py b/src/basic_data_handling/set_nodes.py index 33909c6..bb52ef8 100644 --- a/src/basic_data_handling/set_nodes.py +++ b/src/basic_data_handling/set_nodes.py @@ -37,8 +37,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - values = kwargs.values() - return (set(values[:-1]),) + values = list(kwargs.values())[:-1] + return (set(values),) class SetCreateFromBoolean(ComfyNodeABC): @@ -63,8 +63,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - values = [bool(value) for value in kwargs.values()] - return (set(values[:-1]),) + values = [bool(value) for value in list(kwargs.values())[:-1]] + return (set(values),) class SetCreateFromFloat(ComfyNodeABC): @@ -89,8 +89,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - values = [float(value) for value in kwargs.values()] - return (set(values[:-1]),) + values = [float(value) for value in list(kwargs.values())[:-1]] + return (set(values),) class SetCreateFromInt(ComfyNodeABC): @@ -115,8 +115,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - values = [int(value) for value in kwargs.values()] - return (set(values[:-1]),) + values = [int(value) for value in list(kwargs.values())[:-1]] + return (set(values),) class SetCreateFromString(ComfyNodeABC): @@ -141,8 +141,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - values = [str(value) for value in kwargs.values()] - return (set(values[:-1]),) + values = [str(value) for value in list(kwargs.values())[:-1]] + return (set(values),) class SetAdd(ComfyNodeABC): diff --git a/src/basic_data_handling/string_nodes.py b/src/basic_data_handling/string_nodes.py index 8e59d29..3fa6f26 100644 --- a/src/basic_data_handling/string_nodes.py +++ b/src/basic_data_handling/string_nodes.py @@ -890,6 +890,74 @@ def removesuffix(self, string, suffix): return (string.removesuffix(suffix),) +class StringUnescape(ComfyNodeABC): + """ + Unescapes a string by converting escape sequences to their actual characters. + + This node converts escape sequences like '\n' (two characters) to actual newlines (one character), + '\t' to tabs, '\\' to backslashes, etc. Useful for processing strings where escape sequences + are represented literally rather than interpreted. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "string": (IO.STRING, {"default": ""}), + } + } + + RETURN_TYPES = (IO.STRING,) + CATEGORY = "Basic/STRING" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "unescape" + + def unescape(self, string): + # Decode escaped sequences only for control characters + result = ( + string + .replace(r'\\', '\u0000') + .replace(r'\n', '\n') + .replace(r'\t', '\t') + .replace(r'\"', '"') + .replace(r'\'', "'") + .replace('\u0000', '\\') + ) + return (result,) + + +class StringEscape(ComfyNodeABC): + """ + Escapes a string by converting special characters to escape sequences. + + This node converts characters like newlines, tabs, quotes, and backslashes to their escaped + representation (like '\n', '\t', '\"', '\\'). Useful when you need to prepare strings + for formats that require escaped sequences instead of literal special characters. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "string": (IO.STRING, {"default": ""}), + } + } + + RETURN_TYPES = (IO.STRING,) + CATEGORY = "Basic/STRING" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "escape" + + def escape(self, string): + result = ( + string + .replace('\\', r'\\') + .replace('\n', r'\n') + .replace('\t', r'\t') + .replace('"', r'\"') + .replace("'", r"\'") + ) + return (result,) + + class StringReplace(ComfyNodeABC): """ Replaces occurrences of a substring with another substring. @@ -1384,6 +1452,7 @@ def zfill(self, string, width): "Basic data handling: StringDecode": StringDecode, "Basic data handling: StringEncode": StringEncode, "Basic data handling: StringEndswith": StringEndswith, + "Basic data handling: StringEscape": StringEscape, "Basic data handling: StringExpandtabs": StringExpandtabs, "Basic data handling: StringFind": StringFind, "Basic data handling: StringFormatMap": StringFormatMap, @@ -1422,6 +1491,7 @@ def zfill(self, string, width): "Basic data handling: StringStrip": StringStrip, "Basic data handling: StringSwapcase": StringSwapcase, "Basic data handling: StringTitle": StringTitle, + "Basic data handling: StringUnescape": StringUnescape, "Basic data handling: StringUpper": StringUpper, "Basic data handling: StringZfill": StringZfill, } @@ -1435,6 +1505,7 @@ def zfill(self, string, width): "Basic data handling: StringDecode": "decode", "Basic data handling: StringEncode": "encode", "Basic data handling: StringEndswith": "endswith", + "Basic data handling: StringEscape": "escape", "Basic data handling: StringExpandtabs": "expandtabs", "Basic data handling: StringFind": "find", "Basic data handling: StringFormatMap": "format_map", @@ -1473,6 +1544,7 @@ def zfill(self, string, width): "Basic data handling: StringStrip": "strip", "Basic data handling: StringSwapcase": "swapcase", "Basic data handling: StringTitle": "title", + "Basic data handling: StringUnescape": "unescape", "Basic data handling: StringUpper": "upper", "Basic data handling: StringZfill": "zfill", } diff --git a/tests/test_dict_nodes.py b/tests/test_dict_nodes.py index 7b21d50..f314a0d 100644 --- a/tests/test_dict_nodes.py +++ b/tests/test_dict_nodes.py @@ -55,7 +55,7 @@ def test_dict_set(): def test_dict_create_from_boolean(): node = DictCreateFromBoolean() # Test with dynamic inputs - result = node.create(key_0="key1", value_0=True, key_1="key2", value_1=False) + result = node.create(key_0="key1", value_0=True, key_1="key2", value_1=False, key_2="", value_2="") assert result == ({"key1": True, "key2": False},) # Test with empty inputs assert node.create() == ({},) @@ -64,7 +64,7 @@ def test_dict_create_from_boolean(): def test_dict_create_from_float(): node = DictCreateFromFloat() # Test with dynamic inputs - result = node.create(key_0="key1", value_0=1.5, key_1="key2", value_1=2.5) + result = node.create(key_0="key1", value_0=1.5, key_1="key2", value_1=2.5, key_2="", value_2="") assert result == ({"key1": 1.5, "key2": 2.5},) # Test with empty inputs assert node.create() == ({},) @@ -73,7 +73,7 @@ def test_dict_create_from_float(): def test_dict_create_from_int(): node = DictCreateFromInt() # Test with dynamic inputs - result = node.create(key_0="key1", value_0=1, key_1="key2", value_1=2) + result = node.create(key_0="key1", value_0=1, key_1="key2", value_1=2, key_2="", value_2="") assert result == ({"key1": 1, "key2": 2},) # Test with empty inputs assert node.create() == ({},) @@ -82,7 +82,7 @@ def test_dict_create_from_int(): def test_dict_create_from_string(): node = DictCreateFromString() # Test with dynamic inputs - result = node.create(key_0="key1", value_0="value1", key_1="key2", value_1="value2") + result = node.create(key_0="key1", value_0="value1", key_1="key2", value_1="value2", key_2="", value_2="") assert result == ({"key1": "value1", "key2": "value2"},) # Test with empty inputs assert node.create() == ({},) diff --git a/tests/test_list_nodes.py b/tests/test_list_nodes.py index 2c8abbf..668fba8 100644 --- a/tests/test_list_nodes.py +++ b/tests/test_list_nodes.py @@ -176,36 +176,36 @@ def test_list_max(): def test_list_create(): node = ListCreate() - assert node.create_list(item_0=1, item_1=2, item_2=3) == ([1, 2, 3],) + assert node.create_list(item_0=1, item_1=2, item_2=3, item_3="") == ([1, 2, 3],) assert node.create_list() == ([],) # Empty list - assert node.create_list(item_0="test") == (["test"],) + assert node.create_list(item_0="test", item_1="") == (["test"],) def test_list_create_from_boolean(): node = ListCreateFromBoolean() - assert node.create_list(item_0=True, item_1=False, item_2=True) == ([True, False, True],) - assert node.create_list(item_0=True) == ([True],) + assert node.create_list(item_0=True, item_1=False, item_2=True, item_3="") == ([True, False, True],) + assert node.create_list(item_0=True, item_1="") == ([True],) assert node.create_list() == ([],) def test_list_create_from_float(): node = ListCreateFromFloat() - assert node.create_list(item_0=1.1, item_1=2.2, item_2=3.3) == ([1.1, 2.2, 3.3],) - assert node.create_list(item_0=0.0) == ([0.0],) + assert node.create_list(item_0=1.1, item_1=2.2, item_2=3.3, item_3="") == ([1.1, 2.2, 3.3],) + assert node.create_list(item_0=0.0, item_1="") == ([0.0],) assert node.create_list() == ([],) def test_list_create_from_int(): node = ListCreateFromInt() - assert node.create_list(item_0=1, item_1=2, item_2=3) == ([1, 2, 3],) - assert node.create_list(item_0=0) == ([0],) + assert node.create_list(item_0=1, item_1=2, item_2=3, item_3="") == ([1, 2, 3],) + assert node.create_list(item_0=0, item_1="") == ([0],) assert node.create_list() == ([],) def test_list_create_from_string(): node = ListCreateFromString() - assert node.create_list(item_0="a", item_1="b", item_2="c") == (["a", "b", "c"],) - assert node.create_list(item_0="test") == (["test"],) + assert node.create_list(item_0="a", item_1="b", item_2="c", item_3="") == (["a", "b", "c"],) + assert node.create_list(item_0="test", item_1="") == (["test"],) assert node.create_list() == ([],) diff --git a/tests/test_set_nodes.py b/tests/test_set_nodes.py index 83a91d9..6ad37a1 100644 --- a/tests/test_set_nodes.py +++ b/tests/test_set_nodes.py @@ -26,29 +26,29 @@ def test_set_create(): node = SetCreate() # Testing with kwargs to simulate dynamic inputs - assert node.create_set(item_0=1, item_1=2, item_2=3) == ({1, 2, 3},) - assert node.create_set(item_0="a", item_1="b") == ({"a", "b"},) + assert node.create_set(item_0=1, item_1=2, item_2=3, item_3="") == ({1, 2, 3},) + assert node.create_set(item_0="a", item_1="b", item_2="") == ({"a", "b"},) assert node.create_set() == (set(),) # Empty set with no arguments # Mixed types - assert node.create_set(item_0=1, item_1="b", item_2=True) == ({1, "b", True},) + assert node.create_set(item_0=1, item_1="b", item_2=True, item_3="") == ({1, "b", True},) def test_set_create_from_int(): node = SetCreateFromInt() - assert node.create_set(item_0=1, item_1=2, item_2=3) == ({1, 2, 3},) - assert node.create_set(item_0=5) == ({5},) # Single item set - assert node.create_set(item_0=1, item_1=1) == ({1},) # Duplicate items become single item + assert node.create_set(item_0=1, item_1=2, item_2=3, item_3="") == ({1, 2, 3},) + assert node.create_set(item_0=5, item_1="") == ({5},) # Single item set + assert node.create_set(item_0=1, item_1=1, item_2="") == ({1},) # Duplicate items become single item assert node.create_set() == (set(),) # Empty set with no arguments def test_set_create_from_string(): node = SetCreateFromString() - result = node.create_set(item_0="apple", item_1="banana") + result = node.create_set(item_0="apple", item_1="banana", item_2="") assert isinstance(result[0], set) assert result[0] == {"apple", "banana"} # Duplicate strings - result = node.create_set(item_0="apple", item_1="apple") + result = node.create_set(item_0="apple", item_1="apple", item_2="") assert result[0] == {"apple"} # Empty set @@ -57,19 +57,19 @@ def test_set_create_from_string(): def test_set_create_from_float(): node = SetCreateFromFloat() - assert node.create_set(item_0=1.5, item_1=2.5) == ({1.5, 2.5},) - assert node.create_set(item_0=3.14) == ({3.14},) # Single item set - assert node.create_set(item_0=1.0, item_1=1.0) == ({1.0},) # Duplicate items + assert node.create_set(item_0=1.5, item_1=2.5, item_2="") == ({1.5, 2.5},) + assert node.create_set(item_0=3.14, item_1="") == ({3.14},) # Single item set + assert node.create_set(item_0=1.0, item_1=1.0, item_2="") == ({1.0},) # Duplicate items assert node.create_set() == (set(),) # Empty set with no arguments def test_set_create_from_boolean(): node = SetCreateFromBoolean() - assert node.create_set(item_0=True, item_1=False) == ({True, False},) - assert node.create_set(item_0=True, item_1=True) == ({True},) # Duplicate booleans + assert node.create_set(item_0=True, item_1=False, item_2="") == ({True, False},) + assert node.create_set(item_0=True, item_1=True, item_2="") == ({True},) # Duplicate booleans assert node.create_set() == (set(),) # Empty set with no arguments # Test conversion from non-boolean values - assert node.create_set(item_0=1, item_1=0) == ({True, False},) + assert node.create_set(item_0=1, item_1=0, item_2="") == ({True, False},) def test_set_add(): diff --git a/tests/test_string_nodes.py b/tests/test_string_nodes.py index 61897d3..eec010f 100644 --- a/tests/test_string_nodes.py +++ b/tests/test_string_nodes.py @@ -10,6 +10,7 @@ StringDecode, StringEncode, StringEndswith, + StringEscape, StringExpandtabs, StringFind, StringFormatMap, @@ -47,6 +48,7 @@ StringStrip, StringSwapcase, StringTitle, + StringUnescape, StringUpper, StringZfill, ) @@ -364,6 +366,38 @@ def test_zfill(): assert node.zfill("123", 2) == ("123",) # Width smaller than string length assert node.zfill("", 3) == ("000",) # Empty string +def test_unescape(): + node = StringUnescape() + # Test control characters + assert node.unescape(r"Hello\nWorld") == ("Hello\nWorld",) # Newline + assert node.unescape(r"Hello\tWorld") == ("Hello\tWorld",) # Tab + assert node.unescape(r"C:\\Program Files\\App") == (r"C:\Program Files\App",) # Backslash + assert node.unescape(r"Quote: \"Text\"") == ("Quote: \"Text\"",) # Double quote + assert node.unescape(r"Quote: \'Text\'") == ("Quote: 'Text'",) # Single quote + + # Test that normal Unicode characters remain unchanged + assert node.unescape("German: äöüß") == ("German: äöüß",) # Umlauts should be preserved + assert node.unescape("Hello äöü\nWorld") == ("Hello äöü\nWorld",) # Umlauts with control char + + # Test complex string with multiple escape sequences + assert node.unescape(r"Path: C:\\folder\\file.txt\nLine1\tLine2") == ("Path: C:\\folder\\file.txt\nLine1\tLine2",) + +def test_escape(): + node = StringEscape() + # Test control characters + assert node.escape("Hello\nWorld") == (r"Hello\nWorld",) # Newline + assert node.escape("Hello\tWorld") == (r"Hello\tWorld",) # Tab + assert node.escape(r"C:\Program Files\App") == (r"C:\\Program Files\\App",) # Backslash + assert node.escape('Quote: "Text"') == (r'Quote: \"Text\"',) # Double quote + assert node.escape("Quote: 'Text'") == (r"Quote: \'Text\'",) # Single quote + + # Test that normal Unicode characters remain unchanged + assert node.escape("German: äöüß") == (r"German: äöüß",) # Umlauts should be preserved + assert node.escape("Hello äöü\nWorld") == (r"Hello äöü\nWorld",) # Umlauts with control char + + # Test complex string with multiple special characters + assert node.escape("Path: C:\\folder\\file.txt\nLine1\tLine2") == (r"Path: C:\\folder\\file.txt\nLine1\tLine2",) + def test_length(): node = StringLength() assert node.length("hello") == (5,) From f2322ead4d71e6bfbc883a7f9afb292a5497a953 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Tue, 3 Jun 2025 19:41:05 +0200 Subject: [PATCH 4/4] Update README and description --- README.md | 359 ++++++++++++++++++++++++++++++------------ pyproject.toml | 23 ++- web/js/dynamicnode.js | 27 ---- 3 files changed, 280 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index e310275..6b24d18 100644 --- a/README.md +++ b/README.md @@ -16,91 +16,271 @@ These nodes are very lightweight and require no additional dependencies. # Features -- **STRING**: String manipulation nodes - - Basic functions: capitalize, casefold, center, concat, count, encode/decode, find/rfind, join, lower/upper, replace, split, strip, etc. - - String checking: contains, endswith, startswith - - Case conversion: lower, upper, swapcase, title, capitalize - - Validation: isalnum, isalpha, isdigit, isnumeric, etc. - -- **LIST**: Python list manipulation nodes (as a single variable) - - Conversion: convert to data list - - Creation: any to LIST - - Modification: append, extend, insert, remove, pop, clear, set_item - - Access: get_item, slice, index, contains - - Information: length, count - - Operations: sort, reverse, min, max - -- **SET**: Python set manipulation nodes (as a single variable) - - Conversion: convert to data list, to LIST - - Creation: any to SET, LIST to SET - - Modification: add, remove, discard, pop, clear - - Information: length, contains - - Set operations: union, intersection, difference, symmetric_difference - - Comparison: is_subset, is_superset, is_disjoint - -- **data list**: ComfyUI list manipulation nodes (for processing individual items) - - Creation: create_empty - - Modification: append, filter, extend, insert, remove, pop, clear, set_item - - Access: get_item, slice, index, contains - - Information: length, count - - Operations: sort, reverse, copy, zip, min, max - -- **cast**: Type conversion nodes for ComfyUI data types - - Basic conversions: to STRING, to INT, to FLOAT, to BOOLEAN - - Collection conversions: to LIST, to SET, to DICT - - Special conversions: data list to LIST, data list to SET - -- **path**: File system path manipulation nodes - - Basic operations: join, split, splitext, basename, dirname, normalize - - Path information: abspath, exists, is_file, is_dir, is_absolute, get_size, get_extension - - Directory operations: list_dir, get_cwd - - Path searching: glob, common_prefix - - Path conversions: relative, expand_vars - -- **DICT**: Dictionary manipulation nodes - - Creation: create, from_items, from_lists, fromkeys, any_to_DICT - - Access: get, get_multiple, keys, values, items - - Modification: set, update, setdefault, merge - - Removal: pop, popitem, remove, clear - - Information: length, contains_key - - Operations: copy, filter_by_keys, exclude_keys, invert, compare - - Conversion: get_keys_values - -- **INT**: Integer operation nodes - - Basic operations: add, subtract, multiply, divide, modulus, power - - Bit operations: bit_length, to_bytes, from_bytes, bit_count - -- **FLOAT**: Floating-point operation nodes - - Basic operations: add, subtract, multiply, divide, power, round - - Specialized: is_integer, as_integer_ratio, to_hex, from_hex - -- **BOOLEAN**: Boolean logic nodes - - Logic operations: and, or, not, xor, nand, nor - -- **Comparison**: Value comparison nodes - - Basic comparisons: equal (==), not equal (!=), greater than (>), less than (<), etc. - - String comparison: case-sensitive/insensitive string comparison with various operators - - Special comparisons: number in range, is null, compare length - - Container operations: length comparison for strings, lists, and other containers - -- **Flow Control**: Workflow control nodes - - Conditional: if/else for branching logic based on conditions - - Selection: switch/case for selecting from multiple options based on an index - -- **Math**: Mathematical operations - - Trigonometric: sin, cos, tan, asin, acos, atan, atan2 - - Logarithmic/Exponential: log, log10, exp, sqrt - - Numerical: min, max - - Constants: pi, e - - Conversion: degrees/radians - - Rounding: floor, ceil - - Other: abs (absolute value) +## Nodes + +### BOOLEAN: Boolean logic nodes +- Logic operations: and, or, not, xor, nand, nor + +### cast: Type conversion nodes for ComfyUI data types +- Basic data type conversions: + - to BOOLEAN - Converts any input to a Boolean using Python's truthy/falsy rules + - to FLOAT - Converts numeric input to a floating-point number (raises ValueError for invalid inputs) + - to INT - Converts numeric input to an integer (raises ValueError for invalid inputs) + - to STRING - Converts any input to a string using Python's str() function +- Collection type conversions: + - to DICT - Converts compatible inputs (mappings or lists of key-value pairs) to a dictionary + - to LIST - Converts input to a LIST (wraps single items in a list, preserves existing lists) + - to SET - Converts input to a SET (creates a set from single items or collections, removing duplicates) + +### Comparison: Value comparison nodes +- Basic comparisons: equal (==), not equal (!=), greater than (>), greater than or equal (>=), less than (<), less than or equal (<=) +- String comparison: StringComparison node with case-sensitive/insensitive options and various operators (==, !=, >, <, >=, <=) +- Special comparisons: NumberInRange (check if a value is within specified bounds), IsNull (check if a value is null) +- Container operations: CompareLength (compare the length of strings, lists, and other containers) + +### data list: ComfyUI list manipulation nodes (for processing individual items) +- Creation: + - create Data List - Generic creation from any type inputs (dynamically expandable) + - Type-specific creation: + - create Data List from BOOLEANs - Creates a data list from Boolean values + - create Data List from FLOATs - Creates a data list from floating-point values + - create Data List from INTs - Creates a data list from integer values + - create Data List from STRINGs - Creates a data list from string values +- Modification: + - append - Adds an item to the end of a data list + - extend - Combines elements from multiple data lists + - insert - Inserts an item at a specified position + - set item - Replaces an item at a specified position + - remove - Removes the first occurrence of a specified value + - pop - Removes and returns an item at a specified position + - pop random - Removes and returns a random element +- Filtering: + - filter - Filters a data list using boolean values + - filter select - Separates items into two lists based on boolean filters +- Access: + - get item - Retrieves an item at a specified position + - first - Returns the first element in a data list + - last - Returns the last element in a data list + - slice - Creates a subset of a data list using start/stop/step parameters + - index - Finds the position of a value in a data list + - contains - Checks if a data list contains a specified value +- Information: + - length - Returns the number of items in a data list + - count - Counts occurrences of a value in a data list +- Operations: + - sort - Orders items (with optional reverse parameter) + - reverse - Reverses the order of items + - zip - Combines multiple data lists element-wise + - min - Finds the minimum value in a list of numbers + - max - Finds the maximum value in a list of numbers +- Conversion: + - convert to LIST - Converts a data list to a LIST object + - convert to SET - Converts a data list to a SET (removing duplicates) + +### DICT: Dictionary manipulation nodes +- Creation: create (generic and type-specific versions), create from items (data list and LIST versions), create from lists, fromkeys +- Access: get, get_multiple, keys, values, items +- Modification: set, update, setdefault, merge +- Removal: pop, popitem, pop random, remove +- Information: length, contains_key +- Operations: filter_by_keys, exclude_keys, invert, compare +- Conversion: get_keys_values + +### FLOAT: Floating-point operation nodes +- Creation: create FLOAT from string +- Basic arithmetic: add, subtract, multiply, divide, divide (zero safe), power +- Formatting: round (to specified decimal places) +- Conversion: to_hex (hexadecimal representation), from_hex (create from hex string) +- Analysis: is_integer (check if float has no fractional part), as_integer_ratio (get numerator/denominator) + +### Flow Control: Workflow control nodes +- Conditional branching: + - if/else - Basic conditional that returns one of two values based on a condition + - if/elif/.../else - Extended conditional with multiple conditions and outcomes +- Selection: + - switch/case - Selects from multiple options based on an integer index + - flow select - Directs a value to either "true" or "false" output path based on a condition +- Execution control: + - force execution order - Coordinates the execution sequence of nodes in a workflow + +### INT: Integer operation nodes +- Creation: create INT (from string), create INT with base (convert string with specified base) +- Basic arithmetic: add, subtract, multiply, divide, divide (zero safe), modulus, power +- Bit operations: bit_length (bits needed to represent number), bit_count (count of 1 bits) +- Byte conversion: to_bytes (convert integer to bytes), from_bytes (convert bytes to integer) + +### LIST: Python list manipulation nodes (as a single variable) +- Creation: create LIST (generic), create from type-specific values (BOOLEANs, FLOATs, INTs, STRINGs) +- Modification: append, extend, insert, remove, pop, pop random, set_item +- Access: get_item, first, last, slice, index, contains +- Information: length, count +- Operations: sort (with optional reverse), reverse, min, max +- Conversion: convert to data list, convert to SET + +### Math: Mathematical operations +- Trigonometric functions: + - Basic: sin, cos, tan - Calculate sine, cosine, and tangent of angles (in degrees or radians) + - Inverse: asin, acos, atan - Calculate inverse sine, cosine, and tangent (returns degrees or radians) + - Special: atan2 - Calculate arc tangent of y/x with correct quadrant handling +- Logarithmic/Exponential functions: + - log - Natural logarithm (base e) with optional custom base + - log10 - Base-10 logarithm + - exp - Exponential function (e^x) + - sqrt - Square root +- Constants: + - pi - Mathematical constant π (3.14159...) + - e - Mathematical constant e (2.71828...) +- Angle conversion: + - degrees - Convert radians to degrees + - radians - Convert degrees to radians +- Rounding operations: + - floor - Return largest integer less than or equal to input + - ceil - Return smallest integer greater than or equal to input +- Min/Max functions: + - min - Return minimum of two values + - max - Return maximum of two values +- Other: + - abs - Absolute value (magnitude without sign) + +### path: File system path manipulation nodes +- Basic operations: + - join - Joins multiple path components intelligently with correct separators + - split - Splits a path into directory and filename components + - splitext - Splits a path into name and extension components + - basename - Extracts the filename component from a path + - dirname - Extracts the directory component from a path + - normalize - Collapses redundant separators and resolves up-level references +- Path information: + - abspath - Returns the absolute (full) path by resolving relative components + - exists - Checks if a path exists in the filesystem + - is_file - Checks if a path points to a regular file + - is_dir - Checks if a path points to a directory + - is_absolute - Checks if a path is absolute (begins at root directory) + - get_size - Returns the size of a file in bytes + - get_extension - Extracts the file extension from a path (including the dot) +- Directory operations: + - list_dir - Lists files and directories in a specified path with filtering options + - get_cwd - Returns the current working directory +- Path searching: + - glob - Finds paths matching a pattern with wildcard support + - common_prefix - Finds the longest common leading component of given paths +- Path conversions: + - relative - Computes a relative path from a start path to a target path + - expand_vars - Replaces environment variables in a path with their values + +### SET: Python set manipulation nodes (as a single variable) +- Creation: + - create SET - Generic creation from any type inputs (dynamically expandable) + - Type-specific creation: + - create SET from BOOLEANs - Creates a set from Boolean values + - create SET from FLOATs - Creates a set from floating-point values + - create SET from INTs - Creates a set from integer values + - create SET from STRINGs - Creates a set from string values +- Modification: + - add - Adds an item to a set + - remove - Removes an item from a set (raises error if not present) + - discard - Removes an item if present (no error if missing) + - pop - Removes and returns an arbitrary element + - pop random - Removes and returns a random element +- Information: + - length - Returns the number of items in a set + - contains - Checks if a set contains a specified value +- Set operations: + - union - Combines elements from multiple sets + - intersection - Returns elements common to all input sets + - difference - Returns elements in first set but not in second set + - symmetric_difference - Returns elements in either set but not in both +- Set comparison: + - is_subset - Checks if first set is a subset of second set + - is_superset - Checks if first set is a superset of second set + - is_disjoint - Checks if two sets have no common elements +- Conversion: + - convert to data list - Converts a SET to a ComfyUI data list + - convert to LIST - Converts a SET to a LIST + +### STRING: String manipulation nodes +Available nodes grouped by functionality: + +#### Text case conversion +- capitalize - Converts first character to uppercase, rest to lowercase +- casefold - Aggressive lowercase for case-insensitive comparisons +- lower - Converts string to lowercase +- swapcase - Swaps case of all characters +- title - Converts string to titlecase +- upper - Converts string to uppercase + +#### Text inspection and validation +- contains (in) - Checks if string contains a substring +- endswith - Checks if string ends with a specific suffix +- find - Finds first occurrence of a substring +- length - Returns the number of characters in the string +- rfind - Finds last occurrence of a substring +- startswith - Checks if string starts with a specific prefix + +#### Character type checking +- isalnum - Checks if all characters are alphanumeric +- isalpha - Checks if all characters are alphabetic +- isascii - Checks if all characters are ASCII +- isdecimal - Checks if all characters are decimal +- isdigit - Checks if all characters are digits +- isidentifier - Checks if string is a valid Python identifier +- islower - Checks if all characters are lowercase +- isnumeric - Checks if all characters are numeric +- isprintable - Checks if all characters are printable +- isspace - Checks if all characters are whitespace +- istitle - Checks if string is titlecased +- isupper - Checks if all characters are uppercase + +#### Text formatting and alignment +- center - Centers text within specified width +- expandtabs - Replaces tabs with spaces +- ljust - Left-aligns text within specified width +- rjust - Right-aligns text within specified width +- zfill - Pads string with zeros on the left + +#### Text splitting and joining +- join (from data list) - Joins strings from a data list +- join (from LIST) - Joins strings from a LIST +- rsplit (from data list) - Splits string from right into a data list +- rsplit (from LIST) - Splits string from right into a LIST +- split (to data list) - Splits string into a data list +- split (to LIST) - Splits string into a LIST +- splitlines (from data list) - Splits string at line boundaries into a data list +- splitlines (to LIST) - Splits string at line boundaries into a LIST + +#### Text modification +- concat - Combines two strings together +- count - Counts occurrences of a substring +- replace - Replaces occurrences of a substring +- strip - Removes leading and trailing characters +- lstrip - Removes leading characters +- rstrip - Removes trailing characters +- removeprefix - Removes prefix if present +- removesuffix - Removes suffix if present + +#### Encoding and escaping +- decode - Converts bytes-like string to text +- encode - Converts string to bytes +- escape - Converts special characters to escape sequences +- unescape - Converts escape sequences to actual characters +- format_map - Formats string using values from dictionary ## Understanding LIST vs. data list vs. SET ComfyUI has different data types that serve different purposes: -### 1. LIST datatype +### 1. data list +- A native ComfyUI list where **items are processed individually** +- Acts like a standard array/list in most programming contexts +- Items can be accessed individually by compatible nodes +- Supports built-in ComfyUI iteration over each item +- Best for: + - Working directly with multiple items in parallel + - Batch processing scenarios + - When you need to apply the same operation to multiple inputs + - When your operation needs to work with individual items separately + +### 2. LIST datatype - A Python list represented as a **single variable** in the workflow - Treated as a self-contained object that can be passed between nodes - Cannot directly connect to nodes that expect individual items @@ -110,7 +290,7 @@ ComfyUI has different data types that serve different purposes: - Passing collections between different parts of your workflow - Complex data storage that shouldn't be split apart -### 2. SET datatype +### 3. SET datatype - A Python set represented as a **single variable** in the workflow - Stores unique values with no duplicates - Supports mathematical set operations (union, intersection, etc.) @@ -120,23 +300,6 @@ ComfyUI has different data types that serve different purposes: - Set operations (union, difference, etc.) - When element order doesn't matter -### 3. data list -- A native ComfyUI list where **items are processed individually** -- Acts like a standard array/list in most programming contexts -- Items can be accessed individually by compatible nodes -- Supports built-in ComfyUI iteration over each item -- Best for: - - Working directly with multiple items in parallel - - Batch processing scenarios - - When you need to apply the same operation to multiple inputs - - When your operation needs to work with individual items separately - -### Converting between types -- Use `convert to data list` node to transform a LIST or SET into a ComfyUI data list -- Use `convert to LIST` node to transform a ComfyUI data list into a LIST object -- Use `LIST to SET` to convert a LIST to a SET (removing duplicates) -- Use `SET to LIST` to convert a SET to a LIST - ### When to use which type - Use **LIST** when you need: - Ordered collection with potential duplicates diff --git a/pyproject.toml b/pyproject.toml index ff70645..cc8688d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,26 @@ build-backend = "setuptools.build_meta" name = "basic_data_handling" version = "0.3.5" description = """NOTE: Still in development! Expect breaking changes! + Basic Python functions for manipulating data that every programmer is used to. -Currently supported ComfyUI data types: BOOLEAN, FLOAT, INT, STRING and data lists. -Additionally supported Python data types as custom data types: DICT, LIST. -Data handling is supported with comparisons and control flow operations. -Also contains math, regex, and file system path manipulation functions.""" +Comprehensive node collection for data manipulation in ComfyUI workflows. + +Supported data types: +- ComfyUI native: BOOLEAN, FLOAT, INT, STRING, and data lists +- Python types as custom data types: DICT, LIST, SET + +Feature categories: +- Boolean logic operations (and, or, not, xor, nand, nor) +- Type casting/conversion between all supported data types +- Comparison operations (equality, numerical comparison, range checking) +- Data structures manipulation (data lists, LIST, DICT, SET) +- Flow control (conditionals, branching, execution order) +- Mathematical operations (arithmetic, trigonometry, logarithmic functions) +- String manipulation (case conversion, formatting, splitting, validation) +- File system path handling (path operations, information, searching) +- SET operations (creation, modification, comparison, mathematical set theory) + +All nodes are lightweight with no additional dependencies required.""" authors = [ {name = "StableLlama"} ] diff --git a/web/js/dynamicnode.js b/web/js/dynamicnode.js index db620fe..a3b170c 100644 --- a/web/js/dynamicnode.js +++ b/web/js/dynamicnode.js @@ -565,33 +565,6 @@ app.registerExtension({ return originalReturn; }; - // --- Core Dynamic Methods (swapInputs, getDynamicSlots, addNewDynamicInputForGroup) --- - nodeType.prototype.swapInputs = function(indexA, indexB) { // (Content unchanged) - if (indexA < 0 || indexB < 0 || indexA >= this.inputs.length || indexB >= this.inputs.length || indexA === indexB) { - console.error("[Basic Data Handling] Invalid input indices for swapping:", indexA, indexB); return; - } - const hasWidgetA = this.inputs[indexA].widget !== undefined; - const hasWidgetB = this.inputs[indexB].widget !== undefined; - if (hasWidgetA && hasWidgetB) { - const widgetIdxA = this.widgets.findIndex((w) => w.name === this.inputs[indexA].widget.name); - const widgetIdxB = this.widgets.findIndex((w) => w.name === this.inputs[indexB].widget.name); - if(widgetIdxA !== -1 && widgetIdxB !== -1) { - [this.widgets[widgetIdxA].y, this.widgets[widgetIdxB].y] = [this.widgets[widgetIdxB].y, this.widgets[widgetIdxA].y]; - [this.widgets[widgetIdxA].last_y, this.widgets[widgetIdxB].last_y] = [this.widgets[widgetIdxB].last_y, this.widgets[widgetIdxA].last_y]; - [this.widgets[widgetIdxA], this.widgets[widgetIdxB]] = [this.widgets[widgetIdxB], this.widgets[widgetIdxA]]; - if (this.widgets_values) { - [this.widgets_values[widgetIdxA], this.widgets_values[widgetIdxB]] = [this.widgets_values[widgetIdxB], this.widgets_values[widgetIdxA]]; - } - } - } else if (hasWidgetA || hasWidgetB) { - console.error("[Basic Data Handling] Bad swap: one input has a widget but the other doesn't", indexA, indexB); - } - [this.inputs[indexA].boundingRect, this.inputs[indexB].boundingRect] = [this.inputs[indexB].boundingRect, this.inputs[indexA].boundingRect]; - [this.inputs[indexA].pos, this.inputs[indexB].pos] = [this.inputs[indexB].pos, this.inputs[indexA].pos]; - [this.inputs[indexA], this.inputs[indexB]] = [this.inputs[indexB], this.inputs[indexA]]; - updateSlotIndices(this); - }; - nodeType.prototype.getDynamicSlots = function(filterDynamicGroup = null) { // (Content largely unchanged, relies on isDynamicInputEmpty which is updated) const dynamicSlotsResult = []; const dynamicGroupCount = {};