import { IBimComponent } from '../../types/component'; import { ThemeConfig } from '../../themes/types'; import { localeManager, t } from '../../services/locale'; import { MenuItemConfig } from './item'; import { MenuOptions } from './types'; import './index.css'; import { themeManager } from '../../services/theme'; /** * 通用菜单列表组件 * 负责渲染一组菜单项,支持分组、排序、图标、快捷键提示和递归多级子菜单。 * 它不包含定位逻辑,仅负责内容渲染。 */ export class BimMenu implements IBimComponent { public element: HTMLElement; private options: MenuOptions; private unsubscribeLocale: (() => void) | null = null; private unsubscribeTheme: (() => void) | null = null; // 当前激活的子菜单引用,用于自动关闭 private activeSubMenu: { menu: BimMenu; container: HTMLElement } | null = null; constructor(options: MenuOptions) { this.options = options; this.element = document.createElement('ul'); this.element.className = 'bim-menu'; } /** * 初始化组件 * 渲染 DOM 结构并订阅语言变更 */ public init(): void { this.render(); // 订阅语言变更事件,实现国际化自动更新 this.unsubscribeLocale = localeManager.subscribe(() => { this.setLocales(); }); // 自动订阅主题变更 this.unsubscribeTheme = themeManager.subscribe((theme) => { this.setTheme(theme); }); } /** * 设置主题 * @param theme 全局主题配置 */ public setTheme(theme: ThemeConfig) { const style = this.element.style; style.setProperty('--bim-bg-elevated', theme.bgElevated); style.setProperty('--bim-text-primary', theme.textPrimary); style.setProperty('--bim-divider', theme.divider); style.setProperty('--bim-component-bg-hover', theme.componentBgHover); style.setProperty('--bim-shadow-lg', theme.shadowLg); } /** * 响应语言变更 * 重新渲染整个菜单以更新文本 */ public setLocales(): void { this.element.innerHTML = ''; this.render(); } /** * 销毁组件 * 清理事件监听、子菜单和 DOM 元素 */ public destroy(): void { // 取消语言订阅 if (this.unsubscribeLocale) { this.unsubscribeLocale(); this.unsubscribeLocale = null; } if (this.unsubscribeTheme) { this.unsubscribeTheme(); this.unsubscribeTheme = null; } // 关闭并销毁所有打开的子菜单 this.closeSubMenu(); // 移除自身 DOM this.element.remove(); } /** * 获取组件根元素 * 实现 IRightKeyContent 接口,允许被 RightKey 容器挂载 */ public getElement(): HTMLElement { return this.element; } /** * 核心渲染逻辑 * 处理分组、排序和 DOM 生成 */ private render(): void { const { items, groupOrder } = this.options; // 1. 数据分桶:按 group 字段将菜单项分组 const groups = new Map(); const defaultGroup = 'default'; items.forEach(item => { const groupName = item.group || defaultGroup; if (!groups.has(groupName)) { groups.set(groupName, []); } groups.get(groupName)!.push(item); }); // 2. 确定分组顺序 let sortedGroupKeys: string[] = []; if (groupOrder) { // 优先按照 groupOrder 指定的顺序排序 sortedGroupKeys = groupOrder.filter(g => groups.has(g)); // 将未在 groupOrder 中定义的组追加到最后 for (const key of groups.keys()) { if (!sortedGroupKeys.includes(key)) { sortedGroupKeys.push(key); } } } else { // 如果未指定顺序,则按默认遍历顺序 sortedGroupKeys = Array.from(groups.keys()); } // 3. 渲染分组和组内项 sortedGroupKeys.forEach((groupName, index) => { // 除了第一组外,每组之前插入分割线 if (index > 0) { const divider = document.createElement('li'); divider.className = 'bim-menu-divider'; this.element.appendChild(divider); } const groupItems = groups.get(groupName)!; // 组内排序:根据 item.order 升序排列 groupItems.sort((a, b) => (a.order || 0) - (b.order || 0)); groupItems.forEach(item => { // 仅渲染可见的项 if (item.visible !== false) { this.element.appendChild(this.createItemElement(item)); } }); }); } /** * 创建单个菜单项的 DOM 元素 */ private createItemElement(item: MenuItemConfig): HTMLElement { const li = document.createElement('li'); // 根据状态设置样式类 const isEnabled = !item.disabled; li.className = `bim-menu-item ${isEnabled ? '' : 'disabled'}`; // 1. 图标区域 (Icon Slot) const iconDiv = document.createElement('div'); iconDiv.className = 'bim-menu-item-icon'; if (item.icon) { iconDiv.innerHTML = item.icon; } li.appendChild(iconDiv); // 2. 文本区域 (Label Slot) const labelDiv = document.createElement('div'); labelDiv.className = 'bim-menu-item-label'; // 获取翻译后的文本 labelDiv.textContent = t(item.label); li.appendChild(labelDiv); // 3. 子菜单指示器 (Arrow Slot) const children = item.children; const hasChildren = children && children.length > 0; if (hasChildren) { const arrowDiv = document.createElement('div'); arrowDiv.className = 'bim-menu-item-arrow'; // 简单的右箭头 SVG arrowDiv.innerHTML = ''; li.appendChild(arrowDiv); // 绑定子菜单交互事件 // 鼠标移入:打开子菜单 li.addEventListener('mouseenter', () => this.openSubMenu(item, li)); } else { // 鼠标移入普通项:关闭当前已打开的子菜单 li.addEventListener('mouseenter', () => this.closeSubMenu()); } // 4. 绑定点击事件 if (isEnabled) { // Debug Log: 检查是否绑定了事件 // console.log(`[BimMenu] Binding click for ${item.id}, hasChildren: ${hasChildren}, hasOnClick: ${!!item.onClick}`); li.addEventListener('click', (e) => { e.stopPropagation(); // 防止冒泡 console.log(`[BimMenu] Clicked item: ${item.id}`); // 如果是叶子节点(没有子菜单),则触发点击动作 if (!hasChildren) { if (item.onClick) { console.log(`[BimMenu] Executing onClick for ${item.id}`); item.onClick(); } else { console.warn(`[BimMenu] No onClick handler for ${item.id}`); } } }); } return li; } /** * 打开子菜单 * @param item 当前菜单项 * @param parentLi 触发的 DOM 元素(用于定位) */ private openSubMenu(item: MenuItemConfig, parentLi: HTMLElement): void { const children = item.children; if (!children || children.length === 0) return; // 如果当前已经打开了子菜单,先关闭它 this.closeSubMenu(); // 创建子菜单容器 (模拟一个临时的悬浮层) const container = document.createElement('div'); container.style.position = 'fixed'; container.style.zIndex = '10001'; // 确保比父菜单层级高 // 初步计算位置:位于父项右侧 const rect = parentLi.getBoundingClientRect(); container.style.top = `${rect.top}px`; container.style.left = `${rect.right}px`; // 关键修复:阻止 mousedown 冒泡 // 防止点击子菜单时触发 BimRightKey 的全局关闭逻辑(因为它认为点击发生在主菜单外部) container.addEventListener('mousedown', (e) => e.stopPropagation()); // 递归创建新的 BimMenu 实例 const subMenu = new BimMenu({ items: children }); subMenu.init(); container.appendChild(subMenu.element); document.body.appendChild(container); // 保存引用以便后续清理 this.activeSubMenu = { menu: subMenu, container }; // 边界检测:如果超出屏幕右侧,则向左展开 const subRect = container.getBoundingClientRect(); if (subRect.right > window.innerWidth) { container.style.left = `${rect.left - subRect.width}px`; } // TODO: 垂直方向边界检测 } /** * 关闭当前激活的子菜单 */ private closeSubMenu(): void { if (this.activeSubMenu) { this.activeSubMenu.menu.destroy(); this.activeSubMenu.container.remove(); this.activeSubMenu = null; } } }