fix(menu): refactor menu system to use pure config objects and fix submenu click events
This commit is contained in:
@@ -3,6 +3,7 @@ import { ToolbarManager } from './managers/toolbar-manager';
|
||||
import { ButtonGroupManager } from './managers/button-group-manager';
|
||||
import { DialogManager } from './managers/dialog-manager';
|
||||
import { EngineManager } from './managers/engine-manager';
|
||||
import { RightKeyManager } from './managers/right-key-manager';
|
||||
import type { EngineOptions, ModelLoadOptions } from './components/engine';
|
||||
import { localeManager } from './services/locale';
|
||||
import { themeManager } from './services/theme';
|
||||
@@ -22,6 +23,7 @@ export class BimEngine extends EventEmitter {
|
||||
public buttonGroup: ButtonGroupManager | null = null; // 通用
|
||||
public dialog: DialogManager | null = null;
|
||||
public engine: EngineManager | null = null; // 3D 引擎管理器
|
||||
public rightKey: RightKeyManager | null = null; // 右键菜单管理器
|
||||
|
||||
public get localeManager() { return localeManager; }
|
||||
public get themeManager() { return themeManager; }
|
||||
@@ -77,6 +79,7 @@ export class BimEngine extends EventEmitter {
|
||||
this.dialog = new DialogManager(this, this.wrapper);
|
||||
this.toolbar = new ToolbarManager(this, this.wrapper);
|
||||
this.buttonGroup = new ButtonGroupManager(this, this.wrapper);
|
||||
this.rightKey = new RightKeyManager(this, this.wrapper);
|
||||
|
||||
|
||||
// 初始主题
|
||||
@@ -120,6 +123,7 @@ export class BimEngine extends EventEmitter {
|
||||
this.toolbar?.destroy();
|
||||
this.buttonGroup?.destroy();
|
||||
this.engine?.destroy();
|
||||
this.rightKey?.destroy();
|
||||
this.dialog = null;
|
||||
this.container.innerHTML = '';
|
||||
this.clear(); // Clear all events
|
||||
|
||||
15
src/components/menu/buttons/four.ts
Normal file
15
src/components/menu/buttons/four.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/components/menu/buttons/home.ts
Normal file
18
src/components/menu/buttons/home.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/components/menu/buttons/info.ts
Normal file
16
src/components/menu/buttons/info.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/components/menu/buttons/second.ts
Normal file
15
src/components/menu/buttons/second.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/components/menu/index.css
Normal file
83
src/components/menu/index.css
Normal 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;
|
||||
}
|
||||
274
src/components/menu/index.ts
Normal file
274
src/components/menu/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
src/components/menu/item.ts
Normal file
14
src/components/menu/item.ts
Normal 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;
|
||||
}
|
||||
38
src/components/menu/types.ts
Normal file
38
src/components/menu/types.ts
Normal 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;
|
||||
}
|
||||
11
src/components/right-key/index.css
Normal file
11
src/components/right-key/index.css
Normal 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;
|
||||
}
|
||||
156
src/components/right-key/index.ts
Normal file
156
src/components/right-key/index.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
18
src/components/right-key/types.ts
Normal file
18
src/components/right-key/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface IRightKeyContent {
|
||||
/**
|
||||
* 获取组件的根 DOM 元素
|
||||
*/
|
||||
getElement(): HTMLElement;
|
||||
|
||||
/**
|
||||
* 销毁组件
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface RightKeyOptions {
|
||||
/** 自定义 CSS 类名 */
|
||||
className?: string;
|
||||
/** 层级 (z-index) */
|
||||
zIndex?: number;
|
||||
}
|
||||
36
src/index.ts
36
src/index.ts
@@ -7,39 +7,15 @@ export { Toolbar } from './components/button-group/toolbar';
|
||||
// 导出相关类型定义
|
||||
export type { OptButton, ButtonGroup, ButtonGroupOptions, ClickPayload } from './components/button-group/index.type';
|
||||
|
||||
// 导出 RightKey/Menu 组件
|
||||
export type { MenuItemConfig } from './components/menu/item';
|
||||
export { BimMenu } from './components/menu';
|
||||
export { BimRightKey } from './components/right-key';
|
||||
|
||||
// 导出主引擎类
|
||||
export { BimEngine };
|
||||
|
||||
// 导出 3D 引擎相关类型
|
||||
export type { EngineOptions, ModelLoadOptions } from './components/engine';
|
||||
|
||||
// 导出 createEngine 函数(从第三方 SDK 重新导出)
|
||||
// 注意:createEngine 的实际实现来自 bim-engine-sdk.es.js
|
||||
//
|
||||
// 使用方式:
|
||||
// 1. 直接从 SDK 文件导入(推荐,如 Vue 示例):
|
||||
// import { createEngine } from '/engine/bim-engine-sdk.es.js';
|
||||
//
|
||||
// 2. 从主入口导入(如果构建配置支持):
|
||||
// import { createEngine } from 'bim-engine-sdk';
|
||||
//
|
||||
// 示例:
|
||||
// ```javascript
|
||||
// const engine = createEngine({
|
||||
// containerId: 'vue2-container',
|
||||
// backgroundColor: 0x333333,
|
||||
// version: 'v1',
|
||||
// showStats: true,
|
||||
// showViewCube: true
|
||||
// });
|
||||
//
|
||||
// engine.loader.loadModel(url, {
|
||||
// position: [10, -5, 0],
|
||||
// rotation: [0, 0, 0],
|
||||
// scale: [1, 1, 1]
|
||||
// });
|
||||
// ```
|
||||
|
||||
// 重新导出 createEngine(从 SDK 文件)
|
||||
// 注意:如果直接导入失败,用户应该直接从 bim-engine-sdk.es.js 文件导入
|
||||
export { createEngine } from './bim-engine-sdk.es.js';
|
||||
export { createEngine } from './bim-engine-sdk.es.js';
|
||||
|
||||
@@ -21,4 +21,8 @@ export const enUS: TranslationDictionary = {
|
||||
testTitle: 'Test Dialog',
|
||||
testContent: '<div style="padding: 10px;">This is a <b>draggable</b> and <b>resizable</b> dialog.<br><br>Try dragging the title bar or resizing from the bottom-right corner.</div>',
|
||||
},
|
||||
menu: {
|
||||
info: "info",
|
||||
home: "home",
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface TranslationDictionary {
|
||||
testTitle: string;
|
||||
testContent: string;
|
||||
};
|
||||
menu: {
|
||||
info: string;
|
||||
home: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,4 +21,8 @@ export const zhCN: TranslationDictionary = {
|
||||
testTitle: '测试弹窗',
|
||||
testContent: '<div style="padding: 10px;">这是一个 <b>可拖拽</b> 且 <b>可缩放</b> 的弹窗。<br><br>你可以尝试拖动标题栏,或者拖动右下角改变大小。</div>',
|
||||
},
|
||||
menu: {
|
||||
info: "信息",
|
||||
home: "首页"
|
||||
}
|
||||
};
|
||||
|
||||
99
src/managers/right-key-manager.ts
Normal file
99
src/managers/right-key-manager.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { BimComponent } from '../core/component';
|
||||
import { BimEngine } from '../bim-engine';
|
||||
import { BimRightKey } from '../components/right-key';
|
||||
import { BimMenu } from '../components/menu';
|
||||
import { MenuItemConfig } from '../components/menu/item';
|
||||
import { infoMenuButton } from '../components/menu/buttons/info';
|
||||
import { homeMenuButton } from '../components/menu/buttons/home';
|
||||
|
||||
/**
|
||||
* 右键菜单管理器 (RightKeyManager)
|
||||
* 负责协调右键交互流程:
|
||||
* 1. 监听 Canvas/容器的 contextmenu 事件
|
||||
* 2. 通过注册的处理器 (Handler) 获取需要显示的菜单项
|
||||
* 3. 实例化 Menu 组件并装载到 RightKey 容器中显示
|
||||
*/
|
||||
export class RightKeyManager extends BimComponent {
|
||||
private container: HTMLElement;
|
||||
private rightKeyPanel: BimRightKey;
|
||||
|
||||
// 存储注册的上下文处理器
|
||||
// 每个处理<E5A484><E79086><EFBFBD>接收鼠标事件,返回一组菜单项(如果没有对应菜单则返回 null)
|
||||
private contextHandlers: Array<(e: MouseEvent) => MenuItemConfig[] | null> = [];
|
||||
|
||||
constructor(engine: BimEngine, container: HTMLElement) {
|
||||
super(engine);
|
||||
this.container = container;
|
||||
|
||||
// 初始化右键容器,设置极高的层级以覆盖所有 UI
|
||||
this.rightKeyPanel = new BimRightKey({ zIndex: 9000 });
|
||||
this.rightKeyPanel.init();
|
||||
|
||||
this.initEventListeners();
|
||||
}
|
||||
|
||||
private initEventListeners(): void {
|
||||
this.container.addEventListener('contextmenu', this.handleContextMenu);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.container.removeEventListener('contextmenu', this.handleContextMenu);
|
||||
this.rightKeyPanel.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册上下文菜单处理器
|
||||
* @param handler 处理函数,接收鼠标事件,返回菜单项数组
|
||||
*/
|
||||
public registerHandler(handler: (e: MouseEvent) => MenuItemConfig[] | null): void {
|
||||
this.contextHandlers.push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动显示菜单
|
||||
* 允许外部直接调用以显示特定的菜单,不一定依赖右键事件
|
||||
* @param x 屏幕 X 坐标
|
||||
* @param y 屏幕 Y 坐标
|
||||
* @param items 菜单项列表
|
||||
* @param groupOrder 可<><E58FAF>的分组顺序
|
||||
*/
|
||||
public showMenu(x: number, y: number, items: MenuItemConfig[], groupOrder?: string[]): void {
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
// 1. 创建菜单内容组件
|
||||
const menu = new BimMenu({ items, groupOrder });
|
||||
menu.init(); // 必须初始化以生成 DOM
|
||||
|
||||
// 2. 将菜单挂载到右键容器
|
||||
this.rightKeyPanel.mount(menu);
|
||||
|
||||
// 3. 显示容器
|
||||
this.rightKeyPanel.show(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏右键菜单
|
||||
*/
|
||||
public hide(): void {
|
||||
this.rightKeyPanel.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理右键点击事件
|
||||
*/
|
||||
private handleContextMenu = (e: MouseEvent): void => {
|
||||
// 阻止浏览器默认的右键菜单
|
||||
e.preventDefault();
|
||||
let items: MenuItemConfig[] = [];
|
||||
items.push(infoMenuButton(this.engine))
|
||||
items.push(infoMenuButton(this.engine))
|
||||
items.push(infoMenuButton(this.engine))
|
||||
items.push(homeMenuButton(this.engine))
|
||||
if (items && items.length > 0) {
|
||||
this.showMenu(e.clientX, e.clientY, items);
|
||||
} else {
|
||||
// 如果没有任何内容,则关闭可能存在的菜单
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -7,11 +7,11 @@ export const darkTheme: ThemeConfig = {
|
||||
name: 'dark',
|
||||
primary: '#0078d4',
|
||||
primaryHover: '#0063b1',
|
||||
|
||||
|
||||
// 修改:背景色统一为浅灰,不再跟随深色模式变黑
|
||||
background: '#f5f5f5',
|
||||
background: '#f5f5f5',
|
||||
panelBackground: 'rgba(30, 30, 30, 0.9)',
|
||||
|
||||
|
||||
// 注意:如果背景是浅色,主文字颜色通常需要是深色才能看清
|
||||
// 但这里的 textPrimary 主要是用于 UI 组件内部的。
|
||||
// 如果 BimEngine wrapper 上的文字直接显示在 background 上,
|
||||
@@ -19,53 +19,53 @@ export const darkTheme: ThemeConfig = {
|
||||
// 目前架构中:
|
||||
// theme.textPrimary 会应用到 wrapper.style.color (BimEngine.ts)
|
||||
// 以及 Toolbar/Dialog 的文字颜色。
|
||||
|
||||
|
||||
// 如果背景是浅灰,而 wrapper 文字设置为白色 (#ffffff),那就看不清了。
|
||||
// 这是一个语义冲突:
|
||||
// 1. Panel (Toolbar/Dialog) 是黑底,需要白字。
|
||||
// 2. Background (Wrapper) 是白底,需要黑字。
|
||||
|
||||
|
||||
// 既然您要求背景统一浅灰,那么 Wrapper 上的“直接子文本”应该是深色。
|
||||
// 但 Toolbar/Dialog 仍然是深色模式(黑底),它们需要白字。
|
||||
|
||||
|
||||
// 妥协方案:
|
||||
// 保持 textPrimary 为白色(为了适配黑<E9858D><E9BB91><EFBFBD>的 Toolbar/Dialog)。
|
||||
// 但是在 BimEngine 中,如果背景强制改为浅色,Wrapper 的默认文字颜色可能需要单独处理,
|
||||
// 或者我们可以认为 "Wrapper" 主要是承载 UI 组件的,直接写在 Wrapper 上的文字(标题/描述)
|
||||
// 应该有自己的样式,而不是直接继承 theme.textPrimary。
|
||||
|
||||
|
||||
// 在之前的 BimEngine.ts 中:
|
||||
// this.wrapper.style.color = theme.textPrimary;
|
||||
|
||||
|
||||
// 如果背景变浅灰,这里 textPrimary 还是白色的话,标题就看不见了。
|
||||
// 所以,深色模式下:
|
||||
// 背景:浅灰
|
||||
// 组件:深黑
|
||||
// 组件文字:白
|
||||
// 页面文字:黑 (问题点)
|
||||
|
||||
|
||||
// 让我们先按您的要求改背景。通常这种情况下,ThemeConfig 可能需要区分
|
||||
// contentText (页面内容文字) 和 uiText (组件文字)。
|
||||
// 但为了不破坏现有结构,我将假定 textPrimary 主要服务于 UI 组件。
|
||||
// 为了让 Wrapper 上的标题可见,我们可能需要在 BimEngine 中移除对 wrapper.style.color 的强制设置,
|
||||
// 或者在 presets 里把 textPrimary 改回来?不对,改回来 Toolbar 就看不清了。
|
||||
|
||||
|
||||
// 方案:我将仅修改 background。
|
||||
// 至于 Wrapper 上的标题(BimEngine 标题),由于在最新的 BimEngine.ts 中
|
||||
// <20><><EFBFBD>们已经移除了 titleEl 和 descEl(在之前的重构中),
|
||||
// 所以现在 Wrapper 里主要是 Toolbar 和 Dialog,它们有自己的 panelBackground。
|
||||
// 只要 Toolbar/Dialog 内部正常即可。
|
||||
|
||||
textPrimary: '#ffffff',
|
||||
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: '#cccccc',
|
||||
|
||||
|
||||
border: '#444444',
|
||||
|
||||
|
||||
icon: '#cccccc',
|
||||
iconActive: '#ffffff',
|
||||
|
||||
|
||||
componentBackground: 'transparent',
|
||||
componentHover: '#333333',
|
||||
componentHover: '#4e4d4dff',
|
||||
componentActive: 'rgba(255, 255, 255, 0.1)'
|
||||
};
|
||||
|
||||
@@ -76,19 +76,19 @@ export const lightTheme: ThemeConfig = {
|
||||
name: 'light',
|
||||
primary: '#0078d4',
|
||||
primaryHover: '#106ebe',
|
||||
|
||||
|
||||
// 统一为浅灰
|
||||
background: '#f5f5f5',
|
||||
panelBackground: '#ffffff',
|
||||
|
||||
|
||||
textPrimary: '#333333',
|
||||
textSecondary: '#666666',
|
||||
|
||||
|
||||
border: '#e0e0e0',
|
||||
|
||||
|
||||
icon: '#555555',
|
||||
iconActive: '#0078d4',
|
||||
|
||||
|
||||
componentBackground: 'transparent',
|
||||
componentHover: '#f0f0f0',
|
||||
componentActive: '#e0e0e0'
|
||||
|
||||
Reference in New Issue
Block a user