From 07f49559beb9916fd8ab9ed2c345cb378787acf4 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 19 Jun 2026 09:18:49 +0800 Subject: [PATCH] fix(virtual-scroll): avoid full reload on move-to-folder, scroll to top on filter/page reset - MoveManager/SidebarManager: replace resetAndReload with in-place VirtualScroller update after move operations (remove non-visible, update visible items' file_path). Preserves scroll position and avoids empty grid. - VirtualScroller: add removeMultipleItemsByFilePath for efficient batch removal with Array.isArray guard. - baseModelApi: scroll to top on loadMoreWithVirtualScroll(true), covering filter/sort/search/folder/views changes. - SidebarManager selectFolder: scroll now handled centrally. --- static/js/api/baseModelApi.js | 10 ++ static/js/components/SidebarManager.js | 132 +++++++++++++++++++++---- static/js/managers/MoveManager.js | 87 +++++++++++++--- static/js/utils/VirtualScroller.js | 32 ++++++ 4 files changed, 232 insertions(+), 29 deletions(-) diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 602cb5f2..f89e6eeb 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -133,6 +133,16 @@ export class BaseModelApiClient { pageState.hasMore = result.hasMore; pageState.currentPage = pageState.currentPage + 1; + // When resetting to page 1, scroll back to the top + // This covers: folder selection, filter/sort/search changes, + // favorites/update/excluded view toggles, alphabet filter, etc. + if (resetPage) { + const scrollContainer = document.querySelector('.page-content'); + if (scrollContainer) { + scrollContainer.scrollTop = 0; + } + } + if (updateFolders) { sidebarManager.refresh(); } diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index afba1f8a..f1933d69 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -4,7 +4,7 @@ import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getModelApiClient } from '../api/modelApiFactory.js'; import { translate } from '../utils/i18nHelpers.js'; -import { state } from '../state/index.js'; +import { state, getCurrentPageState } from '../state/index.js'; import { bulkManager } from '../managers/BulkManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; @@ -457,21 +457,69 @@ export class SidebarManager { try { console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); + let movedFiles = []; // Array of { original_file_path, new_file_path } + if (useBulkMove) { - await this.apiClient.moveBulkModels(this.draggedFilePaths, destination); + const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination); + movedFiles = (results || []) + .filter(r => r.success) + .map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path })); } else { - await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination); + const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination); + if (result) { + movedFiles.push({ + original_file_path: result.original_file_path || this.draggedFilePaths[0], + new_file_path: result.new_file_path + }); + } } console.log('[SidebarManager] apiClient.move successful'); - if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { - console.log('[SidebarManager] calling resetAndReload'); - await this.pageControls.resetAndReload(true); - } else { - console.log('[SidebarManager] calling refresh'); - await this.refresh(); + // Update VirtualScroller in-place instead of full reload + if (movedFiles.length > 0 && state.virtualScroller) { + const pageState = getCurrentPageState(); + const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, ''); + const isRecursive = pageState.searchOptions?.recursive ?? true; + const isFolderFiltered = pageState.activeFolder !== null; + + const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, ''); + + // Determine if items in the target folder are visible in the current view + let itemsRemainVisible = true; + if (isFolderFiltered) { + if (isRecursive) { + itemsRemainVisible = normalizedActive === '' || + normalizedTarget === normalizedActive || + normalizedTarget.startsWith(normalizedActive + '/'); + } else { + itemsRemainVisible = normalizedTarget === normalizedActive; + } + } + + if (itemsRemainVisible) { + // Items stay visible — update each item's file_path to reflect new location + for (const moved of movedFiles) { + if (moved.original_file_path && moved.new_file_path) { + state.virtualScroller.updateSingleItem(moved.original_file_path, { + file_path: moved.new_file_path, + folder: normalizedTarget + }); + } + } + } else { + // Items no longer visible in current folder — remove from VirtualScroller + const pathsToRemove = movedFiles + .map(m => m.original_file_path) + .filter(Boolean); + if (pathsToRemove.length > 0) { + state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove); + } + } } + // Refresh sidebar folder tree only (no model data reload) + await this.refresh(); + if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { bulkManager.toggleBulkMode(); } @@ -530,21 +578,69 @@ export class SidebarManager { try { console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); + let movedFiles = []; // Array of { original_file_path, new_file_path } + if (useBulkMove) { - await this.apiClient.moveBulkModels(draggedFilePaths, destination); + const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination); + movedFiles = (results || []) + .filter(r => r.success) + .map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path })); } else { - await this.apiClient.moveSingleModel(draggedFilePaths[0], destination); + const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination); + if (result) { + movedFiles.push({ + original_file_path: result.original_file_path || draggedFilePaths[0], + new_file_path: result.new_file_path + }); + } } console.log('[SidebarManager] apiClient.move successful'); - if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { - console.log('[SidebarManager] calling resetAndReload'); - await this.pageControls.resetAndReload(true); - } else { - console.log('[SidebarManager] calling refresh'); - await this.refresh(); + // Update VirtualScroller in-place instead of full reload + if (movedFiles.length > 0 && state.virtualScroller) { + const pageState = getCurrentPageState(); + const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, ''); + const isRecursive = pageState.searchOptions?.recursive ?? true; + const isFolderFiltered = pageState.activeFolder !== null; + + const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, ''); + + // Determine if items in the target folder are visible in the current view + let itemsRemainVisible = true; + if (isFolderFiltered) { + if (isRecursive) { + itemsRemainVisible = normalizedActive === '' || + normalizedTarget === normalizedActive || + normalizedTarget.startsWith(normalizedActive + '/'); + } else { + itemsRemainVisible = normalizedTarget === normalizedActive; + } + } + + if (itemsRemainVisible) { + // Items stay visible — update each item's file_path to reflect new location + for (const moved of movedFiles) { + if (moved.original_file_path && moved.new_file_path) { + state.virtualScroller.updateSingleItem(moved.original_file_path, { + file_path: moved.new_file_path, + folder: normalizedTarget + }); + } + } + } else { + // Items no longer visible in current folder — remove from VirtualScroller + const pathsToRemove = movedFiles + .map(m => m.original_file_path) + .filter(Boolean); + if (pathsToRemove.length > 0) { + state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove); + } + } } + // Refresh sidebar folder tree only (no model data reload) + await this.refresh(); + if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { bulkManager.toggleBulkMode(); } @@ -1346,7 +1442,7 @@ export class SidebarManager { this.pageControls.pageState.activeFolder = normalizedPath; setStorageItem(`${this.pageType}_activeFolder`, normalizedPath); - // Reload models with new filter + // Reload models with new filter (loadMoreWithVirtualScroll will scroll to top) await this.pageControls.resetAndReload(); } diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 4a7cc856..f79c6c30 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -321,29 +321,94 @@ class MoveManager { } try { + let movedFiles = []; // Array of { original_file_path, new_file_path } + if (this.bulkFilePaths) { // Bulk move mode - await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath); - + const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath); + movedFiles = (results || []) + .filter(r => r.success) + .map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path })); + // Deselect moving items this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path)); } else { // Single move mode - await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath); - + const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath); + if (result) { + movedFiles.push({ + original_file_path: result.original_file_path || this.currentFilePath, + new_file_path: result.new_file_path + }); + } + // Deselect moving item bulkManager.deselectItem(this.currentFilePath); } - // Refresh UI by reloading the current page, same as drag-and-drop behavior - // This ensures all metadata (like preview URLs) are correctly formatted by the backend - if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') { - await sidebarManager.pageControls.resetAndReload(true); - } else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') { - await sidebarManager.lastPageControls.resetAndReload(true); + // Update VirtualScroller in-place instead of full reload + if (movedFiles.length > 0 && state.virtualScroller) { + // Get current page state for folder filter check + const pageState = getCurrentPageState(); + const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, ''); + const isRecursive = pageState.searchOptions?.recursive ?? true; + const isFolderFiltered = pageState.activeFolder !== null; + + // Determine which items are still visible after the move + const pathsToRemove = []; + const pathsToUpdate = []; // { originalPath, newData } + + for (const moved of movedFiles) { + if (!moved.original_file_path) continue; + + if (isFolderFiltered) { + // Compute relative folder of the new path + const newRelativeFolder = this._getRelativeFolder(moved.new_file_path); + const normalizedNewFolder = newRelativeFolder.replace(/\\/g, '/').replace(/\/$/, ''); + + // Check if the new location is still within the active folder + let stillVisible; + if (isRecursive) { + stillVisible = normalizedActive === '' || + normalizedNewFolder === normalizedActive || + normalizedNewFolder.startsWith(normalizedActive + '/'); + } else { + stillVisible = normalizedNewFolder === normalizedActive; + } + + if (stillVisible) { + pathsToUpdate.push({ + originalPath: moved.original_file_path, + newData: { + file_path: moved.new_file_path, + folder: newRelativeFolder + } + }); + } else { + pathsToRemove.push(moved.original_file_path); + } + } else { + // No folder filter active — items remain visible, just update path + pathsToUpdate.push({ + originalPath: moved.original_file_path, + newData: { + file_path: moved.new_file_path, + folder: this._getRelativeFolder(moved.new_file_path) + } + }); + } + } + + // Apply updates to the VirtualScroller + if (pathsToRemove.length > 0) { + state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove); + } + for (const update of pathsToUpdate) { + state.virtualScroller.updateSingleItem(update.originalPath, update.newData); + } } - // Refresh folder tree in sidebar + // Refresh folder tree in sidebar (no model data reload) await sidebarManager.refresh(); modalManager.closeModal('moveModal'); diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index 1ba45466..ea3e21fe 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -931,6 +931,38 @@ export class VirtualScroller { 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); + + // Update the spacer height + this.updateSpacerHeight(); + + // Re-render to fill gaps left by removed items + this.clearRenderedItems(); + this.scheduleRender(); + + console.log(`Removed ${removedCount} items from virtual scroller data`); + return true; + } + // Add keyboard navigation methods handlePageUpDown(direction) { // Prevent duplicate animations by checking last trigger time