From d04deff5cad59937a9e0a7693773e257ff40bf6e Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 13 Aug 2025 14:41:21 +0800 Subject: [PATCH] feat: enhance download and move modals with improved folder path input, autocomplete, and folder tree integration --- .../css/components/modal/download-modal.css | 6 +- static/js/components/FolderTreeManager.js | 143 +++++++++++---- static/js/managers/DownloadManager.js | 6 + static/js/managers/MoveManager.js | 171 ++++++++++-------- .../components/modals/download_modal.html | 8 +- templates/components/modals/move_modal.html | 32 +++- 6 files changed, 241 insertions(+), 125 deletions(-) diff --git a/static/css/components/modal/download-modal.css b/static/css/components/modal/download-modal.css index 5a3f50c9..9d717c0d 100644 --- a/static/css/components/modal/download-modal.css +++ b/static/css/components/modal/download-modal.css @@ -1,8 +1,4 @@ /* Download Modal Styles */ -.download-step { - margin: var(--space-2) 0; -} - .input-group { margin-bottom: var(--space-2); } @@ -184,7 +180,7 @@ .path-suggestions { position: absolute; - top: 42%; + top: 46%; left: 0; right: 0; z-index: 1000; diff --git a/static/js/components/FolderTreeManager.js b/static/js/components/FolderTreeManager.js index 20f329b9..058bd2e1 100644 --- a/static/js/components/FolderTreeManager.js +++ b/static/js/components/FolderTreeManager.js @@ -8,6 +8,8 @@ export class FolderTreeManager { this.expandedNodes = new Set(); this.pathSuggestions = []; this.onPathChangeCallback = null; + this.activeSuggestionIndex = -1; + this.elementsPrefix = ''; // Bind methods this.handleTreeClick = this.handleTreeClick.bind(this); @@ -15,33 +17,31 @@ export class FolderTreeManager { this.handlePathSuggestionClick = this.handlePathSuggestionClick.bind(this); this.handleCreateFolder = this.handleCreateFolder.bind(this); this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this); + this.handlePathKeyDown = this.handlePathKeyDown.bind(this); } /** * Initialize the folder tree manager * @param {Object} config - Configuration object * @param {Function} config.onPathChange - Callback when path changes + * @param {string} config.elementsPrefix - Prefix for element IDs (e.g., 'move' for move modal) */ init(config = {}) { this.onPathChangeCallback = config.onPathChange; + this.elementsPrefix = config.elementsPrefix || ''; this.setupEventHandlers(); } setupEventHandlers() { - const pathInput = document.getElementById('folderPath'); - const createFolderBtn = document.getElementById('createFolderBtn'); - const folderTree = document.getElementById('folderTree'); - const breadcrumbNav = document.getElementById('breadcrumbNav'); - const pathSuggestions = document.getElementById('pathSuggestions'); + const pathInput = document.getElementById(this.getElementId('folderPath')); + const createFolderBtn = document.getElementById(this.getElementId('createFolderBtn')); + const folderTree = document.getElementById(this.getElementId('folderTree')); + const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav')); + const pathSuggestions = document.getElementById(this.getElementId('pathSuggestions')); if (pathInput) { pathInput.addEventListener('input', this.handlePathInput); - pathInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - this.selectCurrentInput(); - } - }); + pathInput.addEventListener('keydown', this.handlePathKeyDown); } if (createFolderBtn) { @@ -62,13 +62,81 @@ export class FolderTreeManager { // Hide suggestions when clicking outside document.addEventListener('click', (e) => { - const pathInput = document.getElementById('folderPath'); - const suggestions = document.getElementById('pathSuggestions'); + const pathInput = document.getElementById(this.getElementId('folderPath')); + const suggestions = document.getElementById(this.getElementId('pathSuggestions')); if (pathInput && suggestions && !pathInput.contains(e.target) && !suggestions.contains(e.target)) { suggestions.style.display = 'none'; + this.activeSuggestionIndex = -1; + } + }); + } + + /** + * Get element ID with prefix + */ + getElementId(elementName) { + return this.elementsPrefix ? `${this.elementsPrefix}${elementName.charAt(0).toUpperCase()}${elementName.slice(1)}` : elementName; + } + + /** + * Handle path input key events with enhanced keyboard navigation + */ + handlePathKeyDown(event) { + const suggestions = document.getElementById(this.getElementId('pathSuggestions')); + const isVisible = suggestions && suggestions.style.display !== 'none'; + + if (isVisible) { + const suggestionItems = suggestions.querySelectorAll('.path-suggestion'); + const maxIndex = suggestionItems.length - 1; + + switch (event.key) { + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.hideSuggestions(); + this.activeSuggestionIndex = -1; + break; + + case 'ArrowDown': + event.preventDefault(); + this.activeSuggestionIndex = Math.min(this.activeSuggestionIndex + 1, maxIndex); + this.updateActiveSuggestion(suggestionItems); + break; + + case 'ArrowUp': + event.preventDefault(); + this.activeSuggestionIndex = Math.max(this.activeSuggestionIndex - 1, -1); + this.updateActiveSuggestion(suggestionItems); + break; + + case 'Enter': + event.preventDefault(); + if (this.activeSuggestionIndex >= 0 && suggestionItems[this.activeSuggestionIndex]) { + const path = suggestionItems[this.activeSuggestionIndex].dataset.path; + this.selectPath(path); + this.hideSuggestions(); + } else { + this.selectCurrentInput(); + } + break; + } + } else if (event.key === 'Enter') { + event.preventDefault(); + this.selectCurrentInput(); + } + } + + /** + * Update active suggestion highlighting + */ + updateActiveSuggestion(suggestionItems) { + suggestionItems.forEach((item, index) => { + item.classList.toggle('active', index === this.activeSuggestionIndex); + if (index === this.activeSuggestionIndex) { + item.scrollIntoView({ block: 'nearest' }); } }); } @@ -105,7 +173,7 @@ export class FolderTreeManager { * Render the complete folder tree */ renderTree() { - const folderTree = document.getElementById('folderTree'); + const folderTree = document.getElementById(this.getElementId('folderTree')); if (!folderTree) return; folderTree.innerHTML = this.renderTreeNode(this.treeData, ''); @@ -183,6 +251,8 @@ export class FolderTreeManager { const input = event.target; const query = input.value.toLowerCase(); + this.activeSuggestionIndex = -1; // Reset active suggestion + if (query.length === 0) { this.hideSuggestions(); return; @@ -199,7 +269,7 @@ export class FolderTreeManager { * Show path suggestions */ showSuggestions(suggestions, query) { - const suggestionsEl = document.getElementById('pathSuggestions'); + const suggestionsEl = document.getElementById(this.getElementId('pathSuggestions')); if (!suggestionsEl) return; if (suggestions.length === 0) { @@ -213,13 +283,14 @@ export class FolderTreeManager { }).join(''); suggestionsEl.style.display = 'block'; + this.activeSuggestionIndex = -1; // Reset active index } /** * Hide path suggestions */ hideSuggestions() { - const suggestionsEl = document.getElementById('pathSuggestions'); + const suggestionsEl = document.getElementById(this.getElementId('pathSuggestions')); if (suggestionsEl) { suggestionsEl.style.display = 'none'; } @@ -264,7 +335,7 @@ export class FolderTreeManager { // Find the parent node in the tree const parentNode = parentPath ? document.querySelector(`[data-path="${parentPath}"]`) : - document.getElementById('folderTree'); + document.getElementById(this.getElementId('folderTree')); if (!parentNode) return; @@ -369,22 +440,25 @@ export class FolderTreeManager { this.selectedPath = path; // Update path input - const pathInput = document.getElementById('folderPath'); + const pathInput = document.getElementById(this.getElementId('folderPath')); if (pathInput) { pathInput.value = path; } // Update tree selection - document.querySelectorAll('.tree-node-content').forEach(node => { - node.classList.remove('selected'); - }); - - const selectedNode = document.querySelector(`[data-path="${path}"] .tree-node-content`); - if (selectedNode) { - selectedNode.classList.add('selected'); + const treeContainer = document.getElementById(this.getElementId('folderTree')); + if (treeContainer) { + treeContainer.querySelectorAll('.tree-node-content').forEach(node => { + node.classList.remove('selected'); + }); - // Expand parents to show selection - this.expandPathParents(path); + const selectedNode = treeContainer.querySelector(`[data-path="${path}"] .tree-node-content`); + if (selectedNode) { + selectedNode.classList.add('selected'); + + // Expand parents to show selection + this.expandPathParents(path); + } } // Update breadcrumbs @@ -415,7 +489,7 @@ export class FolderTreeManager { * Update breadcrumb navigation */ updateBreadcrumbs(path) { - const breadcrumbNav = document.getElementById('breadcrumbNav'); + const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav')); if (!breadcrumbNav) return; const parts = path ? path.split('/') : []; @@ -449,7 +523,7 @@ export class FolderTreeManager { * Select current input value as path */ selectCurrentInput() { - const pathInput = document.getElementById('folderPath'); + const pathInput = document.getElementById(this.getElementId('folderPath')); if (pathInput) { const path = pathInput.value.trim(); this.selectPath(path); @@ -474,14 +548,15 @@ export class FolderTreeManager { * Clean up event handlers */ destroy() { - const pathInput = document.getElementById('folderPath'); - const createFolderBtn = document.getElementById('createFolderBtn'); - const folderTree = document.getElementById('folderTree'); - const breadcrumbNav = document.getElementById('breadcrumbNav'); - const pathSuggestions = document.getElementById('pathSuggestions'); + const pathInput = document.getElementById(this.getElementId('folderPath')); + const createFolderBtn = document.getElementById(this.getElementId('createFolderBtn')); + const folderTree = document.getElementById(this.getElementId('folderTree')); + const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav')); + const pathSuggestions = document.getElementById(this.getElementId('pathSuggestions')); if (pathInput) { pathInput.removeEventListener('input', this.handlePathInput); + pathInput.removeEventListener('keydown', this.handlePathKeyDown); } if (createFolderBtn) { createFolderBtn.removeEventListener('click', this.handleCreateFolder); diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index de16f252..bbec8781 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -306,6 +306,12 @@ export class DownloadManager { modelRoot.value = defaultRoot; } + // Set autocomplete="off" on folderPath input + const folderPathInput = document.getElementById('folderPath'); + if (folderPathInput) { + folderPathInput.setAttribute('autocomplete', 'off'); + } + // Initialize folder tree await this.initializeFolderTree(); diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index b651c033..ab17b2e6 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -4,48 +4,31 @@ import { modalManager } from './ModalManager.js'; import { bulkManager } from './BulkManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; import { getModelApiClient } from '../api/modelApiFactory.js'; +import { FolderTreeManager } from '../components/FolderTreeManager.js'; class MoveManager { constructor() { this.currentFilePath = null; this.bulkFilePaths = null; - this.modal = document.getElementById('moveModal'); - this.modelRootSelect = document.getElementById('moveModelRoot'); - this.folderBrowser = document.getElementById('moveFolderBrowser'); - this.newFolderInput = document.getElementById('moveNewFolder'); - this.pathDisplay = document.getElementById('moveTargetPathDisplay'); - this.modalTitle = document.getElementById('moveModalTitle'); - this.rootLabel = document.getElementById('moveRootLabel'); - - this.initializeEventListeners(); + this.folderTreeManager = new FolderTreeManager(); + this.initialized = false; + + // Bind methods + this.updateTargetPath = this.updateTargetPath.bind(this); } initializeEventListeners() { + if (this.initialized) return; + + const modelRootSelect = document.getElementById('moveModelRoot'); + // Initialize model root directory selector - this.modelRootSelect.addEventListener('change', () => this.updatePathPreview()); - - // Folder selection event - this.folderBrowser.addEventListener('click', (e) => { - const folderItem = e.target.closest('.folder-item'); - if (!folderItem) return; - - // If clicking already selected folder, deselect it - if (folderItem.classList.contains('selected')) { - folderItem.classList.remove('selected'); - } else { - // Deselect other folders - this.folderBrowser.querySelectorAll('.folder-item').forEach(item => { - item.classList.remove('selected'); - }); - // Select current folder - folderItem.classList.add('selected'); - } - - this.updatePathPreview(); + modelRootSelect.addEventListener('change', async () => { + await this.initializeFolderTree(); + this.updateTargetPath(); }); - - // New folder input event - this.newFolderInput.addEventListener('input', () => this.updatePathPreview()); + + this.initialized = true; } async showMoveModal(filePath, modelType = null) { @@ -65,31 +48,30 @@ class MoveManager { return; } this.bulkFilePaths = selectedPaths; - this.modalTitle.textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`; + document.getElementById('moveModalTitle').textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`; } else { // Single file mode this.currentFilePath = filePath; - this.modalTitle.textContent = `Move ${modelConfig.displayName}`; + document.getElementById('moveModalTitle').textContent = `Move ${modelConfig.displayName}`; } // Update UI labels based on model type - this.rootLabel.textContent = `Select ${modelConfig.displayName} Root:`; - this.pathDisplay.querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`; + document.getElementById('moveRootLabel').textContent = `Select ${modelConfig.displayName} Root:`; + document.getElementById('moveTargetPathDisplay').querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`; - // Clear previous selections - this.folderBrowser.querySelectorAll('.folder-item').forEach(item => { - item.classList.remove('selected'); - }); - this.newFolderInput.value = ''; + // Clear folder path input + const folderPathInput = document.getElementById('moveFolderPath'); + if (folderPathInput) { + folderPathInput.value = ''; + } try { // Fetch model roots + const modelRootSelect = document.getElementById('moveModelRoot'); let rootsData; if (modelType) { - // For checkpoints, use the specific API method that considers modelType rootsData = await apiClient.fetchModelRoots(modelType); } else { - // For other model types, use the generic method rootsData = await apiClient.fetchModelRoots(); } @@ -98,27 +80,38 @@ class MoveManager { } // Populate model root selector - this.modelRootSelect.innerHTML = rootsData.roots.map(root => + modelRootSelect.innerHTML = rootsData.roots.map(root => `` ).join(''); // Set default root if available - const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; // Remove 's' from plural + const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; const defaultRoot = getStorageItem('settings', {})[settingsKey]; if (defaultRoot && rootsData.roots.includes(defaultRoot)) { - this.modelRootSelect.value = defaultRoot; + modelRootSelect.value = defaultRoot; } - // Fetch folders dynamically - const foldersData = await apiClient.fetchModelFolders(); + // Initialize event listeners + this.initializeEventListeners(); - // Update folder browser with dynamic content - this.folderBrowser.innerHTML = foldersData.folders.map(folder => - `