275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
|
|
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-ui_bg_color', theme.panelBackground);
|
|||
|
|
style.setProperty('--bim-ui_text_primary', theme.textPrimary);
|
|||
|
|
style.setProperty('--bim-ui_border_color', theme.border);
|
|||
|
|
style.setProperty('--bim-ui_bg_hover', theme.componentHover);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 响应语言变更
|
|||
|
|
* 重新渲染整个菜单以更新文本
|
|||
|
|
*/
|
|||
|
|
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<string, MenuItemConfig[]>();
|
|||
|
|
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 = '<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>';
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|