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 <noreply@anthropic.com>
This commit is contained in:
Will Miao
2026-05-11 21:06:47 +08:00
parent ab6100f596
commit 75f74d54d8
14 changed files with 316 additions and 53 deletions

View File

@@ -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...",

View File

@@ -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}...",

View File

@@ -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}...",

View File

@@ -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}...",

View File

@@ -696,6 +696,14 @@
"clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
"sendToWorkflow": "שלח ל-Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "מטא-נתונים",
"attributes": "מאפיינים",
"organize": "ארגן",
"download": "הורדה"
},
"autoOrganizeProgress": {
"initializing": "מאתחל ארגון אוטומטי...",
"starting": "מתחיל ארגון אוטומטי עבור {type}...",

View File

@@ -696,6 +696,14 @@
"clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)",
"sendToWorkflow": "ワークフローに送信",
"sections": {
"workflow": "ワークフロー",
"metadata": "メタデータ",
"attributes": "属性",
"organize": "整理",
"download": "ダウンロード"
},
"autoOrganizeProgress": {
"initializing": "自動整理を初期化中...",
"starting": "{type}の自動整理を開始中...",

View File

@@ -696,6 +696,14 @@
"clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)",
"sendToWorkflow": "워크플로우로 보내기",
"sections": {
"workflow": "워크플로우",
"metadata": "메타데이터",
"attributes": "속성",
"organize": "정리",
"download": "다운로드"
},
"autoOrganizeProgress": {
"initializing": "자동 정리 초기화 중...",
"starting": "{type}에 대한 자동 정리 시작...",

View File

@@ -696,6 +696,14 @@
"clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
"sendToWorkflow": "Отправить в Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Метаданные",
"attributes": "Атрибуты",
"organize": "Организовать",
"download": "Скачать"
},
"autoOrganizeProgress": {
"initializing": "Инициализация автоматической организации...",
"starting": "Запуск автоматической организации для {type}...",

View File

@@ -696,6 +696,14 @@
"clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
"sendToWorkflow": "发送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元数据",
"attributes": "属性",
"organize": "整理",
"download": "下载"
},
"autoOrganizeProgress": {
"initializing": "正在初始化自动整理...",
"starting": "正在为 {type} 启动自动整理...",

View File

@@ -696,6 +696,14 @@
"clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
"sendToWorkflow": "發送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元數據",
"attributes": "屬性",
"organize": "整理",
"download": "下載"
},
"autoOrganizeProgress": {
"initializing": "正在初始化自動整理...",
"starting": "正在開始自動整理 {type}...",

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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() {

View File

@@ -53,52 +53,74 @@
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="refresh-all">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
<div class="context-menu-section" data-section="workflow">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.workflow') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="send-to-workflow">
<i class="fas fa-paper-plane"></i>
<span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="send-to-workflow-append">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow-replace">
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
</div>
<div class="context-menu-item" data-action="copy-all">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</div>
</div>
</div>
</div>
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
<div class="context-menu-section" data-section="metadata">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.metadata') }}</div>
<div class="context-menu-item" data-action="refresh-all">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</div>
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</div>
<div class="context-menu-item" data-action="skip-metadata-refresh">
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
</div>
<div class="context-menu-item" data-action="resume-metadata-refresh">
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
</div>
</div>
<div class="context-menu-item" data-action="copy-all">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
<div class="context-menu-section" data-section="attributes">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.attributes') }}</div>
<div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div>
<div class="context-menu-item" data-action="set-base-model">
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
</div>
<div class="context-menu-item" data-action="set-favorite">
<i class="fas fa-star"></i> <span>{{ t('loras.bulkOperations.setFavorite') }}</span>
</div>
<div class="context-menu-item" data-action="set-content-rating">
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
</div>
</div>
<div class="context-menu-item" data-action="send-to-workflow-append">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
<div class="context-menu-section" data-section="organize">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.organize') }}</div>
<div class="context-menu-item" data-action="auto-organize">
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
</div>
<div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div>
</div>
<div class="context-menu-item" data-action="send-to-workflow-replace">
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
</div>
<div class="context-menu-item" data-action="auto-organize">
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
</div>
<div class="context-menu-item" data-action="download-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
</div>
<div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div>
<div class="context-menu-item" data-action="set-base-model">
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
</div>
<div class="context-menu-item" data-action="set-favorite">
<i class="fas fa-star"></i> <span>{{ t('loras.bulkOperations.setFavorite') }}</span>
</div>
<div class="context-menu-item" data-action="set-content-rating">
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
</div>
<div class="context-menu-item" data-action="skip-metadata-refresh">
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
</div>
<div class="context-menu-item" data-action="resume-metadata-refresh">
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
<div class="context-menu-section" data-section="download">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
<div class="context-menu-item" data-action="download-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div>
<div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div>
<div class="context-menu-item delete-item" data-action="delete-all">
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
</div>