feat(model-modal): add keyboard navigation and UI controls for model browsing, fixes #714 and #350

- Add CSS for modal navigation buttons with hover and disabled states
- Implement keyboard shortcuts (arrow keys) for navigating between models
- Add navigation controls UI to modal header with previous/next buttons
- Store navigation state to enable sequential model browsing
- Clean up event handlers to prevent memory leaks when modal closes
This commit is contained in:
Will Miao
2025-12-05 22:20:31 +08:00
parent 22ee37b817
commit 4d6f4fcf69
4 changed files with 258 additions and 9 deletions

View File

@@ -7,6 +7,7 @@
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
position: relative;
}
.modal-header-actions {
@@ -18,6 +19,55 @@
margin-bottom: var(--space-1);
}
.modal-header-row {
width: 85%;
display: flex;
align-items: flex-start;
gap: var(--space-2);
position: relative;
padding-right: 96px; /* Reserve space for nav buttons to prevent wrapping overlap */
}
.modal-nav-controls {
display: inline-flex;
gap: 8px;
align-items: center;
margin-left: auto;
position: absolute;
top: 0;
right: 0;
}
.modal-nav-btn {
display: grid;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 50%;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
}
.modal-nav-btn:hover:not(:disabled) {
background: var(--bg-hover, var(--card-bg));
border-color: var(--lora-accent);
transform: translateY(-1px);
}
.modal-nav-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.modal-nav-btn i {
font-size: 14px;
}
.modal-header-actions .license-restrictions {
margin-left: auto;
}

View File

