fix(menu): refactor menu system to use pure config objects and fix submenu click events

This commit is contained in:
yuding
2025-12-09 18:34:43 +08:00
parent c112c87dad
commit 9ae1d9d809
38 changed files with 6756 additions and 2575 deletions

View File

@@ -0,0 +1,15 @@
import { BimEngine } from "../../../bim-engine";
import { MenuItemConfig } from "../item";
export const fourMenuButton = (engine: BimEngine): MenuItemConfig => {
return {
id: "fourMenu",
label: "menu.info",
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 7q.425 0 .713-.288T13 6t-.288-.712T12 5t-.712.288T11 6t.288.713T12 7m0 8q.425 0 .713-.288T13 14v-4q0-.425-.288-.712T12 9t-.712.288T11 10v4q0 .425.288.713T12 15m-6 3l-2.3 2.3q-.475.475-1.088.213T2 19.575V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18z"/></svg>',
onClick: () => {
console.log('dianjile')
engine.dialog?.showInfoDialog()
engine.rightKey?.hide()
}
}
}

View File

@@ -0,0 +1,18 @@
import { BimEngine } from "../../../bim-engine";
import { MenuItemConfig } from "../item";
import { fourMenuButton } from "./four";
import { secondMenuButton } from "./second";
export const homeMenuButton = (engine: BimEngine): MenuItemConfig => {
return {
id: "homeMenu",
label: "menu.home",
group: 'home',
children: [secondMenuButton(engine), fourMenuButton(engine)],
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 7q.425 0 .713-.288T13 6t-.288-.712T12 5t-.712.288T11 6t.288.713T12 7m0 8q.425 0 .713-.288T13 14v-4q0-.425-.288-.712T12 9t-.712.288T11 10v4q0 .425.288.713T12 15m-6 3l-2.3 2.3q-.475.475-1.088.213T2 19.575V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18z"/></svg>',
onClick: () => {
engine.dialog?.showInfoDialog()
engine.rightKey?.hide()
}
}
}

View File

@@ -0,0 +1,16 @@
import { BimEngine } from "../../../bim-engine";
import { MenuItemConfig } from "../item";
export const infoMenuButton = (engine: BimEngine): MenuItemConfig => {
return {
id: "infoMenu",
label: "menu.info",
group: 'info',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 7q.425 0 .713-.288T13 6t-.288-.712T12 5t-.712.288T11 6t.288.713T12 7m0 8q.425 0 .713-.288T13 14v-4q0-.425-.288-.712T12 9t-.712.288T11 10v4q0 .425.288.713T12 15m-6 3l-2.3 2.3q-.475.475-1.088.213T2 19.575V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18z"/></svg>',
onClick: () => {
console.log('dianjile')
engine.dialog?.showInfoDialog()
engine.rightKey?.hide()
}
}
}

View File

@@ -0,0 +1,15 @@
import { BimEngine } from "../../../bim-engine";
import { MenuItemConfig } from "../item";
export const secondMenuButton = (engine: BimEngine): MenuItemConfig => {
return {
id: "infoMenu",
label: "menu.info",
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 7q.425 0 .713-.288T13 6t-.288-.712T12 5t-.712.288T11 6t.288.713T12 7m0 8q.425 0 .713-.288T13 14v-4q0-.425-.288-.712T12 9t-.712.288T11 10v4q0 .425.288.713T12 15m-6 3l-2.3 2.3q-.475.475-1.088.213T2 19.575V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18z"/></svg>',
onClick: () => {
console.log('dianjile')
engine.dialog?.showInfoDialog()
engine.rightKey?.hide()
}
}
}

View File

