mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-09 07:20:15 -03:00
feat(recipes): complete MasonryScroller public API parity with VirtualScroller
This commit is contained in:
@@ -712,4 +712,388 @@ export class MasonryScroller {
|
|||||||
this.gridLoadingOverlay = null;
|
this.gridLoadingOverlay = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add disable method to stop rendering and events
|
||||||
|
disable() {
|
||||||
|
// Detach scroll event listener
|
||||||
|
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
||||||
|
|
||||||
|
// Clear all rendered items from the DOM
|
||||||
|
this.clearRenderedItems();
|
||||||
|
|
||||||
|
// Hide the spacer element
|
||||||
|
if (this.spacerElement) {
|
||||||
|
this.spacerElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag as disabled
|
||||||
|
this.disabled = true;
|
||||||
|
|
||||||
|
console.log('Masonry scroller disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enable method to resume rendering and events
|
||||||
|
enable() {
|
||||||
|
if (!this.disabled) return;
|
||||||
|
|
||||||
|
// Reattach scroll event listener
|
||||||
|
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
||||||
|
|
||||||
|
// Check if spacer element exists in the DOM, if not, recreate it
|
||||||
|
// (duplicates mode destroys it via gridElement.innerHTML = '')
|
||||||
|
if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
|
||||||
|
console.log('Spacer element not found in DOM, recreating it');
|
||||||
|
|
||||||
|
// Create a new spacer element
|
||||||
|
this.spacerElement = document.createElement('div');
|
||||||
|
this.spacerElement.className = 'virtual-scroll-spacer';
|
||||||
|
this.spacerElement.style.width = '100%';
|
||||||
|
this.spacerElement.style.height = '0px';
|
||||||
|
this.spacerElement.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Append it to the grid
|
||||||
|
this.gridElement.appendChild(this.spacerElement);
|
||||||
|
} else {
|
||||||
|
// Show the spacer element if it exists
|
||||||
|
this.spacerElement.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masonry needs a full synchronous re-placement on re-enable: column
|
||||||
|
// heights and the spacer height must be recomputed before rendering.
|
||||||
|
this._layoutItems();
|
||||||
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
|
// Flag as enabled
|
||||||
|
this.disabled = false;
|
||||||
|
|
||||||
|
// Re-render items
|
||||||
|
this.scheduleRender();
|
||||||
|
|
||||||
|
console.log('Masonry scroller enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for deep merging objects - only updates existing keys in target
|
||||||
|
deepMerge(target, source) {
|
||||||
|
if (!source || !target) return target;
|
||||||
|
|
||||||
|
// Initialize result with a copy of target
|
||||||
|
const result = { ...target };
|
||||||
|
|
||||||
|
if (!source) return result;
|
||||||
|
|
||||||
|
// Iterate over all keys in the source object
|
||||||
|
Object.keys(source).forEach(key => {
|
||||||
|
const targetValue = target[key];
|
||||||
|
const sourceValue = source[key];
|
||||||
|
|
||||||
|
// If both values are non-null objects and not arrays, merge recursively
|
||||||
|
if (
|
||||||
|
targetValue !== null &&
|
||||||
|
typeof targetValue === 'object' &&
|
||||||
|
!Array.isArray(targetValue) &&
|
||||||
|
sourceValue !== null &&
|
||||||
|
typeof sourceValue === 'object' &&
|
||||||
|
!Array.isArray(sourceValue)
|
||||||
|
) {
|
||||||
|
result[key] = this.deepMerge(targetValue || {}, sourceValue);
|
||||||
|
} else {
|
||||||
|
// Otherwise update with source value (includes primitives, arrays, and new keys)
|
||||||
|
result[key] = sourceValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSingleItem(filePath, updatedItem) {
|
||||||
|
if (!filePath || !updatedItem) {
|
||||||
|
console.error('Invalid parameters for updateSingleItem');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the index of the item with the matching file_path
|
||||||
|
const index = this.items.findIndex(item => item.file_path === filePath);
|
||||||
|
if (index === -1) {
|
||||||
|
console.warn(`Item with file path ${filePath} not found in masonry scroller data`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the item data using deep merge
|
||||||
|
this.items[index] = this.deepMerge(this.items[index], updatedItem);
|
||||||
|
|
||||||
|
// Full synchronous re-placement: width/height changes (e.g. preview
|
||||||
|
// re-fetched) must re-flow the affected column and everything after it.
|
||||||
|
this._layoutItems();
|
||||||
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
|
// If the item is currently rendered, update its DOM representation
|
||||||
|
if (this.renderedItems.has(index)) {
|
||||||
|
const element = this.renderedItems.get(index);
|
||||||
|
|
||||||
|
// Remove the old element
|
||||||
|
element.remove();
|
||||||
|
this.renderedItems.delete(index);
|
||||||
|
|
||||||
|
// Create and render the updated element
|
||||||
|
const updatedElement = this.createItemElement(this.items[index], index);
|
||||||
|
|
||||||
|
// Add update indicator visual effects
|
||||||
|
updatedElement.classList.add('updated');
|
||||||
|
|
||||||
|
// Add temporary update tag
|
||||||
|
const updateIndicator = document.createElement('div');
|
||||||
|
updateIndicator.className = 'update-indicator';
|
||||||
|
updateIndicator.textContent = 'Updated';
|
||||||
|
updatedElement.querySelector('.card-preview').appendChild(updateIndicator);
|
||||||
|
|
||||||
|
// Automatically remove the updated class after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
updatedElement.classList.remove('updated');
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
// Automatically remove the indicator after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
if (updateIndicator && updateIndicator.parentNode) {
|
||||||
|
updateIndicator.remove();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
this.renderedItems.set(index, updatedElement);
|
||||||
|
this.gridElement.appendChild(updatedElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove an item by file path
|
||||||
|
removeItemByFilePath(filePath) {
|
||||||
|
if (!filePath || this.disabled || this.items.length === 0) return false;
|
||||||
|
|
||||||
|
// Find the index of the item with the matching file path
|
||||||
|
const index = this.items.findIndex(item => item.file_path === filePath);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
console.warn(`Item with file path ${filePath} not found in masonry scroller data`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the item from the data array
|
||||||
|
this.items.splice(index, 1);
|
||||||
|
|
||||||
|
// Decrement total count
|
||||||
|
this.totalItems = Math.max(0, this.totalItems - 1);
|
||||||
|
|
||||||
|
// Full synchronous re-placement of all remaining items, then spacer
|
||||||
|
this._layoutItems();
|
||||||
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
|
// Re-render to ensure proper layout
|
||||||
|
this.clearRenderedItems();
|
||||||
|
this.scheduleRender();
|
||||||
|
|
||||||
|
console.log(`Removed item with file path ${filePath} from masonry scroller data`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove multiple items by their file paths.
|
||||||
|
* More efficient than calling removeItemByFilePath individually.
|
||||||
|
* @param {string[]} filePaths - Array of file paths to remove
|
||||||
|
* @returns {boolean} - True if any items were removed
|
||||||
|
*/
|
||||||
|
removeMultipleItemsByFilePath(filePaths) {
|
||||||
|
if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false;
|
||||||
|
|
||||||
|
// Build a set for fast lookup
|
||||||
|
const pathsToRemove = new Set(filePaths);
|
||||||
|
const originalLength = this.items.length;
|
||||||
|
|
||||||
|
// Filter out removed items; keep those not in the set
|
||||||
|
this.items = this.items.filter(item => !pathsToRemove.has(item.file_path));
|
||||||
|
|
||||||
|
const removedCount = originalLength - this.items.length;
|
||||||
|
if (removedCount === 0) return false;
|
||||||
|
|
||||||
|
this.totalItems = Math.max(0, this.totalItems - removedCount);
|
||||||
|
|
||||||
|
// Full synchronous re-placement of all remaining items, then spacer
|
||||||
|
this._layoutItems();
|
||||||
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
|
// Re-render to fill gaps left by removed items
|
||||||
|
this.clearRenderedItems();
|
||||||
|
this.scheduleRender();
|
||||||
|
|
||||||
|
console.log(`Removed ${removedCount} items from masonry scroller data`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add keyboard navigation methods
|
||||||
|
handlePageUpDown(direction) {
|
||||||
|
// Prevent duplicate animations by checking last trigger time
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.lastPageNavTime && now - this.lastPageNavTime < 300) {
|
||||||
|
return; // Ignore rapid repeated triggers
|
||||||
|
}
|
||||||
|
this.lastPageNavTime = now;
|
||||||
|
|
||||||
|
const scrollContainer = this.scrollContainer;
|
||||||
|
const viewportHeight = scrollContainer.clientHeight;
|
||||||
|
|
||||||
|
// Calculate scroll distance (one viewport minus 10% overlap for context)
|
||||||
|
const scrollDistance = viewportHeight * 0.9;
|
||||||
|
|
||||||
|
// Determine the new scroll position
|
||||||
|
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
|
||||||
|
|
||||||
|
// Remove any existing transition indicators
|
||||||
|
this.removeExistingTransitionIndicator();
|
||||||
|
|
||||||
|
// Scroll to the new position with smooth animation
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: newScrollTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force render after scrolling
|
||||||
|
setTimeout(() => this.renderItems(), 100);
|
||||||
|
setTimeout(() => this.renderItems(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to remove existing indicators
|
||||||
|
removeExistingTransitionIndicator() {
|
||||||
|
const existingIndicator = document.querySelector('.page-transition-indicator');
|
||||||
|
if (existingIndicator) {
|
||||||
|
existingIndicator.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToTop() {
|
||||||
|
this.removeExistingTransitionIndicator();
|
||||||
|
|
||||||
|
this.scrollContainer.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force render after scrolling
|
||||||
|
setTimeout(() => this.renderItems(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
this.removeExistingTransitionIndicator();
|
||||||
|
|
||||||
|
// Start loading all remaining pages to ensure content is available
|
||||||
|
this.loadRemainingPages().then(() => {
|
||||||
|
// After loading all content, scroll to the very bottom
|
||||||
|
const maxScroll = this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight;
|
||||||
|
this.scrollContainer.scrollTo({
|
||||||
|
top: maxScroll,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all remaining pages (used by End key navigation)
|
||||||
|
async loadRemainingPages() {
|
||||||
|
// If we're already at the end or loading, don't proceed
|
||||||
|
if (!this.hasMore || this.isLoading) return;
|
||||||
|
|
||||||
|
console.log('Loading all remaining pages for End key navigation...');
|
||||||
|
|
||||||
|
// Keep loading pages until we reach the end
|
||||||
|
while (this.hasMore && !this.isLoading) {
|
||||||
|
await this.loadMoreItems();
|
||||||
|
|
||||||
|
// Force render after each page load
|
||||||
|
this.renderItems();
|
||||||
|
|
||||||
|
// Small delay to prevent overwhelming the browser
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Finished loading all pages');
|
||||||
|
|
||||||
|
// Final render to ensure all content is displayed
|
||||||
|
this.renderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the index of an item by its file path.
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {number} index of the item or -1 when not found
|
||||||
|
*/
|
||||||
|
findIndexByFilePath(filePath) {
|
||||||
|
if (!filePath) return -1;
|
||||||
|
return this.items.findIndex(item => item.file_path === filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return navigation state for the given item.
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {{index: number, hasPrev: boolean, hasNext: boolean, loadedItems: number, totalItems: number}}
|
||||||
|
*/
|
||||||
|
getNavigationState(filePath) {
|
||||||
|
const index = this.findIndexByFilePath(filePath);
|
||||||
|
const hasPrev = index > 0;
|
||||||
|
const hasNext = index !== -1 && (index < this.items.length - 1 || this.hasMore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
hasPrev,
|
||||||
|
hasNext,
|
||||||
|
loadedItems: this.items.length,
|
||||||
|
totalItems: this.totalItems
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the adjacent item relative to the provided file path.
|
||||||
|
* When the target index falls outside the loaded items and more pages
|
||||||
|
* are available, this method will request additional pages until the
|
||||||
|
* target item is available or no more data exists.
|
||||||
|
* @param {string} filePath
|
||||||
|
* @param {'prev' | 'next'} direction
|
||||||
|
* @returns {Promise<{item: Object, index: number} | null>}
|
||||||
|
*/
|
||||||
|
async getAdjacentItemByFilePath(filePath, direction = 'next') {
|
||||||
|
const currentIndex = this.findIndexByFilePath(filePath);
|
||||||
|
if (currentIndex === -1) return null;
|
||||||
|
|
||||||
|
const offset = direction === 'prev' ? -1 : 1;
|
||||||
|
let targetIndex = currentIndex + offset;
|
||||||
|
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to load more items if needed to reach the target index
|
||||||
|
let safetyCounter = 0;
|
||||||
|
while (targetIndex >= this.items.length && this.hasMore && safetyCounter < 10) {
|
||||||
|
safetyCounter++;
|
||||||
|
const newItems = await this.loadMoreItems();
|
||||||
|
if (!newItems || newItems.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex < 0 || targetIndex >= this.items.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: this.items[targetIndex],
|
||||||
|
index: targetIndex
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data windowing is accepted for API parity but never enabled here;
|
||||||
|
// these stubs keep the public method surface identical to VirtualScroller.
|
||||||
|
async fetchDataWindow(targetIndex) {
|
||||||
|
if (!this.enableDataWindowing) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async slideDataWindow() {
|
||||||
|
if (!this.enableDataWindowing) return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { MasonryScroller } from '../../../static/js/utils/MasonryScroller.js';
|
import { MasonryScroller } from '../../../static/js/utils/MasonryScroller.js';
|
||||||
|
import { VirtualScroller } from '../../../static/js/utils/VirtualScroller.js';
|
||||||
import { getCurrentPageState, setCurrentPageType } from '../../../static/js/state/index.js';
|
import { getCurrentPageState, setCurrentPageType } from '../../../static/js/state/index.js';
|
||||||
|
|
||||||
// jsdom does not always provide requestAnimationFrame; polyfill when missing
|
// jsdom does not always provide requestAnimationFrame; polyfill when missing
|
||||||
@@ -21,6 +22,9 @@ function createItemFn() {
|
|||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'model-card';
|
card.className = 'model-card';
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'card-preview';
|
||||||
|
card.appendChild(preview);
|
||||||
el.appendChild(card);
|
el.appendChild(card);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -303,4 +307,295 @@ describe('MasonryScroller', () => {
|
|||||||
expect(grid.classList.contains('masonry-layout')).toBe(false);
|
expect(grid.classList.contains('masonry-layout')).toBe(false);
|
||||||
expect(grid.querySelector('.virtual-scroll-spacer')).toBeNull();
|
expect(grid.querySelector('.virtual-scroll-spacer')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes every VirtualScroller prototype method (API parity)', () => {
|
||||||
|
const virtualMethods = Object.getOwnPropertyNames(VirtualScroller.prototype);
|
||||||
|
const masonryMethods = new Set(Object.getOwnPropertyNames(MasonryScroller.prototype));
|
||||||
|
|
||||||
|
const missing = virtualMethods.filter((name) => !masonryMethods.has(name));
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the VirtualScroller property surface after construction', () => {
|
||||||
|
const { scroller } = track(createScroller());
|
||||||
|
|
||||||
|
const expectedProperties = [
|
||||||
|
'items',
|
||||||
|
'renderedItems',
|
||||||
|
'totalItems',
|
||||||
|
'hasMore',
|
||||||
|
'isLoading',
|
||||||
|
'gridElement',
|
||||||
|
'containerElement',
|
||||||
|
'scrollContainer',
|
||||||
|
'columnsCount',
|
||||||
|
'itemWidth',
|
||||||
|
'disabled',
|
||||||
|
'spacerElement',
|
||||||
|
'pageSize',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const prop of expectedProperties) {
|
||||||
|
expect(scroller[prop]).not.toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateSingleItem re-places items and shows the updated indicator on rendered cards', () => {
|
||||||
|
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
|
||||||
|
// Item 2 (height 124) sits in column 2 with items 3 and 5 stacked below it
|
||||||
|
const items = makeItems([
|
||||||
|
{ width: 100, height: 200 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 50 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
]);
|
||||||
|
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, items.length, false);
|
||||||
|
wrapper.scrollTop = 0;
|
||||||
|
scroller.overscan = 5;
|
||||||
|
scroller.renderItems();
|
||||||
|
|
||||||
|
const spacerBefore = scroller.spacerElement.style.height;
|
||||||
|
const colsBefore = scroller.positions.map((p) => p.col);
|
||||||
|
|
||||||
|
const result = scroller.updateSingleItem('/recipes/item-2.png', { width: 100, height: 150 });
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Item 2 height grew 124 -> 372, so the full synchronous re-placement
|
||||||
|
// re-flows every later item (item 3 moves from column 2 to column 1)
|
||||||
|
expect(scroller.positions[2].height).toBeCloseTo(ITEM_WIDTH * 1.5);
|
||||||
|
expect(scroller.positions.map((p) => p.col)).not.toEqual(colsBefore);
|
||||||
|
expect(scroller.spacerElement.style.height).not.toBe(spacerBefore);
|
||||||
|
|
||||||
|
// The re-placement equals a fresh full layout of the same items
|
||||||
|
const { scroller: reference } = track(createScroller({ items }));
|
||||||
|
reference.refreshWithData(scroller.items.slice(), items.length, false);
|
||||||
|
expect(scroller.positions.map((p) => p.col)).toEqual(reference.positions.map((p) => p.col));
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
expect(scroller.positions[i].top).toBeCloseTo(reference.positions[i].top);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rendered card was recreated in place with the update indicator
|
||||||
|
const updatedCard = grid.querySelector('.virtual-scroll-item.updated');
|
||||||
|
expect(updatedCard).not.toBeNull();
|
||||||
|
const indicator = updatedCard.querySelector('.update-indicator');
|
||||||
|
expect(indicator).not.toBeNull();
|
||||||
|
expect(indicator.textContent).toBe('Updated');
|
||||||
|
expect(updatedCard.querySelector('.card-preview').contains(indicator)).toBe(true);
|
||||||
|
expect(updatedCard.style.height).toBe(`${scroller.positions[2].height}px`);
|
||||||
|
expect(updatedCard.style.top).toBe(`${scroller.positions[2].top}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateSingleItem returns false for an unknown file path without throwing', () => {
|
||||||
|
const items = makeItems([{ width: 100, height: 100 }]);
|
||||||
|
const { scroller } = track(createScroller({ items }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, items.length, false);
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
let result;
|
||||||
|
expect(() => {
|
||||||
|
result = scroller.updateSingleItem('/recipes/does-not-exist.png', { title: 'x' });
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeItemByFilePath re-places the remaining items and decrements the total', () => {
|
||||||
|
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
|
||||||
|
const items = makeItems([
|
||||||
|
{ width: 100, height: 200 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 50 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
]);
|
||||||
|
const { scroller } = track(createScroller({ items }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, 60, false);
|
||||||
|
|
||||||
|
const result = scroller.removeItemByFilePath('/recipes/item-2.png');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(scroller.items.length).toBe(5);
|
||||||
|
expect(scroller.totalItems).toBe(59);
|
||||||
|
expect(scroller.positions.length).toBe(5);
|
||||||
|
|
||||||
|
// Remaining heights: 496, 248, 248, 248, 248 -> shortest-column placement
|
||||||
|
expect(scroller.positions.map((p) => p.col)).toEqual([0, 1, 2, 1, 2]);
|
||||||
|
expect(scroller.positions[3].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
|
||||||
|
expect(scroller.positions[4].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
|
||||||
|
|
||||||
|
// Spacer reflects the tallest remaining column
|
||||||
|
const maxColumnHeight = Math.max(...scroller.columnHeights);
|
||||||
|
const expected = maxColumnHeight - ROW_GAP + PAD_TOP + PAD_BOTTOM;
|
||||||
|
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeItemByFilePath returns false for an unknown file path', () => {
|
||||||
|
const items = makeItems([{ width: 100, height: 100 }]);
|
||||||
|
const { scroller } = track(createScroller({ items }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, items.length, false);
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
expect(scroller.removeItemByFilePath('/recipes/missing.png')).toBe(false);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeMultipleItemsByFilePath re-places items with no layout gaps', () => {
|
||||||
|
const items = makeItems([
|
||||||
|
{ width: 100, height: 200 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 50 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
]);
|
||||||
|
const { scroller } = track(createScroller({ items }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, 60, false);
|
||||||
|
|
||||||
|
const result = scroller.removeMultipleItemsByFilePath([
|
||||||
|
'/recipes/item-1.png',
|
||||||
|
'/recipes/item-3.png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(scroller.items.map((i) => i.file_path)).toEqual([
|
||||||
|
'/recipes/item-0.png',
|
||||||
|
'/recipes/item-2.png',
|
||||||
|
'/recipes/item-4.png',
|
||||||
|
'/recipes/item-5.png',
|
||||||
|
]);
|
||||||
|
expect(scroller.totalItems).toBe(58);
|
||||||
|
|
||||||
|
// The remaining items are laid out exactly as a fresh full placement:
|
||||||
|
// compare against a second scroller fed the same remaining items
|
||||||
|
const remaining = scroller.items.slice();
|
||||||
|
const { scroller: reference } = track(createScroller({ items: remaining }));
|
||||||
|
reference.refreshWithData(remaining, remaining.length, false);
|
||||||
|
|
||||||
|
expect(scroller.positions.map((p) => p.col)).toEqual(reference.positions.map((p) => p.col));
|
||||||
|
for (let i = 0; i < remaining.length; i++) {
|
||||||
|
expect(scroller.positions[i].top).toBeCloseTo(reference.positions[i].top);
|
||||||
|
expect(scroller.positions[i].left).toBeCloseTo(reference.positions[i].left);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeMultipleItemsByFilePath returns false when nothing matches', () => {
|
||||||
|
const items = makeItems([{ width: 100, height: 100 }]);
|
||||||
|
const { scroller } = track(createScroller({ items }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, items.length, false);
|
||||||
|
|
||||||
|
expect(scroller.removeMultipleItemsByFilePath(['/recipes/missing.png'])).toBe(false);
|
||||||
|
expect(scroller.removeMultipleItemsByFilePath([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disable stops rendering and enable recreates the spacer after innerHTML is cleared', async () => {
|
||||||
|
const items = makeItems([
|
||||||
|
{ width: 100, height: 200 },
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 50 },
|
||||||
|
]);
|
||||||
|
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, items.length, false);
|
||||||
|
wrapper.scrollTop = 0;
|
||||||
|
scroller.overscan = 5;
|
||||||
|
scroller.renderItems();
|
||||||
|
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(3);
|
||||||
|
|
||||||
|
scroller.disable();
|
||||||
|
|
||||||
|
expect(scroller.disabled).toBe(true);
|
||||||
|
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(0);
|
||||||
|
expect(scroller.spacerElement.style.display).toBe('none');
|
||||||
|
|
||||||
|
// Duplicates mode wipes the grid contents, destroying the spacer
|
||||||
|
grid.innerHTML = '';
|
||||||
|
expect(grid.contains(scroller.spacerElement)).toBe(false);
|
||||||
|
|
||||||
|
scroller.enable();
|
||||||
|
|
||||||
|
expect(scroller.disabled).toBe(false);
|
||||||
|
expect(grid.contains(scroller.spacerElement)).toBe(true);
|
||||||
|
expect(scroller.spacerElement.className).toBe('virtual-scroll-spacer');
|
||||||
|
|
||||||
|
// Full re-placement ran synchronously on re-enable
|
||||||
|
expect(scroller.positions.length).toBe(items.length);
|
||||||
|
|
||||||
|
// Rendering resumes after the scheduled rAF
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAdjacentItemByFilePath loads more pages when the target is beyond loaded items', async () => {
|
||||||
|
const page1 = [0, 1, 2].map((i) => ({
|
||||||
|
file_path: `/recipes/page1-${i}.png`,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}));
|
||||||
|
const page2 = [0, 1].map((i) => ({
|
||||||
|
file_path: `/recipes/page2-${i}.png`,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}));
|
||||||
|
const fetchMock = vi.fn(async () => ({ items: page2, totalItems: 5, hasMore: false }));
|
||||||
|
const { scroller } = track(createScroller({ fetchItemsFn: fetchMock }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(page1, 5, true);
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
const expectedPage = pageState.currentPage;
|
||||||
|
|
||||||
|
const result = await scroller.getAdjacentItemByFilePath('/recipes/page1-2.png', 'next');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expectedPage, scroller.pageSize);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result.index).toBe(3);
|
||||||
|
expect(result.item.file_path).toBe('/recipes/page2-0.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAdjacentItemByFilePath returns null at boundaries and for unknown paths', async () => {
|
||||||
|
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
|
||||||
|
const { scroller } = track(createScroller({ items }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, items.length, false);
|
||||||
|
|
||||||
|
await expect(scroller.getAdjacentItemByFilePath('/recipes/item-0.png', 'prev')).resolves.toBeNull();
|
||||||
|
await expect(scroller.getAdjacentItemByFilePath('/recipes/item-1.png', 'next')).resolves.toBeNull();
|
||||||
|
await expect(scroller.getAdjacentItemByFilePath('/recipes/missing.png', 'next')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getNavigationState reports index, prev/next availability and totals', () => {
|
||||||
|
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
|
||||||
|
const { scroller } = track(createScroller({ items }));
|
||||||
|
|
||||||
|
scroller.refreshWithData(items, 10, true);
|
||||||
|
|
||||||
|
expect(scroller.getNavigationState('/recipes/item-0.png')).toEqual({
|
||||||
|
index: 0,
|
||||||
|
hasPrev: false,
|
||||||
|
hasNext: true,
|
||||||
|
loadedItems: 2,
|
||||||
|
totalItems: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const last = scroller.getNavigationState('/recipes/item-1.png');
|
||||||
|
expect(last.index).toBe(1);
|
||||||
|
expect(last.hasPrev).toBe(true);
|
||||||
|
// hasMore keeps forward navigation available past the loaded window
|
||||||
|
expect(last.hasNext).toBe(true);
|
||||||
|
|
||||||
|
expect(scroller.getNavigationState('/recipes/missing.png').index).toBe(-1);
|
||||||
|
expect(scroller.findIndexByFilePath('/recipes/item-1.png')).toBe(1);
|
||||||
|
expect(scroller.findIndexByFilePath('')).toBe(-1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user