mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-23 22:22:11 -03:00
checkpoint
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
/* 卡片网格布局 */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Adjusted from 320px */
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
||||
gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */
|
||||
margin-top: var(--space-2);
|
||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
||||
max-width: 1400px; /* Container width control */
|
||||
max-width: 1400px; /* Base container width */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -17,13 +17,14 @@
|
||||
border-radius: var(--border-radius-base);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: transform 160ms ease-out;
|
||||
aspect-ratio: 896/1152;
|
||||
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
|
||||
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||
max-width: 260px; /* Base size */
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
cursor: pointer; /* Added from recipe-card */
|
||||
display: flex; /* Added from recipe-card */
|
||||
flex-direction: column; /* Added from recipe-card */
|
||||
overflow: hidden; /* Add overflow hidden to contain children */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lora-card:hover {
|
||||
@@ -36,6 +37,30 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for 1440p screens (2K) */
|
||||
@media (min-width: 2000px) {
|
||||
.card-grid {
|
||||
max-width: 1800px; /* Increased for 2K screens */
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
}
|
||||
|
||||
.lora-card {
|
||||
max-width: 270px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments for 4K screens */
|
||||
@media (min-width: 3000px) {
|
||||
.card-grid {
|
||||
max-width: 2400px; /* Increased for 4K screens */
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.lora-card {
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1400px) {
|
||||
.card-grid {
|
||||
@@ -362,4 +387,43 @@
|
||||
padding: 2rem;
|
||||
background: var(--lora-surface-alt);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
/* Virtual scrolling specific styles - updated */
|
||||
.virtual-scroll-item {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
transition: transform 160ms ease-out;
|
||||
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
|
||||
padding: 6px; /* Add consistent padding on all sides */
|
||||
width: 100%; /* Allow width to be set by the VirtualScroller */
|
||||
}
|
||||
|
||||
.virtual-scroll-item:hover {
|
||||
transform: translateY(-2px); /* Keep hover effect */
|
||||
z-index: 1; /* Ensure hovered items appear above others */
|
||||
}
|
||||
|
||||
/* When using virtual scroll, adjust container */
|
||||
.card-grid.virtual-scroll {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
padding: 6px 0; /* Add top/bottom padding equivalent to card padding */
|
||||
height: auto;
|
||||
width: 100%;
|
||||
max-width: 1400px; /* Keep the max-width from original grid */
|
||||
}
|
||||
|
||||
/* For larger screens, allow more space for the cards */
|
||||
@media (min-width: 2000px) {
|
||||
.card-grid.virtual-scroll {
|
||||
max-width: 1800px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.card-grid.virtual-scroll {
|
||||
max-width: 2400px;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,19 @@
|
||||
z-index: var(--z-base);
|
||||
}
|
||||
|
||||
/* Responsive container for larger screens */
|
||||
@media (min-width: 2000px) {
|
||||
.container {
|
||||
max-width: 1800px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.container {
|
||||
max-width: 2400px;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -160,6 +160,125 @@ export async function loadMoreModels(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// New method for virtual scrolling fetch
|
||||
export async function fetchModelsPage(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
endpoint = '/api/loras'
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page,
|
||||
page_size: pageSize || pageState.pageSize || 20,
|
||||
sort_by: pageState.sortBy
|
||||
});
|
||||
|
||||
if (pageState.activeFolder !== null) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
|
||||
// Add favorites filter parameter if enabled
|
||||
if (pageState.showFavoritesOnly) {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
// Add active letter filter if set
|
||||
if (pageState.activeLetterFilter) {
|
||||
params.append('first_letter', pageState.activeLetterFilter);
|
||||
}
|
||||
|
||||
// Add search parameters if there's a search term
|
||||
if (pageState.filters?.search) {
|
||||
params.append('search', pageState.filters.search);
|
||||
params.append('fuzzy', 'true');
|
||||
|
||||
// Add search option parameters if available
|
||||
if (pageState.searchOptions) {
|
||||
params.append('search_filename', pageState.searchOptions.filename.toString());
|
||||
params.append('search_modelname', pageState.searchOptions.modelname.toString());
|
||||
if (pageState.searchOptions.tags !== undefined) {
|
||||
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||
}
|
||||
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Add filter parameters if active
|
||||
if (pageState.filters) {
|
||||
// Handle tags filters
|
||||
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||
// Checkpoints API expects individual 'tag' parameters, Loras API expects comma-separated 'tags'
|
||||
if (modelType === 'checkpoint') {
|
||||
pageState.filters.tags.forEach(tag => {
|
||||
params.append('tag', tag);
|
||||
});
|
||||
} else {
|
||||
params.append('tags', pageState.filters.tags.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle base model filters
|
||||
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
||||
if (modelType === 'checkpoint') {
|
||||
pageState.filters.baseModel.forEach(model => {
|
||||
params.append('base_model', model);
|
||||
});
|
||||
} else {
|
||||
params.append('base_models', pageState.filters.baseModel.join(','));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add model-specific parameters
|
||||
if (modelType === 'lora') {
|
||||
// Check for recipe-based filtering parameters from session storage
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
// Add hash filter parameter if present
|
||||
if (filterLoraHash) {
|
||||
params.append('lora_hash', filterLoraHash);
|
||||
}
|
||||
// Add multiple hashes filter if present
|
||||
else if (filterLoraHashes) {
|
||||
try {
|
||||
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
|
||||
params.append('lora_hashes', filterLoraHashes.join(','));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing lora hashes from session storage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
items: data.items,
|
||||
totalItems: data.total,
|
||||
totalPages: data.total_pages,
|
||||
currentPage: page,
|
||||
hasMore: page < data.total_pages,
|
||||
folders: data.folders
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${modelType}s:`, error);
|
||||
showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update folder tags in the UI
|
||||
export function updateFolderTags(folders) {
|
||||
const folderTagsContainer = document.querySelector('.folder-tags');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLoraCard } from '../components/LoraCard.js';
|
||||
import {
|
||||
loadMoreModels,
|
||||
fetchModelsPage,
|
||||
resetAndReload as baseResetAndReload,
|
||||
refreshModels as baseRefreshModels,
|
||||
deleteModel as baseDeleteModel,
|
||||
@@ -54,6 +55,22 @@ export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch loras with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
* @param {number} pageSize - Number of items per page
|
||||
* @returns {Promise<Object>} Object containing items, total count, and pagination info
|
||||
*/
|
||||
export async function fetchLorasPage(page = 1, pageSize = 50) {
|
||||
console.log('Fetching loras page:', page, pageSize);
|
||||
return fetchModelsPage({
|
||||
modelType: 'lora',
|
||||
page,
|
||||
pageSize,
|
||||
endpoint: '/api/loras'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCivitai() {
|
||||
return fetchCivitaiMetadata({
|
||||
modelType: 'lora',
|
||||
|
||||
@@ -298,6 +298,11 @@ export function createLoraCard(lora) {
|
||||
});
|
||||
}
|
||||
|
||||
// Add a special class for virtual scroll positioning if needed
|
||||
if (state.virtualScroller) {
|
||||
card.classList.add('virtual-scroll-item');
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { PageControls } from './PageControls.js';
|
||||
import { LorasControls } from './LorasControls.js';
|
||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||
import { refreshVirtualScroll } from '../../utils/infiniteScroll.js';
|
||||
|
||||
// Export the classes
|
||||
export { PageControls, LorasControls, CheckpointsControls };
|
||||
@@ -20,4 +21,17 @@ export function createPageControls(pageType) {
|
||||
console.error(`Unknown page type: ${pageType}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Example for a filter method:
|
||||
function applyFilter(filterType, value) {
|
||||
// ...existing filter logic...
|
||||
|
||||
// After filters are applied, refresh the virtual scroll if it exists
|
||||
if (state.virtualScroller) {
|
||||
refreshVirtualScroll();
|
||||
} else {
|
||||
// Fall back to existing reset and reload logic
|
||||
resetAndReload(true);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export class AppCore {
|
||||
// Initialize lazy loading for images on all pages
|
||||
lazyLoadImages();
|
||||
|
||||
// Initialize infinite scroll for pages that need it
|
||||
// Initialize virtual scroll for pages that need it
|
||||
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
|
||||
initializeInfiniteScroll(pageType);
|
||||
}
|
||||
|
||||
@@ -63,8 +63,14 @@ class LoraPageManager {
|
||||
// Initialize the bulk manager
|
||||
bulkManager.initialize();
|
||||
|
||||
// Initialize common page features (lazy loading, infinite scroll)
|
||||
// Initialize common page features (virtual scroll)
|
||||
appCore.initializePageFeatures();
|
||||
|
||||
// Add virtual scroll class to grid for CSS adjustments
|
||||
const loraGrid = document.getElementById('loraGrid');
|
||||
if (loraGrid && state.virtualScroller) {
|
||||
loraGrid.classList.add('virtual-scroll');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
411
static/js/utils/VirtualScroller.js
Normal file
411
static/js/utils/VirtualScroller.js
Normal file
@@ -0,0 +1,411 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from './uiHelpers.js';
|
||||
|
||||
export class VirtualScroller {
|
||||
constructor(options) {
|
||||
// Configuration
|
||||
this.gridElement = options.gridElement;
|
||||
this.createItemFn = options.createItemFn;
|
||||
this.fetchItemsFn = options.fetchItemsFn;
|
||||
this.overscan = options.overscan || 5; // Extra items to render above/below viewport
|
||||
this.containerElement = options.containerElement || this.gridElement.parentElement;
|
||||
this.batchSize = options.batchSize || 50;
|
||||
this.pageSize = options.pageSize || 100;
|
||||
this.itemAspectRatio = 896/1152; // Aspect ratio of cards
|
||||
|
||||
// State
|
||||
this.items = []; // All items metadata
|
||||
this.renderedItems = new Map(); // Map of rendered DOM elements by index
|
||||
this.totalItems = 0;
|
||||
this.isLoading = false;
|
||||
this.hasMore = true;
|
||||
this.lastScrollTop = 0;
|
||||
this.scrollDirection = 'down';
|
||||
this.lastRenderRange = { start: 0, end: 0 };
|
||||
this.pendingScroll = null;
|
||||
this.resizeObserver = null;
|
||||
|
||||
// Responsive layout state
|
||||
this.itemWidth = 0;
|
||||
this.itemHeight = 0;
|
||||
this.columnsCount = 0;
|
||||
this.gridPadding = 12; // Gap between cards
|
||||
this.columnGap = 12; // Horizontal gap
|
||||
|
||||
// Initialize
|
||||
this.initializeContainer();
|
||||
this.setupEventListeners();
|
||||
this.calculateLayout();
|
||||
}
|
||||
|
||||
initializeContainer() {
|
||||
// Add virtual scroll class to grid
|
||||
this.gridElement.classList.add('virtual-scroll');
|
||||
|
||||
// Set the container to have relative positioning
|
||||
if (getComputedStyle(this.containerElement).position === 'static') {
|
||||
this.containerElement.style.position = 'relative';
|
||||
}
|
||||
|
||||
// Create a spacer element with the total height
|
||||
this.spacerElement = document.createElement('div');
|
||||
this.spacerElement.className = 'virtual-scroll-spacer';
|
||||
this.spacerElement.style.width = '100%';
|
||||
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
|
||||
this.spacerElement.style.pointerEvents = 'none';
|
||||
|
||||
// The grid will be used for the actual visible items
|
||||
this.gridElement.style.position = 'relative';
|
||||
this.gridElement.style.minHeight = '0';
|
||||
|
||||
// Place the spacer inside the grid container
|
||||
this.gridElement.appendChild(this.spacerElement);
|
||||
}
|
||||
|
||||
calculateLayout() {
|
||||
// Get container width
|
||||
const containerWidth = this.containerElement.clientWidth;
|
||||
|
||||
// Calculate ideal card width based on breakpoints
|
||||
let baseCardWidth = 260; // Default for 1080p
|
||||
|
||||
// Adjust card width based on screen width
|
||||
if (window.innerWidth >= 3000) { // 4K
|
||||
baseCardWidth = 280;
|
||||
} else if (window.innerWidth >= 2000) { // 2K/1440p
|
||||
baseCardWidth = 270;
|
||||
}
|
||||
|
||||
// Calculate how many columns can fit
|
||||
const availableWidth = Math.min(
|
||||
containerWidth,
|
||||
window.innerWidth >= 3000 ? 2400 : // 4K
|
||||
window.innerWidth >= 2000 ? 1800 : // 2K
|
||||
1400 // 1080p
|
||||
);
|
||||
|
||||
// Calculate column count based on available width and card width
|
||||
this.columnsCount = Math.max(1, Math.floor((availableWidth + this.columnGap) / (baseCardWidth + this.columnGap)));
|
||||
|
||||
// Calculate actual item width based on container and column count
|
||||
this.itemWidth = (availableWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
|
||||
|
||||
// Calculate height based on aspect ratio
|
||||
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
||||
|
||||
// Calculate the left offset to center the grid
|
||||
this.leftOffset = Math.max(0, (containerWidth - availableWidth) / 2);
|
||||
|
||||
// Log layout info
|
||||
console.log('Virtual Scroll Layout:', {
|
||||
containerWidth,
|
||||
availableWidth,
|
||||
columnsCount: this.columnsCount,
|
||||
itemWidth: this.itemWidth,
|
||||
itemHeight: this.itemHeight,
|
||||
leftOffset: this.leftOffset
|
||||
});
|
||||
|
||||
// Update grid element max-width to match available width
|
||||
this.gridElement.style.maxWidth = `${availableWidth}px`;
|
||||
|
||||
// Update spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Re-render with new layout
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Debounced scroll handler
|
||||
this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
|
||||
this.containerElement.addEventListener('scroll', this.scrollHandler);
|
||||
|
||||
// Window resize handler for layout recalculation
|
||||
this.resizeHandler = this.debounce(() => {
|
||||
this.calculateLayout();
|
||||
}, 150);
|
||||
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
|
||||
// Use ResizeObserver for more accurate container size detection
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(this.debounce(() => {
|
||||
this.calculateLayout();
|
||||
}, 150));
|
||||
|
||||
this.resizeObserver.observe(this.containerElement);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
await this.loadInitialBatch();
|
||||
this.scheduleRender();
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize virtual scroller:', err);
|
||||
showToast('Failed to load items', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadInitialBatch() {
|
||||
const pageState = getCurrentPageState();
|
||||
if (this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
|
||||
this.items = items || [];
|
||||
this.totalItems = totalItems || 0;
|
||||
this.hasMore = hasMore;
|
||||
|
||||
// Update the spacer height based on the total number of items
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Reset page state to sync with our virtual scroller
|
||||
pageState.currentPage = 2; // Next page to load would be 2
|
||||
pageState.hasMore = this.hasMore;
|
||||
pageState.isLoading = false;
|
||||
|
||||
return { items, totalItems, hasMore };
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial batch:', err);
|
||||
this.isLoading = false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreItems() {
|
||||
const pageState = getCurrentPageState();
|
||||
if (this.isLoading || !this.hasMore) return;
|
||||
|
||||
this.isLoading = true;
|
||||
pageState.isLoading = true;
|
||||
|
||||
try {
|
||||
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
|
||||
|
||||
if (items && items.length > 0) {
|
||||
this.items = [...this.items, ...items];
|
||||
this.hasMore = hasMore;
|
||||
pageState.hasMore = hasMore;
|
||||
|
||||
// Update page for next request
|
||||
pageState.currentPage++;
|
||||
|
||||
// Update the spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Render the newly loaded items if they're in view
|
||||
this.scheduleRender();
|
||||
} else {
|
||||
this.hasMore = false;
|
||||
pageState.hasMore = false;
|
||||
}
|
||||
|
||||
return items;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more items:', err);
|
||||
showToast('Failed to load more items', 'error');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
pageState.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateSpacerHeight() {
|
||||
if (this.columnsCount === 0) return;
|
||||
|
||||
// Calculate total rows needed based on total items and columns
|
||||
const totalRows = Math.ceil(this.totalItems / this.columnsCount);
|
||||
const totalHeight = totalRows * this.itemHeight;
|
||||
|
||||
// Update spacer height to represent all items
|
||||
this.spacerElement.style.height = `${totalHeight}px`;
|
||||
}
|
||||
|
||||
getVisibleRange() {
|
||||
const scrollTop = this.containerElement.scrollTop;
|
||||
const viewportHeight = this.containerElement.clientHeight;
|
||||
|
||||
// Calculate the visible row range
|
||||
const startRow = Math.floor(scrollTop / this.itemHeight);
|
||||
const endRow = Math.ceil((scrollTop + viewportHeight) / this.itemHeight);
|
||||
|
||||
// Add overscan for smoother scrolling
|
||||
const overscanRows = this.overscan;
|
||||
const firstRow = Math.max(0, startRow - overscanRows);
|
||||
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
|
||||
|
||||
// Calculate item indices
|
||||
const firstIndex = firstRow * this.columnsCount;
|
||||
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
|
||||
|
||||
return { start: firstIndex, end: lastIndex };
|
||||
}
|
||||
|
||||
scheduleRender() {
|
||||
if (this.renderScheduled) return;
|
||||
|
||||
this.renderScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.renderItems();
|
||||
this.renderScheduled = false;
|
||||
});
|
||||
}
|
||||
|
||||
renderItems() {
|
||||
if (this.items.length === 0 || this.columnsCount === 0) return;
|
||||
|
||||
const { start, end } = this.getVisibleRange();
|
||||
|
||||
// Check if render range has significantly changed
|
||||
const isSameRange =
|
||||
start >= this.lastRenderRange.start &&
|
||||
end <= this.lastRenderRange.end &&
|
||||
Math.abs(start - this.lastRenderRange.start) < 10;
|
||||
|
||||
if (isSameRange) return;
|
||||
|
||||
this.lastRenderRange = { start, end };
|
||||
|
||||
// Determine which items need to be added and removed
|
||||
const currentIndices = new Set();
|
||||
for (let i = start; i < end && i < this.items.length; i++) {
|
||||
currentIndices.add(i);
|
||||
}
|
||||
|
||||
// Remove items that are no longer visible
|
||||
for (const [index, element] of this.renderedItems.entries()) {
|
||||
if (!currentIndices.has(index)) {
|
||||
element.remove();
|
||||
this.renderedItems.delete(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new visible items
|
||||
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);
|
||||
this.renderedItems.set(i, element);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
clearRenderedItems() {
|
||||
this.renderedItems.forEach(element => element.remove());
|
||||
this.renderedItems.clear();
|
||||
this.lastRenderRange = { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
refreshWithData(items, totalItems, hasMore) {
|
||||
this.items = items || [];
|
||||
this.totalItems = totalItems || 0;
|
||||
this.hasMore = hasMore;
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Clear all rendered items and redraw
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
createItemElement(item, index) {
|
||||
// Create the DOM element
|
||||
const element = this.createItemFn(item);
|
||||
|
||||
// Add virtual scroll item class
|
||||
element.classList.add('virtual-scroll-item');
|
||||
|
||||
// Calculate the position
|
||||
const row = Math.floor(index / this.columnsCount);
|
||||
const col = index % this.columnsCount;
|
||||
|
||||
// Calculate precise positions
|
||||
const topPos = row * this.itemHeight;
|
||||
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
|
||||
|
||||
// Position the element with absolute positioning
|
||||
element.style.position = 'absolute';
|
||||
element.style.left = `${leftPos}px`;
|
||||
element.style.top = `${topPos}px`;
|
||||
element.style.width = `${this.itemWidth}px`;
|
||||
element.style.height = `${this.itemHeight}px`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
handleScroll() {
|
||||
// Determine scroll direction
|
||||
const scrollTop = this.containerElement.scrollTop;
|
||||
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
|
||||
this.lastScrollTop = scrollTop;
|
||||
|
||||
// Render visible items
|
||||
this.scheduleRender();
|
||||
|
||||
// If we're near the bottom and have more items, load them
|
||||
const { clientHeight, scrollHeight } = this.containerElement;
|
||||
const scrollBottom = scrollTop + clientHeight;
|
||||
const scrollThreshold = scrollHeight - (this.itemHeight * this.overscan);
|
||||
|
||||
if (scrollBottom >= scrollThreshold && this.hasMore && !this.isLoading) {
|
||||
this.loadMoreItems();
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
// Remove all rendered items
|
||||
this.clearRenderedItems();
|
||||
|
||||
// Reset state
|
||||
this.items = [];
|
||||
this.totalItems = 0;
|
||||
this.hasMore = true;
|
||||
|
||||
// Reset spacer height
|
||||
this.spacerElement.style.height = '0px';
|
||||
|
||||
// Schedule a re-render
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// Remove event listeners
|
||||
this.containerElement.removeEventListener('scroll', this.scrollHandler);
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
|
||||
// Clean up the resize observer if present
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
// Remove rendered elements
|
||||
this.clearRenderedItems();
|
||||
|
||||
// Remove spacer
|
||||
this.spacerElement.remove();
|
||||
|
||||
// Remove virtual scroll class
|
||||
this.gridElement.classList.remove('virtual-scroll');
|
||||
}
|
||||
|
||||
// Utility method for debouncing
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
const context = this;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,63 @@
|
||||
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 { VirtualScroller } from './VirtualScroller.js';
|
||||
import { createLoraCard } from '../components/LoraCard.js';
|
||||
import { fetchLorasPage } from '../api/loraApi.js';
|
||||
import { showToast } from './uiHelpers.js';
|
||||
|
||||
export function initializeInfiniteScroll(pageType = 'loras') {
|
||||
// Clean up any existing observer
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
// Function to dynamically import the appropriate card creator based on page type
|
||||
async function getCardCreator(pageType) {
|
||||
if (pageType === 'loras') {
|
||||
return createLoraCard;
|
||||
} else if (pageType === 'recipes') {
|
||||
try {
|
||||
const { createRecipeCard } = await import('../components/RecipeCard.js');
|
||||
return createRecipeCard;
|
||||
} catch (err) {
|
||||
console.error('Failed to load recipe card creator:', err);
|
||||
return null;
|
||||
}
|
||||
} else if (pageType === 'checkpoints') {
|
||||
try {
|
||||
const { createCheckpointCard } = await import('../components/CheckpointCard.js');
|
||||
return createCheckpointCard;
|
||||
} catch (err) {
|
||||
console.error('Failed to load checkpoint card creator:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function to get the appropriate data fetcher based on page type
|
||||
async function getDataFetcher(pageType) {
|
||||
if (pageType === 'loras') {
|
||||
return fetchLorasPage;
|
||||
} else if (pageType === 'recipes') {
|
||||
try {
|
||||
const { fetchRecipesPage } = await import('../api/recipeApi.js');
|
||||
return fetchRecipesPage;
|
||||
} catch (err) {
|
||||
console.error('Failed to load recipe data fetcher:', err);
|
||||
return null;
|
||||
}
|
||||
} else if (pageType === 'checkpoints') {
|
||||
try {
|
||||
const { fetchCheckpointsPage } = await import('../api/checkpointApi.js');
|
||||
return fetchCheckpointsPage;
|
||||
} catch (err) {
|
||||
console.error('Failed to load checkpoint data fetcher:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function initializeInfiniteScroll(pageType = 'loras') {
|
||||
// Clean up any existing virtual scroller
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.dispose();
|
||||
state.virtualScroller = null;
|
||||
}
|
||||
|
||||
// Set the current page type
|
||||
@@ -20,106 +71,83 @@ export function initializeInfiniteScroll(pageType = 'loras') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the load more function and grid ID based on page type
|
||||
let loadMoreFunction;
|
||||
// Use virtual scrolling for all page types
|
||||
await initializeVirtualScroll(pageType);
|
||||
}
|
||||
|
||||
async function initializeVirtualScroll(pageType) {
|
||||
// Determine the grid ID based on page type
|
||||
let gridId;
|
||||
|
||||
switch (pageType) {
|
||||
case 'recipes':
|
||||
loadMoreFunction = () => {
|
||||
if (!pageState.isLoading && pageState.hasMore) {
|
||||
window.recipeManager.loadRecipes(false); // false to not reset pagination
|
||||
}
|
||||
};
|
||||
gridId = 'recipeGrid';
|
||||
break;
|
||||
case 'checkpoints':
|
||||
loadMoreFunction = () => {
|
||||
if (!pageState.isLoading && pageState.hasMore) {
|
||||
loadMoreCheckpoints(false); // false to not reset
|
||||
}
|
||||
};
|
||||
gridId = 'checkpointGrid';
|
||||
break;
|
||||
case 'loras':
|
||||
default:
|
||||
loadMoreFunction = () => {
|
||||
if (!pageState.isLoading && pageState.hasMore) {
|
||||
loadMoreLoras(false); // false to not reset
|
||||
}
|
||||
};
|
||||
gridId = 'loraGrid';
|
||||
break;
|
||||
}
|
||||
|
||||
const debouncedLoadMore = debounce(loadMoreFunction, 100);
|
||||
|
||||
const grid = document.getElementById(gridId);
|
||||
|
||||
if (!grid) {
|
||||
console.warn(`Grid with ID "${gridId}" not found for infinite scroll`);
|
||||
console.warn(`Grid with ID "${gridId}" not found for virtual scroll`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing sentinel
|
||||
const existingSentinel = document.getElementById('scroll-sentinel');
|
||||
if (existingSentinel) {
|
||||
existingSentinel.remove();
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
|
||||
if (!pageContent) {
|
||||
console.warn('Page content element not found for virtual scroll');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a sentinel element after the grid (not inside it)
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'scroll-sentinel';
|
||||
sentinel.style.width = '100%';
|
||||
sentinel.style.height = '20px';
|
||||
sentinel.style.visibility = 'hidden'; // Make it invisible but still affect layout
|
||||
|
||||
// Insert after grid instead of inside
|
||||
grid.parentNode.insertBefore(sentinel, grid.nextSibling);
|
||||
|
||||
// Create observer with appropriate settings, slightly different for checkpoints page
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: pageType === 'checkpoints' ? '0px 0px 200px 0px' : '0px 0px 100px 0px'
|
||||
};
|
||||
|
||||
// Initialize the observer
|
||||
state.observer = new IntersectionObserver((entries) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) {
|
||||
debouncedLoadMore();
|
||||
try {
|
||||
// Get the card creator and data fetcher for this page type
|
||||
const createCardFn = await getCardCreator(pageType);
|
||||
const fetchDataFn = await getDataFetcher(pageType);
|
||||
|
||||
if (!createCardFn || !fetchDataFn) {
|
||||
throw new Error(`Required components not available for ${pageType} page`);
|
||||
}
|
||||
}, observerOptions);
|
||||
|
||||
// Start observing
|
||||
state.observer.observe(sentinel);
|
||||
|
||||
// Clean up any existing scroll event listener
|
||||
if (state.scrollHandler) {
|
||||
window.removeEventListener('scroll', state.scrollHandler);
|
||||
state.scrollHandler = null;
|
||||
|
||||
// Initialize the virtual scroller
|
||||
state.virtualScroller = new VirtualScroller({
|
||||
gridElement: grid,
|
||||
containerElement: pageContent,
|
||||
createItemFn: createCardFn,
|
||||
fetchItemsFn: fetchDataFn,
|
||||
pageSize: 100
|
||||
});
|
||||
|
||||
// Initialize the virtual scroller
|
||||
await state.virtualScroller.initialize();
|
||||
|
||||
// Add grid class for CSS styling
|
||||
grid.classList.add('virtual-scroll');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error initializing virtual scroller for ${pageType}:`, error);
|
||||
showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error');
|
||||
|
||||
// Fallback: show a message in the grid
|
||||
grid.innerHTML = `
|
||||
<div class="placeholder-message">
|
||||
<h3>Failed to initialize ${pageType}</h3>
|
||||
<p>There was an error loading this page. Please try reloading.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add a simple backup scroll handler
|
||||
const handleScroll = debounce(() => {
|
||||
if (pageState.isLoading || !pageState.hasMore) return;
|
||||
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
if (!sentinel) return;
|
||||
|
||||
const rect = sentinel.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (rect.top < windowHeight + 200) {
|
||||
debouncedLoadMore();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
state.scrollHandler = handleScroll;
|
||||
window.addEventListener('scroll', state.scrollHandler);
|
||||
|
||||
// Clear any existing interval
|
||||
if (state.scrollCheckInterval) {
|
||||
clearInterval(state.scrollCheckInterval);
|
||||
state.scrollCheckInterval = null;
|
||||
}
|
||||
|
||||
// Export a method to refresh the virtual scroller when filters change
|
||||
export function refreshVirtualScroll() {
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.reset();
|
||||
state.virtualScroller.initialize();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user