From 5dd8d905fa990db645e5e05731f4b23b61dd5f56 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 9 May 2025 16:33:34 +0800 Subject: [PATCH] refactor: streamline LoraCard event handling and implement virtual scrolling for improved performance --- static/js/api/loraApi.js | 10 - static/js/components/LoraCard.js | 369 ++++++++++++++++--------------- static/js/core.js | 8 +- static/js/loras.js | 10 +- static/js/utils/uiHelpers.js | 56 ++++- static/js/utils/virtualScroll.js | 312 ++++++++++++++++++++++++++ 6 files changed, 570 insertions(+), 195 deletions(-) create mode 100644 static/js/utils/virtualScroll.js diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 80fa3fb7..1f4b3374 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -70,16 +70,6 @@ export async function replacePreview(filePath) { return replaceModelPreview(filePath, 'lora'); } -export function appendLoraCards(loras) { - const grid = document.getElementById('loraGrid'); - const sentinel = document.getElementById('scroll-sentinel'); - - loras.forEach(lora => { - const card = createLoraCard(lora); - grid.appendChild(card); - }); -} - export async function resetAndReload(updateFolders = false) { return baseResetAndReload({ updateFolders, diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index 26c54458..ab10aae8 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -6,6 +6,192 @@ import { NSFW_LEVELS } from '../utils/constants.js'; import { replacePreview, saveModelMetadata } from '../api/loraApi.js' import { showDeleteModal } from '../utils/modalUtils.js'; +// Global event delegation setup function +export function setupLoraCardEventDelegation() { + const loraGrid = document.getElementById('loraGrid'); + if (!loraGrid) return; + + // Remove any existing listeners (in case this runs multiple times) + if (loraGrid._hasEventDelegation) return; + + // Handle clicks on any element within the grid + loraGrid.addEventListener('click', (e) => { + const card = e.target.closest('.lora-card'); + if (!card) return; + + // Handle different elements within the card + if (e.target.closest('.fa-star')) { + handleFavoriteClick(e, card); + } else if (e.target.closest('.fa-globe')) { + handleCivitaiClick(e, card); + } else if (e.target.closest('.fa-copy')) { + handleCopyClick(e, card); + } else if (e.target.closest('.fa-trash')) { + handleDeleteClick(e, card); + } else if (e.target.closest('.fa-image')) { + handleReplacePreviewClick(e, card); + } else if (e.target.closest('.toggle-blur-btn')) { + handleToggleBlurClick(e, card); + } else if (e.target.closest('.show-content-btn')) { + handleShowContentClick(e, card); + } else if (state.bulkMode) { + // Handle bulk selection mode + bulkManager.toggleCardSelection(card); + } else { + // Default card click - show modal + handleCardClick(card); + } + }); + + // Handle video autoplay on hover if enabled + if (state.global?.settings?.autoplayOnHover) { + loraGrid.addEventListener('mouseenter', (e) => { + const card = e.target.closest('.lora-card'); + if (!card) return; + + const video = card.querySelector('video'); + if (video) video.play(); + }, true); + + loraGrid.addEventListener('mouseleave', (e) => { + const card = e.target.closest('.lora-card'); + if (!card) return; + + const video = card.querySelector('video'); + if (video) { + video.pause(); + video.currentTime = 0; + } + }, true); + } + + loraGrid._hasEventDelegation = true; +} + +// Helper functions for card interaction handling +function handleCardClick(card) { + try { + const loraMeta = { + sha256: card.dataset.sha256, + file_path: card.dataset.filepath, + model_name: card.dataset.name, + file_name: card.dataset.file_name, + folder: card.dataset.folder, + modified: card.dataset.modified, + file_size: card.dataset.file_size, + from_civitai: card.dataset.from_civitai === 'true', + base_model: card.dataset.base_model, + usage_tips: card.dataset.usage_tips, + notes: card.dataset.notes, + favorite: card.dataset.favorite === 'true', + civitai: JSON.parse(card.dataset.meta || '{}'), + tags: JSON.parse(card.dataset.tags || '[]'), + modelDescription: card.dataset.modelDescription || '' + }; + showLoraModal(loraMeta); + } catch (e) { + console.error('Error showing lora modal:', e); + } +} + +function handleFavoriteClick(e, card) { + e.stopPropagation(); + const starIcon = e.target.closest('.fa-star'); + const isFavorite = starIcon.classList.contains('fas'); + const newFavoriteState = !isFavorite; + + saveModelMetadata(card.dataset.filepath, { + favorite: newFavoriteState + }).then(() => { + // Update UI based on new state + if (newFavoriteState) { + starIcon.classList.remove('far'); + starIcon.classList.add('fas', 'favorite-active'); + starIcon.title = 'Remove from favorites'; + card.dataset.favorite = 'true'; + showToast('Added to favorites', 'success'); + } else { + starIcon.classList.remove('fas', 'favorite-active'); + starIcon.classList.add('far'); + starIcon.title = 'Add to favorites'; + card.dataset.favorite = 'false'; + showToast('Removed from favorites', 'success'); + } + }).catch(error => { + console.error('Failed to update favorite status:', error); + showToast('Failed to update favorite status', 'error'); + }); +} + +function handleCivitaiClick(e, card) { + e.stopPropagation(); + if (card.dataset.from_civitai === 'true') { + openCivitai(card.dataset.name); + } +} + +function handleCopyClick(e, card) { + e.stopPropagation(); + const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); + const strength = usageTips.strength || 1; + const loraSyntax = ``; + + copyToClipboard(loraSyntax, 'LoRA syntax copied'); +} + +function handleDeleteClick(e, card) { + e.stopPropagation(); + showDeleteModal(card.dataset.filepath); +} + +function handleReplacePreviewClick(e, card) { + e.stopPropagation(); + replacePreview(card.dataset.filepath); +} + +function handleToggleBlurClick(e, card) { + e.stopPropagation(); + toggleBlur(card); +} + +function handleShowContentClick(e, card) { + e.stopPropagation(); + const preview = card.querySelector('.card-preview'); + preview.classList.remove('blurred'); + + // Update the toggle button icon + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + + // Hide the overlay + const overlay = card.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } +} + +// Helper function to toggle blur +function toggleBlur(card) { + const preview = card.querySelector('.card-preview'); + const isBlurred = preview.classList.toggle('blurred'); + const icon = card.querySelector('.toggle-blur-btn i'); + + // Update the icon based on blur state + if (isBlurred) { + icon.className = 'fas fa-eye'; + } else { + icon.className = 'fas fa-eye-slash'; + } + + // Toggle the overlay visibility + const overlay = card.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = isBlurred ? 'flex' : 'none'; + } +} + export function createLoraCard(lora) { const card = document.createElement('div'); card.className = 'lora-card'; @@ -36,12 +222,12 @@ export function createLoraCard(lora) { card.dataset.nsfwLevel = nsfwLevel; // Determine if the preview should be blurred based on NSFW level and user settings - const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; + const shouldBlur = state.settings?.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; if (shouldBlur) { card.classList.add('nsfw-content'); } - // Apply selection state if in bulk mode and this card is in the selected set + // Apply selection state if in bulk mode if (state.bulkMode && state.selectedLoras.has(lora.file_path)) { card.classList.add('selected'); } @@ -62,12 +248,12 @@ export function createLoraCard(lora) { nsfwText = "R-rated Content"; } - // Check if autoplayOnHover is enabled for video previews - const autoplayOnHover = state.global.settings.autoplayOnHover || false; + // Check if autoplayOnHover is enabled + const autoplayOnHover = state.global?.settings?.autoplayOnHover || false; const isVideo = previewUrl.endsWith('.mp4'); const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop'; - // Get favorite status from the lora data + // Get favorite status const isFavorite = lora.favorite === true; card.innerHTML = ` @@ -76,7 +262,7 @@ export function createLoraCard(lora) { `` : - `${lora.model_name}` + `${lora.model_name}` }
${shouldBlur ? @@ -123,154 +309,7 @@ export function createLoraCard(lora) {
`; - // Main card click event - modified to handle bulk mode - card.addEventListener('click', () => { - // Check if we're in bulk mode - if (state.bulkMode) { - // Toggle selection using the bulk manager - bulkManager.toggleCardSelection(card); - } else { - // Normal behavior - show modal - const loraMeta = { - sha256: card.dataset.sha256, - file_path: card.dataset.filepath, - model_name: card.dataset.name, - file_name: card.dataset.file_name, - folder: card.dataset.folder, - modified: card.dataset.modified, - file_size: card.dataset.file_size, - from_civitai: card.dataset.from_civitai === 'true', - base_model: card.dataset.base_model, - usage_tips: card.dataset.usage_tips, - notes: card.dataset.notes, - favorite: card.dataset.favorite === 'true', - // Parse civitai metadata from the card's dataset - civitai: (() => { - try { - // Attempt to parse the JSON string - return JSON.parse(card.dataset.meta || '{}'); - } catch (e) { - console.error('Failed to parse civitai metadata:', e); - return {}; // Return empty object on error - } - })(), - tags: JSON.parse(card.dataset.tags || '[]'), - modelDescription: card.dataset.modelDescription || '' - }; - showLoraModal(loraMeta); - } - }); - - // Toggle blur button functionality - const toggleBlurBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBlurBtn) { - toggleBlurBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const preview = card.querySelector('.card-preview'); - const isBlurred = preview.classList.toggle('blurred'); - const icon = toggleBlurBtn.querySelector('i'); - - // Update the icon based on blur state - if (isBlurred) { - icon.className = 'fas fa-eye'; - } else { - icon.className = 'fas fa-eye-slash'; - } - - // Toggle the overlay visibility - const overlay = card.querySelector('.nsfw-overlay'); - if (overlay) { - overlay.style.display = isBlurred ? 'flex' : 'none'; - } - }); - } - - // Show content button functionality - const showContentBtn = card.querySelector('.show-content-btn'); - if (showContentBtn) { - showContentBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const preview = card.querySelector('.card-preview'); - preview.classList.remove('blurred'); - - // Update the toggle button icon - const toggleBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; - } - - // Hide the overlay - const overlay = card.querySelector('.nsfw-overlay'); - if (overlay) { - overlay.style.display = 'none'; - } - }); - } - - // Favorite button click event - card.querySelector('.fa-star')?.addEventListener('click', async e => { - e.stopPropagation(); - const starIcon = e.currentTarget; - const isFavorite = starIcon.classList.contains('fas'); - const newFavoriteState = !isFavorite; - - try { - // Save the new favorite state to the server - await saveModelMetadata(card.dataset.filepath, { - favorite: newFavoriteState - }); - - // Update the UI - if (newFavoriteState) { - starIcon.classList.remove('far'); - starIcon.classList.add('fas', 'favorite-active'); - starIcon.title = 'Remove from favorites'; - card.dataset.favorite = 'true'; - showToast('Added to favorites', 'success'); - } else { - starIcon.classList.remove('fas', 'favorite-active'); - starIcon.classList.add('far'); - starIcon.title = 'Add to favorites'; - card.dataset.favorite = 'false'; - showToast('Removed from favorites', 'success'); - } - } catch (error) { - console.error('Failed to update favorite status:', error); - showToast('Failed to update favorite status', 'error'); - } - }); - - // Copy button click event - card.querySelector('.fa-copy')?.addEventListener('click', async e => { - e.stopPropagation(); - const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); - const strength = usageTips.strength || 1; - const loraSyntax = ``; - - await copyToClipboard(loraSyntax, 'LoRA syntax copied'); - }); - - // Civitai button click event - if (lora.from_civitai) { - card.querySelector('.fa-globe')?.addEventListener('click', e => { - e.stopPropagation(); - openCivitai(lora.model_name); - }); - } - - // Delete button click event - card.querySelector('.fa-trash')?.addEventListener('click', e => { - e.stopPropagation(); - showDeleteModal(lora.file_path); - }); - - // Replace preview button click event - card.querySelector('.fa-image')?.addEventListener('click', e => { - e.stopPropagation(); - replacePreview(lora.file_path); - }); - - // Apply bulk mode styling if currently in bulk mode + // Apply bulk mode styling if needed if (state.bulkMode) { const actions = card.querySelectorAll('.card-actions'); actions.forEach(actionGroup => { @@ -278,30 +317,10 @@ export function createLoraCard(lora) { }); } - // Add autoplayOnHover handlers for video elements if needed - const videoElement = card.querySelector('video'); - if (videoElement && autoplayOnHover) { - const cardPreview = card.querySelector('.card-preview'); - - // Remove autoplay attribute and pause initially - videoElement.removeAttribute('autoplay'); - videoElement.pause(); - - // Add mouse events to trigger play/pause - cardPreview.addEventListener('mouseenter', () => { - videoElement.play(); - }); - - cardPreview.addEventListener('mouseleave', () => { - videoElement.pause(); - videoElement.currentTime = 0; - }); - } - return card; } -// Add a method to update card appearance based on bulk mode +// Update cards for bulk mode (keep this existing function) export function updateCardsForBulkMode(isBulkMode) { // Update the state state.bulkMode = isBulkMode; diff --git a/static/js/core.js b/static/js/core.js index 337674d9..b16cf007 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -7,7 +7,7 @@ import { HeaderManager } from './components/Header.js'; import { settingsManager } from './managers/SettingsManager.js'; import { exampleImagesManager } from './managers/ExampleImagesManager.js'; import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js'; -import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; +import { initializeVirtualScroll } from './utils/virtualScroll.js'; import { migrateStorageItems } from './utils/storageHelpers.js'; // Core application class @@ -63,9 +63,9 @@ export class AppCore { // Initialize lazy loading for images on all pages lazyLoadImages(); - // Initialize infinite scroll for pages that need it + // Initialize virtual scroll for pages that need it if (['loras', 'recipes', 'checkpoints'].includes(pageType)) { - initializeInfiniteScroll(pageType); + initializeVirtualScroll(pageType); } return this; @@ -81,4 +81,4 @@ document.addEventListener('DOMContentLoaded', () => { export const appCore = new AppCore(); // Export common utilities for global use -export { showToast, lazyLoadImages, initializeInfiniteScroll }; \ No newline at end of file +export { showToast, lazyLoadImages, initializeVirtualScroll }; \ No newline at end of file diff --git a/static/js/loras.js b/static/js/loras.js index 5dcc9638..f19bc378 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -2,7 +2,7 @@ import { appCore } from './core.js'; import { state } from './state/index.js'; import { showLoraModal, toggleShowcase, scrollToTop } from './components/loraModal/index.js'; import { loadMoreLoras } from './api/loraApi.js'; -import { updateCardsForBulkMode } from './components/LoraCard.js'; +import { updateCardsForBulkMode, setupLoraCardEventDelegation } from './components/LoraCard.js'; import { bulkManager } from './managers/BulkManager.js'; import { DownloadManager } from './managers/DownloadManager.js'; import { moveManager } from './managers/MoveManager.js'; @@ -24,7 +24,6 @@ class LoraPageManager { this.pageControls = createPageControls('loras'); // Expose necessary functions to the page that still need global access - // These will be refactored in future updates this._exposeRequiredGlobalFunctions(); } @@ -57,13 +56,16 @@ class LoraPageManager { this.pageControls.initFolderTagsVisibility(); new LoraContextMenu(); - // Initialize cards for current bulk mode state (should be false initially) + // Set up event delegation for lora cards + setupLoraCardEventDelegation(); + + // Initialize cards for current bulk mode state updateCardsForBulkMode(state.bulkMode); // Initialize the bulk manager bulkManager.initialize(); - // Initialize common page features (lazy loading, infinite scroll) + // Initialize common page features (lazy loading, virtual scroll) appCore.initializePageFeatures(); } } diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index a9da3b64..19792ea8 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -92,16 +92,68 @@ export function showToast(message, type = 'info') { } export function lazyLoadImages() { - const observer = new IntersectionObserver(entries => { + // Use a single observer for all images with data-src attribute + const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && entry.target.dataset.src) { + // Only set src when the image becomes visible entry.target.src = entry.target.dataset.src; + + // Once loaded, stop observing this image observer.unobserve(entry.target); + + // Handle load error by replacing with a fallback + entry.target.onerror = () => { + entry.target.src = '/loras_static/images/no-preview.png'; + }; } }); + }, { + rootMargin: '100px', // Load images a bit before they come into view + threshold: 0.1 }); - document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img)); + // Start observing all images with data-src attribute + document.querySelectorAll('img[data-src]').forEach(img => { + observer.observe(img); + }); + + // Store the observer in state to avoid multiple instances + if (state.imageObserver) { + state.imageObserver.disconnect(); + } + state.imageObserver = observer; + + // Add a mutation observer to handle dynamically added images + if (!state.mutationObserver) { + state.mutationObserver = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach(node => { + if (node.nodeType === 1) { // Element node + // Check for img[data-src] in the added node + const images = node.querySelectorAll + ? node.querySelectorAll('img[data-src]') + : []; + + images.forEach(img => observer.observe(img)); + + // Check if the node itself is an image with data-src + if (node.tagName === 'IMG' && node.dataset.src) { + observer.observe(node); + } + } + }); + } + }); + }); + + // Start observing the body for changes + state.mutationObserver.observe(document.body, { + childList: true, + subtree: true + }); + } } export function restoreFolderFilter() { diff --git a/static/js/utils/virtualScroll.js b/static/js/utils/virtualScroll.js new file mode 100644 index 00000000..02750a95 --- /dev/null +++ b/static/js/utils/virtualScroll.js @@ -0,0 +1,312 @@ +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; +}