Files
2026-01-10 17:38:47 +08:00

806 lines
30 KiB
JavaScript

/**
* 按钮菜单服务
* 处理按钮右键点击弹出菜单的逻辑
*/
import { EventManager } from "../utils/eventManager.js";
import { logger } from "../utils/logger.js";
import { ResourceManager } from "../utils/resourceManager.js";
import { UIToolkit } from "../utils/UIToolkit.js";
/**
* 按钮菜单管理类
* 负责创建、显示和隐藏按钮右键菜单
*/
export class ButtonMenu {
/**
* 单例实例
*/
static _instance = null;
/**
* 获取单例实例
*/
static getInstance() {
if (!ButtonMenu._instance) {
ButtonMenu._instance = new ButtonMenu();
}
return ButtonMenu._instance;
}
/**
* 构造函数
* 初始化菜单状态和事件处理
*/
constructor() {
// 当前菜单DOM元素
this.menuElement = null;
// 当前触发菜单的按钮
this.targetButton = null;
// 菜单项配置
this.menuItems = [];
// 菜单是否显示中
this.isMenuVisible = false;
// 菜单上下文数据
this.menuContext = {};
// 初始化全局点击事件,用于关闭菜单
this._setupGlobalEvents();
// 日志
logger.log("按钮菜单服务已初始化");
}
/**
* 设置全局事件处理
* 主要处理点击其他区域关闭菜单
*/
_setupGlobalEvents() {
// 使用事件管理器添加文档点击事件
EventManager.addDOMListener(document, 'click', async (event) => {
// 如果菜单可见,且点击的不是菜单内部元素,则隐藏菜单
if (this.isMenuVisible && this.menuElement) {
if (!this.menuElement.contains(event.target)) {
await this.hideMenu();
}
}
});
// 按ESC键关闭菜单
EventManager.addDOMListener(document, 'keydown', async (event) => {
if (event.key === 'Escape' && this.isMenuVisible) {
await this.hideMenu();
}
});
}
/**
* 创建菜单DOM元素
* @param {Array} items 菜单项配置数组
* @param {Object} context 菜单上下文数据
* @returns {HTMLElement} 菜单DOM元素
*/
_createMenuElement(items, context) {
// 创建菜单容器
const menuContainer = document.createElement('div');
menuContainer.className = 'button-context-menu';
// 创建菜单项列表
const menuList = document.createElement('ul');
menuList.className = 'button-menu-list';
// 添加菜单项
items.forEach(item => {
// 如果是分隔线
if (item.type === 'separator') {
const separator = document.createElement('li');
separator.className = 'button-menu-separator';
menuList.appendChild(separator);
return;
}
// 正常菜单项
const menuItem = document.createElement('li');
menuItem.className = 'button-menu-item';
// 如果存在子菜单,标记样式
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
if (hasChildren) {
menuItem.classList.add('button-menu-item-has-children');
}
// 如果菜单项被禁用
if (item.disabled) {
menuItem.classList.add('button-menu-item-disabled');
} else {
// 添加点击事件(带子菜单也可点击执行自身onClick)
menuItem.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// 若点击的是右侧子菜单箭头或子菜单本身,则不触发本项点击
const isOnRightIcon = e.target && (e.target.classList?.contains('button-menu-right-icon') || e.target.classList?.contains('pi-chevron-right'));
const isInsideSubmenu = e.target && menuItem.querySelector('.button-submenu')?.contains(e.target);
if (isOnRightIcon || isInsideSubmenu) return;
// 隐藏菜单
await this.hideMenu();
// 执行回调
if (typeof item.onClick === 'function') {
item.onClick(context);
}
});
// 悬停时展示子菜单(进入时立即显示,隐藏逻辑在子菜单构建后处理,避免“穿越空隙”导致关闭)
if (hasChildren) {
menuItem.addEventListener('mouseenter', () => {
menuItem.classList.add('submenu-open');
});
// mouseleave 的隐藏放到子菜单构建后添加带延迟的逻辑
}
}
// 处理选中状态
if (item.selected) {
menuItem.classList.add('selected');
}
// 添加图标(如果有)
if (item.icon) {
const iconSpan = document.createElement('span');
iconSpan.className = 'button-menu-icon';
// 支持SVG图标
if (typeof item.icon === 'string' && item.icon.startsWith('<svg')) {
iconSpan.innerHTML = item.icon;
} else {
// 支持字符图标或自定义HTML
iconSpan.innerHTML = item.icon;
}
menuItem.appendChild(iconSpan);
}
// 添加标签
const labelSpan = document.createElement('span');
labelSpan.className = 'button-menu-label';
labelSpan.textContent = item.label || '';
menuItem.appendChild(labelSpan);
// 如果有快捷键提示
if (item.shortcut) {
const shortcutSpan = document.createElement('span');
shortcutSpan.className = 'button-menu-shortcut';
shortcutSpan.textContent = item.shortcut;
menuItem.appendChild(shortcutSpan);
}
// 如果有子菜单,添加右侧图标
if (hasChildren) {
const rightIcon = document.createElement('span');
rightIcon.className = 'button-menu-right-icon pi pi-chevron-right';
menuItem.appendChild(rightIcon);
// 构建子菜单
const submenu = document.createElement('div');
submenu.className = 'button-submenu';
const submenuList = document.createElement('ul');
submenuList.className = 'button-menu-list';
// —— 子菜单显示/隐藏的防抖处理,避免鼠标穿越导致关闭 ——
let hideTimer = null;
// 获取子菜单对齐方式,默认居中
const submenuAlign = item.submenuAlign || 'center';
const openSubmenu = () => {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
menuItem.classList.add('submenu-open');
// 使用 requestAnimationFrame 确保子菜单已渲染再调整位置
requestAnimationFrame(() => {
this._adjustSubmenuPosition(submenu, menuItem, submenuAlign);
});
};
const scheduleCloseSubmenu = () => {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
menuItem.classList.remove('submenu-open');
hideTimer = null;
}, 200);
};
// 父项与子菜单的鼠标事件配合,允许对角线移动
menuItem.addEventListener('mouseleave', scheduleCloseSubmenu);
menuItem.addEventListener('mouseenter', openSubmenu);
submenu.addEventListener('mouseenter', openSubmenu);
submenu.addEventListener('mouseleave', scheduleCloseSubmenu);
item.children.forEach(subItem => {
if (subItem.type === 'separator') {
const sep = document.createElement('li');
sep.className = 'button-menu-separator';
submenuList.appendChild(sep);
return;
}
const subMenuItem = document.createElement('li');
subMenuItem.className = 'button-menu-item';
// 检查子菜单项是否有children(支持三级及以上菜单)
const hasSubChildren = Array.isArray(subItem.children) && subItem.children.length > 0;
if (hasSubChildren) {
subMenuItem.classList.add('button-menu-item-has-children');
}
if (subItem.disabled) {
subMenuItem.classList.add('button-menu-item-disabled');
} else {
subMenuItem.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// 如果点击的是右侧箭头或子子菜单,不触发点击
const isOnRightIcon = e.target && (e.target.classList?.contains('button-menu-right-icon') || e.target.classList?.contains('pi-chevron-right'));
const isInsideSubSubmenu = e.target && subMenuItem.querySelector('.button-submenu')?.contains(e.target);
if (isOnRightIcon || isInsideSubSubmenu) return;
// 隐藏菜单
await this.hideMenu();
if (typeof subItem.onClick === 'function') {
subItem.onClick(context);
}
});
// 如果有子子菜单,添加悬停逻辑
if (hasSubChildren) {
subMenuItem.addEventListener('mouseenter', () => {
subMenuItem.classList.add('submenu-open');
});
}
}
if (subItem.icon) {
const subIcon = document.createElement('span');
subIcon.className = 'button-menu-icon';
if (typeof subItem.icon === 'string' && subItem.icon.startsWith('<svg')) {
subIcon.innerHTML = subItem.icon;
} else {
subIcon.innerHTML = subItem.icon;
}
subMenuItem.appendChild(subIcon);
}
const subLabel = document.createElement('span');
subLabel.className = 'button-menu-label';
subLabel.textContent = subItem.label || '';
subMenuItem.appendChild(subLabel);
if (subItem.shortcut) {
const subShortcut = document.createElement('span');
subShortcut.className = 'button-menu-shortcut';
subShortcut.textContent = subItem.shortcut;
subMenuItem.appendChild(subShortcut);
}
// 如果有子子菜单,递归创建
if (hasSubChildren) {
const subRightIcon = document.createElement('span');
subRightIcon.className = 'button-menu-right-icon pi pi-chevron-right';
subMenuItem.appendChild(subRightIcon);
// 构建子子菜单
const subSubmenu = document.createElement('div');
subSubmenu.className = 'button-submenu';
const subSubmenuList = document.createElement('ul');
subSubmenuList.className = 'button-menu-list';
// 防抖处理
let subHideTimer = null;
// 获取子菜单对齐方式,默认居中
const subSubmenuAlign = subItem.submenuAlign || 'center';
const openSubSubmenu = () => {
if (subHideTimer) { clearTimeout(subHideTimer); subHideTimer = null; }
subMenuItem.classList.add('submenu-open');
// 使用 requestAnimationFrame 确保子菜单已渲染再调整位置
requestAnimationFrame(() => {
this._adjustSubmenuPosition(subSubmenu, subMenuItem, subSubmenuAlign);
});
};
const scheduleCloseSubSubmenu = () => {
if (subHideTimer) clearTimeout(subHideTimer);
subHideTimer = setTimeout(() => {
subMenuItem.classList.remove('submenu-open');
subHideTimer = null;
}, 200);
};
subMenuItem.addEventListener('mouseleave', scheduleCloseSubSubmenu);
subMenuItem.addEventListener('mouseenter', openSubSubmenu);
subSubmenu.addEventListener('mouseenter', openSubSubmenu);
subSubmenu.addEventListener('mouseleave', scheduleCloseSubSubmenu);
// 渲染第三级菜单项
subItem.children.forEach(subSubItem => {
if (subSubItem.type === 'separator') {
const sep = document.createElement('li');
sep.className = 'button-menu-separator';
subSubmenuList.appendChild(sep);
return;
}
const subSubMenuItem = document.createElement('li');
subSubMenuItem.className = 'button-menu-item';
if (subSubItem.disabled) {
subSubMenuItem.classList.add('button-menu-item-disabled');
} else {
subSubMenuItem.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.hideMenu();
if (typeof subSubItem.onClick === 'function') {
subSubItem.onClick(context);
}
});
}
if (subSubItem.icon) {
const subSubIcon = document.createElement('span');
subSubIcon.className = 'button-menu-icon';
subSubIcon.innerHTML = subSubItem.icon;
subSubMenuItem.appendChild(subSubIcon);
}
const subSubLabel = document.createElement('span');
subSubLabel.className = 'button-menu-label';
subSubLabel.textContent = subSubItem.label || '';
subSubMenuItem.appendChild(subSubLabel);
subSubmenuList.appendChild(subSubMenuItem);
});
subSubmenu.appendChild(subSubmenuList);
subMenuItem.appendChild(subSubmenu);
}
submenuList.appendChild(subMenuItem);
});
submenu.appendChild(submenuList);
menuItem.appendChild(submenu);
}
// 添加到菜单列表
menuList.appendChild(menuItem);
});
// 将菜单列表添加到容器
menuContainer.appendChild(menuList);
return menuContainer;
}
/**
* 显示菜单
* @param {HTMLElement} targetButton 触发菜单的按钮
* @param {Array} items 菜单项配置
* @param {Object} context 菜单上下文数据
* @param {Event} event 触发事件(用于定位)
*/
async showMenu(targetButton, items, context = {}, event) {
// Close any open popups first
const { PopupManager } = await import('../utils/popupManager.js');
// 【关键】标记正在切换,防止容器折叠
PopupManager._isTransitioning = true;
await PopupManager.closeAllPopups();
// 先隐藏可能已存在的菜单
await this.hideMenu();
// 在所有其他窗口关闭后,设置活动按钮状态
if (context.widget && targetButton.dataset.id) {
UIToolkit.setActiveButton({
widget: context.widget,
buttonId: targetButton.dataset.id
});
logger.debug(`右键菜单 | 设置活动按钮 | 按钮ID: ${targetButton.dataset.id}`);
}
// 保存参数
this.targetButton = targetButton;
this.menuItems = items;
this.menuContext = context;
// 创建菜单元素
this.menuElement = this._createMenuElement(items, context);
// 添加到文档
document.body.appendChild(this.menuElement);
// 设置菜单位置
this._positionMenu(event);
// 标记菜单为可见
this.isMenuVisible = true;
// 【关键】清除切换标记,因为菜单已经显示
PopupManager._isTransitioning = false;
// 阻止默认右键菜单
if (event) {
event.preventDefault();
event.stopPropagation();
}
// 添加焦点管理
this._setupKeyboardNavigation();
}
/**
* 设置键盘导航
*/
_setupKeyboardNavigation() {
if (!this.menuElement) return;
// 添加键盘事件处理
const keyHandler = (e) => {
if (!this.isMenuVisible) return;
switch (e.key) {
case 'ArrowDown':
this._navigateMenu(1);
e.preventDefault();
break;
case 'ArrowUp':
this._navigateMenu(-1);
e.preventDefault();
break;
case 'Enter':
this._activateSelectedItem();
e.preventDefault();
break;
}
};
// 添加键盘事件监听
document.addEventListener('keydown', keyHandler);
// 保存清理函数
this._keyboardCleanup = () => {
document.removeEventListener('keydown', keyHandler);
};
}
/**
* 菜单导航
* @param {number} direction 导航方向 (1: 下, -1: 上)
*/
_navigateMenu(direction) {
if (!this.menuElement) return;
const items = this.menuElement.querySelectorAll('.button-menu-item:not(.button-menu-item-disabled)');
if (items.length === 0) return;
// 找到当前选中项
let currentIndex = -1;
for (let i = 0; i < items.length; i++) {
if (items[i].classList.contains('button-menu-item-focus')) {
currentIndex = i;
break;
}
}
// 计算下一个索引
let nextIndex;
if (currentIndex === -1) {
nextIndex = direction > 0 ? 0 : items.length - 1;
} else {
nextIndex = (currentIndex + direction + items.length) % items.length;
}
// 移除所有焦点
items.forEach(item => item.classList.remove('button-menu-item-focus'));
// 设置新焦点
items[nextIndex].classList.add('button-menu-item-focus');
}
/**
* 激活当前选中项
*/
_activateSelectedItem() {
if (!this.menuElement) return;
const focusedItem = this.menuElement.querySelector('.button-menu-item-focus');
if (focusedItem) {
focusedItem.click();
}
}
/**
* 隐藏菜单
*/
async hideMenu() {
const widget = this.menuContext?.widget;
if (this.menuElement) {
const menuToHide = this.menuElement; // 捕获要隐藏的菜单元素
// 移除菜单元素
menuToHide.classList.remove('button-menu-visible');
// 清理键盘导航
if (this._keyboardCleanup) {
this._keyboardCleanup();
this._keyboardCleanup = null;
}
// 使用动画完成事件监听器
const handleTransitionEnd = () => {
if (menuToHide && menuToHide.parentNode) {
menuToHide.removeEventListener('transitionend', handleTransitionEnd);
menuToHide.parentNode.removeChild(menuToHide);
// 仅当实例属性仍指向我们移除的菜单时,才将其置空
if (this.menuElement === menuToHide) {
this.menuElement = null;
}
}
};
// 监听动画完成事件
menuToHide.addEventListener('transitionend', handleTransitionEnd);
// 如果动画未开始,设置超时
setTimeout(() => {
if (menuToHide && menuToHide.parentNode) {
handleTransitionEnd();
}
}, 300); // 300ms后强制移除,防止动画不触发
}
// 重置中央按钮状态
UIToolkit.setActiveButton(null);
// logger.debug('右键菜单 | 重置活动按钮');
// 触发小助手可见性更新以允许其自动折叠
if (widget) {
try {
// 处理提示词小助手的情况
if (widget.type === 'prompt_assistant') {
const { promptAssistant } = await import('../modules/PromptAssistant.js');
// 先更新可见性
promptAssistant.updateAssistantVisibility(widget);
// 直接触发自动折叠(如果小助手处于展开状态)
if (!widget.isCollapsed) {
setTimeout(() => {
// 在下一个事件循环中触发自动折叠
promptAssistant.triggerAutoCollapse(widget);
}, 100);
}
}
// 处理图像小助手的情况 - 使用更宽松的检测条件
else if (widget.type === 'image_caption_assistant' || widget.nodeId) {
const { imageCaption } = await import('../modules/imageCaption.js');
// 先更新可见性
imageCaption.updateAssistantVisibility(widget);
// 直接触发自动折叠(如果小助手处于展开状态)
if (!widget.isCollapsed) {
setTimeout(() => {
// 在下一个事件循环中触发自动折叠
imageCaption.triggerAutoCollapse(widget);
}, 100);
}
}
logger.debug(`右键菜单关闭 | 触发小助手可见性更新 | 类型: ${widget.type || '图像小助手'}`);
} catch (error) {
logger.error(`触发小助手可见性更新失败: ${error.message}`);
}
}
// 重置状态
this.isMenuVisible = false;
this.targetButton = null;
this.menuItems = [];
this.menuContext = {};
}
/**
* 定位菜单
* @param {Event} event 触发事件
*/
_positionMenu(event) {
if (!this.menuElement || !this.targetButton) return;
const buttonRect = this.targetButton.getBoundingClientRect();
const menuRect = this.menuElement.getBoundingClientRect();
// 计算菜单位置,使其位于按钮正上方并居中
let x = buttonRect.left + (buttonRect.width / 2) - (menuRect.width / 2);
let y = buttonRect.top - menuRect.height - 4; // 4px的间距
// 设置菜单初始位置
this.menuElement.style.left = `${x}px`;
this.menuElement.style.top = `${y}px`;
// 确保菜单在视口内
setTimeout(() => this._adjustMenuPosition(), 0);
}
/**
* 调整菜单位置,确保在视口内
*/
_adjustMenuPosition() {
if (!this.menuElement) return;
const menuRect = this.menuElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 12; // 边距
// 检查并调整水平位置
if (menuRect.right > viewportWidth - margin) {
const newLeft = viewportWidth - menuRect.width - margin;
this.menuElement.style.left = `${Math.max(margin, newLeft)}px`;
} else if (menuRect.left < margin) {
this.menuElement.style.left = `${margin}px`;
}
// 检查并调整垂直位置
if (menuRect.top < margin && this.targetButton) {
const buttonRect = this.targetButton.getBoundingClientRect();
let newTop = buttonRect.bottom + 4; // 4px margin
// Check if placing it below makes it go off-screen at the bottom
if (newTop + menuRect.height > viewportHeight - margin) {
newTop = viewportHeight - menuRect.height - margin;
}
this.menuElement.style.top = `${Math.max(margin, newTop)}px`;
} else if (menuRect.bottom > viewportHeight - margin) {
const newTop = viewportHeight - menuRect.height - margin;
this.menuElement.style.top = `${Math.max(margin, newTop)}px`;
}
// 添加显示动画
requestAnimationFrame(() => {
this.menuElement.classList.add('button-menu-visible');
});
}
/**
* 调整子菜单位置
* @param {HTMLElement} submenu 子菜单元素
* @param {HTMLElement} parentItem 父级菜单项元素
* @param {string} align 对齐方式:'top'(上对齐), 'center'(居中), 'bottom'(下对齐),默认 'center'
*/
_adjustSubmenuPosition(submenu, parentItem, align = 'center') {
if (!submenu || !parentItem) return;
const parentRect = parentItem.getBoundingClientRect();
const submenuRect = submenu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 12;
// 根据对齐方式计算初始垂直位置
let topOffset;
switch (align) {
case 'top':
// 上对齐:子菜单顶部与父项顶部对齐
topOffset = 0;
break;
case 'bottom':
// 下对齐:子菜单底部与父项底部对齐
topOffset = parentRect.height - submenuRect.height;
break;
case 'center':
default:
// 居中:子菜单中心与父项中心对齐
topOffset = (parentRect.height - submenuRect.height) / 2;
break;
}
// 计算子菜单实际的 top 位置(基于视口)
let actualTop = parentRect.top + topOffset;
// 限制在视口范围内(动态调整)
if (actualTop < margin) {
// 超出顶部,调整到顶部边缘
topOffset = margin - parentRect.top;
} else if (actualTop + submenuRect.height > viewportHeight - margin) {
// 超出底部,调整到底部边缘
topOffset = viewportHeight - margin - submenuRect.height - parentRect.top;
}
// 设置垂直位置
submenu.style.top = `${topOffset}px`;
submenu.style.bottom = 'auto';
// 检查水平位置:如果右侧超出视口,尝试显示在左侧
const rightEdge = parentRect.right + submenuRect.width + 4;
if (rightEdge > viewportWidth - margin) {
// 显示在父菜单左侧
submenu.style.left = 'auto';
submenu.style.right = '100%';
submenu.style.marginLeft = '0';
submenu.style.marginRight = '2px';
} else {
// 默认显示在父菜单右侧
submenu.style.left = '100%';
submenu.style.right = 'auto';
submenu.style.marginLeft = '2px';
submenu.style.marginRight = '0';
}
}
/**
* 公开方法:设置按钮右键菜单
* @param {HTMLElement} button 按钮元素
* @param {Function} getMenuItems 获取菜单项的函数,接收上下文参数,返回菜单项数组或Promise
* @param {Object} context 上下文数据
*/
setupButtonMenu(button, getMenuItems, context = {}) {
if (!button || typeof getMenuItems !== 'function') return;
// 清理可能已存在的事件监听器
if (button._menuEventCleanup) {
button._menuEventCleanup();
}
// 添加右键菜单事件
const removeContextMenu = EventManager.addDOMListener(button, 'contextmenu', async (event) => {
event.preventDefault();
event.stopPropagation();
// 获取当前菜单项 - 支持异步函数
try {
const menuItems = await Promise.resolve(getMenuItems(context));
if (!menuItems || menuItems.length === 0) return;
// 显示菜单
await this.showMenu(button, menuItems, context, event);
} catch (error) {
logger.error(`获取菜单项失败: ${error.message}`);
}
});
// 保存清理函数
button._menuEventCleanup = () => {
removeContextMenu();
};
// 返回清理函数,方便外部调用
return button._menuEventCleanup;
}
}
// 创建并导出样式
export function addButtonMenuStyles() {
// 确保资源管理器已初始化
if (!ResourceManager.isInitialized()) {
ResourceManager.init();
}
// 样式已经在popup.css中定义,不需要额外添加
logger.debug("按钮菜单样式已从popup.css加载");
}
/**
* 导出默认实例
*/
export const buttonMenu = ButtonMenu.getInstance();
// 自动添加样式
addButtonMenuStyles();