From 4d6f4fcf6912f2a7b417a9a925a5bc3cc1d635ca Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 5 Dec 2025 22:20:31 +0800 Subject: [PATCH] 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 --- .../css/components/lora-modal/lora-modal.css | 50 ++++++ static/js/components/shared/ModelModal.js | 144 +++++++++++++++++- static/js/utils/VirtualScroller.js | 69 +++++++++ static/js/utils/uiHelpers.js | 4 +- 4 files changed, 258 insertions(+), 9 deletions(-) diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index 32b1bd3f..cdfe5aaf 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -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; } diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 562562a3..d8a0ddcf 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -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 = ` + `.trim(); const tabPanesContent = modelType === 'loras' ? `
@@ -414,11 +433,15 @@ export async function showModelModal(model, modelType) {