Files
ComfyUI-Lora-Manager/static/js/utils/virtualScroll.js

313 lines
11 KiB
JavaScript

import { state, getCurrentPageState } from '../state/index.js';
import { loadMoreLoras } from '../api/loraApi.js';
import { loadMoreCheckpoints } from '../api/checkpointApi.js';
import { debounce } from './debounce.js';
import { createLoraCard } from '../components/LoraCard.js';
export function initializeVirtualScroll(pageType = 'loras') {
// Clean up any existing observer or handler
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
if (state.scrollHandler) {
window.removeEventListener('scroll', state.scrollHandler);
state.scrollHandler = null;
}
if (state.scrollCheckInterval) {
clearInterval(state.scrollCheckInterval);
state.scrollCheckInterval = null;
}
// Set the current page type
state.currentPageType = pageType;
// Get the current page state
const pageState = getCurrentPageState();
// Skip initializing if in duplicates mode (for recipes page)
if (pageType === 'recipes' && pageState.duplicatesMode) {
return;
}
// Determine the grid element and fetch function based on page type
let gridId;
let fetchMoreItems;
let createCardFunction;
switch (pageType) {
case 'recipes':
fetchMoreItems = async () => {
if (!pageState.isLoading && pageState.hasMore) {
await window.recipeManager.loadRecipes(false);
return pageState.items;
}
return [];
};
gridId = 'recipeGrid';
createCardFunction = window.recipeManager?.createRecipeCard;
break;
case 'checkpoints':
fetchMoreItems = async () => {
if (!pageState.isLoading && pageState.hasMore) {
await loadMoreCheckpoints(false);
return pageState.items;
}
return [];
};
gridId = 'checkpointGrid';
createCardFunction = window.createCheckpointCard;
break;
case 'loras':
default:
fetchMoreItems = async () => {
if (!pageState.isLoading && pageState.hasMore) {
await loadMoreLoras(false);
return pageState.items;
}
return [];
};
gridId = 'loraGrid';
createCardFunction = createLoraCard;
break;
}
// Get the grid container
const gridContainer = document.getElementById(gridId);
if (!gridContainer) {
console.warn(`Grid with ID "${gridId}" not found for virtual scroll`);
return;
}
// Get the scrollable container
const scrollContainer = document.querySelector('.page-content');
if (!scrollContainer) {
console.warn('Scrollable container not found for virtual scroll');
return;
}
// Initialize the virtual scroll state
const virtualScroll = {
itemHeight: 350, // Starting estimate for card height
bufferSize: 10, // Extra items to render above/below viewport
visibleItems: new Map(), // Track rendered items by file_path
allItems: pageState.items || [], // All data items that have been loaded
containerHeight: 0, // Will be updated to show proper scrollbar
containerElement: document.createElement('div'), // Virtual container
gridElement: gridContainer,
itemMeasurements: new Map(), // Map of measured item heights
isUpdating: false
};
// Create a container for the virtualized content with proper height
virtualScroll.containerElement.className = 'virtual-scroll-container';
virtualScroll.containerElement.style.position = 'relative';
virtualScroll.containerElement.style.width = '100%';
virtualScroll.containerElement.style.height = '0px'; // Will be updated
gridContainer.innerHTML = ''; // Clear existing content
gridContainer.appendChild(virtualScroll.containerElement);
// Store the virtual scroll state in the global state
state.virtualScroll = virtualScroll;
// Function to measure a rendered card's height
function measureCardHeight(card) {
if (!card) return virtualScroll.itemHeight;
const height = card.offsetHeight;
return height > 0 ? height : virtualScroll.itemHeight;
}
// Calculate estimated total height for proper scrollbar
function updateContainerHeight() {
if (virtualScroll.allItems.length === 0) return;
// If we've measured some items, use average height
let totalMeasuredHeight = 0;
let measuredCount = 0;
virtualScroll.itemMeasurements.forEach(height => {
totalMeasuredHeight += height;
measuredCount++;
});
const avgHeight = measuredCount > 0
? totalMeasuredHeight / measuredCount
: virtualScroll.itemHeight;
virtualScroll.itemHeight = avgHeight;
virtualScroll.containerHeight = virtualScroll.allItems.length * avgHeight;
virtualScroll.containerElement.style.height = `${virtualScroll.containerHeight}px`;
}
// Function to get visible range of items
function getVisibleRange() {
const scrollTop = scrollContainer.scrollTop;
const viewportHeight = scrollContainer.clientHeight;
// Calculate visible range with buffer
const startIndex = Math.max(0, Math.floor(scrollTop / virtualScroll.itemHeight) - virtualScroll.bufferSize);
const endIndex = Math.min(
virtualScroll.allItems.length - 1,
Math.ceil((scrollTop + viewportHeight) / virtualScroll.itemHeight) + virtualScroll.bufferSize
);
return { startIndex, endIndex };
}
// Update visible items based on scroll position
async function updateVisibleItems() {
if (virtualScroll.isUpdating) return;
virtualScroll.isUpdating = true;
// Get current visible range
const { startIndex, endIndex } = getVisibleRange();
// Set of items that should be visible
const shouldBeVisible = new Set();
// Track total height for accurate positioning
let currentOffset = 0;
let needHeightUpdate = false;
// Create or update visible items
for (let i = 0; i < virtualScroll.allItems.length; i++) {
const item = virtualScroll.allItems[i];
if (!item || !item.file_path) continue;
const itemId = item.file_path;
const knownHeight = virtualScroll.itemMeasurements.get(itemId) || virtualScroll.itemHeight;
// Update position based on known measurements
if (i > 0) {
currentOffset += knownHeight;
}
// Only create/position items in the visible range
if (i >= startIndex && i <= endIndex) {
shouldBeVisible.add(itemId);
// Create item if it doesn't exist
if (!virtualScroll.visibleItems.has(itemId)) {
const card = createCardFunction(item);
card.style.position = 'absolute';
card.style.top = `${currentOffset}px`;
card.style.left = '0';
card.style.right = '0';
card.style.width = '100%';
virtualScroll.containerElement.appendChild(card);
virtualScroll.visibleItems.set(itemId, card);
// Measure actual height after rendering
setTimeout(() => {
const actualHeight = measureCardHeight(card);
if (actualHeight !== knownHeight) {
virtualScroll.itemMeasurements.set(itemId, actualHeight);
needHeightUpdate = true;
window.requestAnimationFrame(updateVisibleItems);
}
}, 0);
} else {
// Update position of existing item
const card = virtualScroll.visibleItems.get(itemId);
card.style.top = `${currentOffset}px`;
}
}
}
// Remove items that shouldn't be visible anymore
for (const [itemId, element] of virtualScroll.visibleItems.entries()) {
if (!shouldBeVisible.has(itemId)) {
// Clean up resources like videos
const video = element.querySelector('video');
if (video) {
video.pause();
video.src = '';
video.load();
}
element.remove();
virtualScroll.visibleItems.delete(itemId);
}
}
// Update container height if needed
if (needHeightUpdate) {
updateContainerHeight();
}
// Check if we're near the end and need to load more
if (endIndex >= virtualScroll.allItems.length - 15 && !pageState.isLoading && pageState.hasMore) {
fetchMoreItems().then(newItems => {
virtualScroll.allItems = pageState.items || [];
updateContainerHeight();
updateVisibleItems();
});
}
virtualScroll.isUpdating = false;
}
// Debounced scroll handler
const handleScroll = debounce(() => {
requestAnimationFrame(updateVisibleItems);
}, 50);
// Set up event listeners
scrollContainer.addEventListener('scroll', handleScroll);
window.addEventListener('resize', debounce(() => {
updateVisibleItems();
}, 100));
// Store the handler for cleanup
state.scrollHandler = handleScroll;
// Initial update
updateContainerHeight();
updateVisibleItems();
// Run periodic updates to catch any rendering issues
state.scrollCheckInterval = setInterval(() => {
if (document.visibilityState === 'visible') {
updateVisibleItems();
}
}, 2000);
return virtualScroll;
}
// Helper to clean up virtual scroll resources
export function cleanupVirtualScroll() {
if (!state.virtualScroll) return;
// Clean up visible items
state.virtualScroll.visibleItems.forEach((element) => {
const video = element.querySelector('video');
if (video) {
video.pause();
video.src = '';
video.load();
}
element.remove();
});
state.virtualScroll.visibleItems.clear();
state.virtualScroll.containerElement.innerHTML = '';
// Remove scroll handler
if (state.scrollHandler) {
document.querySelector('.page-content').removeEventListener('scroll', state.scrollHandler);
state.scrollHandler = null;
}
// Clear interval
if (state.scrollCheckInterval) {
clearInterval(state.scrollCheckInterval);
state.scrollCheckInterval = null;
}
// Clear the state
state.virtualScroll = null;
}