diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index aef11567..c2aaa6b1 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -6,6 +6,181 @@ import { NSFW_LEVELS } from '../utils/constants.js'; import { replacePreview, saveModelMetadata } from '../api/loraApi.js' import { showDeleteModal } from '../utils/modalUtils.js'; +// Add a global event delegation handler +export function setupLoraCardEventDelegation() { + const gridElement = document.getElementById('loraGrid'); + if (!gridElement) return; + + // Remove any existing event listener to prevent duplication + gridElement.removeEventListener('click', handleLoraCardEvent); + + // Add the event delegation handler + gridElement.addEventListener('click', handleLoraCardEvent); +} + +// Event delegation handler for all lora card events +function handleLoraCardEvent(event) { + // Find the closest card element + const card = event.target.closest('.lora-card'); + if (!card) return; + + // Handle specific elements within the card + if (event.target.closest('.toggle-blur-btn')) { + event.stopPropagation(); + toggleBlurContent(card); + return; + } + + if (event.target.closest('.show-content-btn')) { + event.stopPropagation(); + showBlurredContent(card); + return; + } + + if (event.target.closest('.fa-star')) { + event.stopPropagation(); + toggleFavorite(card); + return; + } + + if (event.target.closest('.fa-globe')) { + event.stopPropagation(); + if (card.dataset.from_civitai === 'true') { + openCivitai(card.dataset.name); + } + return; + } + + if (event.target.closest('.fa-copy')) { + event.stopPropagation(); + copyLoraCode(card); + return; + } + + if (event.target.closest('.fa-trash')) { + event.stopPropagation(); + showDeleteModal(card.dataset.filepath); + return; + } + + if (event.target.closest('.fa-image')) { + event.stopPropagation(); + replacePreview(card.dataset.filepath); + return; + } + + // If no specific element was clicked, handle the card click (show modal or toggle selection) + 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); + } +} + +// Helper functions for event handling +function toggleBlurContent(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'; + } +} + +function showBlurredContent(card) { + 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'; + } +} + +async function toggleFavorite(card) { + const starIcon = card.querySelector('.fa-star'); + 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'); + } +} + +async function copyLoraCode(card) { + const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); + const strength = usageTips.strength || 1; + const loraSyntax = ``; + + await copyToClipboard(loraSyntax, 'LoRA syntax copied'); +} + export function createLoraCard(lora) { const card = document.createElement('div'); card.className = 'lora-card'; @@ -123,162 +298,12 @@ 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 - if (state.bulkMode) { - const actions = card.querySelectorAll('.card-actions'); - actions.forEach(actionGroup => { - actionGroup.style.display = 'none'; - }); + // Add a special class for virtual scroll positioning if needed + if (state.virtualScroller) { + card.classList.add('virtual-scroll-item'); } - // Add autoplayOnHover handlers for video elements if needed + // Add video auto-play on hover functionality if needed const videoElement = card.querySelector('video'); if (videoElement && autoplayOnHover) { const cardPreview = card.querySelector('.card-preview'); @@ -287,20 +312,10 @@ export function createLoraCard(lora) { 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; - }); - } - - // Add a special class for virtual scroll positioning if needed - if (state.virtualScroller) { - card.classList.add('virtual-scroll-item'); + // Add mouse events to trigger play/pause using event attributes + // This approach reduces the number of event listeners created + cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()'); + cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}'); } return card; diff --git a/static/js/core.js b/static/js/core.js index fd045944..2f0c526c 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -9,6 +9,7 @@ import { exampleImagesManager } from './managers/ExampleImagesManager.js'; import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { migrateStorageItems } from './utils/storageHelpers.js'; +import { setupLoraCardEventDelegation } from './components/LoraCard.js'; // Core application class export class AppCore { @@ -63,6 +64,11 @@ export class AppCore { // Initialize lazy loading for images on all pages lazyLoadImages(); + // Setup event delegation for lora cards if on the loras page + if (pageType === 'loras') { + setupLoraCardEventDelegation(); + } + // Initialize virtual scroll for pages that need it if (['loras', 'recipes', 'checkpoints'].includes(pageType)) { initializeInfiniteScroll(pageType); diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index 92e76afd..8be0b288 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -27,6 +27,13 @@ export class VirtualScroller { this.pendingScroll = null; this.resizeObserver = null; + // Data windowing parameters + this.windowSize = options.windowSize || 2000; // ±1000 items from current view + this.windowPadding = options.windowPadding || 500; // Buffer before loading more + this.dataWindow = { start: 0, end: 0 }; // Current data window indices + this.absoluteWindowStart = 0; // Start index in absolute terms + this.fetchingWindow = false; // Flag to track window fetching state + // Responsive layout state this.itemWidth = 0; this.itemHeight = 0; @@ -176,9 +183,13 @@ export class VirtualScroller { try { const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize); + + // Initialize the data window with the first batch of items this.items = items || []; this.totalItems = totalItems || 0; this.hasMore = hasMore; + this.dataWindow = { start: 0, end: this.items.length }; + this.absoluteWindowStart = 0; // Update the spacer height based on the total number of items this.updateSpacerHeight(); @@ -337,20 +348,31 @@ export class VirtualScroller { } } - // Add new visible items + // Use DocumentFragment for batch DOM operations + const fragment = document.createDocumentFragment(); + + // Add new visible items to the fragment for (let i = start; i < end && i < this.items.length; i++) { if (!this.renderedItems.has(i)) { const item = this.items[i]; const element = this.createItemElement(item, i); - this.gridElement.appendChild(element); + fragment.appendChild(element); this.renderedItems.set(i, element); } } + // Add the fragment to the grid (single DOM operation) + if (fragment.childNodes.length > 0) { + this.gridElement.appendChild(fragment); + } + // If we're close to the end and have more items to load, fetch them if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) { this.loadMoreItems(); } + + // Check if we need to slide the data window + this.slideDataWindow(); } clearRenderedItems() { @@ -404,11 +426,30 @@ export class VirtualScroller { this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; this.lastScrollTop = scrollTop; + // Handle large jumps in scroll position - check if we need to fetch a new window + const { scrollHeight } = this.scrollContainer; + const scrollRatio = scrollTop / scrollHeight; + + // If we've jumped to a position that's significantly outside our current window + // and we know there are many items, fetch a new data window + if (this.totalItems > this.windowSize) { + const estimatedIndex = Math.floor(scrollRatio * this.totalItems); + const currentWindowStart = this.absoluteWindowStart; + const currentWindowEnd = currentWindowStart + this.items.length; + + // If the estimated position is outside our current window by a significant amount + if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) { + // Fetch a new data window centered on the estimated position + this.fetchDataWindow(Math.max(0, estimatedIndex - Math.floor(this.windowSize / 2))); + return; // Skip normal rendering until new data is loaded + } + } + // Render visible items this.scheduleRender(); // If we're near the bottom and have more items, load them - const { clientHeight, scrollHeight } = this.scrollContainer; + const { clientHeight } = this.scrollContainer; const scrollBottom = scrollTop + clientHeight; // Fix the threshold calculation - use percentage of remaining height instead @@ -423,24 +464,94 @@ export class VirtualScroller { const shouldLoadMore = remainingScroll <= scrollThreshold; - // Enhanced debugging - // console.log('Scroll metrics:', { - // scrollBottom, - // scrollHeight, - // remainingScroll, - // scrollThreshold, - // shouldLoad: shouldLoadMore, - // hasMore: this.hasMore, - // isLoading: this.isLoading, - // itemsLoaded: this.items.length, - // totalItems: this.totalItems - // }); - if (shouldLoadMore && this.hasMore && !this.isLoading) { this.loadMoreItems(); } } + // Method to fetch data for a specific window position + async fetchDataWindow(targetIndex) { + if (this.fetchingWindow) return; + this.fetchingWindow = true; + + try { + // Calculate which page we need to fetch based on target index + const targetPage = Math.floor(targetIndex / this.pageSize) + 1; + console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`); + + const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize); + + if (items && items.length > 0) { + // Calculate new absolute window start + this.absoluteWindowStart = (targetPage - 1) * this.pageSize; + + // Replace the entire data window with new items + this.items = items; + this.dataWindow = { + start: 0, + end: items.length + }; + + this.totalItems = totalItems || 0; + this.hasMore = hasMore; + + // Update the current page for future fetches + const pageState = getCurrentPageState(); + pageState.currentPage = targetPage + 1; + pageState.hasMore = hasMore; + + // Update the spacer height and clear current rendered items + this.updateSpacerHeight(); + this.clearRenderedItems(); + this.scheduleRender(); + + console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`); + } + } catch (err) { + console.error('Failed to fetch data window:', err); + showToast('Failed to load items at this position', 'error'); + } finally { + this.fetchingWindow = false; + } + } + + // Method to slide the data window if we're approaching its edges + async slideDataWindow() { + const { start, end } = this.getVisibleRange(); + const windowStart = this.dataWindow.start; + const windowEnd = this.dataWindow.end; + const absoluteIndex = this.absoluteWindowStart + windowStart; + + // Calculate the midpoint of the visible range + const visibleMidpoint = Math.floor((start + end) / 2); + const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint; + + // Check if we're too close to the window edges + const closeToStart = start - windowStart < this.windowPadding; + const closeToEnd = windowEnd - end < this.windowPadding; + + // If we're close to either edge and have total items > window size + if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) { + // Calculate a new target index centered around the current viewport + const halfWindow = Math.floor(this.windowSize / 2); + const targetIndex = Math.max(0, absoluteMidpoint - halfWindow); + + // Don't fetch a new window if we're already showing items near the beginning + if (targetIndex === 0 && this.absoluteWindowStart === 0) { + return; + } + + // Don't fetch if we're showing the end of the list and are near the end + if (this.absoluteWindowStart + this.items.length >= this.totalItems && + this.totalItems - end < halfWindow) { + return; + } + + // Fetch the new data window + await this.fetchDataWindow(targetIndex); + } + } + reset() { // Remove all rendered items this.clearRenderedItems(); diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index e11741f9..a3a41000 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -1,7 +1,7 @@ import { state, getCurrentPageState } from '../state/index.js'; import { debounce } from './debounce.js'; import { VirtualScroller } from './VirtualScroller.js'; -import { createLoraCard } from '../components/LoraCard.js'; +import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js'; import { fetchLorasPage } from '../api/loraApi.js'; import { showToast } from './uiHelpers.js'; @@ -73,6 +73,11 @@ export async function initializeInfiniteScroll(pageType = 'loras') { // Use virtual scrolling for all page types await initializeVirtualScroll(pageType); + + // Setup event delegation for lora cards if on the loras page + if (pageType === 'loras') { + setupLoraCardEventDelegation(); + } } async function initializeVirtualScroll(pageType) {