Files

330 lines
11 KiB
JavaScript

/*
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Metadata Dialog Controller
*
* Handles UI for metadata editing dialogs and forms.
* Follows the dependency rule: controllers → cores → stores → utils
*/
import { COMFYUI_INPUT_TYPE_MAP, REMIX_KEYS, REMIX_TYPE, TEMPLATE_IDS } from "../utils/constants.js";
import { showDialog } from "./baseDialogController.js";
import { createFormField } from "../factories/componentFactory.js";
import { createGroupPicker } from "./groupPickerController.js";
import { getInputMetadata, updateInputMetadata, reassignGroupInputs } from "../cores/metadataEditorCore.js";
import { buildMetadataContext, getApplicableMetadataFields } from "../cores/workflowExportCore.js";
/**
* Create a metadata form with all applicable fields for the given context.
*
* @param {Object} options - Form configuration
* @param {Object} options.app - ComfyUI app instance
* @param {Object} options.context - Metadata context from buildMetadataContext
* @param {Object} options.currentValues - Current values for form fields
* @param {string} options.primitiveType - Primitive type (int, float, str, bool)
* @param {boolean} options.isInput - Whether this is an input (affects remix type options)
* @param {string} [options.formClass] - Optional CSS class for the form
* @returns {HTMLElement} Form element with all metadata fields
*/
export function createMetadataForm({ app, context, currentValues, primitiveType, isInput, formClass }) {
const form = document.createElement("div");
form.className = formClass || "rtx-remix-metadata-form";
// Export Name field
const { field: nameField } = createFormField({
label: "Export Name",
inputType: "text",
fieldClass: "rtx-remix-metadata-field",
labelClass: "rtx-remix-metadata-label",
inputClass: "rtx-remix-metadata-input",
inputOptions: {
value: currentValues.name || "",
placeholder: "Name used in exported workflow",
},
dataAttributes: {
fieldKey: "name",
},
});
if (nameField) form.appendChild(nameField);
// RTX Remix Type dropdown (only for inputs with string type)
if (isInput && primitiveType === "str") {
const typeMap = COMFYUI_INPUT_TYPE_MAP;
const validTypes = typeMap[primitiveType] || [REMIX_TYPE.AUTO];
const typeField = document.createElement("div");
typeField.className = "rtx-remix-metadata-field";
const label = document.createElement("label");
label.className = "rtx-remix-metadata-label";
label.textContent = "RTX Remix Type";
typeField.appendChild(label);
const select = document.createElement("select");
select.className = "rtx-remix-metadata-input";
select.dataset.fieldKey = "remixType";
validTypes.forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = type;
if (type === currentValues.remixType) {
option.selected = true;
}
select.appendChild(option);
});
typeField.appendChild(select);
form.appendChild(typeField);
}
// NOTE: Order field removed - order is now controlled via drag-and-drop in the list UI
// Get applicable additional fields from METADATA_FIELD_CONFIG
const applicableFields = getApplicableMetadataFields(context);
// Create additional metadata fields
applicableFields.forEach((fieldConfig) => {
const currentValue = currentValues.additionalData?.[fieldConfig.key] ?? fieldConfig.computedDefault;
// Special handling for group field - render as combobox
if (fieldConfig.key === "group") {
const groupField = createGroupCombobox({
app,
currentValue,
});
form.appendChild(groupField);
} else {
const { field } = createFormField({
label: fieldConfig.label,
inputType: fieldConfig.inputType,
fieldClass: "rtx-remix-metadata-field",
labelClass: "rtx-remix-metadata-label",
inputClass: "rtx-remix-metadata-input",
inputOptions: {
value: currentValue,
},
dataAttributes: {
fieldKey: fieldConfig.key,
isAdditionalData: "true",
},
});
if (field) form.appendChild(field);
}
});
return form;
}
/**
* Create a group combobox field with existing groups and "Create new group" option.
* Uses the custom group picker component with inline create/rename/delete functionality.
* @param {Object} options
* @param {Object} options.app - ComfyUI app instance
* @param {string} options.currentValue - Current group value
* @returns {HTMLElement} Field element with custom group picker
*/
function createGroupCombobox({ app, currentValue }) {
const fieldDiv = document.createElement("div");
fieldDiv.className = "rtx-remix-metadata-field";
const labelEl = document.createElement("label");
labelEl.className = "rtx-remix-metadata-label";
labelEl.textContent = "Group";
fieldDiv.appendChild(labelEl);
const picker = createGroupPicker({
app,
currentValue: currentValue || "",
onChange: (newGroup) => {
// Value is stored in data attribute for extractFormValues
picker.dataset.selectedGroup = newGroup || "";
},
onGroupDelete: (groupName) => {
// Update all inputs in this group to be ungrouped (DELETE their group metadata)
reassignGroupInputs(app, groupName, null);
},
onGroupRename: (oldName, newName) => {
// Update all inputs with old group name to new name
reassignGroupInputs(app, oldName, newName);
},
});
picker.dataset.fieldKey = "group";
picker.dataset.isAdditionalData = "true";
picker.dataset.selectedGroup = currentValue || "";
fieldDiv.appendChild(picker);
return fieldDiv;
}
/**
* Extract form values from a metadata form element.
*
* @param {HTMLElement} form - Form element created by createMetadataForm
* @returns {Object} Extracted values {name, remixType, additionalData}
*/
export function extractFormValues(form) {
const result = {
name: "",
remixType: REMIX_TYPE.AUTO,
additionalData: {},
};
form.querySelectorAll("[data-field-key]").forEach((element) => {
const key = element.dataset.fieldKey;
const isAdditionalData = element.dataset.isAdditionalData === "true";
let value;
// Handle custom group picker component (stores value in dataset.selectedGroup)
if (key === "group" && element.dataset.selectedGroup !== undefined) {
value = element.dataset.selectedGroup;
} else {
// Handle regular input/select elements
value = element.value;
// Convert numeric values
if (element.type === "number") {
const numValue = parseFloat(value);
value = isNaN(numValue) ? null : numValue;
}
}
if (isAdditionalData) {
// For additionalData fields, only include non-empty values
// Empty/null values will be deleted from metadata
if (value !== null && value !== "") {
result.additionalData[key] = value;
}
} else if (key === "name") {
result.name = value?.trim() || "";
} else if (key === "remixType") {
result.remixType = value || REMIX_TYPE.AUTO;
}
});
return result;
}
/**
* Show an Edit Input Metadata dialog for a single input slot.
* This metadata is shared across all presets and affects the exported workflow.
*
* @param {Object} options - Dialog options
* @param {Object} options.app - ComfyUI app instance
* @param {string|number} options.nodeId - Node ID
* @param {string} options.slotName - Slot name
* @param {string} options.nodeTitle - Node title for display
* @param {Function} [options.onSave] - Callback when changes are saved
* @returns {Promise<boolean>} True if saved, false if cancelled
*/
export function showEditGlobalSettingsDialog({ app, nodeId, slotName, nodeTitle, onSave }) {
// Get current metadata first
const currentMetadata = getInputMetadata(app, nodeId, slotName);
if (!currentMetadata) {
return Promise.resolve(false);
}
return showDialog(TEMPLATE_IDS.EDIT_GLOBAL_SETTINGS_DIALOG, {
onOpen: ({ overlay, dialog, close }) => {
const titleEl = overlay.querySelector("[data-bind='title']");
const subtitleNodeEl = overlay.querySelector("[data-bind='subtitle-node']");
const subtitleSlotEl = overlay.querySelector("[data-bind='subtitle-slot']");
const formContainer = overlay.querySelector("[data-element='form-container']");
const cancelBtn = overlay.querySelector("[data-action='cancel']");
const saveBtn = overlay.querySelector("[data-action='save']");
// Set dialog title and subtitle
if (titleEl) {
titleEl.textContent = "Edit Input Metadata";
}
if (subtitleNodeEl) {
subtitleNodeEl.textContent = nodeTitle;
subtitleNodeEl.title = nodeTitle; // Tooltip for full text
}
if (subtitleSlotEl) {
subtitleSlotEl.textContent = slotName;
subtitleSlotEl.title = slotName; // Tooltip for full text
}
// Build context for field applicability
const node = app.graph.getNodeById?.(nodeId);
const context = buildMetadataContext({
app,
node,
slotName,
primitiveType: currentMetadata.type,
isInput: true,
remixType: currentMetadata.remixType,
});
// Create the metadata form
const form = createMetadataForm({
app,
context,
currentValues: currentMetadata,
primitiveType: currentMetadata.type,
isInput: true,
formClass: "rtx-remix-edit-details-form",
});
if (formContainer) {
formContainer.appendChild(form);
}
// Save handler
function handleSave() {
const values = extractFormValues(form);
// Update the node's metadata
// Note: order is not included - it's controlled by drag-and-drop in the list UI
const success = updateInputMetadata(app, nodeId, slotName, {
name: values.name,
remixType: values.remixType,
additionalData: values.additionalData,
});
if (success) {
onSave?.();
close(true);
} else {
close(false);
}
}
// Event listeners
cancelBtn?.addEventListener("click", () => close(false));
saveBtn?.addEventListener("click", handleSave);
// Ctrl+Enter to save
overlay.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.ctrlKey) {
e.preventDefault();
handleSave();
}
});
// Focus first input
setTimeout(() => {
const firstInput = form.querySelector("input, select, textarea");
firstInput?.focus();
}, 100);
},
});
}