Files
NVIDIAGameWorks-ComfyUI-RTX…/web/controllers/exportDialogController.js
T

680 lines
22 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 } from "../utils/constants.js";
import { cloneTemplate, bindTemplateData } from "../utils/html.js";
import {
addRemixMetadataToPrompt,
applySlotEditsToGraphNodes,
extractTaggedSlots,
getApplicableMetadataFields,
} from "../cores/workflowExportCore.js";
/**
* Export workflow handler - retrieves current workflow name and shows export dialog
* @param {Object} app - ComfyUI app instance
*/
export async function exportWorkflow(app) {
// Get current workflow filename (without extension)
let workflowName = "workflow";
// Use the active workflow filename from the workflow store
const activeWorkflow = app.extensionManager?.workflow?.activeWorkflow;
if (activeWorkflow?.filename) {
// Use filename without extension (the dialog will add .json automatically)
workflowName = activeWorkflow.filename;
}
// Show custom export dialog which handles the entire export process
await showExportDialog({
app,
defaultValue: workflowName,
});
}
/**
* Create metadata accordion with dynamic fields based on slot context
* @param {Object} options - Accordion options
* @returns {HTMLElement} Accordion element
*/
function createMetadataAccordion({ context, slotData }) {
const accordion = cloneTemplate("rtx-remix-metadata-accordion-template");
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) => {
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);
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;
// Get current value from slotData additional_data or use computed default
const currentValue = slotData.additionalData?.[fieldConfig.key] ?? fieldConfig.computedDefault;
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 {string} options.defaultValue - Default filename
* @returns {Promise<boolean>} - True if export succeeded, false if cancelled or failed
*/
export async function showExportDialog({ app, defaultValue = "workflow" } = {}) {
return new Promise((resolve) => {
// Clone the dialog template
const overlay = cloneTemplate("rtx-remix-export-dialog-template");
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 input = overlay.querySelector('[data-input="filename"]');
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"]');
const inputsTbody = overlay.querySelector('[data-element="inputs-tbody"]');
const outputsTbody = overlay.querySelector('[data-element="outputs-tbody"]');
// Set up initial values
input.value = defaultValue;
// Extract and populate tagged slots
const slotData = extractTaggedSlots(app);
populateSlotsTable(slotData);
// Validation function
function validateFilename(filename) {
if (!filename) {
showValidation("Filename cannot be empty");
return false;
}
const invalidChars = /[<>:"|?*\\/]/;
if (invalidChars.test(filename)) {
showValidation("Filename contains invalid characters");
return false;
}
hideValidation();
return true;
}
function showValidation(message) {
validationMsg.textContent = message;
validationMsg.classList.add("show");
input.classList.add("error");
}
function hideValidation() {
validationMsg.classList.remove("show");
input.classList.remove("error");
}
// Real-time validation
input.addEventListener("input", () => {
const filename = input.value.trim();
if (filename) {
validateFilename(filename);
} else {
hideValidation();
}
});
// Close dialog function
function closeDialog(result) {
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(filename) {
// Get filename stem (without extension) - backend adds .json automatically
const lastDotIndex = filename.lastIndexOf(".");
const workflowName = lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename;
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, inputsTbody, outputsTbody);
// 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 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({
name: workflowName,
workflows: {
api: enrichedPrompt,
full: workflowGraph,
},
}),
});
const responseData = await response.json();
if (responseData.success) {
// Update active workflow to point to the newly saved file (Save As behavior)
const workflowManager = app.extensionManager?.workflow;
if (workflowManager?.activeWorkflow && responseData.workflows?.full?.user?.path) {
const workflow = workflowManager.activeWorkflow;
const newName = responseData.name;
const newPath = responseData.workflows.full.user.path;
// Update workflow properties
workflow.filename = newName;
workflow.fullFilename = newName + ".json";
workflow.path = newPath;
// Mark workflow as saved by replicating what workflow.save() does internally
// (without making another API call since we already saved via our custom API)
const serialized = app.graph.serialize();
const content = JSON.stringify(serialized);
workflow.content = content;
workflow.originalContent = content;
workflow.changeTracker.reset();
workflow.isModified = false;
}
app.extensionManager.toast.add({
severity: "success",
summary: "Export Successful",
detail: `Workflow saved as "${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("rtx-remix-confirm-dialog-template");
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 = "") {
input.disabled = loading;
cancelBtn.disabled = loading;
exportBtn.disabled = loading;
if (loading) {
const spinner = cloneTemplate("rtx-remix-spinner-template");
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.textContent = "The .json extension will be added automatically if not provided.";
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();
if (!exportBtn.disabled) {
closeDialog(false);
}
} else if (e.key === "Enter") {
e.preventDefault();
if (!exportBtn.disabled) {
const filename = input.value.trim();
if (validateFilename(filename)) {
handleExport(filename);
}
}
}
}
// Event listeners
cancelBtn.addEventListener("click", () => {
if (!exportBtn.disabled) {
closeDialog(false);
}
});
exportBtn.addEventListener("click", async () => {
const filename = input.value.trim();
if (validateFilename(filename)) {
await handleExport(filename);
}
});
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;
});
// Create a table row for a slot using template
function createSlotRow(slot, isInput) {
// Clone the row template
const row = cloneTemplate("rtx-remix-slot-row-template");
if (!row) {
console.error("Failed to load slot row template");
return null;
}
// Bind data to template
bindTemplateData(row, {
nodeTitle: slot.nodeTitle,
slotName: slot.slotName,
exportName: slot.exportName,
comfyuiType: slot.primitiveType || "unknown",
nodeId: slot.nodeId,
slotName: slot.slotName,
isInput: isInput,
});
// Populate the remix type dropdown
const remixTypeSelect = row.querySelector(".rtx-remix-slot-type-select");
if (remixTypeSelect) {
// Get valid types based on slot direction and primitive type
const typeMap = isInput ? COMFYUI_INPUT_TYPE_MAP : COMFYUI_OUTPUT_TYPE_MAP;
const validTypes = typeMap[slot.primitiveType] || typeMap["*"];
validTypes.forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = type;
if (type === slot.remixType) {
option.selected = true;
}
remixTypeSelect.appendChild(option);
});
}
// Create metadata accordion
const accordion = createMetadataAccordion({
context: slot.context,
slotData: slot,
});
// Setup chevron button to toggle accordion
const chevronBtn = row.querySelector(".rtx-remix-details-btn");
if (chevronBtn) {
chevronBtn.addEventListener("click", () => {
const isExpanded = accordion.style.display !== "none";
accordion.style.display = isExpanded ? "none" : "table-row";
chevronBtn.classList.toggle("expanded", !isExpanded);
});
}
// Return both row and accordion as a fragment
const fragment = document.createDocumentFragment();
fragment.appendChild(row);
fragment.appendChild(accordion);
return fragment;
}
// Setup drag and drop for a tbody
function setupDragAndDrop(tbody) {
let draggedRow = null;
let sourceTbody = null;
// Disable row dragging when focusing interactive elements
tbody.addEventListener(
"focus",
(e) => {
const row = e.target.closest("tr");
if (row) row.setAttribute("draggable", "false");
},
true
);
// Re-enable row dragging when blurring interactive elements
tbody.addEventListener(
"blur",
(e) => {
const row = e.target.closest("tr");
if (row) row.setAttribute("draggable", "true");
},
true
);
tbody.addEventListener("dragstart", (e) => {
if (e.target.classList.contains("rtx-remix-draggable-row")) {
draggedRow = e.target;
sourceTbody = tbody;
e.target.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
}
});
tbody.addEventListener("dragend", (e) => {
if (e.target.classList.contains("rtx-remix-draggable-row")) {
e.target.classList.remove("dragging");
draggedRow = null;
sourceTbody = null;
}
// Remove all drag-over indicators
tbody.querySelectorAll(".drag-over, .drag-over-bottom").forEach((el) => {
el.classList.remove("drag-over", "drag-over-bottom");
});
});
tbody.addEventListener("dragover", (e) => {
// Only allow drag over if we're in the same tbody as the source
if (sourceTbody !== tbody) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Find the closest draggable row, not accordion rows
const row = e.target.closest("tr.rtx-remix-draggable-row");
if (!row || row === draggedRow) {
return;
}
// Remove all drag-over indicators
tbody.querySelectorAll(".drag-over, .drag-over-bottom").forEach((el) => {
el.classList.remove("drag-over", "drag-over-bottom");
});
// Determine if we should insert before or after
const rect = row.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
if (e.clientY < midpoint) {
row.classList.add("drag-over");
} else {
row.classList.add("drag-over-bottom");
}
});
tbody.addEventListener("drop", (e) => {
// Only allow drop if we're in the same tbody as the source
if (sourceTbody !== tbody) {
return;
}
e.preventDefault();
const targetRow = e.target.closest("tr.rtx-remix-draggable-row");
if (!targetRow || !draggedRow || targetRow === draggedRow) {
return;
}
// Get the accordion row that follows the dragged row
const draggedAccordion = draggedRow.nextElementSibling;
const isDraggedAccordion =
draggedAccordion && draggedAccordion.classList.contains("rtx-remix-metadata-accordion");
// Determine insert position
const rect = targetRow.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
if (e.clientY < midpoint) {
// Insert before target
tbody.insertBefore(draggedRow, targetRow);
if (isDraggedAccordion) {
tbody.insertBefore(draggedAccordion, targetRow);
}
} else {
// Insert after target (and its accordion if it has one)
const targetAccordion = targetRow.nextElementSibling;
const isTargetAccordion =
targetAccordion && targetAccordion.classList.contains("rtx-remix-metadata-accordion");
if (isTargetAccordion) {
tbody.insertBefore(draggedRow, targetAccordion.nextSibling);
if (isDraggedAccordion) {
tbody.insertBefore(draggedAccordion, targetAccordion.nextSibling);
}
} else {
tbody.insertBefore(draggedRow, targetRow.nextSibling);
if (isDraggedAccordion) {
tbody.insertBefore(draggedAccordion, targetRow.nextSibling);
}
}
}
// Remove drag indicators
tbody.querySelectorAll(".drag-over, .drag-over-bottom").forEach((el) => {
el.classList.remove("drag-over", "drag-over-bottom");
});
});
}
// Populate the slots tables
function populateSlotsTable(slotData) {
const { inputs, outputs } = slotData;
// Clear existing rows
inputsTbody.innerHTML = "";
outputsTbody.innerHTML = "";
// Populate inputs table
if (inputs.length > 0) {
inputsWrapper.style.display = "block";
inputs.forEach((slot) => {
const row = createSlotRow(slot, true);
if (row) {
inputsTbody.appendChild(row);
}
});
// Setup drag and drop for inputs table
setupDragAndDrop(inputsTbody);
} else {
// Show warning when no inputs are tagged
inputsWrapper.style.display = "block";
const warningRow = cloneTemplate("rtx-remix-warning-row-template");
if (warningRow) {
bindTemplateData(warningRow, {
message: "No input slots tagged. Tag at least one input slot using the node context menu.",
});
inputsTbody.appendChild(warningRow);
}
}
// Populate outputs table
if (outputs.length > 0) {
outputsWrapper.style.display = "block";
outputs.forEach((slot) => {
const row = createSlotRow(slot, false);
if (row) {
outputsTbody.appendChild(row);
}
});
// Setup drag and drop for outputs table
setupDragAndDrop(outputsTbody);
} else {
// Show warning when no outputs are tagged
outputsWrapper.style.display = "block";
const warningRow = cloneTemplate("rtx-remix-warning-row-template");
if (warningRow) {
bindTemplateData(warningRow, {
message: "No output nodes tagged. Tag at least one output node using the node context menu.",
});
outputsTbody.appendChild(warningRow);
}
}
}
// Add to DOM
document.body.appendChild(overlay);
// Focus input and select all text
setTimeout(() => {
input.focus();
input.select();
}, 100);
});
}