feat(selection): implement marquee selection for bulk operations

This commit is contained in:
Will Miao
2025-09-05 11:24:10 +08:00
parent 7475de366b
commit 3250fa89cb
11 changed files with 294 additions and 344 deletions

View File

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

View File

@@ -318,14 +318,13 @@
"bulkOperations": { "bulkOperations": {
"selected": "{count} selected", "selected": "{count} selected",
"selectedSuffix": "selected", "selectedSuffix": "selected",
"viewSelected": "Click to view selected items", "viewSelected": "View Selected",
"addTags": "Add Tags", "addTags": "Add Tags to All",
"sendToWorkflow": "Send to Workflow", "copyAll": "Copy All Syntax",
"copyAll": "Copy All", "refreshAll": "Refresh All Metadata",
"refreshAll": "Refresh All", "moveAll": "Move All to Folder",
"moveAll": "Move All", "deleteAll": "Delete All Models",
"deleteAll": "Delete All", "clear": "Clear Selection"
"clear": "Clear"
}, },
"contextMenu": { "contextMenu": {
"refreshMetadata": "Refresh Civitai Data", "refreshMetadata": "Refresh Civitai Data",
@@ -983,6 +982,7 @@
"deleteFailed": "Error: {error}", "deleteFailed": "Error: {error}",
"deleteFailedGeneral": "Failed to delete models", "deleteFailedGeneral": "Failed to delete models",
"selectedAdditional": "Selected {count} additional {type}(s)", "selectedAdditional": "Selected {count} additional {type}(s)",
"marqueeSelectionComplete": "Selected {count} {type}(s) with marquee selection",
"refreshMetadataFailed": "Failed to refresh metadata", "refreshMetadataFailed": "Failed to refresh metadata",
"nameCannotBeEmpty": "Model name cannot be empty", "nameCannotBeEmpty": "Model name cannot be empty",
"nameUpdatedSuccessfully": "Model name updated successfully", "nameUpdatedSuccessfully": "Model name updated successfully",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -318,14 +318,13 @@
"bulkOperations": { "bulkOperations": {
"selected": "已选中 {count} 项", "selected": "已选中 {count} 项",
"selectedSuffix": "已选中", "selectedSuffix": "已选中",
"viewSelected": "点击查看已选项目", "viewSelected": "查看已选",
"addTags": "批量添加标签", "addTags": "为所有添加标签",
"sendToWorkflow": "发送到工作流", "copyAll": "复制全部语法",
"copyAll": "全部复制", "refreshAll": "刷新全部元数据",
"refreshAll": "全部刷新", "moveAll": "全部移动到文件夹",
"moveAll": "全部移动", "deleteAll": "删除所有模型",
"deleteAll": "全部删除", "clear": "清除选择"
"clear": "清除"
}, },
"contextMenu": { "contextMenu": {
"refreshMetadata": "刷新 Civitai 数据", "refreshMetadata": "刷新 Civitai 数据",
@@ -983,6 +982,7 @@
"deleteFailed": "错误:{error}", "deleteFailed": "错误:{error}",
"deleteFailedGeneral": "删除模型失败", "deleteFailedGeneral": "删除模型失败",
"selectedAdditional": "已选中 {count} 个额外 {type}", "selectedAdditional": "已选中 {count} 个额外 {type}",
"marqueeSelectionComplete": "框选已选中 {count} 个 {type}",
"refreshMetadataFailed": "刷新元数据失败", "refreshMetadataFailed": "刷新元数据失败",
"nameCannotBeEmpty": "模型名称不能为空", "nameCannotBeEmpty": "模型名称不能为空",
"nameUpdatedSuccessfully": "模型名称更新成功", "nameUpdatedSuccessfully": "模型名称更新成功",

View File

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

View File

@@ -1,81 +1,3 @@
/* Bulk Operations Styles */
.bulk-operations-panel {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateY(100px) translateX(-50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
min-width: 420px;
max-width: 900px;
width: auto;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 0;
}
.bulk-operations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 20px; /* Increase space between count and buttons */
}
#selectedCount {
font-weight: 500;
background: var(--bg-color);
padding: 6px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
min-width: 80px;
text-align: center;
}
.bulk-operations-actions {
display: flex;
gap: 8px;
}
.bulk-operations-actions button {
padding: 6px 12px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
font-size: 14px;
white-space: nowrap;
min-height: 36px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.bulk-operations-actions button:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Danger button style - updated to use proper theme variables */
.bulk-operations-actions button.danger-btn {
background: oklch(70% 0.2 29); /* Light red background that works in both themes */
color: oklch(98% 0.01 0); /* Almost white text for good contrast */
border-color: var(--lora-error);
}
.bulk-operations-actions button.danger-btn:hover {
background: var(--lora-error);
color: oklch(100% 0 0); /* Pure white text on hover for maximum contrast */
}
/* Style for selected cards */ /* Style for selected cards */
.model-card.selected { .model-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent); box-shadow: 0 0 0 2px var(--lora-accent);
@@ -99,203 +21,29 @@
z-index: 1; z-index: 1;
} }
/* Update bulk operations button to match others when active */ /* Marquee selection styles */
#bulkOperationsBtn.active { .marquee-selection {
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 {
position: fixed; position: fixed;
bottom: 80px; /* Position above the bulk operations panel */ border: 2px dashed var(--lora-accent, #007bff);
left: 50%; background: rgba(0, 123, 255, 0.1);
transform: translateX(-50%) translateY(20px); pointer-events: none;
background: var(--card-bg); z-index: 9999;
border: 1px solid var(--border-color); border-radius: 2px;
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;
} }
.selected-thumbnails-strip.visible { /* Visual feedback when marquee selecting */
opacity: 1; .marquee-selecting {
transform: translateX(-50%) translateY(0); cursor: crosshair;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
} }
.thumbnails-container { /* Prevent text selection during marquee */
display: flex; .marquee-selecting * {
gap: 12px; user-select: none;
overflow-x: auto; -webkit-user-select: none;
padding-bottom: 8px; /* Space for scrollbar */ -moz-user-select: none;
max-width: 100%; -ms-user-select: none;
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;
}
} }

View File

@@ -12,6 +12,12 @@ export class BulkManager {
// Remove bulk panel references since we're using context menu now // Remove bulk panel references since we're using context menu now
this.bulkContextMenu = null; // Will be set by core initialization 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 // Model type specific action configurations
this.actionConfig = { this.actionConfig = {
[MODEL_TYPES.LORA]: { [MODEL_TYPES.LORA]: {
@@ -44,6 +50,7 @@ export class BulkManager {
initialize() { initialize() {
this.setupEventListeners(); this.setupEventListeners();
this.setupGlobalKeyboardListeners(); this.setupGlobalKeyboardListeners();
this.setupMarqueeSelection();
} }
setBulkContextMenu(bulkContextMenu) { 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(); export const bulkManager = new BulkManager();