feat(bulk): implement bulk context menu for model operations and remove bulk operations panel

This commit is contained in:
Will Miao
2025-09-04 15:24:54 +08:00
parent ac7d23011c
commit 9bebcc9a4b
7 changed files with 192 additions and 267 deletions

View File

@@ -176,11 +176,6 @@
background: linear-gradient(45deg, #4a90e2, #357abd);
}
/* Remove old node-color-indicator styles */
.node-color-indicator {
display: none;
}
.send-all-item {
border-top: 1px solid var(--border-color);
font-weight: 500;
@@ -217,4 +212,22 @@
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
/* Bulk Context Menu Header */
.bulk-context-header {
padding: 10px 12px;
background: var(--lora-accent);
color: var(--lora-text);
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-size: 14px;
border-radius: var(--border-radius-xs) var(--border-radius-xs) 0 0;
}
.bulk-context-header i {
width: 16px;
text-align: center;
}

View File

@@ -0,0 +1,123 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { state } from '../../state/index.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { translate, updateElementText } from '../../utils/i18nHelpers.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
export class BulkContextMenu extends BaseContextMenu {
constructor() {
super('bulkContextMenu', '.model-card.selected');
this.setupBulkMenuItems();
}
init() {
// Override parent init to handle bulk-specific context menu logic
document.addEventListener('click', () => this.hideMenu());
document.addEventListener('contextmenu', (e) => {
const card = e.target.closest('.model-card');
if (!card || !state.bulkMode) {
this.hideMenu();
return;
}
// Show bulk menu only if right-clicking on a selected card
if (card.classList.contains('selected')) {
e.preventDefault();
this.showMenu(e.clientX, e.clientY, card);
} else {
this.hideMenu();
}
});
// Handle menu item clicks
this.menu.addEventListener('click', (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (!menuItem || !this.currentCard) return;
const action = menuItem.dataset.action;
if (!action) return;
this.handleMenuAction(action, menuItem);
this.hideMenu();
});
}
setupBulkMenuItems() {
if (!this.menu) return;
// Update menu items visibility based on current model type
this.updateMenuItemsForModelType();
// Update selected count in header
this.updateSelectedCountHeader();
}
updateMenuItemsForModelType() {
const currentModelType = state.currentPageType;
const config = bulkManager.actionConfig[currentModelType];
if (!config) return;
// Update button visibility based on model type
const sendToWorkflowItem = this.menu.querySelector('[data-action="send-to-workflow"]');
const copyAllItem = this.menu.querySelector('[data-action="copy-all"]');
const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]');
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
if (sendToWorkflowItem) {
sendToWorkflowItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
}
if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
}
if (refreshAllItem) {
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
}
if (moveAllItem) {
moveAllItem.style.display = config.moveAll ? 'flex' : 'none';
}
if (deleteAllItem) {
deleteAllItem.style.display = config.deleteAll ? 'flex' : 'none';
}
}
updateSelectedCountHeader() {
const headerElement = this.menu.querySelector('.bulk-context-header');
if (headerElement) {
updateElementText(headerElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size });
}
}
showMenu(x, y, card) {
this.updateMenuItemsForModelType();
this.updateSelectedCountHeader();
super.showMenu(x, y, card);
}
handleMenuAction(action, menuItem) {
switch (action) {
case 'send-to-workflow':
bulkManager.sendAllModelsToWorkflow();
break;
case 'copy-all':
bulkManager.copyAllModelsSyntax();
break;
case 'refresh-all':
bulkManager.refreshAllMetadata();
break;
case 'move-all':
window.moveManager.showMoveModal('bulk');
break;
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'clear':
bulkManager.clearSelection();
break;
default:
console.warn(`Unknown bulk action: ${action}`);
}
}
}

View File

