Files
Will Miao 91f955a277 fix(context-menu): keep only one context menu open at a time
The sidebar menus (folder tree, view options) and the page menus (card,
bulk selection, page-global) are independent components that only knew
how to close themselves, so right-clicking a folder left an open card
menu next to the folder menu, and right-clicking a card left the folder
menu behind.

Add a coordinator where every menu registers a closer; opening a menu
first closes all the others. BaseContextMenu takes part automatically,
the sidebar registers its two menus, and the global contextmenu handler
now uses the coordinator instead of hiding three specific menus by hand
(which also covers right-clicks outside the content area).
2026-10-31 11:27:48 +08:00

159 lines
5.1 KiB
JavaScript

import { positionFixedMenu, positionSubmenu } from '../../utils/menuPosition.js';
import { closeContextMenus, registerContextMenuCloser } from '../../utils/contextMenuCoordinator.js';
export class BaseContextMenu {
constructor(menuId, cardSelector) {
this.menu = document.getElementById(menuId);
this.menuId = 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;
}
// Nothing else may stay open while this menu is visible (another page
// menu or a sidebar menu).
registerContextMenuCloser(menuId, () => this.hideMenu());
this.init();
}
init() {
// Hide menu when clicking outside
document.addEventListener('click', (e) => {
if (!this.menu.contains(e.target)) {
this.hideMenu();
}
});
// 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) or disabled items
if (menuItem.classList.contains('has-submenu')) return;
if (menuItem.classList.contains('disabled')) 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.style.top = '';
submenu.style.maxHeight = '';
submenu.style.overflowY = '';
submenu.classList.remove('flip-left');
}
if (this.openSubmenu === trigger) {
this.openSubmenu = null;
}
}
_positionSubmenu(submenu) {
positionSubmenu(submenu);
}
handleMenuAction(action, menuItem) {
// Override in subclass
console.warn('handleMenuAction not implemented');
}
/**
* Hook for subclasses whose entries depend on the right-clicked card
* (excluded state, AI enrichment availability, missing-LoRA entry, ...).
*
* Runs while the menu is displayed but before it is measured, so entries
* toggled or re-labelled here are accounted for when the menu is placed
* against the viewport. Doing this after `showMenu` meant the flip decision
* used the height of the *previous* menu content.
*/
prepareMenu(card) { }
showMenu(x, y, card) {
// Context menus are mutually exclusive: drop whatever is open (another
// page menu or a sidebar menu) before showing this one.
closeContextMenus(this.menuId);
this.currentCard = card;
this.menu.style.display = 'block';
this.prepareMenu(card);
positionFixedMenu(this.menu, x, y);
}
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;
}
}