mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -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);
|
margin-bottom: var(--space-3);
|
||||||
padding-bottom: var(--space-2);
|
padding-bottom: var(--space-2);
|
||||||
border-bottom: 1px solid var(--lora-border);
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header-actions {
|
.modal-header-actions {
|
||||||
@@ -18,6 +19,55 @@
|
|||||||
margin-bottom: var(--space-1);
|
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 {
|
.modal-header-actions .license-restrictions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { parsePresets, renderPresetTags } from './PresetTags.js';
|
|||||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||||
import { loadRecipesForLora } from './RecipeTab.js';
|
import { loadRecipesForLora } from './RecipeTab.js';
|
||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
function getModalFilePath(fallback = '') {
|
function getModalFilePath(fallback = '') {
|
||||||
const modalElement = document.getElementById('modelModal');
|
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) {
|
function hasLicenseField(license, field) {
|
||||||
return Object.prototype.hasOwnProperty.call(license || {}, field);
|
return Object.prototype.hasOwnProperty.call(license || {}, field);
|
||||||
}
|
}
|
||||||
@@ -241,6 +246,8 @@ function renderLicenseIcons(modelData) {
|
|||||||
export async function showModelModal(model, modelType) {
|
export async function showModelModal(model, modelType) {
|
||||||
const modalId = 'modelModal';
|
const modalId = 'modelModal';
|
||||||
const modalTitle = model.model_name;
|
const modalTitle = model.model_name;
|
||||||
|
cleanupNavigationShortcuts();
|
||||||
|
detachModalHandlers(modalId);
|
||||||
|
|
||||||
// Fetch complete civitai metadata
|
// Fetch complete civitai metadata
|
||||||
let completeCivitaiData = model.civitai || {};
|
let completeCivitaiData = model.civitai || {};
|
||||||
@@ -355,6 +362,18 @@ export async function showModelModal(model, modelType) {
|
|||||||
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
||||||
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
||||||
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
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' ?
|
const tabPanesContent = modelType === 'loras' ?
|
||||||
`<div id="showcase-tab" class="tab-pane active">
|
`<div id="showcase-tab" class="tab-pane active">
|
||||||
@@ -414,6 +433,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<button class="close" onclick="modalManager.closeModal('${modalId}')">×</button>
|
<button class="close" onclick="modalManager.closeModal('${modalId}')">×</button>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
|
<div class="modal-header-row">
|
||||||
<div class="model-name-header">
|
<div class="model-name-header">
|
||||||
<h2 class="model-name-content">${modalTitle}</h2>
|
<h2 class="model-name-content">${modalTitle}</h2>
|
||||||
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
||||||
@@ -421,6 +441,9 @@ export async function showModelModal(model, modelType) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${navigationControls}
|
||||||
|
</div>
|
||||||
|
|
||||||
${headerActionsMarkup}
|
${headerActionsMarkup}
|
||||||
|
|
||||||
${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}
|
${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}
|
||||||
@@ -511,6 +534,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
showcaseCleanup();
|
showcaseCleanup();
|
||||||
showcaseCleanup = null;
|
showcaseCleanup = null;
|
||||||
}
|
}
|
||||||
|
cleanupNavigationShortcuts();
|
||||||
};
|
};
|
||||||
|
|
||||||
modalManager.showModal(modalId, content, null, onCloseCallback);
|
modalManager.showModal(modalId, content, null, onCloseCallback);
|
||||||
@@ -539,7 +563,9 @@ export async function showModelModal(model, modelType) {
|
|||||||
setupModelNameEditing(modelWithFullData.file_path);
|
setupModelNameEditing(modelWithFullData.file_path);
|
||||||
setupBaseModelEditing(modelWithFullData.file_path);
|
setupBaseModelEditing(modelWithFullData.file_path);
|
||||||
setupFileNameEditing(modelWithFullData.file_path);
|
setupFileNameEditing(modelWithFullData.file_path);
|
||||||
setupEventHandlers(modelWithFullData.file_path);
|
setupEventHandlers(modelWithFullData.file_path, modelType);
|
||||||
|
setupNavigationShortcuts(modelType);
|
||||||
|
updateNavigationControls();
|
||||||
|
|
||||||
// LoRA specific setup
|
// LoRA specific setup
|
||||||
if (modelType === 'loras' || modelType === 'embeddings') {
|
if (modelType === 'loras' || modelType === 'embeddings') {
|
||||||
@@ -589,11 +615,20 @@ function renderEmbeddingSpecificContent(embedding, escapedWords) {
|
|||||||
return `${renderTriggerWords(escapedWords, embedding.file_path)}`;
|
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
|
* Sets up event handlers using event delegation for LoRA modal
|
||||||
* @param {string} filePath - Path to the model file
|
* @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');
|
const modalElement = document.getElementById('modelModal');
|
||||||
|
|
||||||
// Remove existing event listeners first
|
// Remove existing event listeners first
|
||||||
@@ -628,6 +663,12 @@ function setupEventHandlers(filePath) {
|
|||||||
openFileLocation(filePath);
|
openFileLocation(filePath);
|
||||||
}
|
}
|
||||||
break;
|
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
|
* Call backend to open file location and select the file
|
||||||
* @param {string} filePath
|
* @param {string} filePath
|
||||||
|
|||||||
@@ -1019,4 +1019,73 @@ export class VirtualScroller {
|
|||||||
// Final render to ensure all content is displayed
|
// Final render to ensure all content is displayed
|
||||||
this.renderItems();
|
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') {
|
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||||||
const message = translate(key, params);
|
const message = translate(key, params, fallback);
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
|
|||||||
Reference in New Issue
Block a user