diff --git a/static/css/components/keyboard-nav.css b/static/css/components/keyboard-nav.css new file mode 100644 index 00000000..1f325624 --- /dev/null +++ b/static/css/components/keyboard-nav.css @@ -0,0 +1,96 @@ +/* Keyboard navigation indicator and help */ +.keyboard-nav-hint { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-color); + cursor: help; + transition: all 0.2s ease; + margin-left: 8px; +} + +.keyboard-nav-hint:hover { + background: var(--lora-accent); + color: white; + transform: translateY(-2px); + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); +} + +.keyboard-nav-hint i { + font-size: 14px; +} + +/* Tooltip styling */ +.tooltip { + position: relative; +} + +.tooltip .tooltiptext { + visibility: hidden; + width: 240px; + background-color: var(--lora-surface); + color: var(--text-color); + text-align: center; + border-radius: var(--border-radius-xs); + padding: 8px; + position: absolute; + z-index: 9999; /* 确保在卡片上方显示 */ + left: 120%; /* 将tooltip显示在图标右侧 */ + top: 50%; /* 垂直居中 */ + transform: translateY(-50%); /* 垂直居中 */ + opacity: 0; + transition: opacity 0.3s; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); + border: 1px solid var(--lora-border); + font-size: 0.85em; + line-height: 1.4; +} + +.tooltip .tooltiptext::after { + content: ""; + position: absolute; + top: 50%; /* 箭头垂直居中 */ + right: 100%; /* 箭头在左侧 */ + margin-top: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent var(--lora-border) transparent transparent; /* 箭头指向左侧 */ +} + +.tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; +} + +/* Keyboard shortcuts table */ +.keyboard-shortcuts { + width: 100%; + border-collapse: collapse; + margin-top: 5px; +} + +.keyboard-shortcuts td { + padding: 4px; + text-align: left; +} + +.keyboard-shortcuts td:first-child { + font-weight: bold; + width: 40%; +} + +.key { + display: inline-block; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 3px; + padding: 1px 5px; + font-size: 0.8em; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} diff --git a/static/css/layout.css b/static/css/layout.css index 73b9cc3f..8fd0a562 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -35,6 +35,13 @@ margin-bottom: var(--space-2); } +.controls-right { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; /* Push to the right */ +} + .actions { display: flex; align-items: center; @@ -338,11 +345,14 @@ width: 100%; } + .controls-right { + width: 100%; + justify-content: flex-end; + margin-top: 8px; + } + .toggle-folders-container { margin-left: 0; - width: 100%; - display: flex; - justify-content: flex-end; } .folder-tags-container { diff --git a/static/css/style.css b/static/css/style.css index 64158a7f..d51fa391 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -23,6 +23,7 @@ @import 'components/progress-panel.css'; @import 'components/alphabet-bar.css'; /* Add alphabet bar component */ @import 'components/duplicates.css'; /* Add duplicates component */ +@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */ .initialization-notice { display: flex; diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index 2c1372be..caee0f19 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -804,4 +804,131 @@ export class VirtualScroller { console.log(`Removed item with file path ${filePath} from virtual 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' + }); + + // Page transition indicator removed + // this.showTransitionIndicator(); + + // 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(); + } + } + + // Create a more contained transition indicator - commented out as it's no longer needed + /* + showTransitionIndicator() { + const container = this.containerElement; + const indicator = document.createElement('div'); + indicator.className = 'page-transition-indicator'; + + // Get container position to properly position the indicator + const containerRect = container.getBoundingClientRect(); + + // Style the indicator to match just the container area + indicator.style.position = 'fixed'; + indicator.style.top = `${containerRect.top}px`; + indicator.style.left = `${containerRect.left}px`; + indicator.style.width = `${containerRect.width}px`; + indicator.style.height = `${containerRect.height}px`; + + document.body.appendChild(indicator); + + // Remove after animation completes + setTimeout(() => { + if (indicator.parentNode) { + indicator.remove(); + } + }, 500); + } + */ + + scrollToTop() { + this.removeExistingTransitionIndicator(); + + // Page transition indicator removed + // this.showTransitionIndicator(); + + this.scrollContainer.scrollTo({ + top: 0, + behavior: 'smooth' + }); + + // Force render after scrolling + setTimeout(() => this.renderItems(), 100); + } + + scrollToBottom() { + this.removeExistingTransitionIndicator(); + + // Page transition indicator removed + // this.showTransitionIndicator(); + + // 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' + }); + }); + } + + // New method to load all remaining pages + 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(); + } } diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index b7f8450e..4c77e78e 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -131,6 +131,9 @@ async function initializeVirtualScroll(pageType) { // Add grid class for CSS styling grid.classList.add('virtual-scroll'); + // Setup keyboard navigation + setupKeyboardNavigation(); + } catch (error) { console.error(`Error initializing virtual scroller for ${pageType}:`, error); showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error'); @@ -145,6 +148,61 @@ async function initializeVirtualScroll(pageType) { } } +// Add keyboard navigation setup function +function setupKeyboardNavigation() { + // Keep track of the last keypress time to prevent multiple rapid triggers + let lastKeyTime = 0; + const keyDelay = 300; // ms between allowed keypresses + + // Store the event listener reference so we can remove it later if needed + const keyboardNavHandler = (event) => { + // Only handle keyboard events when not in form elements + if (event.target.matches('input, textarea, select')) return; + + // Prevent rapid keypresses + const now = Date.now(); + if (now - lastKeyTime < keyDelay) return; + lastKeyTime = now; + + // Handle navigation keys + if (event.key === 'PageUp') { + event.preventDefault(); + if (state.virtualScroller) { + state.virtualScroller.handlePageUpDown('up'); + } + } else if (event.key === 'PageDown') { + event.preventDefault(); + if (state.virtualScroller) { + state.virtualScroller.handlePageUpDown('down'); + } + } else if (event.key === 'Home') { + event.preventDefault(); + if (state.virtualScroller) { + state.virtualScroller.scrollToTop(); + } + } else if (event.key === 'End') { + event.preventDefault(); + if (state.virtualScroller) { + state.virtualScroller.scrollToBottom(); + } + } + }; + + // Add the event listener + document.addEventListener('keydown', keyboardNavHandler); + + // Store the handler in state for potential cleanup + state.keyboardNavHandler = keyboardNavHandler; +} + +// Add cleanup function to remove keyboard navigation when needed +export function cleanupKeyboardNavigation() { + if (state.keyboardNavHandler) { + document.removeEventListener('keydown', state.keyboardNavHandler); + state.keyboardNavHandler = null; + } +} + // Export a method to refresh the virtual scroller when filters change export function refreshVirtualScroll() { if (state.virtualScroller) { diff --git a/templates/components/controls.html b/templates/components/controls.html index 2236b997..9d946619 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -47,10 +47,38 @@ -
| Page Up | +Scroll up one page | +
| Page Down | +Scroll down one page | +
| Home | +Jump to top | +
| End | +Jump to bottom | +