From 4793f096af3e4c77b9bddc5def073381fe77ba5f Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 9 May 2025 15:42:56 +0800 Subject: [PATCH] update --- static/js/api/baseModelApi.js | 245 ++++++++++++++++++++- static/js/api/loraApi.js | 15 +- static/js/components/LoraCard.js | 352 ++++++++++++++++--------------- static/js/loras.js | 11 +- 4 files changed, 436 insertions(+), 187 deletions(-) diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 46c13a42..22353ba5 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1,13 +1,177 @@ // filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; -import { showDeleteModal, confirmDelete } from '../utils/modalUtils.js'; import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; /** * Shared functionality for handling models (loras and checkpoints) */ +// Virtual scrolling configuration +const VIRTUAL_SCROLL_CONFIG = { + MAX_DOM_CARDS: 300, // Maximum DOM elements to keep + BUFFER_SIZE: 20, // Extra items to render above/below viewport + CLEANUP_INTERVAL: 5000, // How often to check for cards to clean up (ms) +} + +// Track rendered items and all loaded items +const virtualScrollState = { + visibleItems: new Map(), // Track rendered items by filepath + allItems: [], // All data items loaded so far + observer: null, // IntersectionObserver for visibility tracking + cleanupTimer: null, // Timer for periodic cleanup + initialized: false // Whether virtual scrolling is initialized +} + +// Initialize virtual scrolling +function initVirtualScroll(modelType) { + if (virtualScrollState.initialized) return; + + const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid'; + const gridElement = document.getElementById(gridId); + if (!gridElement) return; + + // Create intersection observer to track visible cards + virtualScrollState.observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + const cardElement = entry.target; + const filepath = cardElement.dataset.filepath; + + if (entry.isIntersecting) { + // Load media for cards entering viewport + lazyLoadCardMedia(cardElement); + } else { + // Card is no longer visible + if (entry.boundingClientRect.top < -1000 || entry.boundingClientRect.top > window.innerHeight + 1000) { + // If card is far outside viewport, consider removing it + virtualScrollState.visibleItems.delete(filepath); + cleanupCardResources(cardElement); + cardElement.remove(); + } + } + }); + }, { + rootMargin: '500px', // Start loading when within 500px of viewport + threshold: 0 + }); + + // Set up periodic cleanup for DOM elements + virtualScrollState.cleanupTimer = setInterval(() => { + checkCardThreshold(modelType); + }, VIRTUAL_SCROLL_CONFIG.CLEANUP_INTERVAL); + + // Set up scroll event listener for loading more content + window.addEventListener('scroll', throttle(() => { + const scrollPosition = window.scrollY + window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + // If we're close to the bottom and not already loading, load more + if (scrollPosition > documentHeight - 1000) { + const pageState = getCurrentPageState(); + if (!pageState.isLoading && pageState.hasMore) { + // This will trigger loading more items using the existing pagination + const loadMoreFunction = modelType === 'checkpoint' ? + window.loadMoreCheckpoints : window.loadMoreLoras; + + if (typeof loadMoreFunction === 'function') { + loadMoreFunction(false, false); + } + } + } + }, 200)); + + virtualScrollState.initialized = true; +} + +// Clean up resources for a card +function cleanupCardResources(cardElement) { + try { + // Stop videos and free resources + const video = cardElement.querySelector('video'); + if (video) { + video.pause(); + video.src = ''; + video.load(); + } + + // Remove from observer + if (virtualScrollState.observer) { + virtualScrollState.observer.unobserve(cardElement); + } + } catch (e) { + console.error('Error cleaning up card resources:', e); + } +} + +// Lazy load media content in a card +function lazyLoadCardMedia(cardElement) { + // Lazy load images + const img = cardElement.querySelector('img[data-src]'); + if (img) { + img.src = img.dataset.src; + img.removeAttribute('data-src'); + } + + // Lazy load videos + const video = cardElement.querySelector('video[data-src]'); + if (video) { + video.src = video.dataset.src; + video.removeAttribute('data-src'); + + // Check if we should autoplay this video + const autoplayOnHover = state?.global?.settings?.autoplayOnHover || false; + + if (!autoplayOnHover) { + // If not in hover-only mode, autoplay videos when they enter viewport + video.muted = true; // Muted videos can autoplay without user interaction + video.play().catch(err => { + console.log("Could not autoplay video, likely due to browser policy:", err); + }); + } + } +} + +// Check if we need to clean up any cards +function checkCardThreshold(modelType) { + const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid'; + const cards = document.querySelectorAll(`#${gridId} .lora-card`); + + if (cards.length > VIRTUAL_SCROLL_CONFIG.MAX_DOM_CARDS) { + // We have more cards than our threshold, remove those far from viewport + const cardsToRemove = cards.length - VIRTUAL_SCROLL_CONFIG.MAX_DOM_CARDS; + console.log(`Cleaning up ${cardsToRemove} cards to maintain performance`); + + let removedCount = 0; + cards.forEach(card => { + if (removedCount >= cardsToRemove) return; + + const rect = card.getBoundingClientRect(); + // Remove cards that are far outside viewport + if (rect.bottom < -1000 || rect.top > window.innerHeight + 1000) { + const filepath = card.dataset.filepath; + virtualScrollState.visibleItems.delete(filepath); + cleanupCardResources(card); + card.remove(); + removedCount++; + } + }); + } +} + +// Utility function to throttle function calls +function throttle(func, limit) { + let inThrottle; + return function() { + const args = arguments; + const context = this; + if (!inThrottle) { + func.apply(context, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + } +} + // Generic function to load more models with pagination export async function loadMoreModels(options = {}) { const { @@ -26,13 +190,20 @@ export async function loadMoreModels(options = {}) { document.body.classList.add('loading'); try { - // Reset to first page if requested + // Initialize virtual scrolling if not already done + initVirtualScroll(modelType); + + // Reset pagination and state if requested if (resetPage) { pageState.currentPage = 1; - // Clear grid if resetting + + // Clear the grid and virtual scroll state const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid'; const grid = document.getElementById(gridId); if (grid) grid.innerHTML = ''; + + virtualScrollState.visibleItems.clear(); + virtualScrollState.allItems = []; } const params = new URLSearchParams({ @@ -135,10 +306,23 @@ export async function loadMoreModels(options = {}) { } else if (data.items.length > 0) { pageState.hasMore = pageState.currentPage < data.total_pages; - // Append model cards using the provided card creation function + // Add new items to our collection of all items + virtualScrollState.allItems = [...virtualScrollState.allItems, ...data.items]; + + // Create and append cards with optimized rendering data.items.forEach(model => { - const card = createCardFunction(model); + // Skip if we already have this card rendered + if (virtualScrollState.visibleItems.has(model.file_path)) return; + + // Create the card with lazy loading for media + const card = createOptimizedCard(model, createCardFunction); grid.appendChild(card); + + // Track this card and observe it + virtualScrollState.visibleItems.set(model.file_path, card); + if (virtualScrollState.observer) { + virtualScrollState.observer.observe(card); + } }); // Increment the page number AFTER successful loading @@ -160,6 +344,57 @@ export async function loadMoreModels(options = {}) { } } +// Create a card with optimizations for lazy loading media +function createOptimizedCard(model, createCardFunction) { + // Create the card using the original function + const card = createCardFunction(model); + + // Optimize image/video loading + const img = card.querySelector('img'); + if (img) { + // Replace src with data-src to defer loading + img.dataset.src = img.src; + img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; // Tiny transparent placeholder + } + + const video = card.querySelector('video'); + if (video) { + const source = video.querySelector('source'); + if (source) { + // Store the video source for lazy loading + video.dataset.src = source.src; + source.removeAttribute('src'); + } else if (video.src) { + // Handle direct src attribute + video.dataset.src = video.src; + video.removeAttribute('src'); + } + + // Save autoplay state but prevent autoplay until visible + if (video.hasAttribute('autoplay')) { + video.dataset.autoplay = 'true'; + video.removeAttribute('autoplay'); + } + } + + return card; +} + +// Clean up virtual scroll when page changes +export function cleanupVirtualScroll() { + if (virtualScrollState.observer) { + virtualScrollState.observer.disconnect(); + } + + if (virtualScrollState.cleanupTimer) { + clearInterval(virtualScrollState.cleanupTimer); + } + + virtualScrollState.visibleItems.clear(); + virtualScrollState.allItems = []; + virtualScrollState.initialized = false; +} + // Update folder tags in the UI export function updateFolderTags(folders) { const folderTagsContainer = document.querySelector('.folder-tags'); diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 80fa3fb7..01176446 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -1,4 +1,4 @@ -import { createLoraCard } from '../components/LoraCard.js'; +import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js'; import { loadMoreModels, resetAndReload as baseResetAndReload, @@ -45,6 +45,9 @@ export async function excludeLora(filePath) { } export async function loadMoreLoras(resetPage = false, updateFolders = false) { + // Make sure event delegation is set up + setupLoraCardEventDelegation(); + return loadMoreModels({ resetPage, updateFolders, @@ -70,16 +73,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..40b1c9e2 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -6,6 +6,184 @@ import { NSFW_LEVELS } from '../utils/constants.js'; import { replacePreview, saveModelMetadata } from '../api/loraApi.js' import { showDeleteModal } from '../utils/modalUtils.js'; +// Set up event delegation for all card interactions +export function setupLoraCardEventDelegation() { + const loraGrid = document.getElementById('loraGrid'); + if (!loraGrid) { + console.warn('Lora grid not found, will try to set up event delegation later'); + // Try again when DOM might be ready + setTimeout(setupLoraCardEventDelegation, 500); + return; + } + + // Remove existing event listener if any + const oldListener = loraGrid._cardClickListener; + if (oldListener) { + loraGrid.removeEventListener('click', oldListener); + } + + // Create and store the event listener + loraGrid._cardClickListener = (e) => { + // Find the card that was clicked + const card = e.target.closest('.lora-card'); + if (!card) return; + + // Handle various click targets + if (e.target.closest('.toggle-blur-btn') || e.target.closest('.show-content-btn')) { + e.stopPropagation(); + toggleCardBlur(card); + } else if (e.target.closest('.fa-star')) { + e.stopPropagation(); + toggleFavoriteStatus(card); + } else if (e.target.closest('.fa-globe') && card.dataset.from_civitai === 'true') { + e.stopPropagation(); + openCivitai(card.dataset.name); + } else if (e.target.closest('.fa-copy')) { + e.stopPropagation(); + copyCardLoraText(card); + } else if (e.target.closest('.fa-trash')) { + e.stopPropagation(); + showDeleteModal(card.dataset.filepath); + } else if (e.target.closest('.fa-image')) { + e.stopPropagation(); + replacePreview(card.dataset.filepath); + } else if (state.bulkMode) { + bulkManager.toggleCardSelection(card); + } else { + // Main card click - show modal + const loraMeta = getLoraDataFromCard(card); + showLoraModal(loraMeta); + } + }; + + console.log('Setting up event delegation for LoRA cards'); + // Add the event listener + loraGrid.addEventListener('click', loraGrid._cardClickListener); + + // Set up hover event delegation for video autoplay if needed + if (state.global?.settings?.autoplayOnHover) { + // Remove any existing handlers + if (loraGrid._mouseEnterListener) { + loraGrid.removeEventListener('mouseenter', loraGrid._mouseEnterListener, true); + } + if (loraGrid._mouseLeaveListener) { + loraGrid.removeEventListener('mouseleave', loraGrid._mouseLeaveListener, true); + } + + // Create and save the handlers + loraGrid._mouseEnterListener = (e) => { + const cardPreview = e.target.closest('.card-preview'); + if (!cardPreview) return; + + const video = cardPreview.querySelector('video'); + if (video) video.play().catch(() => {}); + }; + + loraGrid._mouseLeaveListener = (e) => { + const cardPreview = e.target.closest('.card-preview'); + if (!cardPreview) return; + + const video = cardPreview.querySelector('video'); + if (video) { + video.pause(); + video.currentTime = 0; + } + }; + + // Add the listeners + loraGrid.addEventListener('mouseenter', loraGrid._mouseEnterListener, true); + loraGrid.addEventListener('mouseleave', loraGrid._mouseLeaveListener, true); + } +} + +// Helper function to toggle blur state +function toggleCardBlur(card) { + const preview = card.querySelector('.card-preview'); + const isBlurred = preview.classList.toggle('blurred'); + const icon = card.querySelector('.toggle-blur-btn i'); + + if (icon) { + icon.className = isBlurred ? 'fas fa-eye' : 'fas fa-eye-slash'; + } + + const overlay = card.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = isBlurred ? 'flex' : 'none'; + } +} + +// Helper function to toggle favorite status +async function toggleFavoriteStatus(card) { + const starIcon = card.querySelector('.fa-star'); + if (!starIcon) return; + + 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'); + } +} + +// Helper function to copy LoRA syntax +async function copyCardLoraText(card) { + const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); + const strength = usageTips.strength || 1; + const loraSyntax = ``; + + await copyToClipboard(loraSyntax, 'LoRA syntax copied'); +} + +// Helper function to extract LoRA data from card +function getLoraDataFromCard(card) { + return { + 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 { + return JSON.parse(card.dataset.meta || '{}'); + } catch (e) { + console.error('Failed to parse civitai metadata:', e); + return {}; + } + })(), + tags: JSON.parse(card.dataset.tags || '[]'), + modelDescription: card.dataset.modelDescription || '' + }; +} + export function createLoraCard(lora) { const card = document.createElement('div'); card.className = 'lora-card'; @@ -65,7 +243,8 @@ export function createLoraCard(lora) { // 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'; + // Don't automatically play videos until visible + const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls muted loop'; // Get favorite status from the lora data const isFavorite = lora.favorite === true; @@ -76,8 +255,8 @@ export function createLoraCard(lora) { `` : - `${lora.model_name}` - } + `${lora.model_name} + `}
${shouldBlur ? `
`; - - // 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) { @@ -278,26 +310,6 @@ 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; } diff --git a/static/js/loras.js b/static/js/loras.js index 5dcc9638..79a562ba 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -2,13 +2,14 @@ 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'; import { LoraContextMenu } from './components/ContextMenu/index.js'; import { createPageControls } from './components/controls/index.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; +import { cleanupVirtualScroll } from './api/baseModelApi.js'; // Initialize the LoRA page class LoraPageManager { @@ -63,8 +64,16 @@ class LoraPageManager { // Initialize the bulk manager bulkManager.initialize(); + // Set up event delegation for card interactions + setupLoraCardEventDelegation(); + // Initialize common page features (lazy loading, infinite scroll) appCore.initializePageFeatures(); + + // Handle cleanup when page is unloaded + window.addEventListener('beforeunload', () => { + cleanupVirtualScroll(); + }); } }