@@ -15,6 +15,7 @@ import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js';
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
// Core application class
export class AppCore {
@@ -55,6 +56,10 @@ export class AppCore {
// Initialize the bulk manager
bulkManager.initialize();
// Initialize bulk context menu
const bulkContextMenu = new BulkContextMenu();
bulkManager.setBulkContextMenu(bulkContextMenu);
// Initialize the example images manager
exampleImagesManager.initialize();
// Initialize the help manager

View File

@@ -2,18 +2,14 @@ import { state, getCurrentPageState } from '../state/index.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { moveManager } from './MoveManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { updateElementText } from '../utils/i18nHelpers.js';
export class BulkManager {
constructor() {
this.bulkBtn = document.getElementById('bulkOperationsBtn');
this.bulkPanel = document.getElementById('bulkOperationsPanel');
this.isStripVisible = false;
this.stripMaxThumbnails = 50;
// Remove bulk panel references since we're using context menu now
this.bulkContextMenu = null; // Will be set by core initialization
// Model type specific action configurations
this.actionConfig = {
@@ -46,41 +42,13 @@ export class BulkManager {
this.setupGlobalKeyboardListeners();
}
setBulkContextMenu(bulkContextMenu) {
this.bulkContextMenu = bulkContextMenu;
}
setupEventListeners() {
// Bulk operations button listeners
const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
const clearBtn = this.bulkPanel?.querySelector('[data-action="clear"]');
if (sendToWorkflowBtn) {
sendToWorkflowBtn.addEventListener('click', () => this.sendAllModelsToWorkflow());
}
if (copyAllBtn) {
copyAllBtn.addEventListener('click', () => this.copyAllModelsSyntax());
}
if (refreshAllBtn) {
refreshAllBtn.addEventListener('click', () => this.refreshAllMetadata());
}
if (moveAllBtn) {
moveAllBtn.addEventListener('click', () => {
moveManager.showMoveModal('bulk');
});
}
if (deleteAllBtn) {
deleteAllBtn.addEventListener('click', () => this.showBulkDeleteModal());
}
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearSelection());
}
// Selected count click listener
const selectedCount = document.getElementById('selectedCount');
if (selectedCount) {
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
}
// Only setup bulk mode toggle button listener now
// Context menu actions are handled by BulkContextMenu
}
setupGlobalKeyboardListeners() {
@@ -115,60 +83,15 @@ export class BulkManager {
this.bulkBtn.classList.toggle('active', state.bulkMode);
if (state.bulkMode) {
this.bulkPanel.classList.remove('hidden');
this.updateActionButtonsVisibility();
setTimeout(() => {
this.bulkPanel.classList.add('visible');
}, 10);
} else {
this.bulkPanel.classList.remove('visible');
setTimeout(() => {
this.bulkPanel.classList.add('hidden');
}, 400);
this.hideThumbnailStrip();
}
updateCardsForBulkMode(state.bulkMode);
if (!state.bulkMode) {
this.clearSelection();
// TODO:
document.querySelectorAll('.model-card').forEach(card => {
const actions = card.querySelectorAll('.card-actions, .card-button');
actions.forEach(action => action.style.display = 'flex');
});
}
}
updateActionButtonsVisibility() {
const currentModelType = state.currentPageType;
const config = this.actionConfig[currentModelType];
if (!config) return;
// Update button visibility based on model type
const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
if (sendToWorkflowBtn) {
sendToWorkflowBtn.style.display = config.sendToWorkflow ? 'block' : 'none';
}
if (copyAllBtn) {
copyAllBtn.style.display = config.copyAll ? 'block' : 'none';
}
if (refreshAllBtn) {
refreshAllBtn.style.display = config.refreshAll ? 'block' : 'none';
}
if (moveAllBtn) {
moveAllBtn.style.display = config.moveAll ? 'block' : 'none';
}
if (deleteAllBtn) {
deleteAllBtn.style.display = config.deleteAll ? 'block' : 'none';
// Hide context menu when exiting bulk mode
if (this.bulkContextMenu) {
this.bulkContextMenu.hideMenu();
}
}
}
@@ -177,27 +100,10 @@ export class BulkManager {
card.classList.remove('selected');
});
state.selectedModels.clear();
this.updateSelectedCount();
this.hideThumbnailStrip();
}
updateSelectedCount() {
const countElement = document.getElementById('selectedCount');
if (countElement) {
// Use i18nHelpers.js to update the count text
updateElementText(countElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size });
const existingCaret = countElement.querySelector('.dropdown-caret');
if (existingCaret) {
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
existingCaret.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
} else {
const caretIcon = document.createElement('i');
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
caretIcon.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
countElement.appendChild(caretIcon);
}
// Update context menu header if visible
if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader();
}
}
@@ -222,10 +128,9 @@ export class BulkManager {
});
}
this.updateSelectedCount();
if (this.isStripVisible) {
this.updateThumbnailStrip();
// Update context menu header if visible
if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader();
}
}
@@ -277,8 +182,6 @@ export class BulkManager {
card.classList.remove('selected');
}
});
this.updateSelectedCount();
}
async copyAllModelsSyntax() {
@@ -413,115 +316,6 @@ export class BulkManager {
showToast('toast.models.deleteFailedGeneral', {}, 'error');
}
}
toggleThumbnailStrip() {
if (state.selectedModels.size === 0) return;
const existing = document.querySelector('.selected-thumbnails-strip');
if (existing) {
this.hideThumbnailStrip();
} else {
this.showThumbnailStrip();
}
}
showThumbnailStrip() {
const strip = document.createElement('div');
strip.className = 'selected-thumbnails-strip';
const thumbnailContainer = document.createElement('div');
thumbnailContainer.className = 'thumbnails-container';
strip.appendChild(thumbnailContainer);
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
this.updateThumbnailStrip();
this.isStripVisible = true;
this.updateSelectedCount();
setTimeout(() => strip.classList.add('visible'), 10);
}
hideThumbnailStrip() {
const strip = document.querySelector('.selected-thumbnails-strip');
if (strip && this.isStripVisible) {
strip.classList.remove('visible');
this.isStripVisible = false;
const countElement = document.getElementById('selectedCount');
if (countElement) {
const caret = countElement.querySelector('.dropdown-caret');
if (caret) {
caret.className = 'fas fa-caret-up dropdown-caret';
}
}
setTimeout(() => {
if (strip.parentNode) {
strip.parentNode.removeChild(strip);
}
}, 300);
}
}
updateThumbnailStrip() {
const container = document.querySelector('.thumbnails-container');
if (!container) return;
container.innerHTML = '';
const selectedModels = Array.from(state.selectedModels);
if (selectedModels.length > this.stripMaxThumbnails) {
const counter = document.createElement('div');
counter.className = 'strip-counter';
counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedModels.length} selected`;
container.appendChild(counter);
}
const thumbnailsToShow = selectedModels.slice(0, this.stripMaxThumbnails);
const metadataCache = this.getMetadataCache();
thumbnailsToShow.forEach(filepath => {
const metadata = metadataCache.get(filepath);
if (!metadata) return;
const thumbnail = document.createElement('div');
thumbnail.className = 'selected-thumbnail';
thumbnail.dataset.filepath = filepath;
if (metadata.isVideo) {
thumbnail.innerHTML = `
<video autoplay loop muted playsinline>
<source src="${metadata.previewUrl}" type="video/mp4">
</video>
<span class="thumbnail-name" title="${metadata.modelName}">${metadata.modelName}</span>
<button class="thumbnail-remove"><i class="fas fa-times"></i></button>
`;
} else {
thumbnail.innerHTML = `
<img src="${metadata.previewUrl}" alt="${metadata.modelName}">
<span class="thumbnail-name" title="${metadata.modelName}">${metadata.modelName}</span>
<button class="thumbnail-remove"><i class="fas fa-times"></i></button>
`;
}
thumbnail.addEventListener('click', (e) => {
if (!e.target.closest('.thumbnail-remove')) {
this.deselectItem(filepath);
}
});
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
e.stopPropagation();
this.deselectItem(filepath);
});
container.appendChild(thumbnail);
});
}
deselectItem(filepath) {
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
@@ -530,13 +324,6 @@ export class BulkManager {
}
state.selectedModels.delete(filepath);
this.updateSelectedCount();
this.updateThumbnailStrip();
if (state.selectedModels.size === 0) {
this.hideThumbnailStrip();
}
}
selectAllVisibleModels() {