import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from './uiHelpers.js'; /** * Column-based (masonry) virtual scroller. * * Mirrors the VirtualScroller constructor options and lifecycle, but places * items into the shortest column instead of a fixed row grid. Each item keeps * its natural aspect ratio (width/height provided by the backend); items * without dimensions fall back to the default 896:1152 card ratio. * * Synchronous layout contract: column placement (_layoutItems) and the spacer * height are computed synchronously inside loadInitialBatch/refreshWithData, * BEFORE scheduleRender's rAF callback. This guarantees the scroll container's * scrollHeight is correct within 2 rAFs so restoreScrollPosition * (infiniteScroll.js) can preserve the scroll position after refresh. */ export class MasonryScroller { 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; // Fallback aspect ratio of cards this.rowGap = options.rowGap || 20; // Vertical gap between items (default 20px) this.columnGap = options.columnGap || 12; // Horizontal gap between columns // Container padding properties this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS // Data windowing flag is accepted for API parity but never enabled here this.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.disabled = false; this.resizeObserver = null; // Masonry layout state this.columnsCount = 0; this.itemWidth = 0; this.positions = []; // Per-index placement: { col, top, left, width, height } this.columnHeights = []; // Accumulated height per column this.lastRenderRange = new Set(); // Indices rendered in the last pass // 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 and masonry classes to grid this.gridElement.classList.add('virtual-scroll'); this.gridElement.classList.add('masonry-layout'); // 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'; // Apply padding directly to ensure consistency this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`; this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`; // Place the spacer inside the grid container this.gridElement.appendChild(this.spacerElement); } calculateLayout() { const pageState = getCurrentPageState(); if (pageState.duplicatesMode) { return false; } // 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; // Get display density setting const displayDensity = state.global.settings?.display_density || 'default'; // Base gap between cards const baseGap = 12; this.columnGap = baseGap; // Define minimum card width based on density setting to ensure usability // Cards smaller than this become hard to interact with and view const minCardWidths = { 'default': 240, // Default: comfortable minimum 'medium': 200, // Medium: slightly smaller 'compact': 170 // Compact: smallest usable size }; const minCardWidth = minCardWidths[displayDensity] || 240; // Calculate maximum possible columns that fit in available width // Formula: maxColumns = floor((availableWidth + gap) / (minCardWidth + gap)) const maxPossibleColumns = Math.floor((availableContentWidth + this.columnGap) / (minCardWidth + this.columnGap)); // Ensure at least 1 column const maxColumns = Math.max(1, maxPossibleColumns); // Define preferred maximum columns based on display density and screen size // These are upper limits to prevent too many columns on ultra-wide screens let preferredMaxColumns; if (window.innerWidth >= 3000) { // 4K if (displayDensity === 'default') { preferredMaxColumns = 8; } else if (displayDensity === 'medium') { preferredMaxColumns = 10; } else { // compact preferredMaxColumns = 12; } } else if (window.innerWidth >= 2150) { // 2K/1440p if (displayDensity === 'default') { preferredMaxColumns = 6; } else if (displayDensity === 'medium') { preferredMaxColumns = 8; } else { // compact preferredMaxColumns = 10; } } else { // 1080p and smaller if (displayDensity === 'default') { preferredMaxColumns = 5; } else if (displayDensity === 'medium') { preferredMaxColumns = 6; } else { // compact preferredMaxColumns = 8; } } // Use the smaller of: max columns that fit, or preferred max // This ensures cards are never smaller than minCardWidth this.columnsCount = Math.min(maxColumns, preferredMaxColumns); // Calculate card width to perfectly fill available space // Formula: (availableWidth - totalGap) / columns const totalGap = (this.columnsCount - 1) * this.columnGap; this.itemWidth = (availableContentWidth - totalGap) / this.columnsCount; // Edge-to-edge layout: no offset, grid fills container const actualGridWidth = this.itemWidth * this.columnsCount + totalGap; // Update grid element to fill available width this.gridElement.style.maxWidth = `${actualGridWidth}px`; this.gridElement.style.width = `${actualGridWidth}px`; // Add or remove density classes for style adjustments this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density'); this.gridElement.classList.add(`${displayDensity}-density`); // Recompute placement and spacer synchronously, then re-render this._layoutItems(); this.updateSpacerHeight(); this.clearRenderedItems(); this.scheduleRender(); return true; } /** * Compute the display height of an item at the current column width. * Falls back to the default 896:1152 card ratio when dimensions are * missing (videos, missing previews, legacy data). */ _computeHeight(item) { if (item && item.width && item.height) { return this.itemWidth * item.height / item.width; } return this.itemWidth / this.itemAspectRatio; } /** * Place every loaded item into the shortest column. * Produces this.positions (per-index placement) and this.columnHeights. */ _layoutItems() { this.positions = []; this.columnHeights = []; if (this.columnsCount === 0) return; // All columns start at the container's top padding for (let c = 0; c < this.columnsCount; c++) { this.columnHeights.push(this.containerPaddingTop); } for (let i = 0; i < this.items.length; i++) { const item = this.items[i]; const height = this._computeHeight(item); // Find the shortest column (first wins on ties) let col = 0; for (let c = 1; c < this.columnsCount; c++) { if (this.columnHeights[c] < this.columnHeights[col]) { col = c; } } const top = this.columnHeights[col]; const left = col * (this.itemWidth + this.columnGap); this.positions.push({ col, top, left, width: this.itemWidth, height }); this.columnHeights[col] += height + this.rowGap; } } updateSpacerHeight() { if (this.columnsCount === 0) return; // Total height = tallest column minus the trailing row gap, plus padding const maxColumnHeight = this.columnHeights.length > 0 ? Math.max(...this.columnHeights) : 0; const spacerHeight = Math.max( 0, maxColumnHeight - this.rowGap + this.containerPaddingTop + this.containerPaddingBottom ); // Update spacer height to represent all items this.spacerElement.style.height = `${spacerHeight}px`; } 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 masonry scroller:', err); showToast('toast.virtual.loadFailed', {}, '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); this.items = items || []; this.totalItems = totalItems || 0; this.hasMore = hasMore; // Synchronous layout contract: placement and spacer height must be // computed here, before any rAF-driven render, so the scroll // container's scrollHeight is correct for restoreScrollPosition. this._layoutItems(); 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 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++; // Recompute placement and spacer synchronously this._layoutItems(); 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('toast.virtual.loadMoreFailed', {}, 'error'); } finally { this.isLoading = false; pageState.isLoading = false; this.clearLoadingTimeout(); // Clear the timeout } } // Loading timeout safety: never leave isLoading stuck on a hung fetch 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; } } /** * Return the Set of currently visible item indices. * An item is visible when its vertical span intersects the viewport * expanded by overscanH = overscan * itemWidth on both sides. */ getVisibleRange() { const scrollTop = this.scrollContainer.scrollTop; const viewportHeight = this.scrollContainer.clientHeight; const overscanH = this.overscan * this.itemWidth; const visible = new Set(); const minTop = scrollTop - overscanH; const maxTop = scrollTop + viewportHeight + overscanH; for (let i = 0; i < this.positions.length; i++) { const pos = this.positions[i]; if (pos.top < maxTop && pos.top + pos.height > minTop) { visible.add(i); } } return visible; } scheduleRender() { if (this.disabled || this.renderScheduled) return; this.renderScheduled = true; requestAnimationFrame(() => { this.renderItems(); this.renderScheduled = false; }); } renderItems() { if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return; const visible = this.getVisibleRange(); // Skip re-render when the visible set has not changed if (visible.size === this.lastRenderRange.size) { let identical = visible.size > 0; if (identical) { for (const index of visible) { if (!this.lastRenderRange.has(index)) { identical = false; break; } } } if (identical) return; } this.lastRenderRange = new Set(visible); // Remove items that are no longer visible for (const [index, element] of this.renderedItems.entries()) { if (!visible.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 (const index of visible) { if (index >= this.items.length) continue; if (!this.renderedItems.has(index)) { const item = this.items[index]; const element = this.createItemElement(item, index); fragment.appendChild(element); this.renderedItems.set(index, 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 let lastVisibleIndex = -1; for (const index of visible) { if (index > lastVisibleIndex) lastVisibleIndex = index; } if (lastVisibleIndex >= 0 && lastVisibleIndex >= this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) { this.loadMoreItems(); } } clearRenderedItems() { this.renderedItems.forEach(element => element.remove()); this.renderedItems.clear(); this.lastRenderRange = new Set(); } refreshWithData(items, totalItems, hasMore) { this.items = items || []; this.totalItems = totalItems || 0; this.hasMore = hasMore; // Synchronous layout contract: placement and spacer height are // computed before the rAF-driven render so scroll restoration works. this._layoutItems(); 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'); // Look up the precomputed masonry placement const pos = this.positions[index]; // Position the element with absolute positioning element.style.position = 'absolute'; element.style.left = `${pos.left}px`; element.style.top = `${pos.top}px`; element.style.width = `${pos.width}px`; element.style.height = `${pos.height}px`; // Remove max-width/min-width constraints from the model-card to allow // dynamic sizing. The card is either the item element itself (e.g. // RecipeCard returns the .model-card root) or a descendant (ModelCard). const modelCard = element.classList.contains('model-card') ? element : element.querySelector('.model-card'); if (modelCard) { modelCard.style.maxWidth = 'none'; modelCard.style.minWidth = '0'; } return element; } handleScroll() { // Determine scroll direction const scrollTop = this.scrollContainer.scrollTop; this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; this.lastScrollTop = scrollTop; // Render visible items this.scheduleRender(); // If we're near the bottom and have more items, load them const { clientHeight, scrollHeight } = this.scrollContainer; const scrollBottom = scrollTop + clientHeight; // Bottom of the tallest column's last item (columnHeights include the // trailing row gap after each item, so subtract it back out) const contentBottom = this.columnHeights.length > 0 ? Math.max(...this.columnHeights) - this.rowGap : 0; // Trigger when within 20% of the scroll height, or within two item // widths of the bottom, whichever threshold is smaller const scrollThreshold = Math.min( scrollHeight * 0.2, this.itemWidth * 2 ); const shouldLoadMore = scrollBottom >= contentBottom - scrollThreshold; if (shouldLoadMore && this.hasMore && !this.isLoading) { this.loadMoreItems(); } } reset() { // Remove all rendered items this.clearRenderedItems(); // Reset state this.items = []; this.totalItems = 0; this.hasMore = true; this.positions = []; this.columnHeights = []; // 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 any active grid loading overlay this.hideGridLoading(); // Remove rendered elements this.clearRenderedItems(); // Remove spacer this.spacerElement.remove(); // Remove virtual scroll and masonry classes this.gridElement.classList.remove('virtual-scroll'); this.gridElement.classList.remove('masonry-layout'); // Clear any pending timeout this.clearLoadingTimeout(); } // Placeholder display helpers 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); }; } /** * Show a grid-scoped loading indicator (replaces full-page overlay) * Only covers the card grid area, leaving header/sidebar unaffected. */ showGridLoading() { // Remove any stale overlay from a prior deferred hide (e.g. from final rAF) this.hideGridLoading(); const overlay = document.createElement('div'); overlay.className = 'grid-loading-overlay'; const spinner = document.createElement('div'); spinner.className = 'loading-spinner'; overlay.appendChild(spinner); this.gridElement.appendChild(overlay); this.gridLoadingOverlay = overlay; } /** * Hide the grid-scoped loading indicator. */ hideGridLoading() { if (this.gridLoadingOverlay) { this.gridLoadingOverlay.remove(); this.gridLoadingOverlay = null; } } // 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('Masonry 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); // Check if spacer element exists in the DOM, if not, recreate it // (duplicates mode destroys it via gridElement.innerHTML = '') if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) { console.log('Spacer element not found in DOM, recreating it'); // Create a new spacer element this.spacerElement = document.createElement('div'); this.spacerElement.className = 'virtual-scroll-spacer'; this.spacerElement.style.width = '100%'; this.spacerElement.style.height = '0px'; this.spacerElement.style.pointerEvents = 'none'; // Append it to the grid this.gridElement.appendChild(this.spacerElement); } else { // Show the spacer element if it exists this.spacerElement.style.display = 'block'; } // Masonry needs a full synchronous re-placement on re-enable: column // heights and the spacer height must be recomputed before rendering. this._layoutItems(); this.updateSpacerHeight(); // Flag as enabled this.disabled = false; // Re-render items this.scheduleRender(); console.log('Masonry scroller enabled'); } // Helper function for deep merging objects - only updates existing keys in target deepMerge(target, source) { if (!source || !target) return target; // Initialize result with a copy of target const result = { ...target }; if (!source) return result; // Iterate over all keys in the source object Object.keys(source).forEach(key => { const targetValue = target[key]; const sourceValue = source[key]; // If both values are non-null objects and not arrays, merge recursively if ( targetValue !== null && typeof targetValue === 'object' && !Array.isArray(targetValue) && sourceValue !== null && typeof sourceValue === 'object' && !Array.isArray(sourceValue) ) { result[key] = this.deepMerge(targetValue || {}, sourceValue); } else { // Otherwise update with source value (includes primitives, arrays, and new keys) result[key] = sourceValue; } }); return result; } updateSingleItem(filePath, updatedItem) { if (!filePath || !updatedItem) { console.error('Invalid parameters for updateSingleItem'); return false; } // Find the index of the item with the matching file_path const index = this.items.findIndex(item => item.file_path === filePath); if (index === -1) { console.warn(`Item with file path ${filePath} not found in masonry scroller data`); return false; } // Update the item data using deep merge this.items[index] = this.deepMerge(this.items[index], updatedItem); // Full synchronous re-placement: width/height changes (e.g. preview // re-fetched) must re-flow the affected column and everything after it. this._layoutItems(); this.updateSpacerHeight(); // If the item is currently rendered, update its DOM representation if (this.renderedItems.has(index)) { const element = this.renderedItems.get(index); // Remove the old element element.remove(); this.renderedItems.delete(index); // Create and render the updated element const updatedElement = this.createItemElement(this.items[index], index); // Add update indicator visual effects updatedElement.classList.add('updated'); // Add temporary update tag const updateIndicator = document.createElement('div'); updateIndicator.className = 'update-indicator'; updateIndicator.textContent = 'Updated'; updatedElement.querySelector('.card-preview').appendChild(updateIndicator); // Automatically remove the updated class after animation completes setTimeout(() => { updatedElement.classList.remove('updated'); }, 1500); // Automatically remove the indicator after animation completes setTimeout(() => { if (updateIndicator && updateIndicator.parentNode) { updateIndicator.remove(); } }, 2000); this.renderedItems.set(index, updatedElement); this.gridElement.appendChild(updatedElement); } return true; } // 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); if (index === -1) { console.warn(`Item with file path ${filePath} not found in masonry 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); // Full synchronous re-placement of all remaining items, then spacer this._layoutItems(); this.updateSpacerHeight(); // Re-render to ensure proper layout this.clearRenderedItems(); this.scheduleRender(); console.log(`Removed item with file path ${filePath} from masonry scroller data`); return true; } /** * Remove multiple items by their file paths. * More efficient than calling removeItemByFilePath individually. * @param {string[]} filePaths - Array of file paths to remove * @returns {boolean} - True if any items were removed */ removeMultipleItemsByFilePath(filePaths) { if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false; // Build a set for fast lookup const pathsToRemove = new Set(filePaths); const originalLength = this.items.length; // Filter out removed items; keep those not in the set this.items = this.items.filter(item => !pathsToRemove.has(item.file_path)); const removedCount = originalLength - this.items.length; if (removedCount === 0) return false; this.totalItems = Math.max(0, this.totalItems - removedCount); // Full synchronous re-placement of all remaining items, then spacer this._layoutItems(); this.updateSpacerHeight(); // Re-render to fill gaps left by removed items this.clearRenderedItems(); this.scheduleRender(); console.log(`Removed ${removedCount} items from masonry scroller data`); return true; } // Add keyboard navigation methods handlePageUpDown(direction) { // Prevent duplicate animations by checking last trigger time const now = Date.now(); if (this.lastPageNavTime && now - this.lastPageNavTime < 300) { return; // Ignore rapid repeated triggers } this.lastPageNavTime = now; const scrollContainer = this.scrollContainer; const viewportHeight = scrollContainer.clientHeight; // Calculate scroll distance (one viewport minus 10% overlap for context) const scrollDistance = viewportHeight * 0.9; // Determine the new scroll position const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance); // Remove any existing transition indicators this.removeExistingTransitionIndicator(); // Scroll to the new position with smooth animation scrollContainer.scrollTo({ top: newScrollTop, behavior: 'smooth' }); // Force render after scrolling setTimeout(() => this.renderItems(), 100); setTimeout(() => this.renderItems(), 300); } // Helper to remove existing indicators removeExistingTransitionIndicator() { const existingIndicator = document.querySelector('.page-transition-indicator'); if (existingIndicator) { existingIndicator.remove(); } } scrollToTop() { this.removeExistingTransitionIndicator(); this.scrollContainer.scrollTo({ top: 0, behavior: 'smooth' }); // Force render after scrolling setTimeout(() => this.renderItems(), 100); } scrollToBottom() { this.removeExistingTransitionIndicator(); // Start loading all remaining pages to ensure content is available this.loadRemainingPages().then(() => { // After loading all content, scroll to the very bottom const maxScroll = this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight; this.scrollContainer.scrollTo({ top: maxScroll, behavior: 'smooth' }); }); } // Load all remaining pages (used by End key navigation) async loadRemainingPages() { // If we're already at the end or loading, don't proceed if (!this.hasMore || this.isLoading) return; console.log('Loading all remaining pages for End key navigation...'); // Keep loading pages until we reach the end while (this.hasMore && !this.isLoading) { await this.loadMoreItems(); // Force render after each page load this.renderItems(); // Small delay to prevent overwhelming the browser await new Promise(resolve => setTimeout(resolve, 50)); } console.log('Finished loading all pages'); // Final render to ensure all content is displayed this.renderItems(); } /** * Find the index of an item by its file path. * @param {string} filePath * @returns {number} index of the item or -1 when not found */ findIndexByFilePath(filePath) { if (!filePath) return -1; return this.items.findIndex(item => item.file_path === filePath); } /** * Return navigation state for the given item. * @param {string} filePath * @returns {{index: number, hasPrev: boolean, hasNext: boolean, loadedItems: number, totalItems: number}} */ getNavigationState(filePath) { const index = this.findIndexByFilePath(filePath); const hasPrev = index > 0; const hasNext = index !== -1 && (index < this.items.length - 1 || this.hasMore); return { index, hasPrev, hasNext, loadedItems: this.items.length, totalItems: this.totalItems }; } /** * Get the adjacent item relative to the provided file path. * When the target index falls outside the loaded items and more pages * are available, this method will request additional pages until the * target item is available or no more data exists. * @param {string} filePath * @param {'prev' | 'next'} direction * @returns {Promise<{item: Object, index: number} | null>} */ async getAdjacentItemByFilePath(filePath, direction = 'next') { const currentIndex = this.findIndexByFilePath(filePath); if (currentIndex === -1) return null; const offset = direction === 'prev' ? -1 : 1; let targetIndex = currentIndex + offset; if (targetIndex < 0) { return null; } // Attempt to load more items if needed to reach the target index let safetyCounter = 0; while (targetIndex >= this.items.length && this.hasMore && safetyCounter < 10) { safetyCounter++; const newItems = await this.loadMoreItems(); if (!newItems || newItems.length === 0) { break; } } if (targetIndex < 0 || targetIndex >= this.items.length) { return null; } return { item: this.items[targetIndex], index: targetIndex }; } // Data windowing is accepted for API parity but never enabled here; // these stubs keep the public method surface identical to VirtualScroller. async fetchDataWindow(targetIndex) { if (!this.enableDataWindowing) return; } async slideDataWindow() { if (!this.enableDataWindowing) return; } }