diff --git a/docs/EventManagementImplementation.md b/docs/EventManagementImplementation.md new file mode 100644 index 00000000..6631fc16 --- /dev/null +++ b/docs/EventManagementImplementation.md @@ -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. diff --git a/docs/EventManagerDocs.md b/docs/EventManagerDocs.md new file mode 100644 index 00000000..2ccc8174 --- /dev/null +++ b/docs/EventManagerDocs.md @@ -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. diff --git a/locales/de.json b/locales/de.json index 394ebd6f..8d23c671 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 32656961..b24c5770 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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}", diff --git a/locales/es.json b/locales/es.json index b34b5ee0..9ce54341 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index 8c0a6d94..f5a140f0 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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", diff --git a/locales/ja.json b/locales/ja.json index 285af83b..38e2d31b 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "モデル名が正常に更新されました", diff --git a/locales/ko.json b/locales/ko.json index 09c892af..d0391ef8 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "모델 이름이 성공적으로 업데이트되었습니다", diff --git a/locales/ru.json b/locales/ru.json index 3fc92156..4f0a8895 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Название модели успешно обновлено", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index c9182de6..306e45e4 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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}", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 5b815135..16f65fad 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "模型名稱已成功更新", diff --git a/scripts/sync_translation_keys.py b/scripts/sync_translation_keys.py index 4244bde0..8a0edb66 100644 --- a/scripts/sync_translation_keys.py +++ b/scripts/sync_translation_keys.py @@ -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: diff --git a/static/css/components/bulk.css b/static/css/components/bulk.css index e0a618f9..d7373537 100644 --- a/static/css/components/bulk.css +++ b/static/css/components/bulk.css @@ -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); } \ No newline at end of file diff --git a/static/css/components/menu.css b/static/css/components/menu.css index 481bb213..b2b1ed24 100644 --- a/static/css/components/menu.css +++ b/static/css/components/menu.css @@ -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 */ } \ No newline at end of file diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index 70030764..7c0b42c9 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -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(); diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js index 306777ae..b6be6ccf 100644 --- a/static/js/components/ContextMenu/index.js +++ b/static/js/components/ContextMenu/index.js @@ -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 - }); } \ No newline at end of file diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index 2d6f42f1..06009880 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -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); } diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index 6196297a..16bdf45d 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -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 diff --git a/static/js/components/shared/ModelMetadata.js b/static/js/components/shared/ModelMetadata.js index 9d63ed0b..4dcfab46 100644 --- a/static/js/components/shared/ModelMetadata.js +++ b/static/js/components/shared/ModelMetadata.js @@ -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]) => { diff --git a/static/js/core.js b/static/js/core.js index eb7d8dba..e4420534 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -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); } } diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 7ecdadcf..c2e6155f 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -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(); diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 8313c226..cd198000 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -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; } diff --git a/static/js/utils/EventManager.js b/static/js/utils/EventManager.js new file mode 100644 index 00000000..382f5740 --- /dev/null +++ b/static/js/utils/EventManager.js @@ -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(); \ No newline at end of file diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index 196de1fc..fa62dce7 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -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', diff --git a/static/js/utils/eventManagementInit.js b/static/js/utils/eventManagementInit.js new file mode 100644 index 00000000..ba9908b9 --- /dev/null +++ b/static/js/utils/eventManagementInit.js @@ -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; +} diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 1be00812..2281ecf7 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -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 diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 92e7a5b2..52187615 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -53,8 +53,14 @@
{{ t('loras.bulkOperations.addTags') }}
-
- {{ t('loras.bulkOperations.sendToWorkflow') }} +
+ {{ t('loras.bulkOperations.setBaseModel') }} +
+
+ {{ t('loras.contextMenu.sendToWorkflowAppend') }} +
+
+ {{ t('loras.contextMenu.sendToWorkflowReplace') }}
{{ t('loras.bulkOperations.copyAll') }} diff --git a/templates/components/modals.html b/templates/components/modals.html index 14068b44..3f0e8a14 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -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' %} \ No newline at end of file +{% include 'components/modals/bulk_add_tags_modal.html' %} +{% include 'components/modals/bulk_base_model_modal.html' %} \ No newline at end of file diff --git a/templates/components/modals/bulk_base_model_modal.html b/templates/components/modals/bulk_base_model_modal.html new file mode 100644 index 00000000..1395b93b --- /dev/null +++ b/templates/components/modals/bulk_base_model_modal.html @@ -0,0 +1,38 @@ + diff --git a/scripts/test_i18n.py b/test_i18n.py similarity index 100% rename from scripts/test_i18n.py rename to test_i18n.py