Revert "refactor: streamline LoraCard event handling and implement virtual scrolling for improved performance"

This reverts commit 5dd8d905fa.
This commit is contained in:
Will Miao
2025-05-11 18:50:19 +08:00
parent d516f22159
commit ce7e422169
6 changed files with 195 additions and 570 deletions

View File

@@ -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,

View File

@@ -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 = `<lora:${card.dataset.file_name}:${strength}>`;
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) {
`<video ${videoAttrs}>
<source src="${versionedPreviewUrl}" type="video/mp4">
</video>` :
`<img data-src="${versionedPreviewUrl}" alt="${lora.model_name}">`
`<img src="${versionedPreviewUrl}" alt="${lora.model_name}">`
}
<div class="card-header">
${shouldBlur ?
@@ -309,7 +123,154 @@ export function createLoraCard(lora) {
</div>
`;
// 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 = `<lora:${card.dataset.file_name}:${strength}>`;
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;

View File

@@ -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 };
export { showToast, lazyLoadImages, initializeInfiniteScroll };

View File

@@ -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();
}
}

View File

@@ -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() {

View File

@@ -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;
}