diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 1f4b3374..80fa3fb7 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -70,6 +70,16 @@ 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 ab10aae8..26c54458 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -6,192 +6,6 @@ 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'; @@ -222,12 +36,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 + // Apply selection state if in bulk mode and this card is in the selected set if (state.bulkMode && state.selectedLoras.has(lora.file_path)) { card.classList.add('selected'); } @@ -248,12 +62,12 @@ export function createLoraCard(lora) { nsfwText = "R-rated Content"; } - // Check if autoplayOnHover is enabled - const autoplayOnHover = state.global?.settings?.autoplayOnHover || false; + // Check if autoplayOnHover is enabled for video previews + 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 + // Get favorite status from the lora data const isFavorite = lora.favorite === true; card.innerHTML = ` @@ -262,7 +76,7 @@ export function createLoraCard(lora) { `` : - `${lora.model_name}` + `${lora.model_name}` }
${shouldBlur ? @@ -309,7 +123,154 @@ export function createLoraCard(lora) {
`; - // Apply bulk mode styling if needed + // 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 if (state.bulkMode) { const actions = card.querySelectorAll('.card-actions'); actions.forEach(actionGroup => { @@ -317,10 +278,30 @@ 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; } -// Update cards for bulk mode (keep this existing function) +// Add a method to update card appearance based on bulk mode export function updateCardsForBulkMode(isBulkMode) { // Update the state state.bulkMode = isBulkMode; diff --git a/static/js/core.js b/static/js/core.js index b16cf007..337674d9 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 { initializeVirtualScroll } from './utils/virtualScroll.js'; +import { initializeInfiniteScroll } from './utils/infiniteScroll.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 virtual scroll for pages that need it + // Initialize infinite scroll for pages that need it if (['loras', 'recipes', 'checkpoints'].includes(pageType)) { - initializeVirtualScroll(pageType); + initializeInfiniteScroll(pageType); } return this; @@ -81,4 +81,4 @@ document.addEventListener('DOMContentLoaded', () => { export const appCore = new AppCore(); // Export common utilities for global use -export { showToast, lazyLoadImages, initializeVirtualScroll }; \ No newline at end of file +export { showToast, lazyLoadImages, initializeInfiniteScroll }; \ No newline at end of file diff --git a/static/js/loras.js b/static/js/loras.js index f19bc378..5dcc9638 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, setupLoraCardEventDelegation } from './components/LoraCard.js'; +import { updateCardsForBulkMode } from './components/LoraCard.js'; import { bulkManager } from './managers/BulkManager.js'; import { DownloadManager } from './managers/DownloadManager.js'; import { moveManager } from './managers/MoveManager.js'; @@ -24,6 +24,7 @@ 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(); } @@ -56,16 +57,13 @@ class LoraPageManager { this.pageControls.initFolderTagsVisibility(); new LoraContextMenu(); - // Set up event delegation for lora cards - setupLoraCardEventDelegation(); - - // Initialize cards for current bulk mode state + // Initialize cards for current bulk mode state (should be false initially) updateCardsForBulkMode(state.bulkMode); // Initialize the bulk manager bulkManager.initialize(); - // Initialize common page features (lazy loading, virtual scroll) + // Initialize common page features (lazy loading, infinite scroll) appCore.initializePageFeatures(); } } diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 19792ea8..a9da3b64 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -92,68 +92,16 @@ export function showToast(message, type = 'info') { } export function lazyLoadImages() { - // Use a single observer for all images with data-src attribute - const observer = new IntersectionObserver((entries) => { + 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 }); - // 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 - }); - } + document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img)); } export function restoreFolderFilter() { diff --git a/static/js/utils/virtualScroll.js b/static/js/utils/virtualScroll.js deleted file mode 100644 index 02750a95..00000000 --- a/static/js/utils/virtualScroll.js +++ /dev/null @@ -1,312 +0,0 @@ -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; -}