diff --git a/static/js/utils/MasonryScroller.js b/static/js/utils/MasonryScroller.js index d99823ae..0a86088c 100644 --- a/static/js/utils/MasonryScroller.js +++ b/static/js/utils/MasonryScroller.js @@ -712,4 +712,388 @@ export class MasonryScroller { 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; + } } diff --git a/tests/frontend/utils/masonryScroller.test.js b/tests/frontend/utils/masonryScroller.test.js index 825a1188..4e979eda 100644 --- a/tests/frontend/utils/masonryScroller.test.js +++ b/tests/frontend/utils/masonryScroller.test.js @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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'; // jsdom does not always provide requestAnimationFrame; polyfill when missing @@ -21,6 +22,9 @@ function createItemFn() { const el = document.createElement('div'); const card = document.createElement('div'); card.className = 'model-card'; + const preview = document.createElement('div'); + preview.className = 'card-preview'; + card.appendChild(preview); el.appendChild(card); return el; } @@ -303,4 +307,295 @@ describe('MasonryScroller', () => { expect(grid.classList.contains('masonry-layout')).toBe(false); 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); + }); });