Files

1569 lines
54 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.
*/
/**
* Preset Sidebar Controller
*
* This controller handles all UI rendering and user interaction for the preset sidebar.
* It imports business logic from presetCore.js, following the convention:
* - Controllers can import from cores (allowed)
* - Cores cannot import from controllers (forbidden)
*
* DEPENDENCY RULES:
* controllers → cores → stores → utils
*/
import { PRESET_KEYS, REMIX_KEYS, ASSETS, TEMPLATE_IDS, EVENTS, KEYS, SIDEBAR } from "../utils/constants.js";
import { cloneTemplate, bindTemplateData } from "../utils/html.js";
import { updateInputMetadata } from "../cores/metadataEditorCore.js";
import { setOnInputTagChanged, toggleInputSlotMark } from "../cores/slotMarkingCore.js";
import { showConfirmDialog } from "./baseDialogController.js";
import { createGroupedList } from "./groupedListController.js";
import { openTemplatePopover, openPopoverWithContent, closePopover, isPopoverOpenFor } from "./popoverController.js";
import { createButtonCell } from "./slotRowController.js";
import { showEditGlobalSettingsDialog } from "./metadataDialogController.js";
import { createSplitButton } from "../factories/componentFactory.js";
import { getGroupOrder } from "../stores/graphStore.js";
import { getPresetsStore, getActivePreset, getPresetNames, getPresetDescription, setPresetDescription, getAutoSave } from "../stores/presetStore.js";
import { updateGroupOrder } from "../cores/workflowExportCore.js";
import { getTaggedInputs } from "../cores/inputCore.js";
import { getInputKey, parseInputKey } from "../utils/keys.js";
// Import business logic from core (controllers can import from cores)
import {
setActivePreset,
createPreset,
deletePreset,
renamePreset,
captureDefaultPreset,
applyPreset,
updatePresetValue,
getEffectiveValue,
registerNewlyTaggedInput,
isProgrammaticChange,
setIsProgrammaticChange,
withProgrammaticChange,
cleanupDeletedInputs,
clearDefaultPresetValues,
canClearDefaultPreset,
hasOtherPresets,
hasPresetDataToClear,
toggleAutoSave,
} from "../cores/presetCore.js";
// Module-level state for persistence across re-renders
const collapsedGroups = new Set();
// Module-level pending state (moved from function scope for external access)
// pendingChanges: Map<inputKey, {nodeId, slotName, value}>
const pendingChanges = new Map();
// pendingDefaultChanges: Map<key, {nodeId, slotName, value}>
const pendingDefaultChanges = new Map();
// pendingOverrideRemovals: Set<key>
const pendingOverrideRemovals = new Set();
// Cleanup function for the current sidebar instance
let sidebarCleanup = null;
// Reference to the current sidebar element and app for re-rendering on workflow change
let currentSidebarEl = null;
let currentApp = null;
/**
* Persist all pending changes to the preset store.
* Shared by performSave, handlePendingChangesBeforeAction, and preset-switch save.
* Does NOT clear pending state or refresh UI — callers handle that.
*
* @param {Object} app - ComfyUI app instance
*/
function savePendingToStore(app) {
const active = getActivePreset(app);
const store = getPresetsStore(app);
const presets = store[PRESET_KEYS.PRESETS];
// First-time save for default preset: capture all current widget values as baseline
if (active === PRESET_KEYS.DEFAULT && !presets[PRESET_KEYS.DEFAULT]) {
captureDefaultPreset(app);
}
// Write pending changes to the active preset
pendingChanges.forEach(({ nodeId, slotName, value }) => {
updatePresetValue(app, active, nodeId, slotName, value);
});
// Write pending default changes to the default preset
pendingDefaultChanges.forEach(({ nodeId, slotName, value }) => {
updatePresetValue(app, PRESET_KEYS.DEFAULT, nodeId, slotName, value);
});
// Remove pending override removals from the active preset
pendingOverrideRemovals.forEach((key) => {
delete presets[active]?.inputs?.[key];
});
}
/**
* Check if there are any pending preset changes.
* Can be called from other controllers to check before actions like export.
* @returns {boolean} True if there are pending changes
*/
export function hasPendingPresetChanges() {
return pendingChanges.size > 0 || pendingDefaultChanges.size > 0 || pendingOverrideRemovals.size > 0;
}
/**
* Handle pending preset changes before an action (e.g., export).
* Shows unsaved changes dialog if there are pending changes.
* Returns true if action can proceed, false if cancelled.
*
* @param {Object} app - ComfyUI app instance
* @returns {Promise<boolean>} True if action can proceed, false if cancelled
*/
export async function handlePendingChangesBeforeAction(app) {
if (!hasPendingPresetChanges()) {
return true; // No pending changes, proceed
}
const result = await showUnsavedChangesDialog();
if (result === "cancel") {
return false; // User cancelled, don't proceed
}
if (result === "save") {
savePendingToStore(app);
}
// Clear pending state (for both save and discard)
pendingChanges.clear();
pendingDefaultChanges.clear();
pendingOverrideRemovals.clear();
// Dispatch event to refresh sidebar UI (especially for discard case where no PRESET_VALUE_CHANGED fires)
app.api.dispatchEvent(
new CustomEvent(EVENTS.PRESET_CHANGED, {
detail: { action: "pending-cleared" },
})
);
return true; // Proceed with action
}
/**
* Show unsaved changes dialog using template.
* Returns: "cancel" | "save" | "discard"
*/
function showUnsavedChangesDialog() {
return new Promise((resolve) => {
const overlay = cloneTemplate(TEMPLATE_IDS.UNSAVED_CHANGES_DIALOG);
if (!overlay) {
resolve("cancel");
return;
}
const close = (result) => {
overlay.remove();
resolve(result);
};
overlay.querySelector("[data-action='cancel']")?.addEventListener("click", () => close("cancel"));
overlay.querySelector("[data-action='save']")?.addEventListener("click", () => close("save"));
overlay.querySelector("[data-action='discard']")?.addEventListener("click", () => close("discard"));
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close("cancel");
});
document.body.appendChild(overlay);
});
}
/**
* Register the presets sidebar tab with ComfyUI.
* @param {Object} app - ComfyUI app instance
*/
export function registerPresetsSidebar(app) {
if (!app?.extensionManager?.registerSidebarTab) return;
app.extensionManager.registerSidebarTab({
id: SIDEBAR.ID,
icon: "pi pi-star",
title: SIDEBAR.TITLE,
tooltip: SIDEBAR.TOOLTIP,
type: "custom",
render: (el) => renderSidebarPanel(el, app),
});
}
/**
* Refresh the preset sidebar when the workflow changes.
* Called from afterConfigureGraph hook in main.js.
*/
export function refreshPresetsSidebar() {
if (currentSidebarEl && currentApp) {
renderSidebarPanel(currentSidebarEl, currentApp);
}
}
/**
* Show delete preset confirmation dialog using template.
*/
function showDeletePresetConfirmDialog(presetName) {
return new Promise((resolve) => {
const overlay = cloneTemplate(TEMPLATE_IDS.DELETE_PRESET_CONFIRM);
if (!overlay) {
resolve(false);
return;
}
const nameEl = overlay.querySelector("[data-element='preset-name']");
if (nameEl) nameEl.textContent = presetName;
const close = (result) => {
overlay.remove();
resolve(result);
};
overlay.querySelector("[data-action='cancel']")?.addEventListener("click", () => close(false));
overlay.querySelector("[data-action='confirm']")?.addEventListener("click", () => close(true));
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(false);
});
document.body.appendChild(overlay);
});
}
/**
* Show clear default preset confirmation dialog using template.
*/
function showClearDefaultConfirmDialog() {
return new Promise((resolve) => {
const overlay = cloneTemplate(TEMPLATE_IDS.CLEAR_DEFAULT_CONFIRM);
if (!overlay) {
resolve(false);
return;
}
const close = (result) => {
overlay.remove();
resolve(result);
};
overlay.querySelector("[data-action='cancel']")?.addEventListener("click", () => close(false));
overlay.querySelector("[data-action='confirm']")?.addEventListener("click", () => close(true));
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(false);
});
document.body.appendChild(overlay);
});
}
/**
* Show apply all confirmation dialog using template.
*/
function showApplyAllConfirmDialog() {
return new Promise((resolve) => {
const overlay = cloneTemplate(TEMPLATE_IDS.APPLY_ALL_CONFIRM);
if (!overlay) {
resolve(false);
return;
}
const close = (result) => {
overlay.remove();
resolve(result);
};
overlay.querySelector("[data-action='cancel']")?.addEventListener("click", () => close(false));
overlay.querySelector("[data-action='confirm']")?.addEventListener("click", () => close(true));
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(false);
});
document.body.appendChild(overlay);
});
}
/**
* Show reset all confirmation dialog using template.
*/
function showResetAllConfirmDialog() {
return new Promise((resolve) => {
const overlay = cloneTemplate(TEMPLATE_IDS.RESET_ALL_CONFIRM);
if (!overlay) {
resolve(false);
return;
}
const close = (result) => {
overlay.remove();
resolve(result);
};
overlay.querySelector("[data-action='cancel']")?.addEventListener("click", () => close(false));
overlay.querySelector("[data-action='confirm']")?.addEventListener("click", () => close(true));
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(false);
});
document.body.appendChild(overlay);
});
}
/**
* Show edit preset dialog using template.
*/
function showEditPresetDialog(app, options) {
const { isNew = false, currentName = "", currentDescription = "" } = options || {};
return new Promise((resolve) => {
const overlay = cloneTemplate(TEMPLATE_IDS.EDIT_PRESET_DIALOG);
if (!overlay) {
resolve(null);
return;
}
const titleEl = overlay.querySelector("[data-element='dialog-title']");
const nameInput = overlay.querySelector("[data-input='preset-name']");
const descInput = overlay.querySelector("[data-input='preset-description']");
const validationEl = overlay.querySelector("[data-element='validation']");
const cancelBtn = overlay.querySelector("[data-action='cancel']");
const saveBtn = overlay.querySelector("[data-action='save']");
const icon = overlay.querySelector("[data-asset='remix-icon']");
if (icon) icon.src = ASSETS.REMIX_ICON;
if (titleEl) titleEl.textContent = isNew ? "Create a preset" : "Edit a preset";
if (saveBtn) saveBtn.textContent = isNew ? "Create" : "Edit";
if (nameInput) {
nameInput.value = currentName;
nameInput.select();
}
if (descInput) descInput.value = currentDescription;
const showValidation = (msg) => {
if (validationEl) {
validationEl.textContent = msg;
validationEl.classList.add("show");
}
nameInput?.classList.add("error");
};
const hideValidation = () => {
validationEl?.classList.remove("show");
nameInput?.classList.remove("error");
};
const close = (result) => {
overlay.remove();
resolve(result);
};
const handleSave = () => {
const name = nameInput?.value?.trim() || "";
const description = descInput?.value?.trim() || "";
if (!name) {
showValidation("Name cannot be empty");
return;
}
if (name === PRESET_KEYS.DEFAULT) {
showValidation(`"${PRESET_KEYS.DEFAULT}" is a reserved name`);
return;
}
if (!isNew && name === currentName && description === currentDescription) {
close(null);
return;
}
const presets = getPresetsStore(app)[PRESET_KEYS.PRESETS];
if (presets[name] && (isNew || name !== currentName)) {
showValidation("A preset with that name already exists");
return;
}
hideValidation();
close({ name, description });
};
cancelBtn?.addEventListener("click", () => close(null));
saveBtn?.addEventListener("click", handleSave);
// Handle Enter key to submit, Escape to cancel
overlay.addEventListener("keydown", (e) => {
if (e.key === KEYS.ENTER) {
e.preventDefault();
handleSave();
} else if (e.key === KEYS.ESCAPE) {
e.preventDefault();
close(null);
}
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(null);
});
document.body.appendChild(overlay);
// Focus the name input after appending to DOM
nameInput?.focus();
});
}
/**
* Render the sidebar panel into el and wire events.
*
* FLOW OVERVIEW:
* 1. Clone and mount the sidebar panel template
* 2. Set up dirty state tracking (pendingChanges map)
* 3. Define helper functions for UI state management
* 4. Set up preset list rendering with selection handling
* 5. Set up tagged inputs list rendering with input controls
* 6. Wire up event listeners for buttons and inputs
* 7. Initialize the display
*
* DIRTY STATE FLOW:
* - User changes an input value -> handleChange() -> markDirty() -> updates Save button
* - User clicks Save -> saves all pendingChanges to preset store -> clearDirty()
* - User switches presets -> unsaved changes dialog -> save/discard -> clearDirty()
*
* WIDGET SYNC:
* - When user changes a graph widget directly on the canvas, the wrapped callback:
* 1. Calls original callback
* 2. Marks the change as dirty
* 3. Updates the corresponding input in the panel
*/
export function renderSidebarPanel(el, app) {
// Clean up previous sidebar instance (remove event listeners, etc.)
sidebarCleanup?.();
sidebarCleanup = null;
// Clear pending state from previous workflow to prevent cross-workflow contamination
pendingChanges.clear();
pendingDefaultChanges.clear();
pendingOverrideRemovals.clear();
// Store references for re-rendering on workflow change
currentSidebarEl = el;
currentApp = app;
cleanupDeletedInputs(app);
const panel = cloneTemplate(TEMPLATE_IDS.SIDEBAR_PANEL);
if (!panel) {
const fallback = cloneTemplate(TEMPLATE_IDS.INPUTS_EMPTY);
if (fallback) {
bindTemplateData(fallback, { message: "Failed to load RTX Remix Presets panel" });
el.appendChild(fallback);
}
return;
}
const headerIcon = panel.querySelector("[data-asset='remix-icon']");
if (headerIcon) headerIcon.src = ASSETS.REMIX_ICON;
// ============================================================
// ELEMENT REFERENCES
// ============================================================
const presetListContainer = panel.querySelector("[data-element='preset-list']");
const presetSearchInput = panel.querySelector("[data-input='preset-search']");
const presetSearchClear = panel.querySelector("[data-action='preset-search-clear']");
const createBtn = panel.querySelector("[data-action='create-preset']");
const saveBtnContainer = panel.querySelector("[data-element='save-btn-container']");
const applyAllBtn = panel.querySelector("[data-action='apply-all']");
const resetAllBtn = panel.querySelector("[data-action='reset-all']");
const inputsList = panel.querySelector("[data-element='inputs-list']");
const inputsSearchInput = panel.querySelector("[data-input='inputs-search']");
const inputsSearchClear = panel.querySelector("[data-action='inputs-search-clear']");
// Create split button for Save with auto-save popover
const {
container: splitBtn,
mainBtn: saveBtn,
caretBtn,
} = createSplitButton({
label: "Save",
title: "Save changes to preset",
caretTitle: "Save options",
variant: "primary",
disabled: true,
});
saveBtn.classList.add("rtx-remix-inputs-action-btn");
caretBtn.classList.add("rtx-remix-inputs-action-btn");
saveBtnContainer?.appendChild(splitBtn);
// ============================================================
// DIRTY STATE TRACKING
// ============================================================
// Note: pendingChanges, pendingDefaultChanges, pendingOverrideRemovals are module-level
// for external access (e.g., checking before export dialog)
// widgetCallbacks: Map<widget, originalCallback>
// Stores original widget callbacks before wrapping, for cleanup on refresh.
const widgetCallbacks = new Map();
/**
* Check if a specific key is an override for the active preset.
* An override exists if: not default preset, not pending removal, and (has pending change OR has stored override).
* @param {string} key - Input key in format "nodeId.slotName"
* @returns {boolean} True if the key is an override
*/
function isKeyOverride(key) {
const active = getActivePreset(app);
if (active === PRESET_KEYS.DEFAULT) return false;
if (pendingOverrideRemovals.has(key)) return false;
if (pendingChanges.has(key)) return true;
const store = getPresetsStore(app);
return store[PRESET_KEYS.PRESETS][active]?.inputs?.[key] != null;
}
/**
* Check if a key has a STORED override in the current preset (ignores pending state).
* Used to determine if pendingOverrideRemovals should be updated.
* @param {string} key - Input key in format "nodeId.slotName"
* @returns {boolean} True if there's a stored override for this key
*/
function hasStoredOverride(key) {
const active = getActivePreset(app);
if (active === PRESET_KEYS.DEFAULT) return false;
const store = getPresetsStore(app);
return store[PRESET_KEYS.PRESETS][active]?.inputs?.[key] != null;
}
/**
* Check if the active preset has any overrides (values different from default).
* Takes into account pending override removals.
* Returns true if there are overrides, false otherwise.
*/
function hasOverrides() {
const active = getActivePreset(app);
if (active === PRESET_KEYS.DEFAULT) return false;
const store = getPresetsStore(app);
const inputs = store[PRESET_KEYS.PRESETS][active]?.inputs || {};
// Count overrides that aren't pending removal
const activeOverrides = Object.keys(inputs).filter((k) => !pendingOverrideRemovals.has(k));
return activeOverrides.length > 0 || pendingChanges.size > 0;
}
/**
* Apply a single input's value to the default preset immediately.
* Writes the value to the store right away so it survives Discard.
* The override removal remains pending (requires Save to finalize).
* @param {string} key - Input key in format "nodeId.slotName"
* @param {string|number} nodeId - Node ID
* @param {string} slotName - Slot name
* @param {*} value - Value to apply to default
*/
function markInputForApplyToDefault(key, nodeId, slotName, value) {
updatePresetValue(app, PRESET_KEYS.DEFAULT, nodeId, slotName, value);
// Only mark for removal if there's an actual stored override
if (hasStoredOverride(key)) {
pendingOverrideRemovals.add(key);
}
pendingChanges.delete(key);
}
/**
* Mark a single input to be reset to default value (pending until Save).
* Optionally updates the widget with live preview.
* @param {string} key - Input key in format "nodeId.slotName"
* @param {object} widget - Optional widget to update with live preview
*/
function markInputForReset(key, widget = null) {
// Only mark for removal if there's an actual stored override
if (hasStoredOverride(key)) {
pendingOverrideRemovals.add(key);
}
pendingChanges.delete(key);
// Live preview: update widget to default value
if (widget) {
const { nodeId, slotName } = parseInputKey(key);
const defaultVal = getEffectiveValue(app, PRESET_KEYS.DEFAULT, nodeId, slotName);
setIsProgrammaticChange(true);
try {
widget.value = defaultVal;
widget.callback?.call(widget, defaultVal);
} finally {
setIsProgrammaticChange(false);
}
}
}
/**
* Update UI state for all action buttons based on current dirty state and overrides.
* - Save: enabled when there are pending (unsaved) changes and auto-save is OFF
* - Apply All: enabled when current preset has overrides (values to apply to default)
* - Reset All: enabled when current preset has overrides (values to reset)
* Also sets tooltips explaining why buttons are disabled.
*/
function updateButtonStates() {
const active = getActivePreset(app);
const isDefault = active === PRESET_KEYS.DEFAULT;
const hasOv = hasOverrides();
const isAutoSave = getAutoSave(app);
// Any pending change: value changes, default changes, or override removals
const hasPending = pendingChanges.size > 0 || pendingDefaultChanges.size > 0 || pendingOverrideRemovals.size > 0;
// Save button: disabled when auto-save is ON or no pending changes
if (saveBtn) {
saveBtn.disabled = isAutoSave || !hasPending;
if (isAutoSave) {
saveBtn.title = "Auto-save is enabled";
} else if (hasPending) {
saveBtn.title = "Save changes to preset";
} else {
saveBtn.title = "No unsaved changes";
}
}
// Apply All: disabled if Default preset OR no overrides
if (applyAllBtn) {
const canApply = !isDefault && hasOv;
applyAllBtn.disabled = !canApply;
if (isDefault) {
applyAllBtn.title = "Cannot apply from Default Values preset";
} else if (!hasOv) {
applyAllBtn.title = "No overrides to apply";
} else {
applyAllBtn.title = "Apply all values to Default Values";
}
}
// Reset All: disabled if Default preset OR no overrides
if (resetAllBtn) {
const canReset = !isDefault && hasOv;
resetAllBtn.disabled = !canReset;
if (isDefault) {
resetAllBtn.title = "Cannot reset Default Values preset";
} else if (!hasOv) {
resetAllBtn.title = "No overrides to reset";
} else {
resetAllBtn.title = "Reset all overrides to Default Values";
}
}
}
/**
* Mark a value as changed (dirty). Called when user edits an input.
* A new change overrides any pending reset/apply operations for this key.
* If auto-save is enabled, immediately saves the changes.
* @param {string} key - Input key in format "nodeId.slotName"
* @param {Object} value - Object with nodeId, slotName, and the new value
*/
function markDirty(key, value) {
pendingChanges.set(key, value);
// New change overrides any pending reset/apply operations
pendingOverrideRemovals.delete(key);
pendingDefaultChanges.delete(key);
updateButtonStates();
// Auto-save if enabled - save immediately
if (getAutoSave(app)) {
performSave();
}
}
/**
* Clear all pending changes. Called after save or discard.
*/
function clearDirty() {
pendingChanges.clear();
pendingDefaultChanges.clear();
pendingOverrideRemovals.clear();
updateButtonStates();
}
/**
* Perform save operation - persist all pending changes to the preset store.
* Extracted to allow reuse by auto-save and manual save button.
*/
function performSave() {
savePendingToStore(app);
// Clear dirty state, then apply preset (which sets widget values)
clearDirty();
const active = getActivePreset(app);
setIsProgrammaticChange(true);
try {
applyPreset(app, active);
} finally {
setIsProgrammaticChange(false);
}
refreshInputsList();
}
// Preset list rendering
function refreshPresetList() {
if (!presetListContainer) return;
presetListContainer.innerHTML = "";
const active = getActivePreset(app);
let names = getPresetNames(app);
const searchTerm = (presetSearchInput?.value?.trim() || "").toLowerCase();
if (searchTerm) {
names = names.filter((name) => {
const desc = (getPresetDescription(app, name) || "").toLowerCase();
return name.toLowerCase().includes(searchTerm) || desc.includes(searchTerm);
});
}
if (names.length === 0) {
const empty = cloneTemplate(TEMPLATE_IDS.PRESET_LIST_EMPTY);
if (empty) presetListContainer.appendChild(empty);
return;
}
names.forEach((name) => {
const row = cloneTemplate(TEMPLATE_IDS.PRESET_LIST_ROW);
if (!row) return;
row.dataset.presetName = name;
const nameEl = row.querySelector(".rtx-remix-preset-list-row-name");
const descEl = row.querySelector(".rtx-remix-preset-list-row-desc");
const displayName = name === PRESET_KEYS.DEFAULT ? "Default Values" : name;
if (nameEl) nameEl.textContent = displayName;
if (descEl) {
const description = getPresetDescription(app, name);
descEl.textContent = description || "";
descEl.classList.toggle("rtx-hidden", !description);
}
if (name === active) {
row.classList.add("rtx-remix-preset-list-row-active");
row.title = "Currently active preset. Values are synchronized with the workflow inputs.";
}
const editBtn = row.querySelector("[data-action='preset-edit']");
const deleteBtn = row.querySelector("[data-action='preset-delete']");
const isDefault = name === PRESET_KEYS.DEFAULT;
if (isDefault) {
row.classList.add("rtx-remix-preset-list-row-default");
if (editBtn) editBtn.disabled = true;
// Enable delete for default only when no other presets exist AND there's data to clear
// Tooltip explains the action or why it's disabled (dynamic based on state)
const canClear = canClearDefaultPreset(app);
const otherPresetsExist = hasOtherPresets(app);
const hasData = hasPresetDataToClear(app);
if (deleteBtn) {
deleteBtn.disabled = !canClear;
if (canClear) {
deleteBtn.title = "Clear all default values and group order";
} else if (otherPresetsExist) {
deleteBtn.title = "Delete all other presets first to clear default values";
} else if (!hasData) {
deleteBtn.title = "No default values to clear";
}
}
}
// Non-default presets use the template's default title="Delete"
row.addEventListener("mousedown", async (e) => {
// Ignore clicks on edit/delete buttons
if (e.target.closest("[data-action='preset-edit']") || e.target.closest("[data-action='preset-delete']")) {
return;
}
e.preventDefault();
// Do nothing if clicking the already selected preset
if (name === getActivePreset(app)) return;
// Warn on unsaved changes (any pending state)
const hasPendingChanges =
pendingChanges.size > 0 || pendingDefaultChanges.size > 0 || pendingOverrideRemovals.size > 0;
if (hasPendingChanges) {
const result = await showUnsavedChangesDialog();
if (result === "cancel") return;
if (result === "save") {
savePendingToStore(app);
}
clearDirty();
}
setActivePreset(app, name);
refreshPresetList();
refreshInputsList();
});
editBtn?.addEventListener("pointerdown", (e) => {
e.stopPropagation();
e.preventDefault();
if (isDefault) return;
showEditPresetDialog(app, {
isNew: false,
currentName: name,
currentDescription: getPresetDescription(app, name),
}).then((result) => {
if (!result) return;
const { name: newName, description } = result;
if (newName !== name) renamePreset(app, name, newName);
setPresetDescription(app, newName, description);
refreshPresetList();
refreshInputsList();
});
});
deleteBtn?.addEventListener("pointerdown", (e) => {
e.stopPropagation();
e.preventDefault();
if (isDefault) {
// For default preset, show clear confirmation dialog (only if allowed)
if (!canClearDefaultPreset(app)) return;
showClearDefaultConfirmDialog().then((confirmed) => {
if (!confirmed) return;
clearDefaultPresetValues(app);
refreshPresetList();
refreshInputsList();
});
} else {
// For non-default presets, show delete confirmation dialog
showDeletePresetConfirmDialog(name).then((confirmed) => {
if (!confirmed) return;
deletePreset(app, name);
refreshPresetList();
refreshInputsList();
});
}
});
presetListContainer.appendChild(row);
});
}
// ============================================================
// INPUTS LIST RENDERING
// ============================================================
// Store reference to the grouped list controller for refresh
let groupedListInstance = null;
/**
* Render the tagged inputs list for the active preset.
*
* For each input:
* 1. Creates the appropriate input control (select, checkbox, slider, number, text)
* 2. Wires up change handlers to mark dirty state
* 3. Wraps widget callbacks to sync canvas changes to the panel
* 4. Updates override indicator state
*/
/**
* Filter inputs based on search term.
* Matches against slot name, export name, node title, group, and tooltip.
*/
function filterInputs(inputs, searchTerm) {
if (!searchTerm) return inputs;
const term = searchTerm.toLowerCase();
return inputs.filter((input) => {
return (
input.slotName.toLowerCase().includes(term) ||
(input.exportName || "").toLowerCase().includes(term) ||
input.nodeTitle.toLowerCase().includes(term) ||
(input.group || "").toLowerCase().includes(term) ||
(input.tooltip || "").toLowerCase().includes(term)
);
});
}
function refreshInputsList() {
if (!inputsList) return;
inputsList.innerHTML = "";
// CLEANUP: Restore original widget callbacks before rebuilding.
// This prevents memory leaks and duplicate callback wrapping.
widgetCallbacks.forEach((original, widget) => {
widget.callback = original;
});
widgetCallbacks.clear();
// Cleanup previous grouped list instance
groupedListInstance?.cleanup();
groupedListInstance = null;
const active = getActivePreset(app);
let tagged = getTaggedInputs(app);
// Apply search filter if search term is present
const searchTerm = inputsSearchInput?.value?.trim() || "";
if (searchTerm) {
tagged = filterInputs(tagged, searchTerm);
}
if (tagged.length === 0) {
const empty = cloneTemplate(TEMPLATE_IDS.INPUTS_EMPTY);
if (empty) inputsList.appendChild(empty);
return;
}
// Create container for grouped list
const container = document.createElement("div");
container.className = "rtx-remix-grouped-list";
// Create the grouped list using the shared controller
groupedListInstance = createGroupedList({
container,
items: tagged,
groupOrder: getGroupOrder(app),
collapsedGroups,
renderRow: (item, rowEl) => {
renderPresetInputRow(item, rowEl, active);
},
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);
},
onGroupToggle: (groupName, isCollapsed) => {
// collapsedGroups is already updated by the controller
},
onGroupMenu: async (groupName, groupItems, menuBtn) => {
// Toggle behavior - close if already open for this anchor
if (isPopoverOpenFor(menuBtn)) {
closePopover();
return;
}
openTemplatePopover(menuBtn, TEMPLATE_IDS.GROUP_MENU, {
"untag-group": {
onSelect: async () => {
const confirmed = await showConfirmDialog({
title: "Untag All in Group",
message: `Are you sure you want to untag all <strong>${groupItems.length}</strong> inputs in the "<strong>${groupName || "Ungrouped"}</strong>" group?<br><br>This will remove them from all presets.`,
confirmText: "Untag All",
danger: true,
});
if (!confirmed) return;
for (const item of groupItems) {
const node = app.graph.getNodeById(item.nodeId);
if (node) {
const slotIndex = node.inputs?.findIndex((s) => s.name === item.slotName);
if (slotIndex >= 0) {
toggleInputSlotMark(node, slotIndex, app);
}
}
}
},
},
});
},
emptyMessage: "No inputs have been tagged for presets.",
});
inputsList.appendChild(container);
// Update button states after rendering inputs
updateButtonStates();
}
/**
* Render the content of a preset input row (row delegate).
* Adds grid cells to the provided row element.
*/
function renderPresetInputRow(item, rowEl, active) {
const { nodeId, slotName, exportName, nodeTitle, widget, primitiveType, comboOptions, tooltip, min, max, step } =
item;
// Set tooltip on row for hover display
if (tooltip) {
rowEl.title = tooltip;
}
const key = getInputKey(nodeId, slotName);
// Check pending states
const pendingChange = pendingChanges.get(key);
const isPendingRemoval = pendingOverrideRemovals.has(key);
const pendingDefaultChange = pendingDefaultChanges.get(key);
// Priority: pendingChange (new user edit) > pendingDefaultChange (apply to default) > isPendingRemoval (reset) > stored value
// Note: pendingDefaultChange means "apply to default" was clicked - show that value regardless of isPendingRemoval
let effectiveVal;
if (pendingChange !== undefined) {
effectiveVal = pendingChange.value;
} else if (pendingDefaultChange !== undefined) {
// Apply to default was clicked - show the value that will become the new default
effectiveVal = pendingDefaultChange.value;
} else if (isPendingRemoval) {
// Reset to default was clicked - show the default value
effectiveVal = getEffectiveValue(app, PRESET_KEYS.DEFAULT, nodeId, slotName);
} else {
effectiveVal = getEffectiveValue(app, active, nodeId, slotName);
}
// Use centralized function to check override status
const isOverride = isKeyOverride(key);
// Add override indicator class to the row
if (isOverride) {
rowEl.classList.add("rtx-remix-override-indicator");
}
// Cell 1: Override indicator (inserted first, before drag handle)
const indicatorCell = document.createElement("div");
indicatorCell.className = "rtx-remix-indicator-cell";
const indicator = document.createElement("div");
indicator.className = "rtx-remix-input-row-indicator";
indicator.dataset.element = "override-indicator";
if (isOverride) {
indicator.title = "Value overrides default";
}
indicatorCell.appendChild(indicator);
// Insert at the beginning, before the drag handle cell
rowEl.insertBefore(indicatorCell, rowEl.firstChild);
// Cell 2: Label column (export name + node title)
// Display export name (bold), show actual slot name in tooltip
const labelCell = document.createElement("div");
labelCell.className = "rtx-remix-label-cell";
const labelDiv = document.createElement("div");
labelDiv.className = "rtx-remix-input-row-col1";
const slotLabel = document.createElement("span");
slotLabel.className = "rtx-remix-input-row-slot";
slotLabel.textContent = exportName || slotName;
// Tooltip shows actual node input name if different from export name
if (exportName && exportName !== slotName) {
slotLabel.title = `Node input slot name: "${slotName}"`;
}
const nodeLabel = document.createElement("span");
nodeLabel.className = "rtx-remix-input-row-node";
nodeLabel.textContent = nodeTitle;
labelDiv.appendChild(slotLabel);
labelDiv.appendChild(nodeLabel);
labelCell.appendChild(labelDiv);
rowEl.appendChild(labelCell);
// Cell 3: Input wrap
const inputCell = document.createElement("div");
inputCell.className = "rtx-remix-input-cell";
const inputWrap = document.createElement("div");
inputWrap.className = "rtx-remix-input-row-col2";
inputWrap.dataset.element = "input-wrap";
let inputEl;
let valueLabel = null;
// Create appropriate input control
if (comboOptions && comboOptions.length > 0) {
inputEl = document.createElement("select");
inputEl.className = "rtx-remix-preset-input";
comboOptions.forEach((opt) => {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
if (String(opt) === String(effectiveVal)) o.selected = true;
inputEl.appendChild(o);
});
inputWrap.appendChild(inputEl);
} else if (primitiveType === "bool") {
inputEl = document.createElement("input");
inputEl.type = "checkbox";
inputEl.className = "rtx-remix-preset-input";
inputEl.checked = !!effectiveVal;
inputWrap.appendChild(inputEl);
} else if (primitiveType === "float") {
// Float slider with editable value input
const sliderWrap = cloneTemplate(TEMPLATE_IDS.SLIDER_INPUT);
if (sliderWrap) {
inputEl = sliderWrap.querySelector("[data-element='slider']");
valueLabel = sliderWrap.querySelector("[data-element='value']");
// Use metadata values if set, otherwise fall back to widget options
const minVal = min ?? widget?.options?.min ?? 0;
const maxVal = max ?? widget?.options?.max ?? 1;
const stepVal = step ?? widget?.options?.step ?? 0.01;
if (inputEl) {
inputEl.min = minVal;
inputEl.max = maxVal;
inputEl.step = stepVal;
inputEl.value = effectiveVal ?? 0;
}
if (valueLabel) {
valueLabel.min = minVal;
valueLabel.max = maxVal;
valueLabel.step = stepVal;
valueLabel.value = Number(effectiveVal ?? 0).toFixed(2);
// Sync: slider updates value input
inputEl?.addEventListener("input", () => {
valueLabel.value = Number(inputEl.value).toFixed(2);
});
// Sync: value input updates slider
valueLabel.addEventListener("input", () => {
const val = parseFloat(valueLabel.value) || 0;
inputEl.value = val;
});
// When value input changes (blur/enter), trigger slider's change event
valueLabel.addEventListener("change", () => {
const val = parseFloat(valueLabel.value) || 0;
inputEl.value = val;
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
});
}
inputWrap.appendChild(sliderWrap);
}
} else if (primitiveType === "int") {
inputEl = document.createElement("input");
inputEl.type = "number";
inputEl.className = "rtx-remix-preset-input";
inputEl.value = effectiveVal;
inputEl.step = "1";
inputWrap.appendChild(inputEl);
} else {
inputEl = document.createElement("input");
inputEl.type = "text";
inputEl.className = "rtx-remix-preset-input";
inputEl.value = effectiveVal ?? "";
inputWrap.appendChild(inputEl);
}
inputCell.appendChild(inputWrap);
rowEl.appendChild(inputCell);
// Cell 4: Menu button (using shared utility)
const { cell: menuCell, button: menuBtn } = createButtonCell({
iconClass: "pi pi-ellipsis-v",
title: "More options",
action: "input-menu",
cellClass: "rtx-remix-menu-cell",
buttonClass: "rtx-remix-input-menu-btn",
});
rowEl.appendChild(menuCell);
// Value change handler - marks dirty, updates widget, and updates override indicator
if (inputEl) {
const handleChange = () => {
let val;
if (inputEl.type === "checkbox") val = inputEl.checked;
else if (inputEl.type === "range") val = parseFloat(inputEl.value);
else if (inputEl.type === "number")
val = primitiveType === "int" ? parseInt(inputEl.value, 10) : parseFloat(inputEl.value);
else val = inputEl.value;
markDirty(key, { nodeId, slotName, value: val });
// Live update: apply value to graph widget immediately for preview
if (widget) {
withProgrammaticChange(() => {
widget.value = val;
widget.callback?.call(widget, val);
});
app.graph.setDirtyCanvas?.(true, true);
}
// Update override indicator immediately when value changes (shows pending override)
if (active !== PRESET_KEYS.DEFAULT) {
rowEl.classList.add("rtx-remix-override-indicator");
if (indicator) indicator.title = "Value overrides default";
}
};
inputEl.addEventListener("change", handleChange);
if (inputEl.type === "range") {
inputEl.addEventListener("input", handleChange);
}
}
menuBtn.addEventListener("click", (e) => {
e.stopPropagation();
// Toggle behavior - close if already open for this anchor
if (isPopoverOpenFor(menuBtn)) {
closePopover();
return;
}
// Build menu items with dynamic enable/disable state
const currentActive = getActivePreset(app);
const currentIsOverride = isKeyOverride(key);
const isDefault = currentActive === PRESET_KEYS.DEFAULT;
// Get current effective value for apply action
const currentEffectiveVal = pendingChanges.has(key)
? pendingChanges.get(key).value
: getEffectiveValue(app, currentActive, nodeId, slotName);
openTemplatePopover(menuBtn, TEMPLATE_IDS.INPUT_MENU, {
"focus-node": {
onSelect: () => {
const node = app.graph.getNodeById(nodeId);
if (node) {
app.canvas.centerOnNode(node);
app.canvas.selectNode(node, false);
app.canvas.setDirty(true, true);
}
},
},
"edit-metadata": {
onSelect: async () => {
await showEditGlobalSettingsDialog({
app,
nodeId,
slotName,
nodeTitle,
onSave: () => {
// Refresh to pick up any group/order changes
refreshInputsList();
},
});
},
},
"apply-to-default": {
disabled: isDefault || !currentIsOverride,
tooltip: isDefault
? "Cannot apply when Default Values preset is selected"
: !currentIsOverride
? "No override to apply"
: undefined,
onSelect: () => {
markInputForApplyToDefault(key, nodeId, slotName, currentEffectiveVal);
updateButtonStates();
refreshInputsList();
// Auto-save if enabled
if (getAutoSave(app)) {
performSave();
}
},
},
"reset-to-default": {
disabled: isDefault || !currentIsOverride,
tooltip: isDefault
? "Cannot reset when Default Values preset is selected"
: !currentIsOverride
? "No override to reset"
: undefined,
onSelect: () => {
markInputForReset(key, widget);
app.graph.setDirtyCanvas?.(true, true);
updateButtonStates();
refreshInputsList();
// Auto-save if enabled
if (getAutoSave(app)) {
performSave();
}
},
},
"untag-input": {
onSelect: async () => {
const confirmed = await showConfirmDialog({
title: "Untag Input",
message: `Are you sure you want to untag "<strong>${exportName || slotName}</strong>" from <strong>${nodeTitle}</strong>?<br><br>This will remove the input from all presets.`,
confirmText: "Untag",
danger: true,
});
if (!confirmed) return;
const node = app.graph.getNodeById(nodeId);
if (node) {
const slotIndex = node.inputs?.findIndex((s) => s.name === slotName);
if (slotIndex >= 0) {
toggleInputSlotMark(node, slotIndex, app);
}
}
},
},
});
});
// Wrap widget callback to detect graph changes from the canvas
// This syncs canvas widget edits to the preset panel
if (widget && inputEl) {
const originalCallback = widget.callback;
widgetCallbacks.set(widget, originalCallback);
widget.callback = function (value) {
originalCallback?.call(this, value);
// Only mark dirty if this is a USER-initiated change, not programmatic
if (!isProgrammaticChange()) {
markDirty(key, { nodeId, slotName, value });
// Update override indicator only for user changes
if (active !== PRESET_KEYS.DEFAULT) {
rowEl.classList.add("rtx-remix-override-indicator");
if (indicator) indicator.title = "Value overrides default";
}
}
// Always update input display to match graph widget value
if (inputEl.type === "checkbox") {
inputEl.checked = !!value;
} else if (inputEl.type === "range") {
inputEl.value = value;
if (valueLabel) valueLabel.value = Number(value).toFixed(2);
} else {
inputEl.value = value ?? "";
}
};
}
}
// ============================================================
// EVENT LISTENERS
// ============================================================
// Search filter for preset list
presetSearchInput?.addEventListener("input", () => {
const hasValue = !!presetSearchInput.value?.trim();
presetSearchClear?.classList.toggle("is-hidden", !hasValue);
refreshPresetList();
});
presetSearchClear?.addEventListener("click", () => {
if (!presetSearchInput) return;
presetSearchInput.value = "";
presetSearchClear.classList.add("is-hidden");
refreshPresetList();
presetSearchInput.focus();
});
// Search filter for inputs list
inputsSearchInput?.addEventListener("input", () => {
const hasValue = !!inputsSearchInput.value?.trim();
inputsSearchClear?.classList.toggle("is-hidden", !hasValue);
refreshInputsList();
});
inputsSearchClear?.addEventListener("click", () => {
if (!inputsSearchInput) return;
inputsSearchInput.value = "";
inputsSearchClear.classList.add("is-hidden");
refreshInputsList();
inputsSearchInput.focus();
});
// Save button: persist all pending changes to the preset store
saveBtn?.addEventListener("click", () => performSave());
// Caret button: toggle popover with save options
caretBtn?.addEventListener("click", (e) => {
e.stopPropagation();
// Toggle behavior - close if already open for this anchor
if (isPopoverOpenFor(caretBtn)) {
closePopover();
return;
}
const hasPending = pendingChanges.size > 0 || pendingDefaultChanges.size > 0 || pendingOverrideRemovals.size > 0;
const isAutoSave = getAutoSave(app);
const content = cloneTemplate(TEMPLATE_IDS.SAVE_MENU);
if (!content) return;
// Configure discard action
const discardRow = content.querySelector('[data-action="discard"]');
if (!hasPending) {
discardRow.classList.add("rtx-remix-popover-item-disabled");
discardRow.title = "No unsaved changes to discard";
} else {
discardRow.addEventListener("click", () => {
closePopover();
clearDirty();
withProgrammaticChange(() => applyPreset(app, getActivePreset(app)));
refreshInputsList();
});
}
// Configure auto-save checkbox
const checkbox = content.querySelector('[data-element="auto-save-checkbox"]');
checkbox.checked = isAutoSave;
checkbox.addEventListener("change", () => {
const wasEnabled = getAutoSave(app);
toggleAutoSave(app, checkbox.checked);
updateButtonStates();
// If auto-save was just enabled and there are pending changes, save them immediately
if (checkbox.checked && !wasEnabled && hasPendingPresetChanges()) {
performSave();
}
closePopover();
});
openPopoverWithContent(caretBtn, content);
});
// Apply All: mark all current overrides to be applied to default (pending until Save)
applyAllBtn?.addEventListener("click", async () => {
const active = getActivePreset(app);
if (active === PRESET_KEYS.DEFAULT) return;
const confirmed = await showApplyAllConfirmDialog();
if (!confirmed) return;
const store = getPresetsStore(app);
const activeInputs = store[PRESET_KEYS.PRESETS][active]?.inputs || {};
// For each tagged input that has an override, mark for apply to default
getTaggedInputs(app).forEach(({ nodeId, slotName, widget }) => {
if (!widget) return;
const key = getInputKey(nodeId, slotName);
if (isKeyOverride(key)) {
// Get effective value (pending change takes priority over stored)
const effectiveVal = pendingChanges.has(key)
? pendingChanges.get(key).value
: (activeInputs[key]?.[PRESET_KEYS.VALUE] ?? widget.value);
markInputForApplyToDefault(key, nodeId, slotName, effectiveVal);
}
});
updateButtonStates();
refreshInputsList();
// Auto-save if enabled (after all changes are marked)
if (getAutoSave(app)) {
performSave();
}
});
// Reset All: mark all overrides to be removed (pending until Save)
resetAllBtn?.addEventListener("click", async () => {
const active = getActivePreset(app);
if (active === PRESET_KEYS.DEFAULT) return;
const confirmed = await showResetAllConfirmDialog();
if (!confirmed) return;
// For each tagged input that has an override, mark for removal with live preview
getTaggedInputs(app).forEach(({ nodeId, slotName, widget }) => {
if (!widget) return;
const key = getInputKey(nodeId, slotName);
if (isKeyOverride(key)) {
markInputForReset(key, widget);
}
});
app.graph.setDirtyCanvas?.(true, true);
updateButtonStates();
refreshInputsList();
// Auto-save if enabled (after all changes are marked)
if (getAutoSave(app)) {
performSave();
}
});
// Focus fix: prevent ComfyUI from stealing focus on text inputs
// Only protect actual input fields, not buttons - buttons don't need focus protection
// and intercepting them interferes with native drag behavior
inputsList?.addEventListener(
"pointerdown",
(e) => {
const target = e.target.closest("input, select");
if (!target) return;
// Don't interfere with range sliders or select dropdowns - they need native behavior
if (target.type === "range" || target.tagName === "SELECT") {
e.stopPropagation();
return;
}
// For text/number/checkbox inputs, prevent default and manually focus
e.preventDefault();
target.focus();
e.stopPropagation();
},
true
);
createBtn?.addEventListener("click", () => {
showEditPresetDialog(app, { isNew: true, currentName: "", currentDescription: "" }).then((result) => {
if (!result) return;
const { name, description } = result;
createPreset(app, name);
setPresetDescription(app, name, description);
setActivePreset(app, name);
refreshPresetList();
refreshInputsList();
});
});
// Initial render
refreshPresetList();
refreshInputsList();
// Listen for graph changes
const onGraphChanged = () => {
cleanupDeletedInputs(app);
refreshPresetList();
refreshInputsList();
};
app.api.addEventListener?.("graphChanged", onGraphChanged);
// Subscribe to input tag events from slotMarkingCore
setOnInputTagChanged(({ action, nodeId, slotName, app: eventApp }) => {
if (action === "tagged") {
// Register newly tagged input to default preset
registerNewlyTaggedInput(eventApp, nodeId, slotName);
}
// Clean up deleted inputs and refresh the list
cleanupDeletedInputs(eventApp);
refreshInputsList();
});
// Listen for metadata changes (e.g., min/max/step edited in dialog, group changes)
const onMetadataChanged = () => {
// Clean up orphaned groups from groupOrder when groups are removed from inputs
cleanupDeletedInputs(app);
refreshInputsList();
};
app.api.addEventListener?.(EVENTS.METADATA_CHANGED, onMetadataChanged);
// Listen for preset value changes (e.g., after saving) to update button states
const onPresetValueChanged = () => {
refreshPresetList();
};
app.api.addEventListener?.(EVENTS.PRESET_VALUE_CHANGED, onPresetValueChanged);
// Listen for preset changes (create, delete, clear) to update button states
const onPresetChanged = () => {
refreshPresetList();
};
app.api.addEventListener?.(EVENTS.PRESET_CHANGED, onPresetChanged);
// Store cleanup function for next render
sidebarCleanup = () => {
app.api.removeEventListener?.("graphChanged", onGraphChanged);
app.api.removeEventListener?.(EVENTS.METADATA_CHANGED, onMetadataChanged);
app.api.removeEventListener?.(EVENTS.PRESET_VALUE_CHANGED, onPresetValueChanged);
app.api.removeEventListener?.(EVENTS.PRESET_CHANGED, onPresetChanged);
setOnInputTagChanged(null);
};
// Mount panel and focus for keyboard events
el.innerHTML = "";
el.classList.add("rtx-flex", "rtx-flex-col");
el.style.height = "100%";
el.appendChild(panel);
el.tabIndex = -1;
el.focus();
}