diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 8efcd916..ea974d36 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -1,6 +1,7 @@ import { RecipeCard } from '../components/RecipeCard.js'; import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; +import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js'; const RECIPE_ENDPOINTS = { list: '/api/lm/recipes', @@ -182,10 +183,12 @@ export async function resetAndReloadWithVirtualScroll(options = {}) { const { modelType = 'lora', updateFolders = false, - fetchPageFunction + fetchPageFunction, + preserveScroll = false } = options; const pageState = getCurrentPageState(); + const scrollSnapshot = preserveScroll ? captureScrollPosition() : null; try { pageState.isLoading = true; @@ -207,6 +210,10 @@ export async function resetAndReloadWithVirtualScroll(options = {}) { pageState.hasMore = result.hasMore; pageState.currentPage = 2; // Next page will be 2 + if (scrollSnapshot) { + await restoreScrollPosition(scrollSnapshot); + } + return result; } catch (error) { console.error(`Error reloading ${modelType}s:`, error); @@ -227,10 +234,12 @@ export async function loadMoreWithVirtualScroll(options = {}) { modelType = 'lora', resetPage = false, updateFolders = false, - fetchPageFunction + fetchPageFunction, + preserveScroll = false } = options; const pageState = getCurrentPageState(); + const scrollSnapshot = preserveScroll ? captureScrollPosition() : null; try { // Start loading state @@ -255,6 +264,10 @@ export async function loadMoreWithVirtualScroll(options = {}) { pageState.hasMore = result.hasMore; pageState.currentPage = 2; // Next page to load would be 2 + if (scrollSnapshot) { + await restoreScrollPosition(scrollSnapshot); + } + return result; } catch (error) { console.error(`Error loading ${modelType}s:`, error); @@ -270,11 +283,12 @@ export async function loadMoreWithVirtualScroll(options = {}) { * @param {boolean} updateFolders - Whether to update folder tags * @returns {Promise} The fetch result */ -export async function resetAndReload(updateFolders = false) { +export async function resetAndReload(updateFolders = false, options = {}) { return resetAndReloadWithVirtualScroll({ modelType: 'recipe', updateFolders, - fetchPageFunction: fetchRecipesPage + fetchPageFunction: fetchRecipesPage, + preserveScroll: options.preserveScroll === true }); } @@ -286,7 +300,7 @@ export async function syncChanges() { state.loadingManager.showSimpleLoading('Syncing changes...'); // Simply reload the recipes without rebuilding cache - await resetAndReload(); + await resetAndReload(false, { preserveScroll: true }); showToast('toast.recipes.syncComplete', {}, 'success'); } catch (error) { @@ -314,7 +328,7 @@ export async function refreshRecipes() { } // After successful cache rebuild, reload the recipes - await resetAndReload(); + await resetAndReload(false, { preserveScroll: true }); showToast('toast.recipes.refreshComplete', {}, 'success'); } catch (error) { diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index a3a2eb32..fddb8159 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -23,7 +23,7 @@ export class RecipeContextMenu extends BaseContextMenu { // Override resetAndReload for recipe context async resetAndReload() { const { resetAndReload } = await import('../../api/recipeApi.js'); - return resetAndReload(); + return resetAndReload(false, { preserveScroll: true }); } showMenu(x, y, card) { diff --git a/static/js/managers/BatchImportManager.js b/static/js/managers/BatchImportManager.js index fff900bb..814020e4 100644 --- a/static/js/managers/BatchImportManager.js +++ b/static/js/managers/BatchImportManager.js @@ -432,7 +432,7 @@ export class BatchImportManager { // Refresh recipes list to show newly imported recipes if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') { - window.recipeManager.loadRecipes(); + window.recipeManager.loadRecipes({ preserveScroll: true }); } // Show results step diff --git a/static/js/managers/BulkMissingLoraDownloadManager.js b/static/js/managers/BulkMissingLoraDownloadManager.js index 9fbedfee..fbf9b82e 100644 --- a/static/js/managers/BulkMissingLoraDownloadManager.js +++ b/static/js/managers/BulkMissingLoraDownloadManager.js @@ -311,7 +311,7 @@ export class BulkMissingLoraDownloadManager { // Refresh the recipes list to update LoRA status if (window.recipeManager) { - window.recipeManager.loadRecipes(); + window.recipeManager.loadRecipes({ preserveScroll: true }); } } diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js index 8068ab2e..18ad6f65 100644 --- a/static/js/managers/import/DownloadManager.js +++ b/static/js/managers/import/DownloadManager.js @@ -122,7 +122,7 @@ export class DownloadManager { modalManager.closeModal('importModal'); // Refresh the recipe - window.recipeManager.loadRecipes(); + window.recipeManager.loadRecipes({ preserveScroll: true }); } catch (error) { console.error('Error:', error); diff --git a/static/js/recipes.js b/static/js/recipes.js index 7b12e971..76e79854 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -328,16 +328,32 @@ class RecipeManager { }); } + normalizeLoadRecipesOptions(options = true) { + if (typeof options === 'boolean') { + return { + resetPage: options, + preserveScroll: false + }; + } + + return { + resetPage: options?.resetPage !== false, + preserveScroll: options?.preserveScroll === true + }; + } + // This method is kept for compatibility but now uses virtual scrolling - async loadRecipes(resetPage = true) { + async loadRecipes(options = true) { // Skip loading if in duplicates mode const pageState = getCurrentPageState(); if (pageState.duplicatesMode) { return; } + const { resetPage, preserveScroll } = this.normalizeLoadRecipesOptions(options); + if (resetPage) { - refreshVirtualScroll(); + await refreshVirtualScroll({ preserveScroll }); } } diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index e2edb55c..1a676d43 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -4,6 +4,43 @@ import { createModelCard, setupModelCardEventDelegation } from '../components/sh import { getModelApiClient } from '../api/modelApiFactory.js'; import { showToast } from './uiHelpers.js'; +function getScrollContainer() { + return document.querySelector('.page-content'); +} + +function getClampedScrollTop(scrollContainer, scrollTop) { + const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); + return Math.min(Math.max(scrollTop, 0), maxScrollTop); +} + +function waitForAnimationFrame() { + return new Promise(resolve => requestAnimationFrame(resolve)); +} + +export function captureScrollPosition() { + const scrollContainer = getScrollContainer(); + if (!scrollContainer) { + return null; + } + + return { + scrollContainer, + scrollTop: scrollContainer.scrollTop + }; +} + +export async function restoreScrollPosition(snapshot) { + if (!snapshot?.scrollContainer) { + return; + } + + // Wait for layout and the scheduled virtual-scroll render to settle. + await waitForAnimationFrame(); + await waitForAnimationFrame(); + + snapshot.scrollContainer.scrollTop = getClampedScrollTop(snapshot.scrollContainer, snapshot.scrollTop); +} + // Function to dynamically import the appropriate card creator based on page type async function getCardCreator(pageType) { if (pageType === 'recipes') { @@ -87,7 +124,7 @@ async function initializeVirtualScroll(pageType) { } // Change this line to get the actual scrolling container - const scrollContainer = document.querySelector('.page-content'); + const scrollContainer = getScrollContainer(); const gridContainer = scrollContainer.querySelector('.container'); if (!gridContainer) { @@ -200,9 +237,16 @@ export function cleanupKeyboardNavigation() { } // Export a method to refresh the virtual scroller when filters change -export function refreshVirtualScroll() { +export async function refreshVirtualScroll(options = {}) { + const { preserveScroll = false } = options; + if (state.virtualScroller) { + const scrollSnapshot = preserveScroll ? captureScrollPosition() : null; state.virtualScroller.reset(); - state.virtualScroller.initialize(); + await state.virtualScroller.initialize(); + + if (scrollSnapshot) { + await restoreScrollPosition(scrollSnapshot); + } } -} \ No newline at end of file +} diff --git a/tests/frontend/api/recipeApi.bulk.test.js b/tests/frontend/api/recipeApi.bulk.test.js index 02217c57..1b9ff3b1 100644 --- a/tests/frontend/api/recipeApi.bulk.test.js +++ b/tests/frontend/api/recipeApi.bulk.test.js @@ -4,10 +4,15 @@ const showToastMock = vi.hoisted(() => vi.fn()); const loadingManagerMock = vi.hoisted(() => ({ showSimpleLoading: vi.fn(), hide: vi.fn(), + restoreProgressBar: vi.fn(), })); const virtualScrollerMock = vi.hoisted(() => ({ updateSingleItem: vi.fn(), + refreshWithData: vi.fn(), })); +const getCurrentPageStateMock = vi.hoisted(() => vi.fn()); +const captureScrollPositionMock = vi.hoisted(() => vi.fn()); +const restoreScrollPositionMock = vi.hoisted(() => vi.fn()); vi.mock('../../../static/js/utils/uiHelpers.js', () => { return { @@ -25,16 +30,39 @@ vi.mock('../../../static/js/state/index.js', () => { loadingManager: loadingManagerMock, virtualScroller: virtualScrollerMock, }, - getCurrentPageState: vi.fn(), + getCurrentPageState: getCurrentPageStateMock, }; }); -import { RecipeSidebarApiClient, fetchRecipeDetails, updateRecipeMetadata } from '../../../static/js/api/recipeApi.js'; +vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ + captureScrollPosition: captureScrollPositionMock, + restoreScrollPosition: restoreScrollPositionMock, +})); + +import { + RecipeSidebarApiClient, + fetchRecipeDetails, + resetAndReload, + syncChanges, + updateRecipeMetadata +} from '../../../static/js/api/recipeApi.js'; describe('RecipeSidebarApiClient bulk operations', () => { beforeEach(() => { vi.clearAllMocks(); global.fetch = vi.fn(); + getCurrentPageStateMock.mockReturnValue({ + pageSize: 50, + currentPage: 1, + hasMore: true, + isLoading: false, + sortBy: 'date:desc', + showFavoritesOnly: false, + activeFolder: null, + searchOptions: { recursive: true }, + customFilter: { active: false }, + filters: {}, + }); }); afterEach(() => { @@ -148,4 +176,44 @@ describe('RecipeSidebarApiClient bulk operations', () => { { title: 'Updated Title' } ); }); + + it('preserves scroll position for recipe reloads when requested', async () => { + const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 }; + captureScrollPositionMock.mockReturnValue(scrollSnapshot); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + items: [{ id: 'recipe-1' }], + total: 1, + total_pages: 1, + }), + }); + + await resetAndReload(false, { preserveScroll: true }); + + expect(captureScrollPositionMock).toHaveBeenCalledTimes(1); + expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith( + [{ id: 'recipe-1' }], + 1, + false + ); + expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot); + }); + + it('uses scroll-preserving reloads for syncChanges', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + items: [], + total: 0, + total_pages: 0, + }), + }); + + await syncChanges(); + + expect(captureScrollPositionMock).toHaveBeenCalledTimes(1); + expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1); + expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/frontend/pages/recipesPage.test.js b/tests/frontend/pages/recipesPage.test.js index cb514d5e..719d78f0 100644 --- a/tests/frontend/pages/recipesPage.test.js +++ b/tests/frontend/pages/recipesPage.test.js @@ -212,6 +212,19 @@ describe('RecipeManager', () => { expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1); }); + it('supports preserve-scroll options while keeping boolean compatibility', async () => { + const manager = new RecipeManager(); + + await manager.loadRecipes({ preserveScroll: true }); + expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(1, { preserveScroll: true }); + + await manager.loadRecipes(false); + expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1); + + await manager.loadRecipes({ resetPage: true, preserveScroll: false }); + expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(2, { preserveScroll: false }); + }); + it('proxies duplicate management and refresh helpers', async () => { const manager = new RecipeManager();