Merge pull request #400 from willmiao/bulk-menu

Bulk menu
This commit is contained in:
pixelpaws
2025-09-05 17:44:40 +08:00
committed by GitHub
30 changed files with 1703 additions and 525 deletions

View File

@@ -0,0 +1,182 @@
# Event Management Implementation Summary
## What Has Been Implemented
### 1. Enhanced EventManager Class
- **Location**: `static/js/utils/EventManager.js`
- **Features**:
- Priority-based event handling
- Conditional execution based on application state
- Element filtering (target/exclude selectors)
- Mouse button filtering
- Automatic cleanup with cleanup functions
- State tracking for app modes
- Error handling for event handlers
### 2. BulkManager Integration
- **Location**: `static/js/managers/BulkManager.js`
- **Migrated Events**:
- Global keyboard shortcuts (Ctrl+A, Escape, B key)
- Marquee selection events (mousedown, mousemove, mouseup, contextmenu)
- State synchronization with EventManager
- **Benefits**:
- Centralized priority handling
- Conditional execution based on modal state
- Better coordination with other components
### 3. UIHelpers Integration
- **Location**: `static/js/utils/uiHelpers.js`
- **Migrated Events**:
- Mouse position tracking for node selector positioning
- Node selector click events (outside clicks and selection)
- State management for node selector
- **Benefits**:
- Reduced direct DOM listeners
- Coordinated state tracking
- Better cleanup
### 4. ModelCard Integration
- **Location**: `static/js/components/shared/ModelCard.js`
- **Migrated Events**:
- Model card click delegation
- Action button handling (star, globe, copy, etc.)
- Better return value handling for event propagation
- **Benefits**:
- Single event listener for all model cards
- Priority-based execution
- Better event flow control
### 5. Documentation and Initialization
- **EventManagerDocs.md**: Comprehensive documentation
- **eventManagementInit.js**: Initialization and global handlers
- **Features**:
- Global escape key handling
- Modal state synchronization
- Error handling
- Analytics integration points
- Cleanup on page unload
## Application States Tracked
1. **bulkMode**: When bulk selection mode is active
2. **marqueeActive**: When marquee selection is in progress
3. **modalOpen**: When any modal dialog is open
4. **nodeSelectorActive**: When node selector popup is visible
## Priority Levels Used
- **250+**: Critical system events (escape keys)
- **200+**: High priority system events (modal close)
- **100-199**: Application-level shortcuts (bulk operations)
- **80-99**: UI interactions (marquee selection)
- **60-79**: Component interactions (model cards)
- **10-49**: Tracking and monitoring
- **1-9**: Analytics and low-priority tasks
## Event Flow Examples
### Bulk Mode Toggle (B key)
1. **Priority 100**: BulkManager keyboard handler catches 'b' key
2. Toggles bulk mode state
3. Updates EventManager state
4. Updates UI accordingly
5. Stops propagation (returns true)
### Marquee Selection
1. **Priority 80**: BulkManager mousedown handler (only in .models-container, excluding cards/buttons)
2. Starts marquee selection
3. **Priority 90**: BulkManager mousemove handler (only when marquee active)
4. Updates selection rectangle
5. **Priority 90**: BulkManager mouseup handler ends selection
### Model Card Click
1. **Priority 60**: ModelCard delegation handler checks for specific elements
2. If action button: handles action and stops propagation
3. If general card click: continues to other handlers
4. Bulk selection may also handle the event if in bulk mode
## Remaining Event Listeners (Not Yet Migrated)
### High Priority for Migration
1. **SearchManager keyboard events** - Global search shortcuts
2. **ModalManager escape handling** - Already integrated with initialization
3. **Scroll-based events** - Back to top, virtual scrolling
4. **Resize events** - Panel positioning, responsive layouts
### Medium Priority
1. **Form input events** - Tag inputs, settings forms
2. **Component-specific events** - Recipe modal, showcase view
3. **Sidebar events** - Resize handling, toggle events
### Low Priority (Can Remain As-Is)
1. **VirtualScroller events** - Performance-critical, specialized
2. **Component lifecycle events** - Modal open/close callbacks
3. **One-time setup events** - Theme initialization, etc.
## Benefits Achieved
### Performance Improvements
- **Reduced DOM listeners**: From ~15+ individual listeners to ~5 coordinated handlers
- **Conditional execution**: Handlers only run when conditions are met
- **Priority ordering**: Important events handled first
- **Better memory management**: Automatic cleanup prevents leaks
### Coordination Improvements
- **State synchronization**: All components aware of app state
- **Event flow control**: Proper propagation stopping
- **Conflict resolution**: Priority system prevents conflicts
- **Debugging**: Centralized event handling for easier debugging
### Code Quality Improvements
- **Consistent patterns**: All event handling follows same patterns
- **Better separation of concerns**: Event logic separated from business logic
- **Error handling**: Centralized error catching and reporting
- **Documentation**: Clear patterns for future development
## Next Steps (Recommendations)
### 1. Migrate Search Events
```javascript
// In SearchManager.js
eventManager.addHandler('keydown', 'search-shortcuts', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
this.focusSearchInput();
return true;
}
}, { priority: 120 });
```
### 2. Integrate Resize Events
```javascript
// Create ResizeManager
eventManager.addHandler('resize', 'layout-resize', debounce((e) => {
this.updateLayoutDimensions();
}, 250), { priority: 50 });
```
### 3. Add Debug Mode
```javascript
// In EventManager.js
if (window.DEBUG_EVENTS) {
console.log(`Event ${eventType} handled by ${source} (priority: ${priority})`);
}
```
### 4. Create Event Analytics
```javascript
// Track event patterns for optimization
eventManager.addHandler('*', 'analytics', (e) => {
this.trackEventUsage(e.type, performance.now());
}, { priority: 1 });
```
## Testing Recommendations
1. **Verify bulk mode interactions** work correctly
2. **Test marquee selection** in various scenarios
3. **Check modal state synchronization**
4. **Verify node selector** positioning and cleanup
5. **Test keyboard shortcuts** don't conflict
6. **Verify proper cleanup** when components are destroyed
The centralized event management system provides a solid foundation for coordinated, efficient event handling across the application while maintaining good performance and code organization.

301
docs/EventManagerDocs.md Normal file
View File

