From 75f74d54d86c61114d0094746c88a39771ccbb24 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 11 May 2026 21:06:47 +0800 Subject: [PATCH] feat(bulk): reorganize context menu with sections and submenu for workflow actions Group 15 flat menu items into 5 logical sections (Workflow, Metadata, Attributes, Organize, Download) with section headers to reduce cognitive load. Nest the three workflow-related actions (Append, Replace, Copy Syntax) into a single "Send to Workflow" hover-triggered submenu. Add submenu infrastructure to BaseContextMenu with mouseover/mouseout boundary detection, 250ms close delay, and viewport-aware positioning. Co-Authored-By: Claude Opus 4.7 --- locales/de.json | 8 ++ locales/en.json | 8 ++ locales/es.json | 8 ++ locales/fr.json | 8 ++ locales/he.json | 8 ++ locales/ja.json | 8 ++ locales/ko.json | 8 ++ locales/ru.json | 8 ++ locales/zh-CN.json | 8 ++ locales/zh-TW.json | 8 ++ static/css/components/menu.css | 57 +++++++++ .../components/ContextMenu/BaseContextMenu.js | 114 ++++++++++++++++-- .../components/ContextMenu/BulkContextMenu.js | 16 +++ templates/components/context_menu.html | 102 ++++++++++------ 14 files changed, 316 insertions(+), 53 deletions(-) diff --git a/locales/de.json b/locales/de.json index 951794a8..04456945 100644 --- a/locales/de.json +++ b/locales/de.json @@ -696,6 +696,14 @@ "clear": "Auswahl löschen", "skipMetadataRefreshCount": "Überspringen({count} Modelle)", "resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)", + "sendToWorkflow": "An Workflow senden", + "sections": { + "workflow": "Workflow", + "metadata": "Metadaten", + "attributes": "Attribute", + "organize": "Organisieren", + "download": "Download" + }, "autoOrganizeProgress": { "initializing": "Automatische Organisation wird initialisiert...", "starting": "Automatische Organisation für {type} wird gestartet...", diff --git a/locales/en.json b/locales/en.json index 6aa15b5f..dcae4616 100644 --- a/locales/en.json +++ b/locales/en.json @@ -696,6 +696,14 @@ "clear": "Clear Selection", "skipMetadataRefreshCount": "Skip ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)", + "sendToWorkflow": "Send to Workflow", + "sections": { + "workflow": "Workflow", + "metadata": "Metadata", + "attributes": "Attributes", + "organize": "Organize", + "download": "Download" + }, "autoOrganizeProgress": { "initializing": "Initializing auto-organize...", "starting": "Starting auto-organize for {type}...", diff --git a/locales/es.json b/locales/es.json index c6f43f0c..6cf502a1 100644 --- a/locales/es.json +++ b/locales/es.json @@ -696,6 +696,14 @@ "clear": "Limpiar selección", "skipMetadataRefreshCount": "Omitir({count} modelos)", "resumeMetadataRefreshCount": "Reanudar({count} modelos)", + "sendToWorkflow": "Enviar al workflow", + "sections": { + "workflow": "Workflow", + "metadata": "Metadatos", + "attributes": "Atributos", + "organize": "Organizar", + "download": "Descargar" + }, "autoOrganizeProgress": { "initializing": "Inicializando auto-organización...", "starting": "Iniciando auto-organización para {type}...", diff --git a/locales/fr.json b/locales/fr.json index 8bfe3876..6df5c18c 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -696,6 +696,14 @@ "clear": "Effacer la sélection", "skipMetadataRefreshCount": "Ignorer({count} modèles)", "resumeMetadataRefreshCount": "Reprendre({count} modèles)", + "sendToWorkflow": "Envoyer au workflow", + "sections": { + "workflow": "Workflow", + "metadata": "Métadonnées", + "attributes": "Attributs", + "organize": "Organiser", + "download": "Télécharger" + }, "autoOrganizeProgress": { "initializing": "Initialisation de l'auto-organisation...", "starting": "Démarrage de l'auto-organisation pour {type}...", diff --git a/locales/he.json b/locales/he.json index 73a914e1..9370a48d 100644 --- a/locales/he.json +++ b/locales/he.json @@ -696,6 +696,14 @@ "clear": "נקה בחירה", "skipMetadataRefreshCount": "דילוג({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)", + "sendToWorkflow": "שלח ל-Workflow", + "sections": { + "workflow": "Workflow", + "metadata": "מטא-נתונים", + "attributes": "מאפיינים", + "organize": "ארגן", + "download": "הורדה" + }, "autoOrganizeProgress": { "initializing": "מאתחל ארגון אוטומטי...", "starting": "מתחיל ארגון אוטומטי עבור {type}...", diff --git a/locales/ja.json b/locales/ja.json index 42c95e00..1d43189f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -696,6 +696,14 @@ "clear": "選択をクリア", "skipMetadataRefreshCount": "スキップ({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)", + "sendToWorkflow": "ワークフローに送信", + "sections": { + "workflow": "ワークフロー", + "metadata": "メタデータ", + "attributes": "属性", + "organize": "整理", + "download": "ダウンロード" + }, "autoOrganizeProgress": { "initializing": "自動整理を初期化中...", "starting": "{type}の自動整理を開始中...", diff --git a/locales/ko.json b/locales/ko.json index 7a6a4d99..76924c10 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -696,6 +696,14 @@ "clear": "선택 지우기", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)", + "sendToWorkflow": "워크플로우로 보내기", + "sections": { + "workflow": "워크플로우", + "metadata": "메타데이터", + "attributes": "속성", + "organize": "정리", + "download": "다운로드" + }, "autoOrganizeProgress": { "initializing": "자동 정리 초기화 중...", "starting": "{type}에 대한 자동 정리 시작...", diff --git a/locales/ru.json b/locales/ru.json index 16b3cb8f..8f98132a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -696,6 +696,14 @@ "clear": "Очистить выбор", "skipMetadataRefreshCount": "Пропустить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)", + "sendToWorkflow": "Отправить в Workflow", + "sections": { + "workflow": "Workflow", + "metadata": "Метаданные", + "attributes": "Атрибуты", + "organize": "Организовать", + "download": "Скачать" + }, "autoOrganizeProgress": { "initializing": "Инициализация автоматической организации...", "starting": "Запуск автоматической организации для {type}...", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index b5478abd..08cab57b 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -696,6 +696,14 @@ "clear": "清除选择", "skipMetadataRefreshCount": "跳过({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)", + "sendToWorkflow": "发送到工作流", + "sections": { + "workflow": "工作流", + "metadata": "元数据", + "attributes": "属性", + "organize": "整理", + "download": "下载" + }, "autoOrganizeProgress": { "initializing": "正在初始化自动整理...", "starting": "正在为 {type} 启动自动整理...", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index ce60948b..a154687f 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -696,6 +696,14 @@ "clear": "清除選取", "skipMetadataRefreshCount": "跳過({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)", + "sendToWorkflow": "發送到工作流", + "sections": { + "workflow": "工作流", + "metadata": "元數據", + "attributes": "屬性", + "organize": "整理", + "download": "下載" + }, "autoOrganizeProgress": { "initializing": "正在初始化自動整理...", "starting": "正在開始自動整理 {type}...", diff --git a/static/css/components/menu.css b/static/css/components/menu.css index b76694a3..230bd1f6 100644 --- a/static/css/components/menu.css +++ b/static/css/components/menu.css @@ -41,6 +41,63 @@ text-align: center; } +/* Section Headers */ +.context-menu-section-header { + padding: 6px 12px 2px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + cursor: default; + user-select: none; +} + +/* Submenu */ +.context-menu-item.has-submenu { + position: relative; + justify-content: space-between; +} + +.submenu-arrow { + margin-left: auto; + font-size: 10px; + width: auto !important; +} + +.context-submenu { + position: absolute; + left: calc(100% - 4px); + top: -1px; + display: none; + background: var(--lora-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: 0; + min-width: 200px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1001; + backdrop-filter: blur(10px); +} + +.context-submenu .context-menu-item { + white-space: nowrap; + margin: 0; +} + +.context-submenu .context-menu-item:first-child { + padding-top: 9px; +} + +.context-submenu .context-menu-item:last-child { + padding-bottom: 9px; +} + +.context-submenu.flip-left { + left: auto; + right: 100%; +} + /* NSFW Level Selector */ .nsfw-level-selector { position: fixed; diff --git a/static/js/components/ContextMenu/BaseContextMenu.js b/static/js/components/ContextMenu/BaseContextMenu.js index 8ec2d9eb..3e1f25ac 100644 --- a/static/js/components/ContextMenu/BaseContextMenu.js +++ b/static/js/components/ContextMenu/BaseContextMenu.js @@ -3,32 +3,113 @@ export class BaseContextMenu { this.menu = document.getElementById(menuId); this.cardSelector = cardSelector; this.currentCard = null; - + this.submenuTimeout = null; + this.openSubmenu = null; + if (!this.menu) { console.error(`Context menu element with ID ${menuId} not found`); return; } - + this.init(); } init() { - // Hide menu on regular clicks - document.addEventListener('click', () => this.hideMenu()); + // Hide menu when clicking outside + document.addEventListener('click', (e) => { + if (!this.menu.contains(e.target)) { + this.hideMenu(); + } + }); - // Handle menu item clicks + // Handle menu item clicks (including submenu items) this.menu.addEventListener('click', (e) => { const menuItem = e.target.closest('.context-menu-item'); if (!menuItem || !this.currentCard) return; + // Ignore clicks on submenu trigger (has-submenu parent) + if (menuItem.classList.contains('has-submenu')) return; + const action = menuItem.dataset.action; if (!action) return; - + this.handleMenuAction(action, menuItem); this.hideMenu(); }); + + // Submenu hover handling + // Use mouseover/mouseout (which bubble) with relatedTarget checks + // to reliably detect crossing the .has-submenu boundary + this.menu.addEventListener('mouseover', (e) => { + const trigger = e.target.closest('.has-submenu'); + if (!trigger) return; + + // Only act when entering from outside this trigger's tree + if (e.relatedTarget && trigger.contains(e.relatedTarget)) return; + + this._openSubmenu(trigger); + }); + + this.menu.addEventListener('mouseout', (e) => { + const trigger = e.target.closest('.has-submenu'); + if (!trigger) return; + + // Only close when leaving the trigger's tree entirely + if (e.relatedTarget && trigger.contains(e.relatedTarget)) return; + + this._scheduleSubmenuClose(trigger); + }); } - + + _openSubmenu(trigger) { + // Clear any pending close + if (this.submenuTimeout) { + clearTimeout(this.submenuTimeout); + this.submenuTimeout = null; + } + + // Hide any previously open submenu + if (this.openSubmenu && this.openSubmenu !== trigger) { + this._hideSubmenu(this.openSubmenu); + } + + const submenu = trigger.querySelector('.context-submenu'); + if (!submenu) return; + + submenu.style.display = 'block'; + this.openSubmenu = trigger; + this._positionSubmenu(submenu); + } + + _scheduleSubmenuClose(trigger) { + this.submenuTimeout = setTimeout(() => { + this._hideSubmenu(trigger); + this.submenuTimeout = null; + }, 250); + } + + _hideSubmenu(trigger) { + const submenu = trigger.querySelector('.context-submenu'); + if (submenu) { + submenu.style.display = 'none'; + submenu.classList.remove('flip-left'); + } + if (this.openSubmenu === trigger) { + this.openSubmenu = null; + } + } + + _positionSubmenu(submenu) { + const submenuRect = submenu.getBoundingClientRect(); + const viewportWidth = document.documentElement.clientWidth; + + if (submenuRect.right > viewportWidth) { + submenu.classList.add('flip-left'); + } else { + submenu.classList.remove('flip-left'); + } + } + handleMenuAction(action, menuItem) { // Override in subclass console.warn('handleMenuAction not implemented'); @@ -40,34 +121,41 @@ export class BaseContextMenu { // Get menu dimensions const menuRect = this.menu.getBoundingClientRect(); - + // Get viewport dimensions const viewportWidth = document.documentElement.clientWidth; const viewportHeight = document.documentElement.clientHeight; - + // Calculate position let finalX = x; let finalY = y; - + // Ensure menu doesn't go offscreen right if (x + menuRect.width > viewportWidth) { finalX = x - menuRect.width; } - + // Ensure menu doesn't go offscreen bottom if (y + menuRect.height > viewportHeight) { finalY = y - menuRect.height; } - + // Position menu this.menu.style.left = `${finalX}px`; this.menu.style.top = `${finalY}px`; } hideMenu() { + if (this.submenuTimeout) { + clearTimeout(this.submenuTimeout); + this.submenuTimeout = null; + } + if (this.openSubmenu) { + this._hideSubmenu(this.openSubmenu); + } if (this.menu) { this.menu.style.display = 'none'; } this.currentCard = null; } -} \ No newline at end of file +} diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index e63ad7f8..43e28a1c 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -51,6 +51,14 @@ export class BulkContextMenu extends BaseContextMenu { if (copyAllItem) { copyAllItem.style.display = config.copyAll ? 'flex' : 'none'; } + + // Submenu parent visibility + const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]'); + if (sendToWorkflowSubmenu) { + const hasWorkflowActions = config.sendToWorkflow || config.copyAll; + sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none'; + } + if (refreshAllItem) { refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none'; } @@ -148,6 +156,14 @@ export class BulkContextMenu extends BaseContextMenu { ); } } + + // Hide empty sections + this.menu.querySelectorAll('.context-menu-section').forEach(section => { + const items = Array.from(section.querySelectorAll('.context-menu-item')) + .filter(item => !item.closest('.context-submenu')); + const allHidden = items.length > 0 && items.every(item => item.style.display === 'none'); + section.style.display = allHidden ? 'none' : ''; + }); } updateSelectedCountHeader() { diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index e98c88d3..74d916ad 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -53,52 +53,74 @@ {{ t('loras.bulkOperations.selected', {'count': 0}) }}
-
- {{ t('loras.bulkOperations.refreshAll') }} +
+
{{ t('loras.bulkOperations.sections.workflow') }}
+
+ + {{ t('loras.bulkOperations.sendToWorkflow') }} + +
+
+ {{ t('loras.contextMenu.sendToWorkflowAppend') }} +
+
+ {{ t('loras.contextMenu.sendToWorkflowReplace') }} +
+
+ {{ t('loras.bulkOperations.copyAll') }} +
+
+
-
- {{ t('loras.bulkOperations.checkUpdates') }} +
+
{{ t('loras.bulkOperations.sections.metadata') }}
+
+ {{ t('loras.bulkOperations.refreshAll') }} +
+
+ {{ t('loras.bulkOperations.checkUpdates') }} +
+
+ {{ t('loras.bulkOperations.skipMetadataRefresh') }} +
+
+ {{ t('loras.bulkOperations.resumeMetadataRefresh') }} +
-
- {{ t('loras.bulkOperations.copyAll') }} +
+
{{ t('loras.bulkOperations.sections.attributes') }}
+
+ {{ t('loras.bulkOperations.addTags') }} +
+
+ {{ t('loras.bulkOperations.setBaseModel') }} +
+
+ {{ t('loras.bulkOperations.setFavorite') }} +
+
+ {{ t('loras.bulkOperations.setContentRating') }} +
-
- {{ t('loras.contextMenu.sendToWorkflowAppend') }} +
+
{{ t('loras.bulkOperations.sections.organize') }}
+
+ {{ t('loras.bulkOperations.autoOrganize') }} +
+
+ {{ t('loras.bulkOperations.moveAll') }} +
-
- {{ t('loras.contextMenu.sendToWorkflowReplace') }} -
-
- {{ t('loras.bulkOperations.autoOrganize') }} -
-
- {{ t('loras.bulkOperations.downloadExamples') }} -
-
- {{ t('loras.bulkOperations.addTags') }} -
-
- {{ t('loras.bulkOperations.setBaseModel') }} -
-
- {{ t('loras.bulkOperations.setFavorite') }} -
-
- {{ t('loras.bulkOperations.setContentRating') }} -
-
- {{ t('loras.bulkOperations.skipMetadataRefresh') }} -
-
- {{ t('loras.bulkOperations.resumeMetadataRefresh') }} +
+
{{ t('loras.bulkOperations.sections.download') }}
+
+ {{ t('loras.bulkOperations.downloadExamples') }} +
+
+ {{ t('loras.bulkOperations.downloadMissingLoras') }} +
-
- {{ t('loras.bulkOperations.downloadMissingLoras') }} -
-
- {{ t('loras.bulkOperations.moveAll') }} -
{{ t('loras.bulkOperations.deleteAll') }}