@@ -0,0 +1,83 @@
.bim-menu {
display: flex;
flex-direction: column;
background: var(--bim-ui_bg_color, #2b2d30);
border-radius: 4px;
padding: 4px 0;
min-width: 160px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
user-select: none;
color: var(--bim-ui_text_primary, #ffffff);
}
.bim-menu-group {
display: flex;
flex-direction: column;
}
.bim-menu-divider {
height: 1px;
background-color: var(--bim-ui_border_color, #3e4145);
margin: 4px 0;
}
.bim-menu-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 13px;
position: relative;
color: var(--bim-ui_text_primary, #ffffff);
}
.bim-menu-item:hover {
background-color: var(--bim-ui_bg_hover, #3e4145);
}
.bim-menu-item.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.bim-menu-item-icon {
width: 16px;
height: 16px;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bim-menu-item-icon svg {
width: 100%;
height: 100%;
fill: currentColor;
}
.bim-menu-item-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bim-menu-item-arrow {
width: 12px;
height: 12px;
margin-left: 8px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
}
.bim-menu-item-arrow svg {
width: 100%;
height: 100%;
fill: currentColor;
}

View File

@@ -0,0 +1,274 @@
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;
}
}
}

View File

@@ -0,0 +1,14 @@
/**
* 菜单项配置接口 (用于简化的对象配置)
*/
export interface MenuItemConfig {
id: string;
label: string;
onClick?: () => void;
icon?: string;
group?: string;
order?: number;
children?: MenuItemConfig[];
disabled?: boolean;
visible?: boolean;
}

View File

@@ -0,0 +1,38 @@
import { MenuItemConfig } from './item';
/**
* <20><><EFBFBD>单组件配置选项
*/
export interface MenuOptions {
/**
* 菜单项列表
* 可以是扁平数组,组件会根据 group 字段自动分组
*/
items: MenuItemConfig[];
/**
* 分组显示顺序
* 包含组 ID 的字符串数组。
* 例如: ['view', 'edit', 'tools']
* 未在此数组中定义的组将按默认顺序排在最后。
*/
groupOrder?: string[];
}
/**
* 右键容器内容接口
* 任何想要挂载到 RightKey 容器中的组件都必须实现此接口
*/
export interface IRightKeyContent {
/**
* 获取组件的根 DOM 元素
* 容器将把此元素 append 到自身内部
*/
getElement(): HTMLElement;
/**
* 销毁组件
* 容器在关闭或切换内容时会调用此方法,组件应在此清理资源
*/
destroy(): void;
}

View File

@@ -0,0 +1,11 @@
.bim-right-key {
position: fixed;
z-index: 10000;
display: none;
background: transparent;
/* Container styles if needed, but usually the content provides the visual box */
}
.bim-right-key.visible {
display: block;
}

View File

@@ -0,0 +1,156 @@
import { IBimComponent } from '../../types/component';
import { ThemeConfig } from '../../themes/types';
import { IRightKeyContent, RightKeyOptions } from './types';
import './index.css';
/**
* 右键浮层容器组件 (RightKey)
* 这是一个纯粹的定位容器,负责在屏幕指定位置显示内容。
* 它不关心具体内容是什么,只处理定位、边界检测和关闭逻辑。
*/
export class BimRightKey implements IBimComponent {
private element: HTMLElement;
private content: IRightKeyContent | null = null;
private isVisible: boolean = false;
private onCloseCallback?: () => void;
constructor(options?: RightKeyOptions) {
this.element = document.createElement('div');
this.element.className = `bim-right-key ${options?.className || ''}`;
// 设置层级,默认很高以覆盖其他 UI
if (options?.zIndex) {
this.element.style.zIndex = options.zIndex.toString();
}
// 挂载到 body 以便进行固定定位
document.body.appendChild(this.element);
}
public init(): void {
// 绑定全局点击事件,用于实现"点击外部关闭"
document.addEventListener('mousedown', this.handleGlobalClick);
// 阻止在容器自身上触发系统默认右键菜单
this.element.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
public setTheme(_theme: ThemeConfig): void {
// 容器本身通常是透明的,主题样式主要由内容组件处理
// 如果容器需要背景色,可以在这里设置
// 使用 _theme 前缀避免 TS 未使用变量报错
}
public setLocales(): void {
// 容器不包含文本,无需处理国际化
// 内容组件的国际化由内容组件自身处理
}
public destroy(): void {
document.removeEventListener('mousedown', this.handleGlobalClick);
this.unmountContent();
this.element.remove();
}
/**
* 设置关闭时的回调函数
* 通常用于通知 Manager 状态变更
*/
public setOnClose(callback: () => void): void {
this.onCloseCallback = callback;
}
/**
* 挂载内容组件
* @param content 实现了 IRightKeyContent 接口的组件实例
*/
public mount(content: IRightKeyContent): void {
// 先卸载旧内容,防止内存泄漏
this.unmountContent();
this.content = content;
this.element.appendChild(content.getElement());
}
/**
* 卸载当前内容
*/
public unmountContent(): void {
if (this.content) {
this.content.destroy(); // 重要:调用组件销毁方法清理资源
this.element.innerHTML = '';
this.content = null;
}
}
/**
* 在指定位置显示容器
* 包含智能边界检测逻辑,防止溢出屏幕
* @param x 目标 X 坐标 (通常是鼠标点击位置)
* @param y 目标 Y 坐标
*/
public show(x: number, y: number): void {
this.element.classList.add('visible');
this.isVisible = true;
// 1. 先定位到目标位置,以便测量尺寸
this.element.style.left = `${x}px`;
this.element.style.top = `${y}px`;
// 2. 获取容器<E5AEB9><E599A8><EFBFBD>寸和视口尺寸
const rect = this.element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let newX = x;
let newY = y;
// 3. 水平方向边界检测:如果溢出右边界,则向左对齐
if (x + rect.width > viewportWidth) {
newX = x - rect.width;
}
// 4. 垂直方向边界检测:如果溢出下边界,则向上对齐
if (y + rect.height > viewportHeight) {
newY = y - rect.height;
}
// 5. 应用修正后的坐标
this.element.style.left = `${newX}px`;
this.element.style.top = `${newY}px`;
}
/**
* 隐藏容器
*/
public hide(): void {
this.element.classList.remove('visible');
this.isVisible = false;
// 为了状态重置,通常隐藏时也卸载内容
this.unmountContent();
if (this.onCloseCallback) {
this.onCloseCallback();
}
}
/**
* 处理全局点击事件
* 用于检测是否点击了容器外部
*/
private handleGlobalClick = (e: MouseEvent): void => {
if (!this.isVisible) return;
// 如果点击的是容器内部,不做处理
if (this.element.contains(e.target as Node)) {
return;
}
// 点击外部,关闭容器
this.hide();
};
}

View File

@@ -0,0 +1,18 @@
export interface IRightKeyContent {
/**
* 获取组件的根 DOM 元素
*/
getElement(): HTMLElement;
/**
* 销毁组件
*/
destroy(): void;
}
export interface RightKeyOptions {
/** 自定义 CSS 类名 */
className?: string;
/** 层级 (z-index) */
zIndex?: number;
}