@@ -0,0 +1,301 @@
# Centralized Event Management System
This document describes the centralized event management system that coordinates event handling across the ComfyUI LoRA Manager application.
## Overview
The `EventManager` class provides a centralized way to handle DOM events with priority-based execution, conditional execution based on application state, and proper cleanup mechanisms.
## Features
- **Priority-based execution**: Handlers with higher priority run first
- **Conditional execution**: Handlers can be executed based on application state
- **Element filtering**: Handlers can target specific elements or exclude others
- **Automatic cleanup**: Cleanup functions are called when handlers are removed
- **State tracking**: Tracks application states like bulk mode, modal open, etc.
## Basic Usage
### Importing
```javascript
import { eventManager } from './EventManager.js';
```
### Adding Event Handlers
```javascript
eventManager.addHandler('click', 'myComponent', (event) => {
console.log('Button clicked!');
return true; // Stop propagation to other handlers
}, {
priority: 100,
targetSelector: '.my-button',
skipWhenModalOpen: true
});
```
### Removing Event Handlers
```javascript
// Remove specific handler
eventManager.removeHandler('click', 'myComponent');
// Remove all handlers for a component
eventManager.removeAllHandlersForSource('myComponent');
```
### Updating Application State
```javascript
// Set state
eventManager.setState('bulkMode', true);
eventManager.setState('modalOpen', true);
// Get state
const isBulkMode = eventManager.getState('bulkMode');
```
## Available States
- `bulkMode`: Whether bulk selection mode is active
- `marqueeActive`: Whether marquee selection is in progress
- `modalOpen`: Whether any modal is currently open
- `nodeSelectorActive`: Whether the node selector popup is active
## Handler Options
### Priority
Higher numbers = higher priority. Handlers run in descending priority order.
```javascript
{
priority: 100 // High priority
}
```
### Conditional Execution
```javascript
{
onlyInBulkMode: true, // Only run when bulk mode is active
onlyWhenMarqueeActive: true, // Only run when marquee selection is active
skipWhenModalOpen: true, // Skip when any modal is open
skipWhenNodeSelectorActive: true, // Skip when node selector is active
onlyWhenNodeSelectorActive: true // Only run when node selector is active
}
```
### Element Filtering
```javascript
{
targetSelector: '.model-card', // Only handle events on matching elements
excludeSelector: 'button, input', // Exclude events from these elements
button: 0 // Only handle specific mouse button (0=left, 1=middle, 2=right)
}
```
### Cleanup Functions
```javascript
{
cleanup: () => {
// Custom cleanup logic
console.log('Handler cleaned up');
}
}
```
## Integration Examples
### BulkManager Integration
```javascript
class BulkManager {
registerEventHandlers() {
// High priority keyboard shortcuts
eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
return this.handleGlobalKeyboard(e);
}, {
priority: 100,
skipWhenModalOpen: true
});
// Marquee selection
eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
return this.handleMarqueeStart(e);
}, {
priority: 80,
skipWhenModalOpen: true,
targetSelector: '.models-container',
excludeSelector: '.model-card, button, input',
button: 0
});
}
cleanup() {
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
}
}
```
### Modal Integration
```javascript
class ModalManager {
showModal(modalId) {
// Update state when modal opens
eventManager.setState('modalOpen', true);
this.displayModal(modalId);
}
closeModal(modalId) {
// Update state when modal closes
eventManager.setState('modalOpen', false);
this.hideModal(modalId);
}
}
```
### Component Event Delegation
```javascript
export function setupComponentEvents() {
eventManager.addHandler('click', 'myComponent-actions', (event) => {
const button = event.target.closest('.action-button');
if (!button) return false;
this.handleAction(button.dataset.action);
return true; // Stop propagation
}, {
priority: 60,
targetSelector: '.component-container'
});
}
```
## Best Practices
### 1. Use Descriptive Source Names
Use the format `componentName-purposeDescription`:
```javascript
// Good
'bulkManager-marqueeSelection'
'nodeSelector-clickOutside'
'modelCard-delegation'
// Avoid
'bulk'
'click'
'handler1'
```
### 2. Set Appropriate Priorities
- 200+: Critical system events (escape keys, critical modals)
- 100-199: High priority application events (keyboard shortcuts)
- 50-99: Normal UI interactions (buttons, cards)
- 1-49: Low priority events (tracking, analytics)
### 3. Use Conditional Execution
Instead of checking state inside handlers, use options:
```javascript
// Good
eventManager.addHandler('click', 'bulk-action', handler, {
onlyInBulkMode: true
});
// Avoid
eventManager.addHandler('click', 'bulk-action', (e) => {
if (!state.bulkMode) return;
// handler logic
});
```
### 4. Clean Up Properly
Always clean up handlers when components are destroyed:
```javascript
class MyComponent {
constructor() {
this.registerEvents();
}
destroy() {
eventManager.removeAllHandlersForSource('myComponent');
}
}
```
### 5. Return Values Matter
- Return `true` to stop event propagation to other handlers
- Return `false` or `undefined` to continue with other handlers
## Migration Guide
### From Direct Event Listeners
**Before:**
```javascript
document.addEventListener('click', (e) => {
if (e.target.closest('.my-button')) {
this.handleClick(e);
}
});
```
**After:**
```javascript
eventManager.addHandler('click', 'myComponent-button', (e) => {
this.handleClick(e);
}, {
targetSelector: '.my-button'
});
```
### From Event Delegation
**Before:**
```javascript
container.addEventListener('click', (e) => {
const card = e.target.closest('.model-card');
if (!card) return;
if (e.target.closest('.action-btn')) {
this.handleAction(e);
}
});
```
**After:**
```javascript
eventManager.addHandler('click', 'container-actions', (e) => {
const card = e.target.closest('.model-card');
if (!card) return false;
if (e.target.closest('.action-btn')) {
this.handleAction(e);
return true;
}
}, {
targetSelector: '.container'
});
```
## Performance Benefits
1. **Reduced DOM listeners**: Single listener per event type instead of multiple
2. **Conditional execution**: Handlers only run when conditions are met
3. **Priority ordering**: Important handlers run first, avoiding unnecessary work
4. **Automatic cleanup**: Prevents memory leaks from orphaned listeners
5. **Centralized debugging**: All event handling flows through one system
## Debugging
Enable debug logging to trace event handling:
```javascript
// Add to EventManager.js for debugging
console.log(`Handling ${eventType} event with ${handlers.length} handlers`);
```
The event manager provides a foundation for coordinated, efficient event handling across the entire application.

View File