@@ -20,6 +20,7 @@ import { parsePresets, renderPresetTags } from './PresetTags.js';
import { initVersionsTab } from './ModelVersionsTab.js';
import { loadRecipesForLora } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
function getModalFilePath(fallback = '') {
const modalElement = document.getElementById('modelModal');
@@ -56,6 +57,10 @@ const COMMERCIAL_ICON_CONFIG = [
}
];
let navigationKeyHandler = null;
let navigationModelType = null;
let navigationInProgress = false;
function hasLicenseField(license, field) {
return Object.prototype.hasOwnProperty.call(license || {}, field);
}
@@ -241,6 +246,8 @@ function renderLicenseIcons(modelData) {
export async function showModelModal(model, modelType) {
const modalId = 'modelModal';
const modalTitle = model.model_name;
cleanupNavigationShortcuts();
detachModalHandlers(modalId);
// Fetch complete civitai metadata
let completeCivitaiData = model.civitai || {};
@@ -355,6 +362,18 @@ export async function showModelModal(model, modelType) {
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
const civitaiVersionId = modelWithFullData.civitai?.id || '';
const navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation');
const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)');
const nextTitle = translate('modals.model.navigation.nextWithShortcut', {}, 'Next model (→)');
const navigationControls = `
<div class="modal-nav-controls" role="group" aria-label="${navAriaLabel}">
<button class="modal-nav-btn" data-action="nav-prev" title="${previousTitle}" aria-label="${previousTitle}">
<i class="fas fa-chevron-left" aria-hidden="true"></i>
</button>
<button class="modal-nav-btn" data-action="nav-next" title="${nextTitle}" aria-label="${nextTitle}">
<i class="fas fa-chevron-right" aria-hidden="true"></i>
</button>
</div>`.trim();
const tabPanesContent = modelType === 'loras' ?
`<div id="showcase-tab" class="tab-pane active">
@@ -414,11 +433,15 @@ export async function showModelModal(model, modelType) {
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('${modalId}')">&times;</button>
<header class="modal-header">
<div class="model-name-header">
<h2 class="model-name-content">${modalTitle}</h2>
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
<i class="fas fa-pencil-alt"></i>
</button>
<div class="modal-header-row">
<div class="model-name-header">
<h2 class="model-name-content">${modalTitle}</h2>
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${navigationControls}
</div>
${headerActionsMarkup}
@@ -511,6 +534,7 @@ export async function showModelModal(model, modelType) {
showcaseCleanup();
showcaseCleanup = null;
}
cleanupNavigationShortcuts();
};
modalManager.showModal(modalId, content, null, onCloseCallback);
@@ -539,7 +563,9 @@ export async function showModelModal(model, modelType) {
setupModelNameEditing(modelWithFullData.file_path);
setupBaseModelEditing(modelWithFullData.file_path);
setupFileNameEditing(modelWithFullData.file_path);
setupEventHandlers(modelWithFullData.file_path);
setupEventHandlers(modelWithFullData.file_path, modelType);
setupNavigationShortcuts(modelType);
updateNavigationControls();
// LoRA specific setup
if (modelType === 'loras' || modelType === 'embeddings') {
@@ -589,11 +615,20 @@ function renderEmbeddingSpecificContent(embedding, escapedWords) {
return `${renderTriggerWords(escapedWords, embedding.file_path)}`;
}
function detachModalHandlers(modalId) {
const modalElement = document.getElementById(modalId);
if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler;
}
}
/**
* Sets up event handlers using event delegation for LoRA modal
* @param {string} filePath - Path to the model file
* @param {string} modelType - Current model type
*/
function setupEventHandlers(filePath) {
function setupEventHandlers(filePath, modelType) {
const modalElement = document.getElementById('modelModal');
// Remove existing event listeners first
@@ -628,6 +663,12 @@ function setupEventHandlers(filePath) {
openFileLocation(filePath);
}
break;
case 'nav-prev':
handleDirectionalNavigation('prev', modelType);
break;
case 'nav-next':
handleDirectionalNavigation('next', modelType);
break;
}
}
@@ -761,6 +802,95 @@ async function saveNotes() {
}
}
function shouldIgnoreNavigationKey(event) {
const target = event.target;
if (!target) return false;
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
return target.isContentEditable || ['input', 'textarea', 'select', 'button'].includes(tagName);
}
function updateNavigationControls() {
const modalElement = document.getElementById('modelModal');
if (!modalElement) return;
const prevBtn = modalElement.querySelector('[data-action="nav-prev"]');
const nextBtn = modalElement.querySelector('[data-action="nav-next"]');
if (!prevBtn || !nextBtn) return;
const scroller = state.virtualScroller;
if (!scroller || typeof scroller.getNavigationState !== 'function') {
prevBtn.disabled = true;
nextBtn.disabled = true;
return;
}
const { hasPrev, hasNext } = scroller.getNavigationState(modalElement.dataset.filePath || '');
prevBtn.disabled = navigationInProgress || !hasPrev;
nextBtn.disabled = navigationInProgress || !hasNext;
}
function cleanupNavigationShortcuts() {
if (navigationKeyHandler) {
document.removeEventListener('keydown', navigationKeyHandler);
navigationKeyHandler = null;
}
navigationModelType = null;
navigationInProgress = false;
}
function setupNavigationShortcuts(modelType) {
const modalElement = document.getElementById('modelModal');
if (!modalElement) return;
navigationModelType = modelType;
cleanupNavigationShortcuts();
navigationKeyHandler = (event) => {
if (shouldIgnoreNavigationKey(event)) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
handleDirectionalNavigation('prev', navigationModelType);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
handleDirectionalNavigation('next', navigationModelType);
}
};
document.addEventListener('keydown', navigationKeyHandler);
}
async function handleDirectionalNavigation(direction, modelType) {
if (navigationInProgress) return;
const modalElement = document.getElementById('modelModal');
const scroller = state.virtualScroller;
const filePath = modalElement?.dataset?.filePath || '';
if (!modalElement || !filePath || !scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') {
return;
}
navigationInProgress = true;
updateNavigationControls();
try {
const adjacent = await scroller.getAdjacentItemByFilePath(filePath, direction);
if (!adjacent || !adjacent.item) {
const toastKey = direction === 'prev' ? 'modals.model.navigation.noPrevious' : 'modals.model.navigation.noNext';
const toastFallback = direction === 'prev' ? 'No previous model available' : 'No next model available';
showToast(toastKey, {}, 'info', toastFallback);
return;
}
navigationModelType = modelType || navigationModelType;
await showModelModal(adjacent.item, navigationModelType || modelType);
} finally {
navigationInProgress = false;
updateNavigationControls();
}
}
/**
* Call backend to open file location and select the file
* @param {string} filePath

View File

@@ -1019,4 +1019,73 @@ export class VirtualScroller {
// Final render to ensure all content is displayed
this.renderItems();
}
/**
* Find the index of an item by its file path.
* @param {string} filePath
* @returns {number} index of the item or -1 when not found
*/
findIndexByFilePath(filePath) {
if (!filePath) return -1;
return this.items.findIndex(item => item.file_path === filePath);
}
/**
* Return navigation state for the given item.
* @param {string} filePath
* @returns {{index: number, hasPrev: boolean, hasNext: boolean, loadedItems: number, totalItems: number}}
*/
getNavigationState(filePath) {
const index = this.findIndexByFilePath(filePath);
const hasPrev = index > 0;
const hasNext = index !== -1 && (index < this.items.length - 1 || this.hasMore);
return {
index,
hasPrev,
hasNext,
loadedItems: this.items.length,
totalItems: this.totalItems
};
}
/**
* Get the adjacent item relative to the provided file path.
* When the target index falls outside the loaded window and more pages
* are available, this method will request additional pages until the
* target item is available or no more data exists.
* @param {string} filePath
* @param {'prev' | 'next'} direction
* @returns {Promise<{item: Object, index: number} | null>}
*/
async getAdjacentItemByFilePath(filePath, direction = 'next') {
const currentIndex = this.findIndexByFilePath(filePath);
if (currentIndex === -1) return null;
const offset = direction === 'prev' ? -1 : 1;
let targetIndex = currentIndex + offset;
if (targetIndex < 0) {
return null;
}
// Attempt to load more items if needed to reach the target index
let safetyCounter = 0;
while (targetIndex >= this.items.length && this.hasMore && safetyCounter < 10) {
safetyCounter++;
const newItems = await this.loadMoreItems();
if (!newItems || newItems.length === 0) {
break;
}
}
if (targetIndex < 0 || targetIndex >= this.items.length) {
return null;
}
return {
item: this.items[targetIndex],
index: targetIndex
};
}
}

View File

@@ -45,8 +45,8 @@ export async function copyToClipboard(text, successMessage = null) {
}
}
export function showToast(key, params = {}, type = 'info') {
const message = translate(key, params);
export function showToast(key, params = {}, type = 'info', fallback = null) {
const message = translate(key, params, fallback);
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;