Add keyboard navigation support and related styles for enhanced user experience

This commit is contained in:
Will Miao
2025-05-15 20:17:57 +08:00
parent 276aedfbb9
commit 2ba7a0ceba
6 changed files with 327 additions and 7 deletions

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -47,10 +47,38 @@
</div>
</div>
</div>
<div class="toggle-folders-container">
<button class="toggle-folders-btn icon-only" title="Toggle folder tags">
<i class="fas fa-tags"></i>
</button>
<div class="controls-right">
<div class="toggle-folders-container">
<button class="toggle-folders-btn icon-only" title="Toggle folder tags">
<i class="fas fa-tags"></i>
</button>
</div>
<div class="keyboard-nav-hint tooltip">
<i class="fas fa-keyboard"></i>
<span class="tooltiptext">
Keyboard Navigation:
<table class="keyboard-shortcuts">
<tr>
<td><span class="key">Page Up</span></td>
<td>Scroll up one page</td>
</tr>
<tr>
<td><span class="key">Page Down</span></td>
<td>Scroll down one page</td>
</tr>
<tr>
<td><span class="key">Home</span></td>
<td>Jump to top</td>
</tr>
<tr>
<td><span class="key">End</span></td>
<td>Jump to bottom</td>
</tr>
</table>
</span>
</div>
</div>
</div>
</div>