@@ -318,14 +318,13 @@
"bulkOperations": {
"selected": "{count} ausgewählt",
"selectedSuffix": "ausgewählt",
"viewSelected": "Klicken Sie, um ausgewählte Elemente anzuzeigen",
"addTags": "Tags hinzufügen",
"sendToWorkflow": "An Workflow senden",
"copyAll": "Alle kopieren",
"refreshAll": "Alle aktualisieren",
"moveAll": "Alle verschieben",
"deleteAll": "Alle löschen",
"clear": "Leeren"
"viewSelected": "Auswahl anzeigen",
"addTags": "Allen Tags hinzufügen",
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"moveAll": "Alle in Ordner verschieben",
"deleteAll": "Alle Modelle löschen",
"clear": "Auswahl löschen"
},
"contextMenu": {
"refreshMetadata": "Civitai-Daten aktualisieren",
@@ -983,6 +982,7 @@
"deleteFailed": "Fehler: {error}",
"deleteFailedGeneral": "Fehler beim Löschen der Modelle",
"selectedAdditional": "{count} zusätzliche {type}(s) ausgewählt",
"marqueeSelectionComplete": "{count} {type}(s) mit Rahmenauswahl ausgewählt",
"refreshMetadataFailed": "Fehler beim Aktualisieren der Metadaten",
"nameCannotBeEmpty": "Modellname darf nicht leer sein",
"nameUpdatedSuccessfully": "Modellname erfolgreich aktualisiert",

View File

@@ -318,14 +318,14 @@
"bulkOperations": {
"selected": "{count} selected",
"selectedSuffix": "selected",
"viewSelected": "Click to view selected items",
"addTags": "Add Tags",
"sendToWorkflow": "Send to Workflow",
"copyAll": "Copy All",
"refreshAll": "Refresh All",
"moveAll": "Move All",
"deleteAll": "Delete All",
"clear": "Clear"
"viewSelected": "View Selected",
"addTags": "Add Tags to All",
"setBaseModel": "Set Base Model for All",
"copyAll": "Copy All Syntax",
"refreshAll": "Refresh All Metadata",
"moveAll": "Move All to Folder",
"deleteAll": "Delete All Models",
"clear": "Clear Selection"
},
"contextMenu": {
"refreshMetadata": "Refresh Civitai Data",
@@ -583,6 +583,14 @@
"replaceTags": "Replace Tags",
"saveChanges": "Save changes"
},
"bulkBaseModel": {
"title": "Set Base Model for Multiple Models",
"description": "Set base model for",
"models": "models",
"selectBaseModel": "Select Base Model",
"save": "Update Base Model",
"cancel": "Cancel"
},
"exampleAccess": {
"title": "Local Example Images",
"message": "No local example images found for this model. View options:",
@@ -983,12 +991,18 @@
"deleteFailed": "Error: {error}",
"deleteFailedGeneral": "Failed to delete models",
"selectedAdditional": "Selected {count} additional {type}(s)",
"marqueeSelectionComplete": "Selected {count} {type}(s) with marquee selection",
"refreshMetadataFailed": "Failed to refresh metadata",
"nameCannotBeEmpty": "Model name cannot be empty",
"nameUpdatedSuccessfully": "Model name updated successfully",
"nameUpdateFailed": "Failed to update model name",
"baseModelUpdated": "Base model updated successfully",
"baseModelUpdateFailed": "Failed to update base model",
"baseModelNotSelected": "Please select a base model",
"bulkBaseModelUpdating": "Updating base model for {count} model(s)...",
"bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)",
"bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)",
"bulkBaseModelUpdateFailed": "Failed to update base model for selected models",
"invalidCharactersRemoved": "Invalid characters removed from filename",
"filenameCannotBeEmpty": "File name cannot be empty",
"renameFailed": "Failed to rename file: {message}",

View File

@@ -318,14 +318,13 @@
"bulkOperations": {
"selected": "{count} seleccionados",
"selectedSuffix": "seleccionados",
"viewSelected": "Clic para ver elementos seleccionados",
"addTags": "Añadir etiquetas",
"sendToWorkflow": "Enviar al flujo de trabajo",
"copyAll": "Copiar todo",
"refreshAll": "Actualizar todo",
"moveAll": "Mover todo",
"deleteAll": "Eliminar todo",
"clear": "Limpiar"
"viewSelected": "Ver seleccionados",
"addTags": "Añadir etiquetas a todos",
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"moveAll": "Mover todos a carpeta",
"deleteAll": "Eliminar todos los modelos",
"clear": "Limpiar selección"
},
"contextMenu": {
"refreshMetadata": "Actualizar datos de Civitai",
@@ -983,6 +982,7 @@
"deleteFailed": "Error: {error}",
"deleteFailedGeneral": "Error al eliminar modelos",
"selectedAdditional": "Seleccionados {count} {type}(s) adicionales",
"marqueeSelectionComplete": "Seleccionados {count} {type}(s) con selección de marco",
"refreshMetadataFailed": "Error al actualizar metadatos",
"nameCannotBeEmpty": "El nombre del modelo no puede estar vacío",
"nameUpdatedSuccessfully": "Nombre del modelo actualizado exitosamente",

View File

@@ -318,14 +318,13 @@
"bulkOperations": {
"selected": "{count} sélectionné(s)",
"selectedSuffix": "sélectionné(s)",
"viewSelected": "Cliquez pour voir les éléments sélectionnés",
"addTags": "Ajouter des tags",
"sendToWorkflow": "Envoyer vers le workflow",
"copyAll": "Tout copier",
"refreshAll": "Tout actualiser",
"moveAll": "Tout déplacer",
"deleteAll": "Tout supprimer",
"clear": "Effacer"
"viewSelected": "Voir la sélection",
"addTags": "Ajouter des tags à tous",
"copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées",
"moveAll": "Déplacer tout vers un dossier",
"deleteAll": "Supprimer tous les modèles",
"clear": "Effacer la sélection"
},
"contextMenu": {
"refreshMetadata": "Actualiser les données Civitai",
@@ -983,6 +982,7 @@
"deleteFailed": "Erreur : {error}",
"deleteFailedGeneral": "Échec de la suppression des modèles",
"selectedAdditional": "{count} {type}(s) supplémentaire(s) sélectionné(s)",
"marqueeSelectionComplete": "{count} {type}(s) sélectionné(s) avec la sélection par glisser-déposer",
"refreshMetadataFailed": "Échec de l'actualisation des métadonnées",
"nameCannotBeEmpty": "Le nom du modèle ne peut pas être vide",
"nameUpdatedSuccessfully": "Nom du modèle mis à jour avec succès",

View File

@@ -318,14 +318,13 @@
"bulkOperations": {
"selected": "{count} 選択中",
"selectedSuffix": "選択中",
"viewSelected": "選択したアイテムを表示するにはクリック",
"addTags": "タグを追加",
"sendToWorkflow": "ワークフローに送信",
"copyAll": "すべてコピー",
"refreshAll": "すべて更新",
"moveAll": "すべて移動",
"deleteAll": "すべて削除",
"clear": "クリア"
"viewSelected": "選択中を表示",
"addTags": "すべてにタグを追加",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"moveAll": "すべてをフォルダに移動",
"deleteAll": "すべてのモデルを削除",
"clear": "選択をクリア"
},
"contextMenu": {
"refreshMetadata": "Civitaiデータを更新",
@@ -983,6 +982,7 @@
"deleteFailed": "エラー:{error}",
"deleteFailedGeneral": "モデルの削除に失敗しました",
"selectedAdditional": "{count} 追加{type}が選択されました",
"marqueeSelectionComplete": "マーキー選択で {count} の{type}が選択されました",
"refreshMetadataFailed": "メタデータの更新に失敗しました",
"nameCannotBeEmpty": "モデル名を空にすることはできません",
"nameUpdatedSuccessfully": "モデル名が正常に更新されました",

View File

@@ -318,14 +318,13 @@
"bulkOperations": {
"selected": "{count}개 선택됨",
"selectedSuffix": "개 선택됨",
"viewSelected": "선택 항목 보기",
"addTags": "태그 추가",
"sendToWorkflow": "워크플로로 전송",
"copyAll": "모두 복사",
"refreshAll": "모두 새로고침",
"moveAll": "모두 이동",
"deleteAll": "모두 삭제",
"clear": "지우기"
"viewSelected": "선택 항목 보기",
"addTags": "모두에 태그 추가",
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"moveAll": "모두 폴더로 이동",
"deleteAll": "모든 모델 삭제",
"clear": "선택 지우기"
},
"contextMenu": {
"refreshMetadata": "Civitai 데이터 새로고침",
@@ -983,6 +982,7 @@
"deleteFailed": "오류: {error}",
"deleteFailedGeneral": "모델 삭제에 실패했습니다",
"selectedAdditional": "추가로 {count}개의 {type}이(가) 선택되었습니다",
"marqueeSelectionComplete": "마키 선택으로 {count}개의 {type}이(가) 선택되었습니다",
"refreshMetadataFailed": "메타데이터 새로고침에 실패했습니다",
"nameCannotBeEmpty": "모델 이름은 비어있을 수 없습니다",
"nameUpdatedSuccessfully": "모델 이름이 성공적으로 업데이트되었습니다",

View File

@@ -318,14 +318,13 @@
"bulkOperations": {
"selected": "Выбрано {count}",
"selectedSuffix": "выбрано",
"viewSelected": "Нажмите для просмотра выбранных элементов",
"addTags": "Добавить теги",
"sendToWorkflow": "Отправить в Workflow",
"copyAll": "Копировать все",
"refreshAll": "Обновить все",
"moveAll": "Переместить все",
"deleteAll": "Удалить все",
"clear": "Очистить"
"viewSelected": "Просмотреть выбранные",
"addTags": "Добавить теги ко всем",
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"moveAll": "Переместить все в папку",
"deleteAll": "Удалить все модели",
"clear": "Очистить выбор"
},
"contextMenu": {
"refreshMetadata": "Обновить данные Civitai",
@@ -983,6 +982,7 @@
"deleteFailed": "Ошибка: {error}",
"deleteFailedGeneral": "Не удалось удалить модели",
"selectedAdditional": "Выбрано дополнительно {count} {type}(ей)",
"marqueeSelectionComplete": "Выбрано {count} {type} с помощью выделения рамкой",
"refreshMetadataFailed": "Не удалось обновить метаданные",
"nameCannotBeEmpty": "Название модели не может быть пустым",
"nameUpdatedSuccessfully": "Название модели успешно обновлено",

View File

@@ -318,14 +318,14 @@
"bulkOperations": {
"selected": "已选中 {count} 项",
"selectedSuffix": "已选中",
"viewSelected": "点击查看已选项目",
"addTags": "批量添加标签",
"sendToWorkflow": "发送到工作流",
"copyAll": "全部复制",
"refreshAll": "全部刷新",
"moveAll": "全部移动",
"deleteAll": "全部删除",
"clear": "清除"
"viewSelected": "查看已选",
"addTags": "为所有添加标签",
"setBaseModel": "为所有设置基础模型",
"copyAll": "复制全部语法",
"refreshAll": "刷新全部元数据",
"moveAll": "全部移动到文件夹",
"deleteAll": "删除所有模型",
"clear": "清除选择"
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 数据",
@@ -583,6 +583,14 @@
"replaceTags": "替换标签",
"saveChanges": "保存更改"
},
"bulkBaseModel": {
"title": "批量设置基础模型",
"description": "为多个模型设置基础模型",
"models": "个模型",
"selectBaseModel": "选择基础模型",
"save": "更新基础模型",
"cancel": "取消"
},
"exampleAccess": {
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
@@ -983,12 +991,18 @@
"deleteFailed": "错误:{error}",
"deleteFailedGeneral": "删除模型失败",
"selectedAdditional": "已选中 {count} 个额外 {type}",
"marqueeSelectionComplete": "框选已选中 {count} 个 {type}",
"refreshMetadataFailed": "刷新元数据失败",
"nameCannotBeEmpty": "模型名称不能为空",
"nameUpdatedSuccessfully": "模型名称更新成功",
"nameUpdateFailed": "模型名称更新失败",
"baseModelUpdated": "基础模型更新成功",
"baseModelUpdateFailed": "基础模型更新失败",
"baseModelNotSelected": "请选择基础模型",
"bulkBaseModelUpdating": "正在为 {count} 个模型更新基础模型...",
"bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型",
"bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败",
"bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败",
"invalidCharactersRemoved": "文件名中的无效字符已移除",
"filenameCannotBeEmpty": "文件名不能为空",
"renameFailed": "重命名文件失败:{message}",

View File

@@ -318,14 +318,13 @@
"bulkOperations": {
"selected": "已選擇 {count} 項",
"selectedSuffix": "已選擇",
"viewSelected": "點擊檢視已選項目",
"addTags": "新增標籤",
"sendToWorkflow": "傳送到工作流",
"copyAll": "全部複製",
"refreshAll": "全部刷新",
"moveAll": "全部移動",
"deleteAll": "全部刪除",
"clear": "清除"
"viewSelected": "檢視已選",
"addTags": "新增標籤到全部",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"moveAll": "全部移動到資料夾",
"deleteAll": "刪除全部模型",
"clear": "清除選取"
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 資料",
@@ -983,6 +982,7 @@
"deleteFailed": "錯誤:{error}",
"deleteFailedGeneral": "刪除模型失敗",
"selectedAdditional": "已選擇 {count} 個額外 {type}",
"marqueeSelectionComplete": "框選已選擇 {count} 個 {type}",
"refreshMetadataFailed": "刷新 metadata 失敗",
"nameCannotBeEmpty": "模型名稱不可為空",
"nameUpdatedSuccessfully": "模型名稱已成功更新",

View File

@@ -78,11 +78,12 @@ class TranslationKeySynchronizer:
"""
Merge the reference JSON structure with existing target translations.
This creates a new structure that matches the reference exactly but preserves
existing translations where available.
existing translations where available. Keys not in reference are removed.
"""
def merge_recursive(ref_obj, target_obj):
if isinstance(ref_obj, (dict, OrderedDict)):
result = OrderedDict()
# Only include keys that exist in the reference
for key, ref_value in ref_obj.items():
if key in target_obj and isinstance(target_obj[key], type(ref_value)):
# Key exists in target with same type
@@ -131,6 +132,7 @@ class TranslationKeySynchronizer:
reference_lines: List[str], dry_run: bool = False) -> bool:
"""
Synchronize a locale file using JSON structure merging.
Handles both addition of missing keys and removal of obsolete keys.
"""
locale_file = os.path.join(self.locales_dir, f'{locale}.json')
@@ -144,24 +146,33 @@ class TranslationKeySynchronizer:
self.log(f"Error loading {locale_file}: {e}", 'ERROR')
return False
# Get keys to check for missing ones
# Get keys to check for differences
ref_keys = self.get_all_leaf_keys(reference_data)
target_keys = self.get_all_leaf_keys(target_data)
missing_keys = set(ref_keys.keys()) - set(target_keys.keys())
obsolete_keys = set(target_keys.keys()) - set(ref_keys.keys())
if not missing_keys:
if not missing_keys and not obsolete_keys:
self.log(f"Locale {locale} is already up to date")
return False
self.log(f"Found {len(missing_keys)} missing keys in {locale}:")
for key in sorted(missing_keys):
self.log(f" - {key}")
# Report changes
if missing_keys:
self.log(f"Found {len(missing_keys)} missing keys in {locale}:")
for key in sorted(missing_keys):
self.log(f" + {key}")
if obsolete_keys:
self.log(f"Found {len(obsolete_keys)} obsolete keys in {locale}:")
for key in sorted(obsolete_keys):
self.log(f" - {key}")
if dry_run:
self.log(f"DRY RUN: Would update {locale} with {len(missing_keys)} new keys")
total_changes = len(missing_keys) + len(obsolete_keys)
self.log(f"DRY RUN: Would update {locale} with {len(missing_keys)} additions and {len(obsolete_keys)} deletions ({total_changes} total changes)")
return True
# Merge the structures
# Merge the structures (this will both add missing keys and remove obsolete ones)
try:
merged_data = self.merge_json_structures(reference_data, target_data)
@@ -176,7 +187,8 @@ class TranslationKeySynchronizer:
with open(locale_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
self.log(f"Successfully updated {locale} with {len(missing_keys)} new keys")
total_changes = len(missing_keys) + len(obsolete_keys)
self.log(f"Successfully updated {locale} with {len(missing_keys)} additions and {len(obsolete_keys)} deletions ({total_changes} total changes)")
return True
except json.JSONDecodeError as e:

View File

@@ -1,81 +1,3 @@
/* Bulk Operations Styles */
.bulk-operations-panel {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateY(100px) translateX(-50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
min-width: 420px;
max-width: 900px;
width: auto;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 0;
}
.bulk-operations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 20px; /* Increase space between count and buttons */
}
#selectedCount {
font-weight: 500;
background: var(--bg-color);
padding: 6px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
min-width: 80px;
text-align: center;
}
.bulk-operations-actions {
display: flex;
gap: 8px;
}
.bulk-operations-actions button {
padding: 6px 12px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
font-size: 14px;
white-space: nowrap;
min-height: 36px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.bulk-operations-actions button:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Danger button style - updated to use proper theme variables */
.bulk-operations-actions button.danger-btn {
background: oklch(70% 0.2 29); /* Light red background that works in both themes */
color: oklch(98% 0.01 0); /* Almost white text for good contrast */
border-color: var(--lora-error);
}
.bulk-operations-actions button.danger-btn:hover {
background: var(--lora-error);
color: oklch(100% 0 0); /* Pure white text on hover for maximum contrast */
}
/* Style for selected cards */
.model-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
@@ -99,203 +21,61 @@
z-index: 1;
}
/* Update bulk operations button to match others when active */
#bulkOperationsBtn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
@media (max-width: 768px) {
.bulk-operations-panel {
width: calc(100% - 40px);
min-width: unset;
max-width: unset;
left: 20px;
transform: none;
border-radius: var(--border-radius-sm);
}
.bulk-operations-actions {
flex-wrap: wrap;
}
}
.bulk-operations-panel.visible {
transform: translateY(0) translateX(-50%);
opacity: 1;
}
/* Thumbnail Strip Styles */
.selected-thumbnails-strip {
/* Marquee selection styles */
.marquee-selection {
position: fixed;
bottom: 80px; /* Position above the bulk operations panel */
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: calc(var(--z-overlay) - 1); /* Just below the bulk panel z-index */
padding: 16px;
max-width: 80%;
width: auto;
transition: all 0.3s ease;
opacity: 0;
overflow: hidden;
border: 2px dashed var(--lora-accent, #007bff);
background: rgba(0, 123, 255, 0.1);
pointer-events: none;
z-index: 9999;
border-radius: 2px;
}
.selected-thumbnails-strip.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
/* Visual feedback when marquee selecting */
.marquee-selecting {
cursor: crosshair;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.thumbnails-container {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px; /* Space for scrollbar */
/* Prevent text selection during marquee */
.marquee-selecting * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Remove bulk base model modal specific styles - now using shared components */
/* Use shared metadata editing styles instead */
/* Override for bulk base model select to ensure proper width */
.bulk-base-model-select {
width: 100%;
max-width: 100%;
align-items: flex-start;
}
.selected-thumbnail {
position: relative;
width: 80px;
min-width: 80px; /* Prevent shrinking */
padding: 6px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
overflow: hidden;
cursor: pointer;
background: var(--bg-color);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.selected-thumbnail:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.selected-thumbnail img,
.selected-thumbnail video {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
}
.thumbnail-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 10px;
padding: 3px 5px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.thumbnail-remove {
position: absolute;
top: 3px;
right: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 10px;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.thumbnail-remove:hover {
opacity: 1;
background: var(--lora-error);
}
.strip-close-btn {
position: absolute;
top: 5px;
right: 5px;
width: 20px;
height: 20px;
background: none;
border: none;
background-color: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease;
font-size: 0.95em;
height: 32px;
}
.strip-close-btn:hover {
opacity: 1;
.bulk-base-model-select:focus {
border-color: var(--lora-accent);
outline: none;
}
/* Style the selectedCount to indicate it's clickable */
.selectable-count {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
transition: background-color 0.2s ease;
/* Dark theme support for bulk base model select */
[data-theme="dark"] .bulk-base-model-select {
background-color: rgba(30, 30, 30, 0.9);
color: var(--text-color);
}
.selectable-count:hover {
background: var(--lora-border);
}
.dropdown-caret {
font-size: 12px;
visibility: hidden; /* Will be shown via JS when items are selected */
}
/* Scrollbar styling for the thumbnails container */
.thumbnails-container::-webkit-scrollbar {
height: 6px;
}
.thumbnails-container::-webkit-scrollbar-track {
background: var(--bg-color);
border-radius: 3px;
}
.thumbnails-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.thumbnails-container::-webkit-scrollbar-thumb:hover {
background: var(--lora-accent);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.selected-thumbnails-strip {
width: calc(100% - 40px);
max-width: none;
left: 20px;
transform: translateY(20px);
border-radius: var(--border-radius-sm);
}
.selected-thumbnails-strip.visible {
transform: translateY(0);
}
.selected-thumbnail {
width: 70px;
min-width: 70px;
}
[data-theme="dark"] .bulk-base-model-select option {
background-color: #2d2d2d;
color: var(--text-color);
}

View File

@@ -217,17 +217,19 @@
/* Bulk Context Menu Header */
.bulk-context-header {
padding: 10px 12px;
background: var(--lora-accent);
color: var(--lora-text);
background: var(--card-bg); /* Use card background for subtlety */
color: var(--text-color); /* Use standard text color */
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;
border-bottom: 1px solid var(--border-color); /* Add subtle separator */
}
.bulk-context-header i {
width: 16px;
text-align: center;
color: var(--lora-accent); /* Accent only the icon for a hint of color */
}

View File

@@ -27,14 +27,19 @@ export class BulkContextMenu extends BaseContextMenu {
// Update button visibility based on model type
const addTagsItem = this.menu.querySelector('[data-action="add-tags"]');
const sendToWorkflowItem = this.menu.querySelector('[data-action="send-to-workflow"]');
const setBaseModelItem = this.menu.querySelector('[data-action="set-base-model"]');
const sendToWorkflowAppendItem = this.menu.querySelector('[data-action="send-to-workflow-append"]');
const sendToWorkflowReplaceItem = this.menu.querySelector('[data-action="send-to-workflow-replace"]');
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 (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
}
if (sendToWorkflowReplaceItem) {
sendToWorkflowReplaceItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
}
if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
@@ -51,6 +56,9 @@ export class BulkContextMenu extends BaseContextMenu {
if (addTagsItem) {
addTagsItem.style.display = config.addTags ? 'flex' : 'none';
}
if (setBaseModelItem) {
setBaseModelItem.style.display = 'flex'; // Base model editing is available for all model types
}
}
updateSelectedCountHeader() {
@@ -71,8 +79,14 @@ export class BulkContextMenu extends BaseContextMenu {
case 'add-tags':
bulkManager.showBulkAddTagsModal();
break;
case 'send-to-workflow':
bulkManager.sendAllModelsToWorkflow();
case 'set-base-model':
bulkManager.showBulkBaseModelModal();
break;
case 'send-to-workflow-append':
bulkManager.sendAllModelsToWorkflow(false);
break;
case 'send-to-workflow-replace':
bulkManager.sendAllModelsToWorkflow(true);
break;
case 'copy-all':
bulkManager.copyAllModelsSyntax();

View File

@@ -8,7 +8,6 @@ import { LoraContextMenu } from './LoraContextMenu.js';
import { RecipeContextMenu } from './RecipeContextMenu.js';
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
import { state } from '../../state/index.js';
// Factory method to create page-specific context menu instances
export function createPageContextMenu(pageType) {
@@ -24,34 +23,4 @@ export function createPageContextMenu(pageType) {
default:
return null;
}
}
// Initialize context menu coordination for pages that support it
export function initializeContextMenuCoordination(pageContextMenu, bulkContextMenu) {
// Centralized context menu event handler
document.addEventListener('contextmenu', (e) => {
const card = e.target.closest('.model-card');
if (!card) {
// Hide all menus if not right-clicking on a card
pageContextMenu?.hideMenu();
bulkContextMenu?.hideMenu();
return;
}
e.preventDefault();
// Hide all menus first
pageContextMenu?.hideMenu();
bulkContextMenu?.hideMenu();
// Determine which menu to show based on bulk mode and selection state
if (state.bulkMode && card.classList.contains('selected')) {
// Show bulk menu for selected cards in bulk mode
bulkContextMenu?.showMenu(e.clientX, e.clientY, card);
} else if (!state.bulkMode) {
// Show regular menu when not in bulk mode
pageContextMenu?.showMenu(e.clientX, e.clientY, card);
}
// Don't show any menu for unselected cards in bulk mode
});
}

View File

@@ -70,7 +70,6 @@ export class PageControls {
async initSidebarManager() {
try {
await this.sidebarManager.initialize(this);
console.log('SidebarManager initialized');
} catch (error) {
console.error('Failed to initialize SidebarManager:', error);
}

View File

@@ -9,48 +9,46 @@ import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
import { translate } from '../../utils/i18nHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
// Add global event delegation handlers
// Add global event delegation handlers using event manager
export function setupModelCardEventDelegation(modelType) {
const gridElement = document.getElementById('modelGrid');
if (!gridElement) return;
// Remove any existing handler first
eventManager.removeHandler('click', 'modelCard-delegation');
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', gridElement._handleModelCardEvent);
// Create event handler with modelType context
const handleModelCardEvent = (event) => handleModelCardEvent_internal(event, modelType);
// Add the event delegation handler
gridElement.addEventListener('click', handleModelCardEvent);
// Store reference to the handler for cleanup
gridElement._handleModelCardEvent = handleModelCardEvent;
// Register model card event delegation with event manager
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
return handleModelCardEvent_internal(event, modelType);
}, {
priority: 60, // Medium priority for model card interactions
targetSelector: '#modelGrid',
skipWhenModalOpen: false // Allow model card interactions even when modals are open (for some actions)
});
}
// Event delegation handler for all model card events
function handleModelCardEvent_internal(event, modelType) {
// Find the closest card element
const card = event.target.closest('.model-card');
if (!card) return;
if (!card) return false; // Continue with other handlers
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-globe')) {
@@ -58,41 +56,42 @@ function handleModelCardEvent_internal(event, modelType) {
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
}
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
handleSendToWorkflow(card, event.shiftKey, modelType);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
handleCopyAction(card, modelType);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
getModelApiClient().replaceModelPreview(card.dataset.filepath);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
handleExampleImagesAccess(card, modelType);
return;
return true; // Stop propagation
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection)
}
// Helper functions for event handling

View File

@@ -2,10 +2,10 @@
* ModelMetadata.js
* Handles model metadata editing functionality - General version
*/
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js';
/**
* Set up model name editing functionality
@@ -172,28 +172,8 @@ export function setupBaseModelEditing(filePath) {
// Flag to track if a change was made
let valueChanged = false;
// Add options from BASE_MODELS constants
const baseModelCategories = {
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.HUNYUAN_VIDEO, BASE_MODELS.WAN_VIDEO,
BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P,
BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B,
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B
],
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA],
'Other Models': [
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.UNKNOWN
]
};
// Add options from BASE_MODEL_CATEGORIES constants
const baseModelCategories = BASE_MODEL_CATEGORIES;
// Create option groups for better organization
Object.entries(baseModelCategories).forEach(([category, models]) => {

View File

@@ -16,14 +16,13 @@ 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';
import { createPageContextMenu, initializeContextMenuCoordination } from './components/ContextMenu/index.js';
import { createPageContextMenu } from './components/ContextMenu/index.js';
import { initializeEventManagement } from './utils/eventManagementInit.js';
// Core application class
export class AppCore {
constructor() {
this.initialized = false;
this.pageContextMenu = null;
this.bulkContextMenu = null;
}
// Initialize core functionality
@@ -70,6 +69,8 @@ export class AppCore {
const cardInfoDisplay = state.global.settings.cardInfoDisplay || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
initializeEventManagement();
// Mark as initialized
this.initialized = true;
@@ -107,15 +108,7 @@ export class AppCore {
// Initialize context menus for the current page
initializeContextMenus(pageType) {
// Create page-specific context menu
this.pageContextMenu = createPageContextMenu(pageType);
// Get bulk context menu from bulkManager
this.bulkContextMenu = bulkManager.bulkContextMenu;
// Initialize context menu coordination
if (this.pageContextMenu || this.bulkContextMenu) {
initializeContextMenuCoordination(this.pageContextMenu, this.bulkContextMenu);
}
window.pageContextMenu = createPageContextMenu(pageType);
}
}

View File

@@ -4,7 +4,9 @@ import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { PRESET_TAGS } from '../utils/constants.js';
import { PRESET_TAGS, BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { eventManager } from '../utils/EventManager.js';
import { translate } from '../utils/i18nHelpers.js';
export class BulkManager {
constructor() {
@@ -12,6 +14,18 @@ export class BulkManager {
// Remove bulk panel references since we're using context menu now
this.bulkContextMenu = null; // Will be set by core initialization
// Marquee selection properties
this.isMarqueeActive = false;
this.isDragging = false;
this.marqueeStart = { x: 0, y: 0 };
this.marqueeElement = null;
this.initialSelectedModels = new Set();
// Drag detection properties
this.dragThreshold = 5; // Pixels to move before considering it a drag
this.mouseDownTime = 0;
this.mouseDownPosition = { x: 0, y: 0 };
// Model type specific action configurations
this.actionConfig = {
[MODEL_TYPES.LORA]: {
@@ -42,49 +56,156 @@ export class BulkManager {
}
initialize() {
this.setupEventListeners();
this.setupGlobalKeyboardListeners();
// Register with event manager for coordinated event handling
this.registerEventHandlers();
// Initialize bulk mode state in event manager
eventManager.setState('bulkMode', state.bulkMode || false);
}
setBulkContextMenu(bulkContextMenu) {
this.bulkContextMenu = bulkContextMenu;
}
setupEventListeners() {
// Only setup bulk mode toggle button listener now
// Context menu actions are handled by BulkContextMenu
/**
* Register all event handlers with the centralized event manager
*/
registerEventHandlers() {
// Register keyboard shortcuts with high priority
eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
return this.handleGlobalKeyboard(e);
}, {
priority: 100,
skipWhenModalOpen: true
});
// Register marquee selection events
eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
return this.handleMarqueeStart(e);
}, {
priority: 80,
skipWhenModalOpen: true,
targetSelector: '.page-content',
excludeSelector: '.model-card, button, input, folder-sidebar, .breadcrumb-item, #path-part, .context-menu',
button: 0 // Left mouse button only
});
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
if (this.isMarqueeActive) {
this.updateMarqueeSelection(e);
} else if (this.mouseDownTime && !this.isDragging) {
// Check if we've moved enough to consider it a drag
const dx = e.clientX - this.mouseDownPosition.x;
const dy = e.clientY - this.mouseDownPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= this.dragThreshold) {
this.isDragging = true;
this.startMarqueeSelection(e, true);
}
}
}, {
priority: 90,
skipWhenModalOpen: true
});
eventManager.addHandler('mouseup', 'bulkManager-marquee-end', (e) => {
if (this.isMarqueeActive) {
this.endMarqueeSelection(e);
return true; // Stop propagation
}
// Reset drag detection if we had a mousedown but didn't drag
if (this.mouseDownTime) {
this.mouseDownTime = 0;
return false; // Allow other handlers to process the click
}
}, {
priority: 90
});
eventManager.addHandler('contextmenu', 'bulkManager-marquee-prevent', (e) => {
if (this.isMarqueeActive) {
e.preventDefault();
return true; // Stop propagation
}
}, {
priority: 100
});
// Modified: Clear selection and exit bulk mode on left-click page-content blank area
// Lower priority to avoid interfering with context menu interactions
eventManager.addHandler('mousedown', 'bulkManager-clear-on-blank', (e) => {
// Only handle left mouse button
if (e.button !== 0) return false;
// Only if in bulk mode and there are selected models
if (state.bulkMode && state.selectedModels && state.selectedModels.size > 0) {
// Check if click is on blank area (not on a model card or excluded elements)
this.clearSelection();
this.toggleBulkMode();
// Prevent further handling
return true;
}
return false;
}, {
priority: 70, // Lower priority to let context menu events process first
onlyInBulkMode: true,
skipWhenModalOpen: true,
targetSelector: '.page-content',
excludeSelector: '.model-card, button, input, folder-sidebar, .breadcrumb-item, #path-part, .context-menu, .context-menu *',
button: 0 // Left mouse button only
});
}
setupGlobalKeyboardListeners() {
document.addEventListener('keydown', (e) => {
if (modalManager.isAnyModalOpen()) {
return;
}
/**
* Clean up event handlers
*/
cleanup() {
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
eventManager.removeAllHandlersForSource('bulkManager-marquee-end');
eventManager.removeAllHandlersForSource('bulkManager-marquee-prevent');
eventManager.removeAllHandlersForSource('bulkManager-clear-on-blank');
}
const searchInput = document.getElementById('searchInput');
if (searchInput && document.activeElement === searchInput) {
return;
}
/**
* Handle global keyboard events through the event manager
*/
handleGlobalKeyboard(e) {
// Skip if modal is open (handled by event manager conditions)
// Skip if search input is focused
const searchInput = document.getElementById('searchInput');
if (searchInput && document.activeElement === searchInput) {
return false; // Don't handle, allow default behavior
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault();
if (!state.bulkMode) {
this.toggleBulkMode();
setTimeout(() => this.selectAllVisibleModels(), 50);
} else {
this.selectAllVisibleModels();
}
} else if (e.key === 'Escape' && state.bulkMode) {
this.toggleBulkMode();
} else if (e.key.toLowerCase() === 'b') {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault();
if (!state.bulkMode) {
this.toggleBulkMode();
setTimeout(() => this.selectAllVisibleModels(), 50);
} else {
this.selectAllVisibleModels();
}
});
return true; // Stop propagation
} else if (e.key === 'Escape' && state.bulkMode) {
this.toggleBulkMode();
return true; // Stop propagation
} else if (e.key.toLowerCase() === 'b') {
this.toggleBulkMode();
return true; // Stop propagation
}
return false; // Continue with other handlers
}
toggleBulkMode() {
state.bulkMode = !state.bulkMode;
// Update event manager state
eventManager.setState('bulkMode', state.bulkMode);
this.bulkBtn.classList.toggle('active', state.bulkMode);
updateCardsForBulkMode(state.bulkMode);
@@ -126,8 +247,6 @@ export class BulkManager {
metadataCache.set(filepath, {
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
previewUrl: this.getCardPreviewUrl(card),
isVideo: this.isCardPreviewVideo(card),
modelName: card.dataset.name
});
}
@@ -155,16 +274,6 @@ export class BulkManager {
return pageState.metadataCache;
}
}
getCardPreviewUrl(card) {
const img = card.querySelector('img');
const video = card.querySelector('video source');
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
}
isCardPreviewVideo(card) {
return card.querySelector('video') !== null;
}
applySelectionState() {
if (!state.bulkMode) return;
@@ -178,8 +287,6 @@ export class BulkManager {
metadataCache.set(filepath, {
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
previewUrl: this.getCardPreviewUrl(card),
isVideo: this.isCardPreviewVideo(card),
modelName: card.dataset.name
});
} else {
@@ -228,7 +335,7 @@ export class BulkManager {
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
}
async sendAllModelsToWorkflow() {
async sendAllModelsToWorkflow(replaceMode = false) {
if (state.currentPageType !== MODEL_TYPES.LORA) {
showToast('toast.loras.sendOnlyForLoras', {}, 'warning');
return;
@@ -265,7 +372,7 @@ export class BulkManager {
return;
}
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora');
}
showBulkDeleteModal() {
@@ -347,8 +454,6 @@ export class BulkManager {
metadataCache.set(item.file_path, {
fileName: item.file_name,
usageTips: item.usage_tips || '{}',
previewUrl: item.preview_url || '/loras_static/images/no-preview.png',
isVideo: item.is_video || false,
modelName: item.name || item.file_name
});
}
@@ -392,8 +497,6 @@ export class BulkManager {
...metadata,
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
previewUrl: this.getCardPreviewUrl(card),
isVideo: this.isCardPreviewVideo(card),
modelName: card.dataset.name
});
}
@@ -734,6 +837,301 @@ export class BulkManager {
}
}
}
/**
* Show bulk base model modal
*/
showBulkBaseModelModal() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noSelectedModels', {}, 'warning');
return;
}
const countElement = document.getElementById('bulkBaseModelCount');
if (countElement) {
countElement.textContent = state.selectedModels.size;
}
modalManager.showModal('bulkBaseModelModal', null, null, () => {
this.cleanupBulkBaseModelModal();
});
// Initialize the bulk base model interface
this.initializeBulkBaseModelInterface();
}
/**
* Initialize bulk base model interface
*/
initializeBulkBaseModelInterface() {
const select = document.getElementById('bulkBaseModelSelect');
if (!select) return;
// Clear existing options
select.innerHTML = '';
// Add placeholder option
const placeholderOption = document.createElement('option');
placeholderOption.value = '';
placeholderOption.textContent = 'Select a base model...';
placeholderOption.disabled = true;
placeholderOption.selected = true;
select.appendChild(placeholderOption);
// Create option groups for better organization
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
const optgroup = document.createElement('optgroup');
optgroup.label = category;
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
optgroup.appendChild(option);
});
select.appendChild(optgroup);
});
}
/**
* Save bulk base model changes
*/
async saveBulkBaseModel() {
const select = document.getElementById('bulkBaseModelSelect');
if (!select || !select.value) {
showToast('toast.models.baseModelNotSelected', {}, 'warning');
return;
}
const newBaseModel = select.value;
const selectedCount = state.selectedModels.size;
if (selectedCount === 0) {
showToast('toast.models.noSelectedModels', {}, 'warning');
return;
}
modalManager.closeModal('bulkBaseModelModal');
try {
let successCount = 0;
let errorCount = 0;
const errors = [];
state.loadingManager.showSimpleLoading(translate('toast.models.bulkBaseModelUpdating'));
for (const filepath of state.selectedModels) {
try {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
successCount++;
} catch (error) {
errorCount++;
errors.push({ filepath, error: error.message });
console.error(`Failed to update base model for ${filepath}:`, error);
}
}
// Show results
if (errorCount === 0) {
showToast('toast.models.bulkBaseModelUpdateSuccess', { count: successCount }, 'success');
} else if (successCount > 0) {
showToast('toast.models.bulkBaseModelUpdatePartial', {
success: successCount,
failed: errorCount
}, 'warning');
} else {
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
}
} catch (error) {
console.error('Error during bulk base model operation:', error);
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
} finally {
state.loadingManager.hideSimpleLoading();
}
}
/**
* Cleanup bulk base model modal
*/
cleanupBulkBaseModelModal() {
const select = document.getElementById('bulkBaseModelSelect');
if (select) {
select.innerHTML = '';
}
}
/**
* Handle marquee start through event manager
*/
handleMarqueeStart(e) {
// Store mousedown info for potential drag detection
this.mouseDownTime = Date.now();
this.mouseDownPosition = { x: e.clientX, y: e.clientY };
this.isDragging = false;
// Don't start marquee yet - wait to see if user is dragging
return false;
}
/**
* Start marquee selection
* @param {MouseEvent} e - Mouse event
* @param {boolean} isDragging - Whether this is triggered from a drag operation
*/
startMarqueeSelection(e, isDragging = false) {
// Store initial mouse position
this.marqueeStart.x = this.mouseDownPosition.x;
this.marqueeStart.y = this.mouseDownPosition.y;
// Store initial selection state
this.initialSelectedModels = new Set(state.selectedModels);
// Enter bulk mode if not already active and we're actually dragging
if (isDragging && !state.bulkMode) {
this.toggleBulkMode();
}
// Create marquee element
this.createMarqueeElement();
this.isMarqueeActive = true;
// Update event manager state
eventManager.setState('marqueeActive', true);
// Add visual feedback class to body
document.body.classList.add('marquee-selecting');
}
/**
* Create the visual marquee selection rectangle
*/
createMarqueeElement() {
this.marqueeElement = document.createElement('div');
this.marqueeElement.className = 'marquee-selection';
this.marqueeElement.style.cssText = `
position: fixed;
border: 2px dashed var(--lora-accent, #007bff);
background: rgba(0, 123, 255, 0.1);
pointer-events: none;
z-index: 9999;
left: ${this.marqueeStart.x}px;
top: ${this.marqueeStart.y}px;
width: 0;
height: 0;
`;
document.body.appendChild(this.marqueeElement);
}
/**
* Update marquee selection rectangle and selected items
*/
updateMarqueeSelection(e) {
if (!this.marqueeElement) return;
const currentX = e.clientX;
const currentY = e.clientY;
// Calculate rectangle bounds
const left = Math.min(this.marqueeStart.x, currentX);
const top = Math.min(this.marqueeStart.y, currentY);
const width = Math.abs(currentX - this.marqueeStart.x);
const height = Math.abs(currentY - this.marqueeStart.y);
// Update marquee element position and size
this.marqueeElement.style.left = left + 'px';
this.marqueeElement.style.top = top + 'px';
this.marqueeElement.style.width = width + 'px';
this.marqueeElement.style.height = height + 'px';
// Check which cards intersect with marquee
this.updateCardSelection(left, top, left + width, top + height);
}
/**
* Update card selection based on marquee bounds
*/
updateCardSelection(left, top, right, bottom) {
const cards = document.querySelectorAll('.model-card');
const newSelection = new Set(this.initialSelectedModels);
cards.forEach(card => {
const rect = card.getBoundingClientRect();
// Check if card intersects with marquee rectangle
const intersects = !(rect.right < left ||
rect.left > right ||
rect.bottom < top ||
rect.top > bottom);
const filepath = card.dataset.filepath;
if (intersects) {
// Add to selection if intersecting
newSelection.add(filepath);
card.classList.add('selected');
// Cache metadata if not already cached
const metadataCache = this.getMetadataCache();
if (!metadataCache.has(filepath)) {
metadataCache.set(filepath, {
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
modelName: card.dataset.name
});
}
} else if (!this.initialSelectedModels.has(filepath)) {
// Remove from selection if not intersecting and wasn't initially selected
newSelection.delete(filepath);
card.classList.remove('selected');
}
});
// Update global selection state
state.selectedModels = newSelection;
// Update context menu header if visible
if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader();
}
}
/**
* End marquee selection
*/
endMarqueeSelection(e) {
// First, mark as inactive to prevent double processing
this.isMarqueeActive = false;
this.isDragging = false;
this.mouseDownTime = 0;
// Update event manager state
eventManager.setState('marqueeActive', false);
// Remove marquee element
if (this.marqueeElement) {
this.marqueeElement.remove();
this.marqueeElement = null;
}
// Remove visual feedback class
document.body.classList.remove('marquee-selecting');
// Get selection count
const selectionCount = state.selectedModels.size;
// If no models were selected, exit bulk mode
if (selectionCount === 0) {
if (state.bulkMode) {
this.toggleBulkMode();
}
}
// Clear initial selection state
this.initialSelectedModels.clear();
}
}
export const bulkManager = new BulkManager();

View File

@@ -247,6 +247,19 @@ export class ModalManager {
});
}
// Register bulkBaseModelModal
const bulkBaseModelModal = document.getElementById('bulkBaseModelModal');
if (bulkBaseModelModal) {
this.registerModal('bulkBaseModelModal', {
element: bulkBaseModelModal,
onClose: () => {
this.getModal('bulkBaseModelModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}

View File

@@ -0,0 +1,201 @@
/**
* Centralized manager for handling DOM events across the application
*/
export class EventManager {
constructor() {
// Store registered handlers
this.handlers = new Map();
// Track active modals/states
this.activeStates = {
bulkMode: false,
marqueeActive: false,
modalOpen: false,
nodeSelectorActive: false
};
// Store references to cleanup functions
this.cleanupFunctions = new Map();
}
/**
* Register an event handler with priority and conditional execution
* @param {string} eventType - The DOM event type (e.g., 'click', 'mousedown')
* @param {string} source - Source identifier (e.g., 'bulkManager', 'contextMenu')
* @param {Function} handler - Event handler function
* @param {Object} options - Additional options including priority and conditions
*/
addHandler(eventType, source, handler, options = {}) {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
// Set up the actual DOM listener once
this.setupDOMListener(eventType);
}
const handlerList = this.handlers.get(eventType);
const handlerEntry = {
source,
handler,
priority: options.priority || 0,
options,
// Store cleanup function if provided
cleanup: options.cleanup || null
};
handlerList.push(handlerEntry);
// Sort by priority
handlerList.sort((a, b) => b.priority - a.priority);
return handlerEntry;
}
/**
* Remove an event handler
*/
removeHandler(eventType, source) {
if (!this.handlers.has(eventType)) return;
const handlerList = this.handlers.get(eventType);
// Find and cleanup handler before removing
const handlerToRemove = handlerList.find(h => h.source === source);
if (handlerToRemove && handlerToRemove.cleanup) {
try {
handlerToRemove.cleanup();
} catch (error) {
console.warn(`Error during cleanup for ${source}:`, error);
}
}
const newList = handlerList.filter(h => h.source !== source);
if (newList.length === 0) {
// Remove the DOM listener if no handlers remain
this.cleanupDOMListener(eventType);
this.handlers.delete(eventType);
} else {
this.handlers.set(eventType, newList);
}
}
/**
* Setup actual DOM event listener
*/
setupDOMListener(eventType) {
const listener = (event) => this.handleEvent(eventType, event);
document.addEventListener(eventType, listener);
this._domListeners = this._domListeners || {};
this._domListeners[eventType] = listener;
}
/**
* Clean up DOM event listener
*/
cleanupDOMListener(eventType) {
if (this._domListeners && this._domListeners[eventType]) {
document.removeEventListener(eventType, this._domListeners[eventType]);
delete this._domListeners[eventType];
}
}
/**
* Process an event through registered handlers
*/
handleEvent(eventType, event) {
if (!this.handlers.has(eventType)) return;
const handlers = this.handlers.get(eventType);
for (const {handler, options} of handlers) {
// Apply conditional execution based on app state
if (options.onlyInBulkMode && !this.activeStates.bulkMode) continue;
if (options.onlyWhenMarqueeActive && !this.activeStates.marqueeActive) continue;
if (options.skipWhenModalOpen && this.activeStates.modalOpen) continue;
if (options.skipWhenNodeSelectorActive && this.activeStates.nodeSelectorActive) continue;
if (options.onlyWhenNodeSelectorActive && !this.activeStates.nodeSelectorActive) continue;
// Apply element-based filters
if (options.targetSelector && !this._matchesSelector(event.target, options.targetSelector)) continue;
if (options.excludeSelector && this._matchesSelector(event.target, options.excludeSelector)) continue;
// Apply button filters
if (options.button !== undefined && event.button !== options.button) continue;
try {
// Execute handler
const result = handler(event);
// Stop propagation if handler returns true
if (result === true) break;
} catch (error) {
console.error(`Error in event handler for ${eventType}:`, error);
}
}
}
/**
* Helper function to check if an element matches or is contained within an element matching the selector
* This improves the robustness of the selector matching
*/
_matchesSelector(element, selector) {
if (element.matches && element.matches(selector)) {
return true;
}
if (element.closest && element.closest(selector)) {
return true;
}
return false;
}
/**
* Update application state
*/
setState(state, value) {
if (this.activeStates.hasOwnProperty(state)) {
this.activeStates[state] = value;
} else {
console.warn(`Unknown state: ${state}`);
}
}
/**
* Get current application state
*/
getState(state) {
return this.activeStates[state];
}
/**
* Remove all handlers for a specific source
*/
removeAllHandlersForSource(source) {
const eventTypes = Array.from(this.handlers.keys());
eventTypes.forEach(eventType => {
this.removeHandler(eventType, source);
});
}
/**
* Clean up all event listeners (useful for app teardown)
*/
cleanup() {
const eventTypes = Array.from(this.handlers.keys());
eventTypes.forEach(eventType => {
const handlers = this.handlers.get(eventType);
// Run cleanup functions
handlers.forEach(h => {
if (h.cleanup) {
try {
h.cleanup();
} catch (error) {
console.warn(`Error during cleanup for ${h.source}:`, error);
}
}
});
this.cleanupDOMListener(eventType);
});
this.handlers.clear();
this.cleanupFunctions.clear();
}
}
export const eventManager = new EventManager();

View File

@@ -164,6 +164,29 @@ export const NODE_TYPE_ICONS = {
// Default ComfyUI node color when bgcolor is null
export const DEFAULT_NODE_COLOR = "#353535";
// Base model categories for organized selection
export const BASE_MODEL_CATEGORIES = {
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.HUNYUAN_VIDEO, BASE_MODELS.WAN_VIDEO,
BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P,
BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B,
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B
],
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA],
'Other Models': [
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.UNKNOWN
]
};
// Preset tag suggestions
export const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing',

View File

@@ -0,0 +1,226 @@
/**
* Event Management Initialization
*
* This module handles the initialization and coordination of the centralized
* event management system across the application.
*/
import { eventManager } from './EventManager.js';
import { modalManager } from '../managers/ModalManager.js';
import { state } from '../state/index.js';
/**
* Initialize the centralized event management system
*/
export function initializeEventManagement() {
console.log('Initializing centralized event management system...');
// Initialize modal state tracking
initializeModalStateTracking();
// Set up global error handling for event handlers
setupGlobalEventErrorHandling();
// Set up cleanup on page unload
setupPageUnloadCleanup();
// Register global event handlers that need coordination
registerContextMenuEvents();
registerGlobalClickHandlers();
console.log('Event management system initialized successfully');
}
/**
* Initialize modal state tracking with the event manager
*/
function initializeModalStateTracking() {
// Override modalManager methods to update event manager state
const originalShowModal = modalManager.showModal.bind(modalManager);
const originalCloseModal = modalManager.closeModal.bind(modalManager);
const originalIsAnyModalOpen = modalManager.isAnyModalOpen.bind(modalManager);
modalManager.showModal = function(...args) {
const result = originalShowModal(...args);
eventManager.setState('modalOpen', this.isAnyModalOpen());
return result;
};
modalManager.closeModal = function(...args) {
const result = originalCloseModal(...args);
eventManager.setState('modalOpen', this.isAnyModalOpen());
return result;
};
}
/**
* Set up global error handling for event handlers
*/
function setupGlobalEventErrorHandling() {
// Override the handleEvent method to add better error handling
const originalHandleEvent = eventManager.handleEvent.bind(eventManager);
eventManager.handleEvent = function(eventType, event) {
try {
return originalHandleEvent(eventType, event);
} catch (error) {
console.error(`Critical error in event management for ${eventType}:`, error);
// Don't let event handling errors crash the app
}
};
}
/**
* Set up cleanup when the page is unloaded
*/
function setupPageUnloadCleanup() {
window.addEventListener('beforeunload', () => {
console.log('Cleaning up event management system...');
eventManager.cleanup();
});
}
/**
* Register context menu related events with proper priority
*/
function registerContextMenuEvents() {
eventManager.addHandler('contextmenu', 'contextMenu-coordination', (e) => {
const card = e.target.closest('.model-card');
if (!card) {
// Hide all menus if not right-clicking on a card
window.pageContextMenu?.hideMenu();
window.bulkManager?.bulkContextMenu?.hideMenu();
return false;
}
e.preventDefault();
// Hide all menus first
window.pageContextMenu?.hideMenu();
window.bulkManager?.bulkContextMenu?.hideMenu();
// Determine which menu to show based on bulk mode and selection state
if (state.bulkMode && card.classList.contains('selected')) {
// Show bulk menu for selected cards in bulk mode
window.bulkManager?.bulkContextMenu?.showMenu(e.clientX, e.clientY, card);
} else if (!state.bulkMode) {
// Show regular menu when not in bulk mode
window.pageContextMenu?.showMenu(e.clientX, e.clientY, card);
}
// Don't show any menu for unselected cards in bulk mode
return true; // Stop propagation
}, {
priority: 200, // Higher priority than bulk manager events
skipWhenModalOpen: true
});
}
/**
* Register global click handlers for context menu hiding
*/
function registerGlobalClickHandlers() {
eventManager.addHandler('click', 'contextMenu-hide', (e) => {
// Hide context menus when clicking elsewhere
if (!e.target.closest('.context-menu')) {
window.pageContextMenu?.hideMenu();
window.bulkManager?.bulkContextMenu?.hideMenu();
}
return false; // Allow other handlers to process
}, {
priority: 50,
skipWhenModalOpen: true
});
}
/**
* Register common application-wide event handlers
*/
export function registerGlobalEventHandlers() {
// Escape key handler for closing modals/panels
eventManager.addHandler('keydown', 'global-escape', (e) => {
if (e.key === 'Escape') {
// Check if any modal is open and close it
if (eventManager.getState('modalOpen')) {
modalManager.closeCurrentModal();
return true; // Stop propagation
}
// Check if node selector is active and close it
if (eventManager.getState('nodeSelectorActive')) {
// The node selector should handle its own escape key
return false; // Continue with other handlers
}
}
return false; // Continue with other handlers
}, {
priority: 250 // Very high priority for escape handling
});
// Global focus management
eventManager.addHandler('focusin', 'global-focus', (e) => {
// Track focus for accessibility and keyboard navigation
window.lastFocusedElement = e.target;
}, {
priority: 10 // Low priority for tracking
});
// Global click tracking for analytics (if needed)
eventManager.addHandler('click', 'global-analytics', (e) => {
// Track clicks for usage analytics
// This runs last and doesn't interfere with other handlers
trackUserInteraction(e);
}, {
priority: 1 // Lowest priority
});
}
/**
* Example analytics tracking function
*/
function trackUserInteraction(event) {
// Implement analytics tracking here
// This is just a placeholder
if (window.analytics && typeof window.analytics.track === 'function') {
const element = event.target;
const elementInfo = {
tag: element.tagName.toLowerCase(),
class: element.className,
id: element.id,
text: element.textContent?.substring(0, 50)
};
window.analytics.track('ui_interaction', elementInfo);
}
}
/**
* Utility function to check if event management is properly initialized
*/
export function isEventManagementInitialized() {
return eventManager && typeof eventManager.addHandler === 'function';
}
/**
* Get event management statistics for debugging
*/
export function getEventManagementStats() {
const stats = {
totalEventTypes: eventManager.handlers.size,
totalHandlers: 0,
handlersBySource: {},
currentStates: { ...eventManager.activeStates }
};
eventManager.handlers.forEach((handlers, eventType) => {
stats.totalHandlers += handlers.length;
handlers.forEach(handler => {
if (!stats.handlersBySource[handler.source]) {
stats.handlersBySource[handler.source] = 0;
}
stats.handlersBySource[handler.source]++;
});
});
return stats;
}

View File

@@ -2,6 +2,7 @@ import { translate } from './i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
import { eventManager } from './EventManager.js';
/**
* Utility function to copy text to clipboard with fallback for older browsers
@@ -528,12 +529,15 @@ function showNodeSelector(nodes, loraSyntax, replaceMode, syntaxType) {
selector.style.display = 'block';
nodeSelectorState.isActive = true;
// Setup event listeners with proper cleanup
// Update event manager state
eventManager.setState('nodeSelectorActive', true);
// Setup event listeners with proper cleanup through event manager
setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, syntaxType);
}
/**
* Setup event listeners for node selector
* Setup event listeners for node selector using event manager
* @param {HTMLElement} selector - The selector element
* @param {Object} nodes - Registry nodes data
* @param {string} loraSyntax - The LoRA syntax to send
@@ -544,17 +548,21 @@ function setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, synta
// Clean up any existing event listeners
cleanupNodeSelectorEvents();
// Handle clicks outside to close
nodeSelectorState.clickHandler = (e) => {
// Register click outside handler with event manager
eventManager.addHandler('click', 'nodeSelector-outside', (e) => {
if (!selector.contains(e.target)) {
hideNodeSelector();
return true; // Stop propagation
}
};
}, {
priority: 200, // High priority to handle before other click handlers
onlyWhenNodeSelectorActive: true
});
// Handle node selection
nodeSelectorState.selectorClickHandler = async (e) => {
// Register node selection handler with event manager
eventManager.addHandler('click', 'nodeSelector-selection', async (e) => {
const nodeItem = e.target.closest('.node-item');
if (!nodeItem) return;
if (!nodeItem) return false; // Continue with other handlers
e.stopPropagation();
@@ -571,33 +579,25 @@ function setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, synta
}
hideNodeSelector();
};
// Add event listeners with a small delay to prevent immediate triggering
setTimeout(() => {
if (nodeSelectorState.isActive) {
document.addEventListener('click', nodeSelectorState.clickHandler);
selector.addEventListener('click', nodeSelectorState.selectorClickHandler);
}
}, 100);
return true; // Stop propagation
}, {
priority: 150, // High priority but lower than outside click
targetSelector: '#nodeSelector',
onlyWhenNodeSelectorActive: true
});
}
/**
* Clean up node selector event listeners
*/
function cleanupNodeSelectorEvents() {
if (nodeSelectorState.clickHandler) {
document.removeEventListener('click', nodeSelectorState.clickHandler);
nodeSelectorState.clickHandler = null;
}
// Remove event handlers from event manager
eventManager.removeHandler('click', 'nodeSelector-outside');
eventManager.removeHandler('click', 'nodeSelector-selection');
if (nodeSelectorState.selectorClickHandler) {
const selector = document.getElementById('nodeSelector');
if (selector) {
selector.removeEventListener('click', nodeSelectorState.selectorClickHandler);
}
nodeSelectorState.selectorClickHandler = null;
}
// Clear legacy references
nodeSelectorState.clickHandler = null;
nodeSelectorState.selectorClickHandler = null;
}
/**
@@ -613,6 +613,9 @@ function hideNodeSelector() {
// Clean up event listeners
cleanupNodeSelectorEvents();
nodeSelectorState.isActive = false;
// Update event manager state
eventManager.setState('nodeSelectorActive', false);
}
/**
@@ -651,11 +654,21 @@ function positionNearMouse(element) {
element.style.visibility = 'visible';
}
// Track mouse position for node selector positioning
document.addEventListener('mousemove', (e) => {
window.lastMouseX = e.clientX;
window.lastMouseY = e.clientY;
});
/**
* Initialize mouse tracking for positioning elements
*/
export function initializeMouseTracking() {
// Register mouse tracking with event manager
eventManager.addHandler('mousemove', 'uiHelpers-mouseTracking', (e) => {
window.lastMouseX = e.clientX;
window.lastMouseY = e.clientY;
}, {
priority: 10 // Low priority since this is just tracking
});
}
// Initialize mouse tracking when module loads
initializeMouseTracking();
/**
* Opens the example images folder for a specific model

View File

@@ -53,8 +53,14 @@
<div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
<div class="context-menu-item" data-action="set-base-model">
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow-append">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow-replace">
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
</div>
<div class="context-menu-item" data-action="copy-all">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>

View File

@@ -10,4 +10,5 @@
{% include 'components/modals/example_access_modal.html' %}
{% include 'components/modals/download_modal.html' %}
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %}
{% include 'components/modals/bulk_base_model_modal.html' %}

View File

@@ -0,0 +1,38 @@
<div id="bulkBaseModelModal" class="modal" style="display: none;">
<div class="modal-content modal-content">
<div class="modal-header">
<h2>{{ t('modals.bulkBaseModel.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('bulkBaseModelModal')">&times;</span>
</div>
<div class="modal-body">
<div class="bulk-add-tags-info">
<p>{{ t('modals.bulkBaseModel.description') }} <span id="bulkBaseModelCount">0</span> {{ t('modals.bulkBaseModel.models') }}</p>
</div>
<div class="model-tags-container bulk-tags-container edit-mode">
<div class="metadata-edit-container" style="display: block;">
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>{{ t('modals.bulkBaseModel.selectBaseModel') }}</label>
</div>
<div class="setting-control">
<div class="select-control">
<select id="bulkBaseModelSelect" class="bulk-base-model-select">
<!-- Options will be populated dynamically -->
</select>
</div>
</div>
<div class="metadata-edit-controls">
<button class="metadata-save-btn bulk-save-base-model-btn" onclick="bulkManager.saveBulkBaseModel()">
<i class="fas fa-save"></i> {{ t('modals.bulkBaseModel.save') }}
</button>
<button class="btn btn-secondary" onclick="modalManager.closeModal('bulkBaseModelModal')">
{{ t('modals.bulkBaseModel.cancel') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>