Files

833 lines
26 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 } from "../utils/html.js";
import { TEMPLATE_IDS } from "../utils/constants.js";
// Special group name for ungrouped items
const UNGROUPED = "";
const UNGROUPED_DISPLAY = "Ungrouped";
/**
* Group items by their group property.
* @param {Array} items - Items with `group` property
* @returns {Map<string, Array>} Map of groupName -> items (sorted by order within each group)
*/
export function groupItemsByGroup(items) {
const groups = new Map();
items.forEach((item) => {
const groupName = item.group || UNGROUPED;
if (!groups.has(groupName)) {
groups.set(groupName, []);
}
groups.get(groupName).push(item);
});
// Sort items within each group by order
groups.forEach((groupItems) => {
groupItems.sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
});
return groups;
}
/**
* Get sorted group names based on explicit order and alphabetical fallback.
* Ungrouped is always first.
* @param {Map<string, Array>} groupedItems - Map from groupItemsByGroup
* @param {string[]} explicitOrder - Explicit group order from metadata
* @returns {string[]} Sorted group names
*/
function getSortedGroupNames(groupedItems, explicitOrder = []) {
const allGroups = Array.from(groupedItems.keys());
const result = [];
// Ungrouped always first
if (allGroups.includes(UNGROUPED)) {
result.push(UNGROUPED);
}
// Add groups in explicit order
explicitOrder.forEach((group) => {
if (allGroups.includes(group) && group !== UNGROUPED && !result.includes(group)) {
result.push(group);
}
});
// Add remaining groups alphabetically
allGroups
.filter((g) => !result.includes(g))
.sort()
.forEach((group) => {
result.push(group);
});
return result;
}
/**
* Create a grouped, reorderable list component using CSS Grid layout.
* Renders into a container <div> element, with one group <div> per group.
*
* @param {Object} options
* @param {HTMLElement} options.container - Container element to render into (will be cleared)
* @param {Array<Object>} options.items - Items to display [{nodeId, slotName, group, order, ...customData}]
* @param {Array<string>} [options.groupOrder] - Explicit group order (groups not listed appear after, alphabetically)
* @param {Function} options.renderRow - Row delegate: (item, rowEl: HTMLDivElement) => void
* @param {Function} [options.renderHeader] - Optional: (headerRow: HTMLDivElement) => void - Renders column header cells
* @param {Function} [options.renderCompanionRow] - Optional: (item, rowEl) => HTMLElement|null - Returns companion row to insert after main row
* @param {string} [options.companionRowClass] - CSS class to identify companion rows (for drag handling)
* @param {Function} options.onItemOrderChange - Called when item order changes within a group: (groupName, orderedItems) => void
* @param {Function} [options.onGroupOrderChange] - Called when groups are reordered: (newOrder: string[]) => void
* @param {Set<string>} [options.collapsedGroups] - Optional external collapsed state
* @param {Function} [options.onGroupToggle] - Called when group is toggled: (groupName, isCollapsed) => void
* @param {Function} [options.onGroupMenu] - Called when group menu button is clicked: (groupName, groupItems, menuBtn) => void
* @param {string} [options.emptyMessage] - Message when no items
* @returns {{refresh: Function, cleanup: Function, getItems: Function}}
*/
export function createGroupedList(options) {
const {
container,
items,
groupOrder = [],
renderRow,
renderHeader,
renderCompanionRow,
companionRowClass = "rtx-remix-companion-row",
onItemOrderChange,
onGroupOrderChange,
collapsedGroups = new Set(),
onGroupToggle,
onGroupMenu,
emptyMessage = "No items to display",
} = options;
let currentItems = [...items];
let draggedRow = null;
let draggedGroup = null;
let dragPlaceholder = null;
let preDragCollapsedState = null; // Saved collapsed state before group drag
let dragOccurred = false; // Flag to prevent click handler from toggling during drag
// Track document-level event listeners for cleanup
const documentListeners = [];
/**
* Render the entire list
*/
function render() {
container.innerHTML = "";
// Render optional header row first (above all groups)
if (renderHeader) {
const headerRow = document.createElement("div");
headerRow.className = "rtx-remix-list-row rtx-remix-list-header";
renderHeader(headerRow);
container.appendChild(headerRow);
}
if (currentItems.length === 0) {
const emptyDiv = document.createElement("div");
emptyDiv.className = "rtx-remix-grouped-list-empty";
emptyDiv.textContent = emptyMessage;
container.appendChild(emptyDiv);
return;
}
const groupedItems = groupItemsByGroup(currentItems);
const sortedGroups = getSortedGroupNames(groupedItems, groupOrder);
sortedGroups.forEach((groupName) => {
const groupItems = groupedItems.get(groupName);
if (!groupItems || groupItems.length === 0) return;
const groupEl = renderGroup(groupName, groupItems);
container.appendChild(groupEl);
});
}
/**
* Render a single group
*/
function renderGroup(groupName, groupItems) {
const groupEl = cloneTemplate(TEMPLATE_IDS.GROUPED_LIST_GROUP);
if (!groupEl) {
// Fallback if template not loaded
const fallbackGroup = document.createElement("div");
fallbackGroup.className = "rtx-remix-list-group";
return fallbackGroup;
}
groupEl.dataset.groupName = groupName;
const isCollapsed = collapsedGroups.has(groupName);
const isUngrouped = groupName === UNGROUPED;
// Apply collapsed class
if (isCollapsed) {
groupEl.classList.add("rtx-remix-group-collapsed");
}
// Mark ungrouped for CSS styling (pinned to top, can't be dragged)
if (isUngrouped) {
groupEl.classList.add("rtx-remix-group-ungrouped");
}
// Update header
const headerBtn = groupEl.querySelector("[data-action='toggle-group']");
const dragHandle = groupEl.querySelector("[data-element='group-drag-handle']");
const chevron = groupEl.querySelector(".rtx-remix-group-chevron");
const nameEl = groupEl.querySelector("[data-bind='groupName']");
const countEl = groupEl.querySelector("[data-bind='groupCount']");
if (nameEl) nameEl.textContent = isUngrouped ? UNGROUPED_DISPLAY : groupName;
if (countEl) countEl.textContent = `(${groupItems.length})`;
if (chevron) chevron.textContent = isCollapsed ? "▶" : "▼";
if (headerBtn) {
headerBtn.setAttribute("aria-expanded", !isCollapsed);
headerBtn.addEventListener("click", (e) => {
// Don't toggle if click was on the drag handle
if (e.target.closest("[data-element='group-drag-handle']")) {
return;
}
// Don't toggle if click was on the menu button
if (e.target.closest("[data-action='group-menu']")) {
return;
}
// Don't toggle if we just finished a drag operation
if (dragOccurred) {
dragOccurred = false;
return;
}
toggleGroup(groupName);
});
}
// Setup group dragging via drag handle (except ungrouped)
if (!isUngrouped && headerBtn && dragHandle) {
setupGroupDragHandlers(headerBtn, dragHandle, groupName);
}
// Setup group menu button (if present in template)
const menuBtn = groupEl.querySelector("[data-action='group-menu']");
if (menuBtn && onGroupMenu) {
menuBtn.addEventListener("click", (e) => {
e.stopPropagation();
onGroupMenu(groupName, groupItems, menuBtn);
});
}
// Get items container
const itemsContainer = groupEl.querySelector(".rtx-remix-group-items");
if (!itemsContainer) return groupEl;
// Render items (only if not collapsed)
if (!isCollapsed) {
groupItems.forEach((item) => {
const row = renderItemRow(item, groupName);
itemsContainer.appendChild(row);
// If a companion row renderer is provided, add companion row after main row
if (renderCompanionRow) {
const companion = renderCompanionRow(item, row);
if (companion) {
companion.classList.add(companionRowClass);
itemsContainer.appendChild(companion);
}
}
});
}
return groupEl;
}
/**
* Render a single item row
*/
function renderItemRow(item, groupName) {
const row = cloneTemplate(TEMPLATE_IDS.GROUPED_LIST_ROW);
if (!row) {
// Fallback if template not loaded
const fallbackRow = document.createElement("div");
fallbackRow.className = "rtx-remix-list-row";
return fallbackRow;
}
row.dataset.nodeId = item.nodeId;
row.dataset.slotName = item.slotName;
row.dataset.group = groupName;
// Track if drag started from the handle
let dragFromHandle = false;
// Setup drag handle
const dragHandle = row.querySelector("[data-element='drag-handle']");
if (dragHandle) {
dragHandle.addEventListener("mousedown", (e) => {
dragFromHandle = true;
// Don't stop propagation - let row mousedown set row.draggable = true
});
dragHandle.addEventListener("mouseup", () => {
// Reset after a short delay to allow dragstart to check the flag
setTimeout(() => {
dragFromHandle = false;
}, 100);
});
} else {
console.warn("[GroupedList] No drag handle found for row:", item.slotName);
}
// Row should NOT be draggable by default - only when drag handle is used
row.draggable = false;
// Make row draggable only when drag handle is pressed
row.addEventListener("mousedown", (e) => {
if (e.target.closest("[data-element='drag-handle']")) {
row.draggable = true;
} else {
row.draggable = false;
}
});
// Setup drag handlers
setupRowDragHandlers(row, item, groupName, () => dragFromHandle);
// Call the row delegate to populate custom content
renderRow(item, row);
return row;
}
/**
* Get companion row following a main row (if any)
*/
function getCompanionRow(mainRow) {
const next = mainRow.nextElementSibling;
return next?.classList.contains(companionRowClass) ? next : null;
}
/**
* Setup drag handlers for item rows
*/
function setupRowDragHandlers(row, item, groupName, isDragFromHandle) {
row.addEventListener("dragstart", (e) => {
// Only allow drag from the handle
if (!isDragFromHandle || !isDragFromHandle()) {
e.preventDefault();
return;
}
draggedRow = row;
draggedGroup = null;
row.classList.add("rtx-remix-dragging");
// Also mark companion row as dragging
const companion = getCompanionRow(row);
if (companion) companion.classList.add("rtx-remix-dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", `item:${item.nodeId}:${item.slotName}`);
// Create placeholder
dragPlaceholder = document.createElement("div");
dragPlaceholder.className = "rtx-remix-drag-placeholder";
});
row.addEventListener("dragend", (e) => {
row.classList.remove("rtx-remix-dragging");
row.draggable = false; // Reset draggable state
// Also unmark companion row
const companion = getCompanionRow(row);
if (companion) companion.classList.remove("rtx-remix-dragging");
cleanupDrag();
});
row.addEventListener("dragover", (e) => {
// During group drag, let event bubble to container
if (draggedGroup) {
return;
}
if (!draggedRow) {
// No drag in progress
return;
}
// Skip if target is a companion row
if (row.classList.contains(companionRowClass)) return;
// Only allow drop within same group
const targetGroup = row.dataset.group;
const sourceGroup = draggedRow.dataset.group;
if (targetGroup !== sourceGroup) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Position placeholder
const rect = row.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
// Skip companion row when positioning
const targetCompanion = getCompanionRow(row);
if (e.clientY < midY) {
row.parentElement.insertBefore(dragPlaceholder, row);
} else {
// Insert after companion if it exists
const insertAfter = targetCompanion || row;
row.parentElement.insertBefore(dragPlaceholder, insertAfter.nextSibling);
}
});
row.addEventListener("drop", (e) => {
if (!draggedRow || draggedGroup) return;
e.preventDefault();
const targetGroup = row.dataset.group;
const sourceGroup = draggedRow.dataset.group;
if (targetGroup !== sourceGroup) return;
// Get companion rows
const draggedCompanion = getCompanionRow(draggedRow);
// Move the row to placeholder position
if (dragPlaceholder && dragPlaceholder.parentElement) {
dragPlaceholder.parentElement.insertBefore(draggedRow, dragPlaceholder);
// Also move companion row if it exists
if (draggedCompanion) {
draggedRow.parentElement.insertBefore(draggedCompanion, draggedRow.nextSibling);
}
}
// Update order based on DOM
updateItemOrderFromDOM(targetGroup);
cleanupDrag();
});
}
/**
* Setup drag handlers for group headers using a drag handle pattern
* Similar to how row dragging works - button is only draggable when drag handle is used
*/
function setupGroupDragHandlers(headerBtn, dragHandle, groupName) {
const groupEl = headerBtn.closest(".rtx-remix-list-group");
// Track if we started dragging from the handle
let dragFromHandle = false;
// Make button draggable only when drag handle is pressed
dragHandle.addEventListener("mousedown", (e) => {
dragFromHandle = true;
headerBtn.draggable = true;
e.stopPropagation(); // Prevent button click
});
// Reset on mouseup anywhere (in case drag doesn't start)
const onMouseUp = () => {
if (dragFromHandle) {
dragFromHandle = false;
headerBtn.draggable = false;
}
};
document.addEventListener("mouseup", onMouseUp);
documentListeners.push(onMouseUp);
headerBtn.addEventListener("dragstart", (e) => {
// Only allow drag from the handle
if (!dragFromHandle) {
e.preventDefault();
return;
}
dragOccurred = true; // Prevent click handler from toggling
draggedGroup = groupName;
draggedRow = null;
// Set up dataTransfer - MUST be done synchronously in dragstart
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", `group:${groupName}`);
// Create a custom drag image using the button
const headerRect = headerBtn.getBoundingClientRect();
const dragImage = headerBtn.cloneNode(true);
dragImage.style.position = "absolute";
dragImage.style.top = "-9999px";
dragImage.style.left = "-9999px";
dragImage.style.width = headerRect.width + "px";
dragImage.style.backgroundColor = "var(--comfy-input-bg, #333)";
dragImage.style.padding = "8px";
dragImage.style.borderRadius = "4px";
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, headerRect.width / 2, 15);
// Clean up drag image after a short delay
setTimeout(() => {
if (dragImage.parentElement) {
document.body.removeChild(dragImage);
}
}, 100);
// Save current collapsed state
preDragCollapsedState = new Set(collapsedGroups);
// Create placeholder immediately (before DOM changes)
dragPlaceholder = document.createElement("div");
dragPlaceholder.className = "rtx-remix-group-placeholder";
// Defer DOM modifications to next frame to avoid interfering with browser's drag image capture
requestAnimationFrame(() => {
// Visually collapse all groups using CSS (don't re-render - it would destroy the dragged element!)
const allGroups = container.querySelectorAll(".rtx-remix-list-group");
allGroups.forEach((el) => {
el.classList.add("rtx-remix-group-drag-collapsed");
});
// Mark the dragged group
if (groupEl) {
groupEl.classList.add("rtx-remix-group-dragging");
}
});
});
headerBtn.addEventListener("dragend", (e) => {
// Reset drag state
dragOccurred = false;
dragFromHandle = false;
headerBtn.draggable = false;
// Remove visual collapse from all groups
container.querySelectorAll(".rtx-remix-list-group").forEach((el) => {
el.classList.remove("rtx-remix-group-drag-collapsed", "rtx-remix-group-dragging");
});
// Restore collapsed state (will be applied on next render or toggle)
if (preDragCollapsedState) {
collapsedGroups.clear();
preDragCollapsedState.forEach((name) => collapsedGroups.add(name));
preDragCollapsedState = null;
}
cleanupDrag();
});
// Also set up dragover/drop on the button for drop target handling
headerBtn.addEventListener("dragover", (e) => {
const targetGroupEl = headerBtn.closest(".rtx-remix-list-group");
const targetGroup = targetGroupEl?.dataset.groupName;
if (!draggedGroup) {
return;
}
// Can't drop on ungrouped or on self
if (targetGroup === UNGROUPED || targetGroup === draggedGroup) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Position placeholder
const rect = targetGroupEl.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
targetGroupEl.parentElement.insertBefore(dragPlaceholder, targetGroupEl);
} else {
targetGroupEl.parentElement.insertBefore(dragPlaceholder, targetGroupEl.nextSibling);
}
});
headerBtn.addEventListener("drop", (e) => {
if (!draggedGroup) return;
e.preventDefault();
const sourceGroupEl = container.querySelector(`.rtx-remix-list-group[data-group-name="${draggedGroup}"]`);
// Move the group to placeholder position
if (dragPlaceholder && dragPlaceholder.parentElement && sourceGroupEl) {
dragPlaceholder.parentElement.insertBefore(sourceGroupEl, dragPlaceholder);
}
// Update group order based on DOM
updateGroupOrderFromDOM();
cleanupDrag();
});
}
/**
* Setup container-level dragover for group reordering and row reordering
* This allows dropping between groups even when not directly over a header
*/
function setupContainerDragHandlers() {
// Handle row dragging at container level for better drop target coverage
container.addEventListener("dragover", (e) => {
// CRITICAL: Always prevent default and set dropEffect when we have an active drag
// This tells the browser this is a valid drop target and keeps the drag alive
if (draggedGroup || draggedRow) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
// Handle row dragging
if (draggedRow && !draggedGroup) {
const sourceGroup = draggedRow.dataset.group;
const itemsContainer = container.querySelector(
`.rtx-remix-list-group[data-group-name="${sourceGroup}"] .rtx-remix-group-items`
);
if (itemsContainer && itemsContainer.contains(e.target)) {
// Find insertion point among rows
const rows = Array.from(itemsContainer.querySelectorAll(`.rtx-remix-list-row:not(.${companionRowClass})`));
let insertBefore = null;
for (const row of rows) {
if (row === draggedRow) continue;
const rect = row.getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
insertBefore = row;
break;
}
}
// Position placeholder
if (dragPlaceholder) {
if (insertBefore) {
itemsContainer.insertBefore(dragPlaceholder, insertBefore);
} else {
// Insert at end
itemsContainer.appendChild(dragPlaceholder);
}
}
}
return;
}
// Handle group dragging
if (!draggedGroup) {
return;
}
// Find which group we're over - filter out Ungrouped and the dragged group
const allGroupEls = Array.from(container.querySelectorAll(".rtx-remix-list-group"));
const draggableGroupEls = allGroupEls.filter((el) => {
const name = el.dataset.groupName;
return name !== UNGROUPED && name !== draggedGroup;
});
if (draggableGroupEls.length === 0) {
return;
}
let targetGroupEl = null;
let insertBefore = true;
for (let i = 0; i < draggableGroupEls.length; i++) {
const groupEl = draggableGroupEls[i];
const rect = groupEl.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
// Within this group's bounds
targetGroupEl = groupEl;
insertBefore = e.clientY < rect.top + rect.height / 2;
break;
} else if (e.clientY < rect.top) {
// Above this group - insert before it
targetGroupEl = groupEl;
insertBefore = true;
break;
} else if (i === draggableGroupEls.length - 1 && e.clientY > rect.bottom) {
// Below the last draggable group - insert after it
targetGroupEl = groupEl;
insertBefore = false;
break;
}
}
if (!targetGroupEl) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Position placeholder
if (insertBefore) {
targetGroupEl.parentElement.insertBefore(dragPlaceholder, targetGroupEl);
} else {
targetGroupEl.parentElement.insertBefore(dragPlaceholder, targetGroupEl.nextSibling);
}
});
container.addEventListener("drop", (e) => {
// Handle row drop
if (draggedRow && !draggedGroup) {
e.preventDefault();
const sourceGroup = draggedRow.dataset.group;
const draggedCompanion = getCompanionRow(draggedRow);
// Move the row to placeholder position
if (dragPlaceholder && dragPlaceholder.parentElement) {
dragPlaceholder.parentElement.insertBefore(draggedRow, dragPlaceholder);
// Also move companion row if it exists
if (draggedCompanion) {
draggedRow.parentElement.insertBefore(draggedCompanion, draggedRow.nextSibling);
}
}
// Update order based on DOM
updateItemOrderFromDOM(sourceGroup);
cleanupDrag();
return;
}
// Handle group drop
if (!draggedGroup) {
return;
}
e.preventDefault();
const sourceGroupEl = container.querySelector(`.rtx-remix-list-group[data-group-name="${draggedGroup}"]`);
// Move the group to placeholder position
if (dragPlaceholder && dragPlaceholder.parentElement && sourceGroupEl) {
dragPlaceholder.parentElement.insertBefore(sourceGroupEl, dragPlaceholder);
}
// Update group order based on DOM
updateGroupOrderFromDOM();
cleanupDrag();
});
}
/**
* Clean up after drag operation
*/
function cleanupDrag() {
if (dragPlaceholder && dragPlaceholder.parentElement) {
dragPlaceholder.parentElement.removeChild(dragPlaceholder);
}
dragPlaceholder = null;
draggedRow = null;
draggedGroup = null;
}
/**
* Update item order based on DOM position and notify callback
*/
function updateItemOrderFromDOM(groupName) {
const groupEl = container.querySelector(`.rtx-remix-list-group[data-group-name="${groupName}"]`);
const itemsContainer = groupEl?.querySelector(".rtx-remix-group-items");
if (!itemsContainer) return;
// Get only main rows, excluding companion rows
const rows = itemsContainer.querySelectorAll(`.rtx-remix-list-row:not(.${companionRowClass})`);
const orderedItems = [];
let orderIndex = 0;
rows.forEach((row) => {
const nodeId = row.dataset.nodeId;
const slotName = row.dataset.slotName;
// Find the item in currentItems
const item = currentItems.find((i) => String(i.nodeId) === nodeId && i.slotName === slotName);
if (item) {
item.order = orderIndex;
orderedItems.push({ ...item, order: orderIndex });
orderIndex++;
}
});
onItemOrderChange?.(groupName, orderedItems);
}
/**
* Update group order based on DOM position and notify callback
*/
function updateGroupOrderFromDOM() {
const groupEls = container.querySelectorAll(".rtx-remix-list-group");
const newOrder = [];
groupEls.forEach((groupEl) => {
const groupName = groupEl.dataset.groupName;
if (groupName && groupName !== UNGROUPED) {
newOrder.push(groupName);
}
});
onGroupOrderChange?.(newOrder);
}
/**
* Toggle group collapse state
*/
function toggleGroup(groupName) {
const isCollapsed = collapsedGroups.has(groupName);
if (isCollapsed) {
collapsedGroups.delete(groupName);
} else {
collapsedGroups.add(groupName);
}
onGroupToggle?.(groupName, !isCollapsed);
render();
}
/**
* Refresh the list with new items
*/
function refresh(newItems) {
currentItems = [...newItems];
render();
}
/**
* Cleanup resources
*/
function cleanup() {
cleanupDrag();
documentListeners.forEach((fn) => document.removeEventListener("mouseup", fn));
documentListeners.length = 0;
container.innerHTML = "";
}
/**
* Get current items
*/
function getItems() {
return currentItems;
}
// Initial render
render();
// Setup container-level drag handlers for group reordering
setupContainerDragHandlers();
return {
refresh,
cleanup,
getItems,
};
}