mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-07 00:46:44 -03:00
fix(recipes): preserve scroll on in-place reloads
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { RecipeCard } from '../components/RecipeCard.js';
|
import { RecipeCard } from '../components/RecipeCard.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
|
||||||
|
|
||||||
const RECIPE_ENDPOINTS = {
|
const RECIPE_ENDPOINTS = {
|
||||||
list: '/api/lm/recipes',
|
list: '/api/lm/recipes',
|
||||||
@@ -182,10 +183,12 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
const {
|
const {
|
||||||
modelType = 'lora',
|
modelType = 'lora',
|
||||||
updateFolders = false,
|
updateFolders = false,
|
||||||
fetchPageFunction
|
fetchPageFunction,
|
||||||
|
preserveScroll = false
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pageState.isLoading = true;
|
pageState.isLoading = true;
|
||||||
@@ -207,6 +210,10 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = 2; // Next page will be 2
|
pageState.currentPage = 2; // Next page will be 2
|
||||||
|
|
||||||
|
if (scrollSnapshot) {
|
||||||
|
await restoreScrollPosition(scrollSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reloading ${modelType}s:`, error);
|
console.error(`Error reloading ${modelType}s:`, error);
|
||||||
@@ -227,10 +234,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
modelType = 'lora',
|
modelType = 'lora',
|
||||||
resetPage = false,
|
resetPage = false,
|
||||||
updateFolders = false,
|
updateFolders = false,
|
||||||
fetchPageFunction
|
fetchPageFunction,
|
||||||
|
preserveScroll = false
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start loading state
|
// Start loading state
|
||||||
@@ -255,6 +264,10 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = 2; // Next page to load would be 2
|
pageState.currentPage = 2; // Next page to load would be 2
|
||||||
|
|
||||||
|
if (scrollSnapshot) {
|
||||||
|
await restoreScrollPosition(scrollSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading ${modelType}s:`, 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
|
* @param {boolean} updateFolders - Whether to update folder tags
|
||||||
* @returns {Promise<Object>} The fetch result
|
* @returns {Promise<Object>} The fetch result
|
||||||
*/
|
*/
|
||||||
export async function resetAndReload(updateFolders = false) {
|
export async function resetAndReload(updateFolders = false, options = {}) {
|
||||||
return resetAndReloadWithVirtualScroll({
|
return resetAndReloadWithVirtualScroll({
|
||||||
modelType: 'recipe',
|
modelType: 'recipe',
|
||||||
updateFolders,
|
updateFolders,
|
||||||
fetchPageFunction: fetchRecipesPage
|
fetchPageFunction: fetchRecipesPage,
|
||||||
|
preserveScroll: options.preserveScroll === true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +300,7 @@ export async function syncChanges() {
|
|||||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
state.loadingManager.showSimpleLoading('Syncing changes...');
|
||||||
|
|
||||||
// Simply reload the recipes without rebuilding cache
|
// Simply reload the recipes without rebuilding cache
|
||||||
await resetAndReload();
|
await resetAndReload(false, { preserveScroll: true });
|
||||||
|
|
||||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
showToast('toast.recipes.syncComplete', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -314,7 +328,7 @@ export async function refreshRecipes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After successful cache rebuild, reload the recipes
|
// After successful cache rebuild, reload the recipes
|
||||||
await resetAndReload();
|
await resetAndReload(false, { preserveScroll: true });
|
||||||
|
|
||||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Override resetAndReload for recipe context
|
// Override resetAndReload for recipe context
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||||
return resetAndReload();
|
return resetAndReload(false, { preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
|||||||
|
|
||||||
// Refresh recipes list to show newly imported recipes
|
// Refresh recipes list to show newly imported recipes
|
||||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
window.recipeManager.loadRecipes();
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show results step
|
// Show results step
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
|
|
||||||
// Refresh the recipes list to update LoRA status
|
// Refresh the recipes list to update LoRA status
|
||||||
if (window.recipeManager) {
|
if (window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes();
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class DownloadManager {
|
|||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
// Refresh the recipe
|
||||||
window.recipeManager.loadRecipes();
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -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
|
// 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
|
// Skip loading if in duplicates mode
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
if (pageState.duplicatesMode) {
|
if (pageState.duplicatesMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { resetPage, preserveScroll } = this.normalizeLoadRecipesOptions(options);
|
||||||
|
|
||||||
if (resetPage) {
|
if (resetPage) {
|
||||||
refreshVirtualScroll();
|
await refreshVirtualScroll({ preserveScroll });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,43 @@ import { createModelCard, setupModelCardEventDelegation } from '../components/sh
|
|||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { showToast } from './uiHelpers.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
|
// Function to dynamically import the appropriate card creator based on page type
|
||||||
async function getCardCreator(pageType) {
|
async function getCardCreator(pageType) {
|
||||||
if (pageType === 'recipes') {
|
if (pageType === 'recipes') {
|
||||||
@@ -87,7 +124,7 @@ async function initializeVirtualScroll(pageType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Change this line to get the actual scrolling container
|
// Change this line to get the actual scrolling container
|
||||||
const scrollContainer = document.querySelector('.page-content');
|
const scrollContainer = getScrollContainer();
|
||||||
const gridContainer = scrollContainer.querySelector('.container');
|
const gridContainer = scrollContainer.querySelector('.container');
|
||||||
|
|
||||||
if (!gridContainer) {
|
if (!gridContainer) {
|
||||||
@@ -200,9 +237,16 @@ export function cleanupKeyboardNavigation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export a method to refresh the virtual scroller when filters change
|
// 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) {
|
if (state.virtualScroller) {
|
||||||
|
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||||
state.virtualScroller.reset();
|
state.virtualScroller.reset();
|
||||||
state.virtualScroller.initialize();
|
await state.virtualScroller.initialize();
|
||||||
|
|
||||||
|
if (scrollSnapshot) {
|
||||||
|
await restoreScrollPosition(scrollSnapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,15 @@ const showToastMock = vi.hoisted(() => vi.fn());
|
|||||||
const loadingManagerMock = vi.hoisted(() => ({
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
showSimpleLoading: vi.fn(),
|
showSimpleLoading: vi.fn(),
|
||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const virtualScrollerMock = vi.hoisted(() => ({
|
const virtualScrollerMock = vi.hoisted(() => ({
|
||||||
updateSingleItem: vi.fn(),
|
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', () => {
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||||
return {
|
return {
|
||||||
@@ -25,16 +30,39 @@ vi.mock('../../../static/js/state/index.js', () => {
|
|||||||
loadingManager: loadingManagerMock,
|
loadingManager: loadingManagerMock,
|
||||||
virtualScroller: virtualScrollerMock,
|
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', () => {
|
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
global.fetch = vi.fn();
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -148,4 +176,44 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
{ title: 'Updated Title' }
|
{ 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -212,6 +212,19 @@ describe('RecipeManager', () => {
|
|||||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('proxies duplicate management and refresh helpers', async () => {
|
||||||
const manager = new RecipeManager();
|
const manager = new RecipeManager();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user