Files

882 lines
31 KiB
JavaScript

/*
* SPDX-FileCopyrightText: Copyright (c) 2025 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.
*/
import { api } from "../../../scripts/api.js";
import {
API_ENDPOINTS,
COMFYUI_INPUT_TYPE_MAP,
COMFYUI_OUTPUT_TYPE_MAP,
EVENTS,
REMIX_TYPE,
REMIX_KEYS,
TEMPLATE_IDS,
} from "../utils/constants.js";
import { cloneTemplate, bindTemplateData } from "../utils/html.js";
import {
createSlotLabelCell,
createInputCell,
createTextCell,
createSelectCell,
createButtonCell,
setRowDataAttributes,
} from "./slotRowController.js";
import {
addRemixMetadataToPrompt,
applySlotEditsToGraphNodes,
deriveWorkflowFilename,
extractTaggedSlots,
getApplicableMetadataFields,
updateGroupOrder,
} from "../cores/workflowExportCore.js";
import { getGroupOrder, getWorkflowInfo, setWorkflowInfo } from "../stores/graphStore.js";
import { createGroupedList } from "./groupedListController.js";
import { updateInputMetadata } from "../cores/metadataEditorCore.js";
import { createGroupPicker } from "./groupPickerController.js";
import { closePopover, isPopoverOpenFor, openPopoverWithContent } from "./popoverController.js";
import { cleanupDeletedInputs } from "../cores/presetCore.js";
import { handlePendingChangesBeforeAction } from "./presetSidebarController.js";
/**
* Initialize the export controller - registers event listeners.
* @param {Object} app - ComfyUI app instance
*/
export function initExportController(app) {
app.api.addEventListener(EVENTS.EXPORT_WORKFLOW_REQUESTED, () => exportWorkflow(app));
}
/**
* Export workflow handler - retrieves current workflow name and shows export dialog
* @param {Object} app - ComfyUI app instance
*/
export async function exportWorkflow(app) {
// Check for pending preset changes before export
const canProceed = await handlePendingChangesBeforeAction(app);
if (!canProceed) {
return; // User cancelled, don't show export dialog
}
// Prefill from the graph metadata, falling back to the active workflow filename
const info = getWorkflowInfo(app);
const displayName = info.displayName || app.extensionManager?.workflow?.activeWorkflow?.filename || "";
// The workflow types and their categories are owned by the backend enum - never hardcode them here
let typeCategories = [];
try {
const response = await api.fetchApi(API_ENDPOINTS.WORKFLOW_TYPES);
const payload = await response.json();
typeCategories = payload?.categories || [];
} catch (error) {
console.error("Failed to load RTX Remix workflow types:", error);
}
// Show custom export dialog which handles the entire export process
await showExportDialog({
app,
typeCategories,
displayName,
description: info.description,
workflowType: info.workflowType,
});
}
/**
* Create metadata accordion with dynamic fields based on slot context
* @param {Object} options - Accordion options
* @param {Object} options.app - ComfyUI app instance (for group picker)
* @param {Object} options.context - Slot context
* @param {Object} options.slotData - Slot data
* @param {boolean} [options.includeGroup=true] - Whether to include the group field
* @returns {HTMLElement} Accordion element
*/
function createMetadataAccordion({ app, context, slotData, includeGroup = true }) {
const accordion = cloneTemplate(TEMPLATE_IDS.METADATA_ACCORDION);
if (!accordion) {
console.error("Failed to load metadata accordion template");
return null;
}
const form = accordion.querySelector('[data-element="form"]');
// Get applicable fields
const applicableFields = getApplicableMetadataFields(context);
// Create field for each applicable metadata
applicableFields.forEach((fieldConfig) => {
// Skip group field if not included
if (!includeGroup && fieldConfig.key === REMIX_KEYS.PROPERTY.ADDITIONAL_DATA.GROUP) {
return;
}
const fieldDiv = document.createElement("div");
fieldDiv.className = "rtx-remix-metadata-field";
const label = document.createElement("label");
label.className = "rtx-remix-metadata-label";
label.textContent = fieldConfig.label;
fieldDiv.appendChild(label);
// Get current value from slotData additional_data or use computed default
const currentValue = slotData.additionalData?.[fieldConfig.key] ?? fieldConfig.computedDefault;
// Use group picker for the group field
if (fieldConfig.key === REMIX_KEYS.PROPERTY.ADDITIONAL_DATA.GROUP) {
const picker = createGroupPicker({
app,
currentValue: currentValue || "",
onChange: (newGroup) => {
// Value is stored in data attribute for form extraction
picker.dataset.selectedGroup = newGroup || "";
},
});
picker.dataset.fieldKey = fieldConfig.key;
picker.dataset.nodeId = context.nodeId;
picker.dataset.slotName = context.slotName || "";
picker.dataset.isInput = context.isInput;
picker.dataset.isAdditionalData = "true";
picker.dataset.selectedGroup = currentValue || "";
fieldDiv.appendChild(picker);
} else {
// Standard input field
let input;
if (fieldConfig.inputType === "textarea") {
input = document.createElement("textarea");
input.className = "rtx-remix-metadata-input";
input.rows = 3;
} else {
input = document.createElement("input");
input.className = "rtx-remix-metadata-input";
input.type = fieldConfig.inputType;
}
input.dataset.fieldKey = fieldConfig.key;
input.dataset.nodeId = context.nodeId;
input.dataset.slotName = context.slotName || "";
input.dataset.isInput = context.isInput;
if (currentValue !== null && currentValue !== undefined) {
input.value = currentValue;
}
fieldDiv.appendChild(input);
}
form.appendChild(fieldDiv);
});
return accordion;
}
/**
* Shows a rich export dialog for RTX Remix workflows and handles the entire export process
* @param {Object} options - Dialog options
* @param {Object} options.app - The ComfyUI app instance
* @param {Array<{name: string, types: Array<{value: string, description: string}>}>} [options.typeCategories]
* - Selectable workflow types grouped by category, each type with its tooltip
* @param {string} [options.displayName] - Prefilled workflow display name
* @param {string} [options.description] - Prefilled workflow description
* @param {string} [options.workflowType] - Prefilled workflow type value
* @returns {Promise<boolean>} - True if export succeeded, false if cancelled or failed
*/
export async function showExportDialog({
app,
typeCategories = [],
displayName = "",
description = "",
workflowType = "",
} = {}) {
return new Promise((resolve) => {
// Clone the dialog template
const overlay = cloneTemplate(TEMPLATE_IDS.EXPORT_DIALOG);
if (!overlay) {
console.error("Failed to load export dialog template");
resolve(false);
return;
}
// Bind assets (icon)
bindTemplateData(overlay, {});
// Get references to elements
const dialog = overlay.querySelector(".rtx-remix-dialog");
const displayNameInput = overlay.querySelector('[data-input="display-name"]');
const typeTrigger = overlay.querySelector('[data-input="workflow-type"]');
const typeValueLabel = overlay.querySelector('[data-element="workflow-type-value"]');
const descriptionInput = overlay.querySelector('[data-input="description"]');
const displayNameError = overlay.querySelector('[data-element="display-name-error"]');
const typeError = overlay.querySelector('[data-element="workflow-type-error"]');
const validationMsg = overlay.querySelector('[data-element="validation"]');
const infoMsg = overlay.querySelector('[data-element="info"]');
const cancelBtn = overlay.querySelector('[data-action="cancel"]');
const exportBtn = overlay.querySelector('[data-action="export"]');
// Slots section elements
const slotsSection = overlay.querySelector('[data-element="slots-section"]');
const inputsWrapper = overlay.querySelector('[data-element="inputs-wrapper"]');
const outputsWrapper = overlay.querySelector('[data-element="outputs-wrapper"]');
// Note: inputs use grouped list (no inputsTbody), outputs use traditional tbody
const outputsTbody = overlay.querySelector('[data-element="outputs-tbody"]');
let isLoading = false;
// Set up initial values
displayNameInput.value = displayName;
descriptionInput.value = description;
// A stale or unknown stored value shows the placeholder, forcing a re-pick
const typeDescriptions = new Map(
typeCategories.flatMap(({ types }) => types.map(({ value, description }) => [value, description || ""]))
);
let selectedType = typeDescriptions.has(workflowType) ? workflowType : "";
function renderSelectedType() {
typeValueLabel.textContent = selectedType || "Select a type...";
typeValueLabel.classList.toggle("placeholder", selectedType === "");
// The type explains itself on hover, so the dialog spends no line on it
typeTrigger.title = typeDescriptions.get(selectedType) || "";
}
renderSelectedType();
// A native <select> popup cannot render the faded category dividers, so the trigger opens the
// shared popover with a menu of category headers plus their types.
function openTypeMenu() {
const menu = document.createElement("div");
menu.className = "rtx-remix-select-menu";
menu.style.width = `${typeTrigger.offsetWidth}px`;
typeCategories.forEach(({ name, types }) => {
const category = document.createElement("div");
category.className = "rtx-remix-select-category";
category.textContent = name;
menu.appendChild(category);
types.forEach(({ value, description }) => {
const option = document.createElement("button");
option.type = "button";
option.className = "rtx-remix-select-option";
option.classList.toggle("selected", value === selectedType);
option.textContent = value;
option.title = description || "";
option.addEventListener("click", () => {
selectedType = value;
renderSelectedType();
closePopover();
hideValidation();
syncExportState();
});
menu.appendChild(option);
});
});
openPopoverWithContent(typeTrigger, menu);
}
typeTrigger.addEventListener("click", () => {
if (isPopoverOpenFor(typeTrigger)) {
closePopover();
} else if (typeCategories.length > 0) {
openTypeMenu();
}
});
// Track collapsed groups state for inputs (must be before populateSlotsTable call)
const inputsCollapsedGroups = new Set();
// Extract and populate tagged slots
const slotData = extractTaggedSlots(app);
populateSlotsTable(slotData);
function showValidation(message) {
validationMsg.textContent = message;
validationMsg.classList.add("show");
}
function hideValidation() {
validationMsg.classList.remove("show");
}
function setFieldError(field, element, message) {
element.textContent = message;
element.classList.toggle("show", message !== "");
field.classList.toggle("error", message !== "");
}
// Display name and workflow type are mandatory and there is no sane default for the type, so
// Export stays disabled until both are set and each field states its own problem in place.
function syncExportState() {
const nameMessage = displayNameInput.value.trim() ? "" : "Display name is required";
const typeMessage =
typeCategories.length === 0
? "Workflow types could not be loaded from the ComfyUI server"
: selectedType
? ""
: "Workflow type is required";
setFieldError(displayNameInput, displayNameError, nameMessage);
setFieldError(typeTrigger, typeError, typeMessage);
exportBtn.disabled = isLoading || nameMessage !== "" || typeMessage !== "";
exportBtn.title = isLoading ? "" : nameMessage || typeMessage;
if (!isLoading) {
// The filename is derived, so it can never contain path characters - just show what it will be
infoMsg.textContent = `Saved as ${deriveWorkflowFilename(displayNameInput.value)}.json`;
}
}
displayNameInput.addEventListener("input", () => {
hideValidation();
syncExportState();
});
syncExportState();
// Close dialog function
function closeDialog(result) {
closePopover();
overlay.style.animation = "rtx-fadeOut 0.15s ease-out";
dialog.style.animation = "rtx-slideOut 0.15s ease-out";
setTimeout(() => {
document.body.removeChild(overlay);
resolve(result);
}, 150);
}
// Handle export process
async function handleExport() {
const workflowName = deriveWorkflowFilename(displayNameInput.value);
setLoading(true, "Checking if file exists...");
try {
// Check if file exists (check both api and full workflows in user directory)
const checkResponse = await api.fetchApi(
`${API_ENDPOINTS.WORKFLOWS_BASE}/api/user/${encodeURIComponent(workflowName)}`,
{ method: "HEAD" }
);
if (checkResponse.status === 200) {
setLoading(false);
const confirmed = await showOverwriteConfirmation(`${workflowName}.json`);
if (!confirmed) {
return;
}
setLoading(true, "Preparing workflow...");
}
// Apply UI edits directly to graph nodes first
setLoading(true, "Preparing workflow...");
applySlotEditsToGraphNodes(app, inputsWrapper, outputsWrapper);
// Clean up stale metadata (orphaned groups, empty presets) before serialization
cleanupDeletedInputs(app);
// Persist the metadata in the graph so it round trips with the user's own workflow save
setWorkflowInfo(app, {
displayName: displayNameInput.value.trim(),
description: descriptionInput.value.trim(),
workflowType: selectedType,
});
// Now serialize the graph (which has updated metadata)
setLoading(true, "Generating workflow data...");
const promptResult = await app.graphToPrompt();
const apiWorkflow = promptResult.output;
const workflowGraph = app.graph.serialize();
// Add RTX Remix metadata inline to the API workflow
const enrichedPrompt = addRemixMetadataToPrompt(apiWorkflow, workflowGraph);
// Send to backend to save (send name without extension)
setLoading(true, "Saving workflow...");
const response = await api.fetchApi(API_ENDPOINTS.WORKFLOWS_SAVE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
displayName: displayNameInput.value.trim(),
description: descriptionInput.value.trim(),
workflowType: selectedType,
workflows: {
api: enrichedPrompt,
full: workflowGraph,
},
}),
});
const responseData = await response.json();
if (responseData.success) {
// Note: We intentionally do NOT save the original workflow here.
// The RTX Remix export saves to user/rtx-remix/workflows/ via the backend.
// The original workflow file should remain unchanged (clean/readonly).
// If users want to save their changes to the original, they can do so manually.
app.extensionManager.toast.add({
severity: "success",
summary: "Export Successful",
detail: `Workflow exported to "${responseData.name}.json"`,
life: 5000,
});
closeDialog(true);
} else {
throw new Error(responseData.message || "Unknown error");
}
} catch (error) {
console.error("Export error:", error);
setLoading(false);
showError(error.message || "Failed to export workflow");
}
}
// Show overwrite confirmation
function showOverwriteConfirmation(filename) {
return new Promise((resolveConfirm) => {
const confirmOverlay = cloneTemplate(TEMPLATE_IDS.CONFIRM_DIALOG);
if (!confirmOverlay) {
resolveConfirm(false);
return;
}
const filenameSpan = confirmOverlay.querySelector('[data-element="filename"]');
const confirmCancelBtn = confirmOverlay.querySelector('[data-action="cancel"]');
const confirmBtn = confirmOverlay.querySelector('[data-action="confirm"]');
filenameSpan.textContent = `"${filename}"`;
confirmCancelBtn.addEventListener("click", () => {
document.body.removeChild(confirmOverlay);
resolveConfirm(false);
});
confirmBtn.addEventListener("click", () => {
document.body.removeChild(confirmOverlay);
resolveConfirm(true);
});
confirmOverlay.addEventListener("click", (e) => {
if (e.target === confirmOverlay) {
document.body.removeChild(confirmOverlay);
resolveConfirm(false);
}
});
document.body.appendChild(confirmOverlay);
});
}
// Set loading state
function setLoading(loading, message = "") {
isLoading = loading;
displayNameInput.disabled = loading;
typeTrigger.disabled = loading;
descriptionInput.disabled = loading;
cancelBtn.disabled = loading;
syncExportState();
if (loading) {
const spinner = cloneTemplate(TEMPLATE_IDS.SPINNER);
if (spinner) {
exportBtn.innerHTML = "";
exportBtn.appendChild(spinner);
} else {
exportBtn.textContent = "Exporting...";
}
if (message) {
infoMsg.textContent = message;
infoMsg.classList.add("loading");
}
} else {
exportBtn.textContent = "Export";
infoMsg.classList.remove("loading");
}
}
// Show error message
function showError(message) {
showValidation(message);
app.extensionManager.toast.add({
severity: "error",
summary: "Export Failed",
detail: message,
life: 5000,
});
}
// Handle keyboard events
function handleKeyDown(e) {
if (e.key === "Escape") {
e.preventDefault();
// The open type menu owns Escape first, so it closes without dismissing the dialog
if (isPopoverOpenFor(typeTrigger)) {
closePopover();
} else if (!isLoading) {
closeDialog(false);
}
} else if (e.key === "Enter" && e.target !== descriptionInput && e.target !== typeTrigger) {
e.preventDefault();
if (!exportBtn.disabled) {
handleExport();
}
}
}
// Event listeners
cancelBtn.addEventListener("click", () => {
if (!isLoading) {
closeDialog(false);
}
});
exportBtn.addEventListener("click", async () => {
if (!exportBtn.disabled) {
await handleExport();
}
});
overlay.addEventListener("keydown", handleKeyDown);
// Track mousedown on overlay to prevent closing on drag-select from inside dialog
let mouseDownOnOverlay = false;
overlay.addEventListener("mousedown", (e) => {
// Only set flag if mousedown is directly on overlay (not on dialog)
if (e.target === overlay) {
mouseDownOnOverlay = true;
}
});
overlay.addEventListener("mouseup", (e) => {
// Only close if both mousedown and mouseup were on the overlay
if (mouseDownOnOverlay && e.target === overlay && !exportBtn.disabled) {
closeDialog(false);
}
mouseDownOnOverlay = false;
});
// Prevent clicks inside the dialog from bubbling
dialog.addEventListener("mousedown", (e) => {
mouseDownOnOverlay = false;
});
/**
* Render header cells into a header row
* @param {HTMLElement} headerRow - The header row element to populate
* @param {boolean} isInput - Whether this is for inputs (affects column labels)
*/
function renderExportHeader(headerRow, isInput = true) {
// Drag column (empty)
const dragCol = document.createElement("div");
headerRow.appendChild(dragCol);
// Chevron spacer column (empty, for alignment with group headers)
const chevronCol = document.createElement("div");
headerRow.appendChild(chevronCol);
// Slot name column
const slotCol = document.createElement("div");
slotCol.textContent = isInput ? "Input" : "Output";
headerRow.appendChild(slotCol);
// Export Name column
const nameCol = document.createElement("div");
nameCol.textContent = "Export Name";
headerRow.appendChild(nameCol);
// Type column
const typeCol = document.createElement("div");
typeCol.textContent = "Type";
headerRow.appendChild(typeCol);
// RTX Remix Type column
const remixTypeCol = document.createElement("div");
remixTypeCol.textContent = "RTX Remix Type";
headerRow.appendChild(remixTypeCol);
// Details column (empty)
const detailsCol = document.createElement("div");
headerRow.appendChild(detailsCol);
}
/**
* Populate a slot row with data (used as row delegate for grouped list)
* @param {Object} slot - Slot data
* @param {HTMLElement} row - The row element to populate
* @param {boolean} isInput - Whether this is an input slot
*/
function populateSlotRow(slot, row, isInput) {
// Set data attributes using shared utility
setRowDataAttributes(row, {
nodeId: slot.nodeId,
slotName: slot.slotName,
isInput,
});
// Cell 1: Stacked slot name (bold) + node title (muted)
const labelCell = createSlotLabelCell({
primaryLabel: slot.slotName,
secondaryLabel: slot.nodeTitle,
primaryTooltip: slot.slotName,
secondaryTooltip: slot.nodeTitle,
});
row.appendChild(labelCell);
// Cell 2: Export name input
const { cell: nameCell } = createInputCell({
value: slot.exportName || slot.slotName,
fieldKey: "exportName",
cellClass: "rtx-remix-slot-name-cell",
inputClass: "rtx-remix-slot-name-input",
});
row.appendChild(nameCell);
// Cell 3: Primitive type (read-only)
const primitiveTypeCell = createTextCell({
text: slot.primitiveType || "unknown",
cellClass: "rtx-remix-slot-primitive-type-cell",
textClass: "rtx-remix-slot-primitive-type",
});
row.appendChild(primitiveTypeCell);
// Cell 4: RTX Remix type dropdown
const typeMap = isInput ? COMFYUI_INPUT_TYPE_MAP : COMFYUI_OUTPUT_TYPE_MAP;
const validTypes = typeMap[slot.primitiveType] || [REMIX_TYPE.AUTO];
const { cell: typeCell } = createSelectCell({
options: validTypes.map((type) => ({ value: type, label: type })),
selectedValue: slot.remixType,
fieldKey: "remixType",
cellClass: "rtx-remix-slot-type-cell",
selectClass: "rtx-remix-slot-type-select",
});
row.appendChild(typeCell);
// Cell 5: Details button (for accordion)
const { cell: detailsCell, button: detailsBtn } = createButtonCell({
iconClass: "pi pi-chevron-down",
title: "Show metadata details",
cellClass: "rtx-remix-slot-details-cell",
buttonClass: "rtx-remix-details-btn",
});
row.appendChild(detailsCell);
// Store button reference for toggle handler (set by companion row renderer)
row._detailsBtn = detailsBtn;
}
/**
* Create companion row (accordion) for a slot
* @param {Object} slot - Slot data
* @param {HTMLElement} mainRow - The main row element
* @returns {HTMLElement|null} Accordion row
*/
function createSlotCompanionRow(slot, mainRow) {
const accordion = createMetadataAccordion({
app,
context: slot.context,
slotData: slot,
});
if (!accordion) return null;
// Initially hidden
accordion.classList.add("rtx-hidden");
// Add companion row class for proper styling
accordion.classList.add("rtx-remix-companion-row");
// Wire up details button to toggle accordion
const detailsBtn = mainRow._detailsBtn;
if (detailsBtn) {
detailsBtn.addEventListener("click", () => {
const isExpanded = !accordion.classList.contains("rtx-hidden");
accordion.classList.toggle("rtx-hidden", isExpanded);
detailsBtn.classList.toggle("expanded", !isExpanded);
});
}
return accordion;
}
/**
* Create accordion for output slots (without group field)
* @param {Object} slot - Slot data
* @returns {HTMLElement|null} Accordion element
*/
function createOutputAccordion(slot) {
return createMetadataAccordion({
app,
context: slot.context,
slotData: slot,
includeGroup: false, // Outputs don't have groups
});
}
// Populate the slots tables
function populateSlotsTable(slotData) {
const { inputs, outputs } = slotData;
// Clear existing content
inputsWrapper.querySelector(".rtx-remix-export-scroll-wrapper")?.remove();
inputsWrapper.querySelector(".rtx-remix-warning-row")?.remove();
outputsWrapper.querySelector(".rtx-remix-export-scroll-wrapper")?.remove();
outputsWrapper.querySelector(".rtx-remix-warning-row")?.remove();
// Populate inputs using grouped list with header
if (inputs.length > 0) {
inputsWrapper.style.display = "block";
// Create scroll wrapper container
const scrollWrapper = document.createElement("div");
scrollWrapper.className = "rtx-remix-export-scroll-wrapper";
// Create grouped list container with export-specific columns
const inputsContainer = document.createElement("div");
inputsContainer.className = "rtx-remix-grouped-list rtx-remix-export-inputs";
// Create grouped list with header, grouping, and companion rows (accordions)
createGroupedList({
container: inputsContainer,
items: inputs.map((slot) => ({
...slot,
group: slot.group || "",
order: slot.order ?? 999,
})),
groupOrder: getGroupOrder(app),
collapsedGroups: inputsCollapsedGroups,
// Column header row (above groups)
renderHeader: (headerRow) => {
renderExportHeader(headerRow, true);
},
// Data row cells
renderRow: (item, rowEl) => {
populateSlotRow(item, rowEl, true);
},
// Accordion below each row
renderCompanionRow: (item, rowEl) => {
return createSlotCompanionRow(item, rowEl);
},
companionRowClass: "rtx-remix-metadata-accordion",
onItemOrderChange: (groupName, orderedItems) => {
// Update node metadata with new order values
orderedItems.forEach((item) => {
updateInputMetadata(app, item.nodeId, item.slotName, { order: item.order });
});
},
onGroupOrderChange: (newOrder) => {
updateGroupOrder(app, newOrder);
},
emptyMessage: "No input slots tagged.",
});
scrollWrapper.appendChild(inputsContainer);
inputsWrapper.appendChild(scrollWrapper);
} else {
// Show warning when no inputs are tagged
inputsWrapper.style.display = "block";
const warningRow = cloneTemplate(TEMPLATE_IDS.WARNING_ROW);
if (warningRow) {
bindTemplateData(warningRow, {
message: "No input slots tagged. Tag at least one input slot using the node context menu.",
});
inputsWrapper.appendChild(warningRow);
}
}
// Populate outputs using flat list with header (no grouping)
if (outputs.length > 0) {
outputsWrapper.style.display = "block";
// Create scroll wrapper container
const scrollWrapper = document.createElement("div");
scrollWrapper.className = "rtx-remix-export-scroll-wrapper";
// Create flat list container with export-specific columns
const outputsContainer = document.createElement("div");
outputsContainer.className = "rtx-remix-grouped-list rtx-remix-export-outputs";
// Add header row manually (same grid, same alignment)
const headerRow = document.createElement("div");
headerRow.className = "rtx-remix-list-row rtx-remix-list-header";
renderExportHeader(headerRow, false);
outputsContainer.appendChild(headerRow);
// Render each output as a simple row (no grouping)
outputs.forEach((slot) => {
// Create row from template
const row = cloneTemplate(TEMPLATE_IDS.GROUPED_LIST_ROW);
if (!row) return;
row.dataset.nodeId = slot.nodeId;
row.dataset.slotName = slot.slotName;
row.draggable = false; // No drag for outputs
// Hide drag handle for outputs
const dragHandle = row.querySelector("[data-element='drag-handle']");
if (dragHandle) {
dragHandle.style.visibility = "hidden";
}
// Populate row cells
populateSlotRow(slot, row, false);
outputsContainer.appendChild(row);
// Create accordion (companion row)
const accordion = createOutputAccordion(slot);
if (accordion) {
accordion.className = "rtx-remix-companion-row rtx-remix-metadata-accordion rtx-hidden";
outputsContainer.appendChild(accordion);
// Wire up details button
const detailsBtn = row.querySelector(".rtx-remix-details-btn");
if (detailsBtn) {
detailsBtn.addEventListener("click", () => {
const isExpanded = !accordion.classList.contains("rtx-hidden");
accordion.classList.toggle("rtx-hidden", isExpanded);
detailsBtn.classList.toggle("expanded", !isExpanded);
});
}
}
});
scrollWrapper.appendChild(outputsContainer);
outputsWrapper.appendChild(scrollWrapper);
} else {
// Show warning when no outputs are tagged
outputsWrapper.style.display = "block";
const warningRow = cloneTemplate(TEMPLATE_IDS.WARNING_ROW);
if (warningRow) {
bindTemplateData(warningRow, {
message: "No output nodes tagged. Tag at least one output node using the node context menu.",
});
outputsWrapper.appendChild(warningRow);
}
}
}
// Add to DOM
document.body.appendChild(overlay);
// Focus input and select all text
setTimeout(() => {
displayNameInput.focus();
displayNameInput.select();
}, 100);
});
}