mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
- 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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}')">×</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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user