import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from './uiHelpers.js'; export class VirtualScroller { constructor(options) { // Configuration this.gridElement = options.gridElement; this.createItemFn = options.createItemFn; this.fetchItemsFn = options.fetchItemsFn; this.overscan = options.overscan || 5; // Extra items to render above/below viewport this.containerElement = options.containerElement || this.gridElement.parentElement; this.scrollContainer = options.scrollContainer || this.containerElement; this.batchSize = options.batchSize || 50; this.pageSize = options.pageSize || 100; this.itemAspectRatio = 896/1152; // Aspect ratio of cards this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px) // Add data windowing enable/disable flag this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false; // State this.items = []; // All items metadata this.renderedItems = new Map(); // Map of rendered DOM elements by index this.totalItems = 0; this.isLoading = false; this.hasMore = true; this.lastScrollTop = 0; this.scrollDirection = 'down'; this.lastRenderRange = { start: 0, end: 0 }; this.pendingScroll = null; this.resizeObserver = null; // Data windowing parameters this.windowSize = options.windowSize || 2000; // ±1000 items from current view this.windowPadding = options.windowPadding || 500; // Buffer before loading more this.dataWindow = { start: 0, end: 0 }; // Current data window indices this.absoluteWindowStart = 0; // Start index in absolute terms this.fetchingWindow = false; // Flag to track window fetching state // Responsive layout state this.itemWidth = 0; this.itemHeight = 0; this.columnsCount = 0; this.gridPadding = 12; // Gap between cards this.columnGap = 12; // Horizontal gap // Add loading timeout state this.loadingTimeout = null; this.loadingTimeoutDuration = options.loadingTimeoutDuration || 15000; // 15 seconds default // Initialize this.initializeContainer(); this.setupEventListeners(); this.calculateLayout(); } initializeContainer() { // Add virtual scroll class to grid this.gridElement.classList.add('virtual-scroll'); // Set the container to have relative positioning if (getComputedStyle(this.containerElement).position === 'static') { this.containerElement.style.position = 'relative'; } // Create a spacer element with the total height this.spacerElement = document.createElement('div'); this.spacerElement.className = 'virtual-scroll-spacer'; this.spacerElement.style.width = '100%'; this.spacerElement.style.height = '0px'; // Will be updated as items are loaded this.spacerElement.style.pointerEvents = 'none'; // The grid will be used for the actual visible items this.gridElement.style.position = 'relative'; this.gridElement.style.minHeight = '0'; // Place the spacer inside the grid container this.gridElement.appendChild(this.spacerElement); } calculateLayout() { // Get container width and style information const containerWidth = this.containerElement.clientWidth; const containerStyle = getComputedStyle(this.containerElement); const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0; const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0; // Calculate available content width (excluding padding) const availableContentWidth = containerWidth - paddingLeft - paddingRight; // Calculate ideal card width based on breakpoints let baseCardWidth = 260; // Default for 1080p // Adjust card width based on screen width if (window.innerWidth >= 3000) { // 4K baseCardWidth = 280; } else if (window.innerWidth >= 2000) { // 2K/1440p baseCardWidth = 270; } // Calculate how many columns can fit const maxGridWidth = window.innerWidth >= 3000 ? 2400 : // 4K window.innerWidth >= 2000 ? 1800 : // 2K 1400; // 1080p // Use the smaller of available content width or max grid width const actualGridWidth = Math.min(availableContentWidth, maxGridWidth); // Calculate column count based on available width and card width this.columnsCount = Math.max(1, Math.floor((actualGridWidth + this.columnGap) / (baseCardWidth + this.columnGap))); // Calculate actual item width this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount; // Calculate height based on aspect ratio this.itemHeight = this.itemWidth / this.itemAspectRatio; // Calculate the left offset to center the grid within the content area this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); // Log layout info console.log('Virtual Scroll Layout:', { containerWidth, availableContentWidth, actualGridWidth, columnsCount: this.columnsCount, itemWidth: this.itemWidth, itemHeight: this.itemHeight, leftOffset: this.leftOffset, paddingLeft, paddingRight, rowGap: this.rowGap // Log row gap for debugging }); // Update grid element max-width to match available width this.gridElement.style.maxWidth = `${actualGridWidth}px`; // Update spacer height this.updateSpacerHeight(); // Re-render with new layout this.clearRenderedItems(); this.scheduleRender(); return true; } setupEventListeners() { // Debounced scroll handler this.scrollHandler = this.debounce(() => this.handleScroll(), 10); this.scrollContainer.addEventListener('scroll', this.scrollHandler); // Window resize handler for layout recalculation this.resizeHandler = this.debounce(() => { this.calculateLayout(); }, 150); window.addEventListener('resize', this.resizeHandler); // Use ResizeObserver for more accurate container size detection if (typeof ResizeObserver !== 'undefined') { this.resizeObserver = new ResizeObserver(this.debounce(() => { this.calculateLayout(); }, 150)); this.resizeObserver.observe(this.containerElement); } } async initialize() { try { await this.loadInitialBatch(); this.scheduleRender(); } catch (err) { console.error('Failed to initialize virtual scroller:', err); showToast('Failed to load items', 'error'); } } async loadInitialBatch() { const pageState = getCurrentPageState(); if (this.isLoading) return; this.isLoading = true; this.setLoadingTimeout(); // Add loading timeout safety try { const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize); // Initialize the data window with the first batch of items this.items = items || []; this.totalItems = totalItems || 0; this.hasMore = hasMore; this.dataWindow = { start: 0, end: this.items.length }; this.absoluteWindowStart = 0; // Update the spacer height based on the total number of items this.updateSpacerHeight(); // Check if there are no items and show placeholder if needed if (this.items.length === 0) { this.showNoItemsPlaceholder(); } else { this.removeNoItemsPlaceholder(); } // Reset page state to sync with our virtual scroller pageState.currentPage = 2; // Next page to load would be 2 pageState.hasMore = this.hasMore; pageState.isLoading = false; return { items, totalItems, hasMore }; } catch (err) { console.error('Failed to load initial batch:', err); this.showNoItemsPlaceholder('Failed to load items. Please try refreshing the page.'); throw err; } finally { this.isLoading = false; this.clearLoadingTimeout(); // Clear the timeout } } async loadMoreItems() { const pageState = getCurrentPageState(); if (this.isLoading || !this.hasMore) return; this.isLoading = true; pageState.isLoading = true; this.setLoadingTimeout(); // Add loading timeout safety try { console.log('Loading more items, page:', pageState.currentPage); const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize); if (items && items.length > 0) { this.items = [...this.items, ...items]; this.hasMore = hasMore; pageState.hasMore = hasMore; // Update page for next request pageState.currentPage++; // Update the spacer height this.updateSpacerHeight(); // Render the newly loaded items if they're in view this.scheduleRender(); console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`); } else { this.hasMore = false; pageState.hasMore = false; console.log('No more items to load'); } return items; } catch (err) { console.error('Failed to load more items:', err); showToast('Failed to load more items', 'error'); } finally { this.isLoading = false; pageState.isLoading = false; this.clearLoadingTimeout(); // Clear the timeout } } // Add new methods for loading timeout setLoadingTimeout() { // Clear any existing timeout first this.clearLoadingTimeout(); // Set a new timeout to prevent loading state from getting stuck this.loadingTimeout = setTimeout(() => { if (this.isLoading) { console.warn('Loading timeout occurred. Resetting loading state.'); this.isLoading = false; const pageState = getCurrentPageState(); pageState.isLoading = false; } }, this.loadingTimeoutDuration); } clearLoadingTimeout() { if (this.loadingTimeout) { clearTimeout(this.loadingTimeout); this.loadingTimeout = null; } } updateSpacerHeight() { if (this.columnsCount === 0) return; // Calculate total rows needed based on total items and columns const totalRows = Math.ceil(this.totalItems / this.columnsCount); // Add row gaps to the total height calculation const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap; // Update spacer height to represent all items this.spacerElement.style.height = `${totalHeight}px`; } getVisibleRange() { const scrollTop = this.scrollContainer.scrollTop; const viewportHeight = this.scrollContainer.clientHeight; // Calculate the visible row range, accounting for row gaps const rowHeight = this.itemHeight + this.rowGap; const startRow = Math.floor(scrollTop / rowHeight); const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight); // Add overscan for smoother scrolling const overscanRows = this.overscan; const firstRow = Math.max(0, startRow - overscanRows); const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows); // Calculate item indices const firstIndex = firstRow * this.columnsCount; const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount); return { start: firstIndex, end: lastIndex }; } // Update the scheduleRender method to check for disabled state scheduleRender() { if (this.disabled || this.renderScheduled) return; this.renderScheduled = true; requestAnimationFrame(() => { this.renderItems(); this.renderScheduled = false; }); } // Update the renderItems method to check for disabled state renderItems() { if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return; const { start, end } = this.getVisibleRange(); // Check if render range has significantly changed const isSameRange = start >= this.lastRenderRange.start && end <= this.lastRenderRange.end && Math.abs(start - this.lastRenderRange.start) < 10; if (isSameRange) return; this.lastRenderRange = { start, end }; // Determine which items need to be added and removed const currentIndices = new Set(); for (let i = start; i < end && i < this.items.length; i++) { currentIndices.add(i); } // Remove items that are no longer visible for (const [index, element] of this.renderedItems.entries()) { if (!currentIndices.has(index)) { element.remove(); this.renderedItems.delete(index); } } // Use DocumentFragment for batch DOM operations const fragment = document.createDocumentFragment(); // Add new visible items to the fragment for (let i = start; i < end && i < this.items.length; i++) { if (!this.renderedItems.has(i)) { const item = this.items[i]; const element = this.createItemElement(item, i); fragment.appendChild(element); this.renderedItems.set(i, element); } } // Add the fragment to the grid (single DOM operation) if (fragment.childNodes.length > 0) { this.gridElement.appendChild(fragment); } // If we're close to the end and have more items to load, fetch them if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) { this.loadMoreItems(); } // Check if we need to slide the data window this.slideDataWindow(); } clearRenderedItems() { this.renderedItems.forEach(element => element.remove()); this.renderedItems.clear(); this.lastRenderRange = { start: 0, end: 0 }; } refreshWithData(items, totalItems, hasMore) { this.items = items || []; this.totalItems = totalItems || 0; this.hasMore = hasMore; this.updateSpacerHeight(); // Check if there are no items and show placeholder if needed if (this.items.length === 0) { this.showNoItemsPlaceholder(); } else { this.removeNoItemsPlaceholder(); } // Clear all rendered items and redraw this.clearRenderedItems(); this.scheduleRender(); } createItemElement(item, index) { // Create the DOM element const element = this.createItemFn(item); // Add virtual scroll item class element.classList.add('virtual-scroll-item'); // Calculate the position const row = Math.floor(index / this.columnsCount); const col = index % this.columnsCount; // Calculate precise positions with row gap included const topPos = row * (this.itemHeight + this.rowGap); // Position correctly with leftOffset (no need to add padding as absolute // positioning is already relative to the padding edge of the container) const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap)); // Position the element with absolute positioning element.style.position = 'absolute'; element.style.left = `${leftPos}px`; element.style.top = `${topPos}px`; element.style.width = `${this.itemWidth}px`; element.style.height = `${this.itemHeight}px`; return element; } handleScroll() { // Determine scroll direction const scrollTop = this.scrollContainer.scrollTop; this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; this.lastScrollTop = scrollTop; // Handle large jumps in scroll position - check if we need to fetch a new window const { scrollHeight } = this.scrollContainer; const scrollRatio = scrollTop / scrollHeight; // Only perform data windowing if the feature is enabled if (this.enableDataWindowing && this.totalItems > this.windowSize) { const estimatedIndex = Math.floor(scrollRatio * this.totalItems); const currentWindowStart = this.absoluteWindowStart; const currentWindowEnd = currentWindowStart + this.items.length; // If the estimated position is outside our current window by a significant amount if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) { // Fetch a new data window centered on the estimated position this.fetchDataWindow(Math.max(0, estimatedIndex - Math.floor(this.windowSize / 2))); return; // Skip normal rendering until new data is loaded } } // Render visible items this.scheduleRender(); // If we're near the bottom and have more items, load them const { clientHeight } = this.scrollContainer; const scrollBottom = scrollTop + clientHeight; // Fix the threshold calculation - use percentage of remaining height instead // We'll trigger loading when within 20% of the bottom of rendered content const remainingScroll = scrollHeight - scrollBottom; const scrollThreshold = Math.min( // Either trigger when within 20% of the total height from bottom scrollHeight * 0.2, // Or when within 2 rows of content from the bottom, whichever is larger (this.itemHeight + this.rowGap) * 2 ); const shouldLoadMore = remainingScroll <= scrollThreshold; if (shouldLoadMore && this.hasMore && !this.isLoading) { this.loadMoreItems(); } } // Method to fetch data for a specific window position async fetchDataWindow(targetIndex) { // Skip if data windowing is disabled or already fetching if (!this.enableDataWindowing || this.fetchingWindow) return; this.fetchingWindow = true; try { // Calculate which page we need to fetch based on target index const targetPage = Math.floor(targetIndex / this.pageSize) + 1; console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`); const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize); if (items && items.length > 0) { // Calculate new absolute window start this.absoluteWindowStart = (targetPage - 1) * this.pageSize; // Replace the entire data window with new items this.items = items; this.dataWindow = { start: 0, end: items.length }; this.totalItems = totalItems || 0; this.hasMore = hasMore; // Update the current page for future fetches const pageState = getCurrentPageState(); pageState.currentPage = targetPage + 1; pageState.hasMore = hasMore; // Update the spacer height and clear current rendered items this.updateSpacerHeight(); this.clearRenderedItems(); this.scheduleRender(); console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`); } } catch (err) { console.error('Failed to fetch data window:', err); showToast('Failed to load items at this position', 'error'); } finally { this.fetchingWindow = false; } } // Method to slide the data window if we're approaching its edges async slideDataWindow() { // Skip if data windowing is disabled if (!this.enableDataWindowing) return; const { start, end } = this.getVisibleRange(); const windowStart = this.dataWindow.start; const windowEnd = this.dataWindow.end; const absoluteIndex = this.absoluteWindowStart + windowStart; // Calculate the midpoint of the visible range const visibleMidpoint = Math.floor((start + end) / 2); const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint; // Check if we're too close to the window edges const closeToStart = start - windowStart < this.windowPadding; const closeToEnd = windowEnd - end < this.windowPadding; // If we're close to either edge and have total items > window size if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) { // Calculate a new target index centered around the current viewport const halfWindow = Math.floor(this.windowSize / 2); const targetIndex = Math.max(0, absoluteMidpoint - halfWindow); // Don't fetch a new window if we're already showing items near the beginning if (targetIndex === 0 && this.absoluteWindowStart === 0) { return; } // Don't fetch if we're showing the end of the list and are near the end if (this.absoluteWindowStart + this.items.length >= this.totalItems && this.totalItems - end < halfWindow) { return; } // Fetch the new data window await this.fetchDataWindow(targetIndex); } } reset() { // Remove all rendered items this.clearRenderedItems(); // Reset state this.items = []; this.totalItems = 0; this.hasMore = true; // Reset spacer height this.spacerElement.style.height = '0px'; // Remove any placeholder this.removeNoItemsPlaceholder(); // Schedule a re-render this.scheduleRender(); } dispose() { // Remove event listeners this.scrollContainer.removeEventListener('scroll', this.scrollHandler); window.removeEventListener('resize', this.resizeHandler); // Clean up the resize observer if present if (this.resizeObserver) { this.resizeObserver.disconnect(); } // Remove rendered elements this.clearRenderedItems(); // Remove spacer this.spacerElement.remove(); // Remove virtual scroll class this.gridElement.classList.remove('virtual-scroll'); // Clear any pending timeout this.clearLoadingTimeout(); } // Add methods to handle placeholder display showNoItemsPlaceholder(message) { // Remove any existing placeholder first this.removeNoItemsPlaceholder(); // Create placeholder message const placeholder = document.createElement('div'); placeholder.className = 'placeholder-message'; // Determine appropriate message based on page type let placeholderText = ''; if (message) { placeholderText = message; } else { const pageType = state.currentPageType; if (pageType === 'recipes') { placeholderText = `
No recipes found
Add recipe images to your recipes folder to see them here.
`; } else if (pageType === 'loras') { placeholderText = `No LoRAs found
Add LoRAs to your models folder to see them here.
`; } else if (pageType === 'checkpoints') { placeholderText = `No checkpoints found
Add checkpoints to your models folder to see them here.
`; } else { placeholderText = `No items found
Try adjusting your search filters or add more content.
`; } } placeholder.innerHTML = placeholderText; placeholder.id = 'virtualScrollPlaceholder'; // Append placeholder to the grid this.gridElement.appendChild(placeholder); } removeNoItemsPlaceholder() { const placeholder = document.getElementById('virtualScrollPlaceholder'); if (placeholder) { placeholder.remove(); } } // Utility method for debouncing debounce(func, wait) { let timeout; return function(...args) { const context = this; clearTimeout(timeout); timeout = setTimeout(() => func.apply(context, args), wait); }; } // Add disable method to stop rendering and events disable() { // Detach scroll event listener this.scrollContainer.removeEventListener('scroll', this.scrollHandler); // Clear all rendered items from the DOM this.clearRenderedItems(); // Hide the spacer element if (this.spacerElement) { this.spacerElement.style.display = 'none'; } // Flag as disabled this.disabled = true; console.log('Virtual scroller disabled'); } // Add enable method to resume rendering and events enable() { if (!this.disabled) return; // Reattach scroll event listener this.scrollContainer.addEventListener('scroll', this.scrollHandler); // Show the spacer element if (this.spacerElement) { this.spacerElement.style.display = 'block'; } // Flag as enabled this.disabled = false; // Re-render items this.scheduleRender(); console.log('Virtual scroller enabled'); } // New method to remove an item by file path removeItemByFilePath(filePath) { if (!filePath || this.disabled || this.items.length === 0) return false; // Find the index of the item with the matching file path const index = this.items.findIndex(item => item.file_path === filePath || item.filepath === filePath || item.path === filePath ); if (index === -1) { console.warn(`Item with file path ${filePath} not found in virtual scroller data`); return false; } // Remove the item from the data array this.items.splice(index, 1); // Decrement total count this.totalItems = Math.max(0, this.totalItems - 1); // Remove the item from rendered items if it exists if (this.renderedItems.has(index)) { this.renderedItems.get(index).remove(); this.renderedItems.delete(index); } // Shift all rendered items with higher indices down by 1 const indicesToUpdate = []; // Collect all indices that need to be updated for (const [idx, element] of this.renderedItems.entries()) { if (idx > index) { indicesToUpdate.push(idx); } } // Update the elements and map entries for (const idx of indicesToUpdate) { const element = this.renderedItems.get(idx); this.renderedItems.delete(idx); // The item is now at the previous index this.renderedItems.set(idx - 1, element); } // Update the spacer height to reflect the new total this.updateSpacerHeight(); // Re-render to ensure proper layout this.clearRenderedItems(); this.scheduleRender(); console.log(`Removed item with file path ${filePath} from virtual scroller data`); return true; } }