From 3250fa89cb5dd5bde7a94057b677bc0866749f2c Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 5 Sep 2025 11:24:10 +0800 Subject: [PATCH] feat(selection): implement marquee selection for bulk operations --- locales/de.json | 16 +- locales/en.json | 16 +- locales/es.json | 16 +- locales/fr.json | 16 +- locales/ja.json | 16 +- locales/ko.json | 16 +- locales/ru.json | 16 +- locales/zh-CN.json | 16 +- locales/zh-TW.json | 16 +- static/css/components/bulk.css | 292 ++---------------------------- static/js/managers/BulkManager.js | 202 +++++++++++++++++++++ 11 files changed, 294 insertions(+), 344 deletions(-) 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..652ce18d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -318,14 +318,13 @@ "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", + "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", @@ -983,6 +982,7 @@ "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", 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..90ca3e48 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.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/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/static/css/components/bulk.css b/static/css/components/bulk.css index e0a618f9..b8aa5620 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,29 @@ 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 */ - max-width: 100%; - align-items: flex-start; -} - -.selected-thumbnail { - position: relative; - width: 80px; - min-width: 80px; /* Prevent shrinking */ - 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; - color: var(--text-color); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.7; - transition: opacity 0.2s ease; -} - -.strip-close-btn:hover { - opacity: 1; -} - -/* 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; -} - -.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; - } +/* Prevent text selection during marquee */ +.marquee-selecting * { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; } \ No newline at end of file diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index a6499d3b..5eafa545 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -12,6 +12,12 @@ 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.marqueeStart = { x: 0, y: 0 }; + this.marqueeElement = null; + this.initialSelectedModels = new Set(); + // Model type specific action configurations this.actionConfig = { [MODEL_TYPES.LORA]: { @@ -44,6 +50,7 @@ export class BulkManager { initialize() { this.setupEventListeners(); this.setupGlobalKeyboardListeners(); + this.setupMarqueeSelection(); } setBulkContextMenu(bulkContextMenu) { @@ -734,6 +741,201 @@ export class BulkManager { } } } + + /** + * Setup marquee selection functionality + */ + setupMarqueeSelection() { + const container = document.querySelector('.models-container') || document.body; + + container.addEventListener('mousedown', (e) => { + // Disable marquee if any modal is open + if (modalManager.isAnyModalOpen()) { + return; + } + // Only start marquee selection on left click in empty areas + if (e.button !== 0 || e.target.closest('.model-card') || e.target.closest('button') || e.target.closest('input')) { + return; + } + + // Prevent text selection during marquee + e.preventDefault(); + + this.startMarqueeSelection(e); + }); + + document.addEventListener('mousemove', (e) => { + // Disable marquee update if any modal is open + if (modalManager.isAnyModalOpen()) { + return; + } + if (this.isMarqueeActive) { + this.updateMarqueeSelection(e); + } + }); + + document.addEventListener('mouseup', (e) => { + if (this.isMarqueeActive) { + this.endMarqueeSelection(e); + } + }); + + // Prevent context menu during marquee selection + document.addEventListener('contextmenu', (e) => { + if (this.isMarqueeActive) { + e.preventDefault(); + } + }); + } + + /** + * Start marquee selection + */ + startMarqueeSelection(e) { + // Store initial mouse position + this.marqueeStart.x = e.clientX; + this.marqueeStart.y = e.clientY; + + // Store initial selection state + this.initialSelectedModels = new Set(state.selectedModels); + + // Enter bulk mode if not already active + if (!state.bulkMode) { + this.toggleBulkMode(); + } + + // Create marquee element + this.createMarqueeElement(); + + this.isMarqueeActive = 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, + previewUrl: this.getCardPreviewUrl(card), + isVideo: this.isCardPreviewVideo(card), + 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) { + this.isMarqueeActive = false; + + // Remove marquee element + if (this.marqueeElement) { + this.marqueeElement.remove(); + this.marqueeElement = null; + } + + // Remove visual feedback class + document.body.classList.remove('marquee-selecting'); + + // Show toast with selection count if any items were selected + const selectionCount = state.selectedModels.size; + if (selectionCount > 0) { + const currentConfig = MODEL_CONFIG[state.currentPageType]; + showToast('toast.models.marqueeSelectionComplete', { + count: selectionCount, + type: currentConfig.displayName.toLowerCase() + }, 'success'); + } + + // Clear initial selection state + this.initialSelectedModels.clear(); + } } export const bulkManager = new BulkManager();