Files

323 lines
10 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 { cloneTemplate, bindTemplateData } from "../utils/html.js";
import { showConfirmDialog } from "./baseDialogController.js";
import { getExistingGroups } from "../cores/metadataEditorCore.js";
import { TEMPLATE_IDS } from "../utils/constants.js";
// Track active dropdown for global cleanup
let activeDropdown = null;
let activeCleanup = null;
/**
* Close the currently open group picker dropdown, if any.
*/
export function closeGroupPicker() {
if (activeDropdown) {
activeDropdown.classList.add("rtx-hidden");
}
activeCleanup?.();
activeDropdown = null;
activeCleanup = null;
}
/**
* Create a custom group picker dropdown with inline editing capabilities.
*
* @param {Object} options
* @param {Object} options.app - ComfyUI app instance
* @param {string} options.currentValue - Currently selected group (null/empty = Ungrouped)
* @param {Function} options.onChange - Called when selection changes: (newGroup) => void
* @param {Function} [options.onGroupDelete] - Called when group is deleted: (groupName) => void
* @param {Function} [options.onGroupRename] - Called when group is renamed: (oldName, newName) => void
* @returns {HTMLElement} The group picker element
*/
export function createGroupPicker(options) {
const { app, currentValue, onChange, onGroupDelete, onGroupRename } = options;
const container = cloneTemplate(TEMPLATE_IDS.GROUP_PICKER);
if (!container) {
console.error("Failed to load group picker template");
return document.createElement("div");
}
const trigger = container.querySelector("[data-element='trigger']");
const dropdown = container.querySelector("[data-element='dropdown']");
const selectedLabel = container.querySelector("[data-bind='selectedGroup']");
// Store current selected value
let selectedGroup = currentValue || "";
container.dataset.selectedGroup = selectedGroup;
// Update the trigger label
function updateTriggerLabel() {
if (selectedLabel) {
selectedLabel.textContent = selectedGroup || "Ungrouped";
}
}
updateTriggerLabel();
/**
* Render the dropdown options
*/
function renderOptions() {
dropdown.innerHTML = "";
// Add "Ungrouped" option (no icons)
const ungroupedOption = cloneTemplate(TEMPLATE_IDS.GROUP_PICKER_OPTION);
if (ungroupedOption) {
bindTemplateData(ungroupedOption, { groupName: "Ungrouped" });
ungroupedOption.dataset.group = "";
// Hide actions for ungrouped
const actions = ungroupedOption.querySelector(".rtx-remix-group-picker-option-actions");
if (actions) actions.classList.add("rtx-hidden");
ungroupedOption.addEventListener("click", (e) => {
if (e.target.closest("[data-action]")) return;
selectGroup("");
});
dropdown.appendChild(ungroupedOption);
}
// Add existing groups
const existingGroups = app ? getExistingGroups(app) : [];
existingGroups.forEach((groupName) => {
const option = cloneTemplate(TEMPLATE_IDS.GROUP_PICKER_OPTION);
if (!option) return;
bindTemplateData(option, { groupName });
option.dataset.group = groupName;
// Handle group selection
option.addEventListener("click", (e) => {
if (e.target.closest("[data-action]")) return;
selectGroup(groupName);
});
// Handle rename action
const renameBtn = option.querySelector("[data-action='rename']");
if (renameBtn) {
renameBtn.addEventListener("click", (e) => {
e.stopPropagation();
showInlineInput(option, groupName, "rename");
});
}
// Handle delete action
const deleteBtn = option.querySelector("[data-action='delete']");
if (deleteBtn) {
deleteBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const confirmed = await showConfirmDialog({
title: "Delete Group",
message: `Are you sure you want to delete the group "<strong>${groupName}</strong>"?<br><br>All inputs in this group will be moved to <strong>Ungrouped</strong>.`,
confirmText: "Delete",
cancelText: "Cancel",
danger: true,
});
if (confirmed) {
onGroupDelete?.(groupName);
// If current selection was this group, reset to ungrouped
if (selectedGroup === groupName) {
selectGroup("");
}
renderOptions();
}
});
}
dropdown.appendChild(option);
});
// Add "Create new group" option
const createOption = cloneTemplate(TEMPLATE_IDS.GROUP_PICKER_CREATE);
if (createOption) {
createOption.addEventListener("click", () => {
showInlineInput(createOption, "", "create");
});
dropdown.appendChild(createOption);
}
}
/**
* Show inline input for creating or renaming a group
*/
function showInlineInput(targetElement, existingName, mode) {
const inputRow = cloneTemplate(TEMPLATE_IDS.GROUP_PICKER_INPUT);
if (!inputRow) return;
const input = inputRow.querySelector("[data-element='input']");
const confirmBtn = inputRow.querySelector("[data-action='confirm']");
const cancelBtn = inputRow.querySelector("[data-action='cancel']");
if (input) {
input.value = existingName;
input.placeholder = mode === "create" ? "New group name" : "Group name";
}
// Replace target element with input row
targetElement.classList.add("rtx-hidden");
targetElement.parentElement.insertBefore(inputRow, targetElement.nextSibling);
// Focus input
setTimeout(() => input?.focus(), 0);
function cleanup() {
inputRow.remove();
targetElement.classList.remove("rtx-hidden");
}
function confirm() {
const newName = input?.value?.trim();
if (!newName) {
cleanup();
return;
}
if (mode === "create") {
selectGroup(newName);
} else if (mode === "rename" && newName !== existingName) {
onGroupRename?.(existingName, newName);
// If current selection was renamed, update it
if (selectedGroup === existingName) {
selectedGroup = newName;
container.dataset.selectedGroup = newName;
updateTriggerLabel();
onChange?.(newName);
}
renderOptions();
}
cleanup();
}
confirmBtn?.addEventListener("click", (e) => {
e.stopPropagation();
confirm();
});
cancelBtn?.addEventListener("click", (e) => {
e.stopPropagation();
cleanup();
});
input?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
confirm();
} else if (e.key === "Escape") {
e.preventDefault();
cleanup();
}
});
}
/**
* Select a group and close dropdown
*/
function selectGroup(groupName) {
selectedGroup = groupName;
container.dataset.selectedGroup = groupName;
updateTriggerLabel();
onChange?.(groupName || null); // null for ungrouped to delete metadata
closeDropdown();
}
/**
* Open the dropdown
*/
function openDropdown() {
// Close any other open dropdown
closeGroupPicker();
renderOptions();
dropdown.classList.remove("rtx-hidden");
// Use fixed positioning for better behavior in scrollable containers
const triggerRect = trigger.getBoundingClientRect();
const dropdownHeight = dropdown.offsetHeight;
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
const maxHeight = 200; // Max height before scrolling
dropdown.style.position = "fixed";
dropdown.style.left = `${triggerRect.left}px`;
dropdown.style.setProperty("width", `${triggerRect.width}px`, "important");
dropdown.style.maxHeight = `${maxHeight}px`;
dropdown.style.overflowY = "auto";
dropdown.style.zIndex = "10000"; // Higher than dialog overlay
// Position below or above based on available space
if (spaceBelow >= Math.min(dropdownHeight, maxHeight) || spaceBelow >= spaceAbove) {
// Position below
dropdown.style.top = `${triggerRect.bottom + 4}px`;
dropdown.style.bottom = "auto";
} else {
// Position above
dropdown.style.top = "auto";
dropdown.style.bottom = `${viewportHeight - triggerRect.top + 4}px`;
}
// Setup close handlers
const onDocClick = (e) => {
if (!container.contains(e.target)) {
closeDropdown();
}
};
const onEscape = (e) => {
if (e.key === "Escape") {
closeDropdown();
}
};
// Delay adding listener to prevent immediate close
setTimeout(() => {
document.addEventListener("mousedown", onDocClick, true);
document.addEventListener("keydown", onEscape);
}, 0);
activeDropdown = dropdown;
activeCleanup = () => {
document.removeEventListener("mousedown", onDocClick, true);
document.removeEventListener("keydown", onEscape);
};
}
/**
* Close the dropdown
*/
function closeDropdown() {
dropdown.classList.add("rtx-hidden");
activeCleanup?.();
activeDropdown = null;
activeCleanup = null;
}
// Toggle dropdown on trigger click
trigger?.addEventListener("click", () => {
if (dropdown.classList.contains("rtx-hidden")) {
openDropdown();
} else {
closeDropdown();
}
});
return container;
}