feat: Refactor engine structure and add UI customization support

- Refactor  to delegate logic to  and
- Add  for manager classes
- Implement dynamic styling for Toolbar (color config, CSS vars)
- Implement dynamic styling for Dialog (options, CSS vars)
- Add  example
- Add documentation for Toolbar and Dialog
- Update demo to showcase new styling features
This commit is contained in:
yuding
2025-12-03 18:35:05 +08:00
parent 14ac91aa6e
commit 4dd923f19e
21 changed files with 2328 additions and 243 deletions

View File

@@ -1,10 +1,26 @@
import './bim-engine.css';
import { OptBtnGroups } from './toolbar';
import { ToolbarManager } from './modules/toolbar-manager';
import { DialogManager } from './modules/dialog-manager';
/**
* BimEngine 主类
* 负责初始化整个应用界面,协调各个子模块(如工具栏、弹窗等)。
*/
export class BimEngine {
/** 主容器元素 */
private container: HTMLElement;
private optBtnGroups: OptBtnGroups | null = null;
/** 内部包装器元素,用于承载所有 UI 组件 */
private wrapper: HTMLElement | null = null;
/** 工具栏管理器实例 */
public toolbar: ToolbarManager | null = null;
/** 弹窗管理器实例 */
public dialog: DialogManager | null = null;
/**
* 构造函数
* @param container 容器元素或容器 ID
*/
constructor(container: HTMLElement | string) {
const el = typeof container === 'string' ? document.getElementById(container) : container;
if (!el) throw new Error('Container not found');
@@ -12,20 +28,24 @@ export class BimEngine {
this.init();
}
/**
* 初始化方法
* 创建 DOM 结构并初始化各子模块
*/
private init() {
// 1. 清空容器可能存在的旧内容
this.container.innerHTML = '';
// 2. 创建外层容器 div
const wrapper = document.createElement('div');
wrapper.className = 'bim-engine-wrapper';
this.wrapper = document.createElement('div');
this.wrapper.className = 'bim-engine-wrapper';
// 3. 创建标题 h1
const title = document.createElement('h1');
title.textContent = 'BimEngine';
title.className = 'bim-engine-title';
// 4. 创建段落 p
// 4. 创建描述段落 p
const desc = document.createElement('p');
desc.textContent = '这是一个使用BIM-ENGINE。';
desc.className = 'bim-engine-desc';
@@ -36,34 +56,58 @@ export class BimEngine {
btnGroupContainer.className = 'bim-engine-opt-btn-container';
// 7. 组装元素
wrapper.appendChild(title);
wrapper.appendChild(desc);
wrapper.appendChild(btnGroupContainer); // 将按钮组放入 wrapper 中
this.wrapper.appendChild(title);
this.wrapper.appendChild(desc);
// 初始化管理器
this.dialog = new DialogManager(this.wrapper);
this.toolbar = new ToolbarManager(btnGroupContainer);
// 8. 挂载到主容器
this.container.appendChild(wrapper);
// 5. 测试按钮(更新为使用管理器)
// 5.1 创建普通测试弹窗按钮
const dialogBtn = document.createElement('button');
dialogBtn.textContent = '打开测试弹窗';
dialogBtn.className = 'bim-engine-btn';
dialogBtn.onclick = () => {
this.dialog?.create({
title: '测试弹窗',
content: '<div style="padding: 10px;">这是一个 <b>可拖拽</b> 且 <b>可缩放</b> 的弹窗。<br><br>你可以尝试拖动标题栏,或者拖动右下角改变大小。</div>',
width: 300,
height: 400,
position: 'top-left',
draggable: true,
resizable: true
});
};
// 9. 初始化操作按钮
this.initOptBtnGroups(btnGroupContainer);
}
private initOptBtnGroups(container: HTMLElement) {
this.optBtnGroups = new OptBtnGroups({
container,
showLabel: true
});
// 初始化并加载默认按钮
this.optBtnGroups.init().catch(err => {
console.error('Failed to initialize OptBtnGroups:', err);
});
// 5.2 创建二次封装信息弹窗按钮
const infoDialogBtn = document.createElement('button');
infoDialogBtn.textContent = '打开信息弹窗 (封装版)';
infoDialogBtn.className = 'bim-engine-btn';
infoDialogBtn.style.marginLeft = '10px';
infoDialogBtn.onclick = () => {
this.dialog?.showInfoDialog();
};
// 将按钮和工具栏容器添加到包装器中
this.wrapper.appendChild(dialogBtn);
this.wrapper.appendChild(infoDialogBtn);
this.wrapper.appendChild(btnGroupContainer);
// 8. 将包装器挂载到主容器
this.container.appendChild(this.wrapper);
}
/**
* 销毁实例
* 清理所有资源和 DOM 元素
*/
public destroy() {
if (this.optBtnGroups) {
this.optBtnGroups.destroy();
this.optBtnGroups = null;
if (this.toolbar) {
this.toolbar.destroy();
this.toolbar = null;
}
this.dialog = null;
this.container.innerHTML = '';
}
}

View File

@@ -0,0 +1,30 @@
.bim-info-dialog-content {
padding: 16px;
font-family: sans-serif;
color: #333;
}
.bim-info-dialog-content h3 {
margin-top: 0;
margin-bottom: 12px;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
color: #0078d4;
}
.bim-info-dialog-content ul {
list-style: none;
padding: 0;
margin: 0;
}
.bim-info-dialog-content li {
margin-bottom: 8px;
font-size: 14px;
display: flex;
}
.bim-info-dialog-content li strong {
width: 80px;
color: #555;
}

View File

@@ -0,0 +1,61 @@
import './index.css';
import { BimDialog } from '../index';
/**
* BimInfoDialog (二次封装示例)
* 这是一个展示项目信息的业务弹窗组件,内部封装了 BimDialog。
*/
export class BimInfoDialog {
private dialog: BimDialog;
/**
* 构造函数
* @param container 父容器
*/
constructor(container: HTMLElement) {
// 创建自定义的 DOM 内容
const contentEl = document.createElement('div');
contentEl.className = 'bim-info-dialog-content';
const infoTitle = document.createElement('h3');
infoTitle.textContent = 'Model Information';
const infoList = document.createElement('ul');
infoList.innerHTML = `
<li><strong>Name:</strong> Sample Project</li>
<li><strong>Version:</strong> 1.0.0</li>
<li><strong>Date:</strong> ${new Date().toLocaleDateString()}</li>
<li><strong>Status:</strong> <span style="color: green;">Active</span></li>
`;
const actionBtn = document.createElement('button');
actionBtn.textContent = 'Update Status';
actionBtn.style.marginTop = '10px';
actionBtn.onclick = () => {
alert('Status updated!');
};
contentEl.appendChild(infoTitle);
contentEl.appendChild(infoList);
contentEl.appendChild(actionBtn);
// 初始化 BimDialog直接传入构建好的 HTMLElement
this.dialog = new BimDialog({
container: container,
title: 'Project Info (Wrapped)',
content: contentEl,
width: 320,
height: 'auto',
position: 'center',
resizable: true,
draggable: true
});
}
/**
* 关闭弹窗
*/
public close() {
this.dialog.close();
}
}

95
src/dialog/index.css Normal file
View File

@@ -0,0 +1,95 @@
:root {
--bim-dialog-bg: rgba(17, 17, 17, 0.95);
--bim-dialog-header-bg: #2a2a2a;
--bim-dialog-title-color: #fff;
--bim-dialog-text-color: #ccc;
--bim-dialog-border-color: #444;
}
.bim-dialog {
position: absolute;
background-color: var(--bim-dialog-bg);
border: 1px solid var(--bim-dialog-border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
z-index: 1000;
color: var(--bim-dialog-title-color);
overflow: hidden;
min-width: 200px;
min-height: 100px;
}
.bim-dialog-header {
height: 32px;
background-color: var(--bim-dialog-header-bg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
cursor: default;
user-select: none;
border-bottom: 1px solid var(--bim-dialog-border-color);
flex-shrink: 0;
}
.bim-dialog-header.draggable {
cursor: move;
}
.bim-dialog-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--bim-dialog-title-color);
}
.bim-dialog-close {
cursor: pointer;
font-size: 18px;
color: #999;
line-height: 1;
margin-left: 8px;
}
.bim-dialog-close:hover {
color: #fff;
}
.bim-dialog-content {
flex: 1;
padding: 10px;
overflow: auto;
font-size: 14px;
color: var(--bim-dialog-text-color);
}
/* 缩放句柄 */
.bim-dialog-resize-handle {
position: absolute;
width: 10px;
height: 10px;
bottom: 0;
right: 0;
cursor: se-resize;
z-index: 10;
}
/* 右下角装饰,类似斜线 */
.bim-dialog-resize-handle::after {
content: '';
position: absolute;
bottom: 3px;
right: 3px;
width: 6px;
height: 6px;
border-right: 2px solid #666;
border-bottom: 2px solid #666;
}
.bim-dialog-resize-handle:hover::after {
border-color: #fff;
}

294
src/dialog/index.ts Normal file
View File

@@ -0,0 +1,294 @@
import './index.css';
import type { DialogOptions } from './index.type';
/**
* 通用弹窗组件类
* 支持拖拽、缩放、自定义内容和位置。
*/
export class BimDialog {
private element: HTMLElement;
private options: DialogOptions;
private container: HTMLElement;
private header: HTMLElement;
private contentArea: HTMLElement;
private _isDestroyed = false;
/**
* 构造函数
* @param options 弹窗配置选项
*/
constructor(options: DialogOptions) {
// 合并默认配置
this.options = {
title: 'Dialog',
width: 300,
height: 'auto',
position: 'center',
draggable: true,
resizable: false,
minWidth: 200,
minHeight: 100,
...options
};
this.container = options.container;
// 创建 DOM 结构
this.element = this.createDom();
this.header = this.element.querySelector('.bim-dialog-header') as HTMLElement;
this.contentArea = this.element.querySelector('.bim-dialog-content') as HTMLElement;
// 初始化
this.init();
}
/**
* 创建弹窗的 DOM 结构
*/
private createDom(): HTMLElement {
const el = document.createElement('div');
el.className = 'bim-dialog';
if (this.options.id) el.id = this.options.id;
// 应用颜色配置到 CSS 变量 (局部作用域)
const style = el.style;
if (this.options.backgroundColor) style.setProperty('--bim-dialog-bg', this.options.backgroundColor);
if (this.options.headerBackgroundColor) style.setProperty('--bim-dialog-header-bg', this.options.headerBackgroundColor);
if (this.options.titleColor) style.setProperty('--bim-dialog-title-color', this.options.titleColor);
if (this.options.textColor) style.setProperty('--bim-dialog-text-color', this.options.textColor);
if (this.options.borderColor) style.setProperty('--bim-dialog-border-color', this.options.borderColor);
// 设置初始尺寸
this.setSize(el, this.options.width, this.options.height);
// 创建标题栏 (Header)
const header = document.createElement('div');
header.className = 'bim-dialog-header';
if (this.options.draggable) header.classList.add('draggable');
const title = document.createElement('span');
title.className = 'bim-dialog-title';
title.textContent = this.options.title || '';
const closeBtn = document.createElement('span');
closeBtn.className = 'bim-dialog-close';
closeBtn.innerHTML = '&times;';
closeBtn.onclick = () => this.close();
header.appendChild(title);
header.appendChild(closeBtn);
// 创建内容区域 (Content)
const content = document.createElement('div');
content.className = 'bim-dialog-content';
if (typeof this.options.content === 'string') {
content.innerHTML = this.options.content;
} else if (this.options.content instanceof HTMLElement) {
content.appendChild(this.options.content);
}
el.appendChild(header);
el.appendChild(content);
// 如果允许缩放,创建缩放手柄
if (this.options.resizable) {
const resizeHandle = document.createElement('div');
resizeHandle.className = 'bim-dialog-resize-handle';
el.appendChild(resizeHandle);
}
return el;
}
/**
* 设置元素尺寸
*/
private setSize(el: HTMLElement, width?: number | string, height?: number | string) {
if (width !== undefined) {
el.style.width = typeof width === 'number' ? `${width}px` : width;
}
if (height !== undefined) {
el.style.height = typeof height === 'number' ? `${height}px` : height;
}
}
/**
* 初始化组件功能
*/
private init() {
this.container.appendChild(this.element);
// 必须先挂载才能计算尺寸进行定位
this.initPosition();
if (this.options.draggable) {
this.initDrag();
}
if (this.options.resizable) {
this.initResize();
}
}
/**
* 初始化弹窗位置
*/
private initPosition() {
const pos = this.options.position;
const elRect = this.element.getBoundingClientRect();
// 计算相对父容器的定位
let left = 0;
let top = 0;
const pW = this.container.clientWidth;
const pH = this.container.clientHeight;
const elW = elRect.width;
const elH = elRect.height;
if (typeof pos === 'object' && 'x' in pos) {
left = pos.x;
top = pos.y;
} else {
switch (pos) {
case 'center':
left = (pW - elW) / 2;
top = (pH - elH) / 2;
break;
case 'top-left': left = 0; top = 0; break;
case 'top-center': left = (pW - elW) / 2; top = 0; break;
case 'top-right': left = pW - elW; top = 0; break;
case 'left-center': left = 0; top = (pH - elH) / 2; break;
case 'right-center': left = pW - elW; top = (pH - elH) / 2; break;
case 'bottom-left': left = 0; top = pH - elH; break;
case 'bottom-center': left = (pW - elW) / 2; top = pH - elH; break;
case 'bottom-right': left = pW - elW; top = pH - elH; break;
default:
left = (pW - elW) / 2;
top = (pH - elH) / 2;
}
}
// 简单的边界检查,防止初始位置溢出
left = Math.max(0, Math.min(left, pW - elW));
top = Math.max(0, Math.min(top, pH - elH));
this.element.style.left = `${left}px`;
this.element.style.top = `${top}px`;
}
/**
* 初始化拖拽功能
*/
private initDrag() {
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
startX = e.clientX;
startY = e.clientY;
startLeft = this.element.offsetLeft;
startTop = this.element.offsetTop;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
// 边界限制,防止拖出容器
const maxLeft = this.container.clientWidth - this.element.offsetWidth;
const maxTop = this.container.clientHeight - this.element.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
this.element.style.left = `${newLeft}px`;
this.element.style.top = `${newTop}px`;
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
this.header.addEventListener('mousedown', onMouseDown);
}
/**
* 初始化缩放功能
*/
private initResize() {
const handle = this.element.querySelector('.bim-dialog-resize-handle') as HTMLElement;
if (!handle) return;
let startX = 0;
let startY = 0;
let startW = 0;
let startH = 0;
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
startX = e.clientX;
startY = e.clientY;
startW = this.element.offsetWidth;
startH = this.element.offsetHeight;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newW = Math.max(this.options.minWidth || 100, startW + dx);
const newH = Math.max(this.options.minHeight || 50, startH + dy);
this.element.style.width = `${newW}px`;
this.element.style.height = `${newH}px`;
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
handle.addEventListener('mousedown', onMouseDown);
}
/**
* 动态设置内容
* @param content 内容元素或 HTML 字符串
*/
public setContent(content: HTMLElement | string) {
this.contentArea.innerHTML = '';
if (typeof content === 'string') {
this.contentArea.innerHTML = content;
} else {
this.contentArea.appendChild(content);
}
}
/**
* 关闭弹窗并销毁
*/
public close() {
if (this._isDestroyed) return;
this.element.remove();
this._isDestroyed = true;
if (this.options.onClose) {
this.options.onClose();
}
}
}

57
src/dialog/index.type.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* 弹窗位置类型定义
* 可以是预设的字符串位置(如 'center', 'top-left' 等),
* 也可以是具体的坐标对象 { x, y }
*/
export type DialogPosition =
| 'center'
| 'top-left' | 'top-center' | 'top-right'
| 'left-center' | 'right-center'
| 'bottom-left' | 'bottom-center' | 'bottom-right'
| { x: number; y: number };
/**
* 弹窗颜色配置
*/
export interface DialogColors {
/** 窗体背景颜色,默认 rgba(17, 17, 17, 0.95) */
backgroundColor?: string;
/** 标题栏背景颜色,默认 #2a2a2a */
headerBackgroundColor?: string;
/** 标题文字颜色,默认 #fff */
titleColor?: string;
/** 内容文字颜色,默认 #ccc */
textColor?: string;
/** 边框颜色,默认 #444 */
borderColor?: string;
}
/**
* 弹窗配置选项接口
*/
export interface DialogOptions extends DialogColors {
/** 弹窗挂载的父容器 */
container: HTMLElement;
/** 弹窗标题 */
title?: string;
/** 弹窗内容,支持 HTML 字符串或 HTMLElement */
content?: HTMLElement | string;
/** 弹窗宽度,数字(像素)或字符串(如 '50%' */
width?: number | string;
/** 弹窗高度 */
height?: number | string;
/** 弹窗位置 */
position?: DialogPosition;
/** 是否可拖拽 */
draggable?: boolean;
/** 是否可调整大小 */
resizable?: boolean;
/** 最小宽度限制 */
minWidth?: number;
/** 最小高度限制 */
minHeight?: number;
/** 关闭时的回调函数 */
onClose?: () => void;
/** 弹窗唯一标识 ID (可选) */
id?: string;
}

View File

@@ -1,4 +1,10 @@
import { BimEngine } from './bim-engine';
// 导出 OptBtnGroups 组件,用于工具栏操作
export { OptBtnGroups } from './toolbar';
// 导出相关类型定义
export type { OptButton, ButtonGroup, OptBtnGroupsOptions, ClickPayload } from './toolbar/index.type';
export { BimEngine };
// 导出主引擎类
export { BimEngine };

View File

@@ -0,0 +1,40 @@
import { BimDialog } from '../dialog';
import { BimInfoDialog } from '../dialog/bimInfoDialog';
import type { DialogOptions } from '../dialog/index.type';
/**
* 弹窗管理器
* 负责创建和管理应用中的各类弹窗。
*/
export class DialogManager {
/** 弹窗挂载的父容器 */
private container: HTMLElement;
/**
* 构造函数
* @param container 弹窗挂载的目标容器
*/
constructor(container: HTMLElement) {
this.container = container;
}
/**
* 创建一个通用弹窗
* @param options 弹窗配置选项(不需要传 container自动使用管理器绑定的容器
* @returns BimDialog 实例
*/
public create(options: Omit<DialogOptions, 'container'>): BimDialog {
return new BimDialog({
container: this.container,
...options
});
}
/**
* 显示二次封装的模型信息弹窗
* 演示如何调用特定的业务弹窗组件
*/
public showInfoDialog() {
new BimInfoDialog(this.container);
}
}

View File

@@ -0,0 +1,132 @@
import type { ToolbarColors } from '../toolbar/index.type';
import { OptBtnGroups } from '../toolbar';
import type { ButtonConfig } from '../toolbar/index.type';
/**
* 工具栏管理器
* 负责管理底部操作栏的按钮组、按钮及其可见性等状态。
*/
export class ToolbarManager {
/** 内部工具栏组件实例 */
private optBtnGroups: OptBtnGroups | null = null;
/** 工具栏挂载的容器 */
private container: HTMLElement;
/**
* 构造函数
* @param container 工具栏挂载的容器元素
*/
constructor(container: HTMLElement) {
this.container = container;
this.init();
}
/**
* 初始化工具栏
*/
private init() {
this.optBtnGroups = new OptBtnGroups({
container: this.container,
showLabel: true
});
// 初始化并加载默认按钮配置
this.optBtnGroups.init().catch(err => {
console.error('Failed to initialize OptBtnGroups:', err);
});
}
/**
* 添加一个工具栏按钮组
* @param groupId 新组的 ID
* @param beforeGroupId (可选) 插入到哪个组之前,不传则追加到最后
*/
public addGroup(groupId: string, beforeGroupId?: string) {
if (this.optBtnGroups) {
this.optBtnGroups.addGroup(groupId, beforeGroupId);
this.optBtnGroups.render(); // 重新渲染以更新 UI
} else {
console.warn('Toolbar not initialized yet.');
}
}
/**
* 添加一个工具栏按钮
* @param config 按钮配置对象
*/
public addButton(config: ButtonConfig) {
if (this.optBtnGroups) {
this.optBtnGroups.addButton(config);
this.optBtnGroups.render(); // 重新渲染以更新 UI
} else {
console.warn('Toolbar not initialized yet.');
}
}
/**
* 设置按钮的可见性
* @param buttonId 按钮 ID
* @param visible 是否可见
*/
public setButtonVisibility(buttonId: string, visible: boolean) {
if (this.optBtnGroups) {
this.optBtnGroups.updateButtonVisibility(buttonId, visible);
} else {
console.warn('Toolbar not initialized yet.');
}
}
/**
* 设置是否显示按钮下方的文字标签
* @param show 是否显示
*/
public setShowLabel(show: boolean) {
if (this.optBtnGroups) {
this.optBtnGroups.setShowLabel(show);
} else {
console.warn('Toolbar not initialized yet.');
}
}
/**
* 设置整个工具栏的可见性
* @param visible 是否可见
*/
public setVisible(visible: boolean) {
this.container.style.display = visible ? 'block' : 'none';
}
/**
* 设置工具栏背景颜色
* @param color CSS 颜色值
*/
public setBackgroundColor(color: string) {
if (this.optBtnGroups) {
this.optBtnGroups.setBackgroundColor(color);
} else {
console.warn('Toolbar not initialized yet.');
}
}
/**
* 设置工具栏详细颜色配置
* @param colors 颜色配置对象
*/
public setColors(colors: ToolbarColors) {
if (this.optBtnGroups) {
this.optBtnGroups.setColors(colors);
} else {
console.warn('Toolbar not initialized yet.');
}
}
/**
* 销毁工具栏管理器
*/
public destroy() {
if (this.optBtnGroups) {
this.optBtnGroups.destroy();
this.optBtnGroups = null;
}
}
}

View File

@@ -1,19 +1,26 @@
:root {
--bim-toolbar-bg: rgba(17, 17, 17, 0.88);
--bim-btn-bg: transparent;
--bim-btn-hover-bg: #444;
--bim-btn-active-bg: rgba(255, 255, 255, 0.15);
--bim-icon-color: #ccc;
--bim-icon-active-color: #fff;
--bim-btn-text-color: #ccc;
--bim-btn-text-active-color: #fff;
}
/* 容器样式 */
.toolbar-container {
display: flex;
align-items: center;
/* 容器本身不需要背景 */
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
/* Firefox 隐藏滚动条 */
-ms-overflow-style: none;
/* IE 10+ 隐藏滚动条 */
}
.toolbar-container::-webkit-scrollbar {
display: none;
/* Chrome/Safari 隐藏滚动条 */
}
/* 按钮组样式 */
@@ -23,15 +30,13 @@
display: flex;
align-items: center;
flex-shrink: 0;
background-color: rgba(17, 17, 17, 0.88);
/* 每个组独立的背景 */
background-color: var(--bim-toolbar-bg);
border-radius: 4px;
padding: 4px 8px;
}
.has-divider {
margin-right: 16px;
/* 增加右边距来分隔组 */
}
/* 按钮包装器 */
@@ -49,23 +54,21 @@
min-height: 50px;
padding: 4px;
cursor: pointer;
color: #ccc;
background-color: var(--bim-btn-bg);
color: var(--bim-icon-color);
transition: all 0.2s;
border-bottom: 2px solid transparent;
/* 默认透明底边 */
}
.opt-btn:hover {
background-color: #444;
color: #fff;
background-color: var(--bim-btn-hover-bg);
color: var(--bim-icon-active-color);
}
.opt-btn.active {
background-color: rgba(255, 255, 255, 0.15);
/* 白色半透明背景 */
color: #fff;
background-color: var(--bim-btn-active-bg);
color: var(--bim-icon-active-color);
border-bottom: 2px solid #fff;
/* 纯白色底部横条 */
}
.opt-btn.disabled {
@@ -81,7 +84,6 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
/* 防止图标被压缩 */
}
.opt-btn-icon svg {
@@ -93,7 +95,13 @@
.opt-btn-label {
font-size: 10px;
margin-top: 2px;
color: var(--bim-btn-text-color);
}
.opt-btn:hover .opt-btn-label,
.opt-btn.active .opt-btn-label {
color: var(--bim-btn-text-active-color);
}
/* 下拉箭头样式 */
.opt-btn-arrow {
@@ -105,12 +113,10 @@
transition: transform 0.2s ease;
}
/* 箭头旋转 (菜单展开时) */
.opt-btn-arrow.rotated {
transform: rotate(180deg);
}
/* 无标签模式调整 */
.opt-btn.no-label .opt-btn-arrow {
top: 2px;
right: 2px;
@@ -119,16 +125,13 @@
/* 下拉菜单样式 */
.opt-btn-dropdown {
position: fixed;
/* 固定定位 */
transform: translate(-50%, -100%);
/* 水平居中并向上移动 */
background-color: rgba(17, 17, 17, 0.88);
background-color: var(--bim-toolbar-bg);
border-radius: 4px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 50px;
z-index: 9999;
/* 高层级 */
display: flex;
flex-direction: column;
}
@@ -137,45 +140,40 @@
.opt-btn-dropdown-item {
display: flex;
flex-direction: column;
/* 垂直布局 */
align-items: center;
justify-content: center;
color: #b3b4b4;
color: var(--bim-icon-color);
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
min-width: 50px;
min-height: 50px;
padding: 4px;
}
.opt-btn-dropdown-item:last-child {
border-bottom: none;
/* 移除最后一项的分隔线 */
background-color: var(--bim-btn-bg);
}
.opt-btn-dropdown-item:hover {
background-color: #444;
color: #fff;
background-color: var(--bim-btn-hover-bg);
color: var(--bim-icon-active-color);
}
.opt-btn-dropdown-item .opt-btn-icon.small {
width: 30px;
/* 与主按钮图标大小一致 */
height: 30px;
margin-right: 0;
/* 移除右边距 */
margin-bottom: 4px;
/* 添加下边距 */
}
.opt-btn-dropdown-item span {
font-size: 10px;
/* 较小的文字 */
color: var(--bim-btn-text-color);
}
.opt-btn-dropdown-item:hover span {
color: var(--bim-btn-text-active-color);
}
/* 无标签模式调整 */
.opt-btn.no-label .opt-btn-icon {
width: 32px;
height: 32px;
}
}

View File

@@ -3,21 +3,37 @@ import type {
OptButton,
ButtonGroup,
OptBtnGroupsOptions,
ButtonConfig
ButtonConfig,
ToolbarColors
} from './index.type';
/**
* 底部操作按钮组组件
* 负责渲染和管理底部工具栏的按钮、下拉菜单及相关交互。
*/
export class OptBtnGroups {
/** 挂载容器 */
private container: HTMLElement;
/** 组件配置选项 */
private options: OptBtnGroupsOptions;
// 改用 Array 存储 Group方便控制顺序
/** 按钮组列表,按顺序存储 */
private groups: ButtonGroup[] = [];
/** 当前处于激活状态的按钮 ID 集合 */
private activeBtnIds: Set<string> = new Set();
/** 按钮 DOM 元素的引用映射,方便快速查找 */
private btnRefs: Map<string, HTMLElement> = new Map();
/** 当<><E5BD93>显示的下拉菜单元素 */
private dropdownElement: HTMLElement | null = null;
/** 鼠标悬停计时器,用于处理菜单显示的防抖 */
private hoverTimeout: number | null = null;
/** 默认图标 SVG */
private readonly DEFAULT_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>';
/**
* 构造函数
* @param options 配置选项
*/
constructor(options: OptBtnGroupsOptions) {
const el = typeof options.container === 'string'
? document.getElementById(options.container)
@@ -33,16 +49,47 @@ export class OptBtnGroups {
};
this.initContainer();
this.applyStyles(); // 应用初始样式配置
}
/**
* 初始化容器
*/
private initContainer(): void {
this.container.innerHTML = '';
this.container.classList.add('toolbar-root'); // 添加一个根类方便定位样式
}
/**
* 应用样式配置到 CSS 变量
*/
private applyStyles(): void {
const style = this.container.style;
if (this.options.backgroundColor) style.setProperty('--bim-toolbar-bg', this.options.backgroundColor);
if (this.options.btnBackgroundColor) style.setProperty('--bim-btn-bg', this.options.btnBackgroundColor);
if (this.options.btnHoverColor) style.setProperty('--bim-btn-hover-bg', this.options.btnHoverColor);
if (this.options.btnActiveColor) style.setProperty('--bim-btn-active-bg', this.options.btnActiveColor);
if (this.options.iconColor) style.setProperty('--bim-icon-color', this.options.iconColor);
if (this.options.iconActiveColor) style.setProperty('--bim-icon-active-color', this.options.iconActiveColor);
if (this.options.textColor) style.setProperty('--bim-btn-text-color', this.options.textColor);
if (this.options.textActiveColor) style.setProperty('--bim-btn-text-active-color', this.options.textActiveColor);
}
/**
* 更新颜色配置
* @param colors 颜色配置对象
*/
public setColors(colors: ToolbarColors): void {
// 更新 options
this.options = { ...this.options, ...colors };
// 应用到 CSS 变量
this.applyStyles();
}
/**
* 添加按钮组
* @param groupId 组ID
* @param beforeGroupId 在哪个组之前插入(可选,不传则插入到最后
* @param beforeGroupId 在哪个组之前插入(可选<EFBFBD><EFBFBD><EFBFBD>,不传则插入到最后
*/
public addGroup(groupId: string, beforeGroupId?: string): void {
if (this.groups.some(g => g.id === groupId)) {
@@ -66,7 +113,7 @@ export class OptBtnGroups {
}
/**
* 添加按钮
* 添加按钮到指定组
* @param config 按钮配置(必须包含 groupId可选包含 parentId
*/
public addButton(config: ButtonConfig): void {
@@ -87,7 +134,7 @@ export class OptBtnGroups {
};
if (parentId) {
// Add as sub-button
// 添加为子按钮(菜单项)
const parentBtn = this.findButton(group.buttons, parentId);
if (!parentBtn) {
throw new Error(`Parent button ${parentId} not found in group ${groupId}`);
@@ -97,11 +144,14 @@ export class OptBtnGroups {
}
parentBtn.children.push(button);
} else {
// Add as main button
// 添加为主按钮
group.buttons.push(button);
}
}
/**
* 递归查找按钮
*/
private findButton(buttons: OptButton[], id: string): OptButton | undefined {
for (const btn of buttons) {
if (btn.id === id) return btn;
@@ -113,7 +163,11 @@ export class OptBtnGroups {
return undefined;
}
/**
* 初始化组件,加载默认按钮配置
*/
public async init(): Promise<void> {
// 动态导入默认按钮配置
const { homeButton } = await import('./buttons/home');
const { locationButton } = await import('./buttons/location');
const { walkMenuButton } = await import('./buttons/walk/walk-menu');
@@ -122,7 +176,7 @@ export class OptBtnGroups {
const { settingButton } = await import('./buttons/setting');
const { infoButton } = await import('./buttons/info');
// 添加组1
// 配置默认组和按钮
this.addGroup('group-1');
this.addButton(homeButton);
this.addButton(walkMenuButton);
@@ -135,6 +189,9 @@ export class OptBtnGroups {
this.render();
}
/**
* 渲染整个工具栏
*/
public render(): void {
this.container.innerHTML = '';
this.btnRefs.clear();
@@ -142,7 +199,7 @@ export class OptBtnGroups {
const wrapper = document.createElement('div');
wrapper.className = 'toolbar-container';
// 直接遍历数组,顺序由 addGroup 控制
// 渲染所有组
this.groups.forEach((group, index) => {
const groupElement = this.renderGroup(group, index, this.groups.length);
wrapper.appendChild(groupElement);
@@ -151,6 +208,9 @@ export class OptBtnGroups {
this.container.appendChild(wrapper);
}
/**
* 渲染单个按钮组
*/
private renderGroup(group: ButtonGroup, index: number, total: number): HTMLElement {
const groupEl = document.createElement('div');
groupEl.className = 'opt-btn-group';
@@ -169,6 +229,9 @@ export class OptBtnGroups {
return groupEl;
}
/**
* 渲染单个按钮
*/
private renderButton(button: OptButton): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'opt-btn-wrapper';
@@ -176,6 +239,7 @@ export class OptBtnGroups {
const btnEl = document.createElement('div');
btnEl.className = 'opt-btn';
// 设置激活状态
if (this.activeBtnIds.has(button.id)) {
btnEl.classList.add('active');
}
@@ -191,11 +255,13 @@ export class OptBtnGroups {
}
}
// 渲染图标
const icon = document.createElement('div');
icon.className = 'opt-btn-icon';
icon.innerHTML = this.getIcon(button.icon);
btnEl.appendChild(icon);
// 渲染标签
if (this.options.showLabel && button.label) {
const label = document.createElement('span');
label.className = 'opt-btn-label';
@@ -203,6 +269,7 @@ export class OptBtnGroups {
btnEl.appendChild(label);
}
// 如果有子菜单,渲染箭头
if (button.children && button.children.length > 0) {
const arrow = document.createElement('span');
arrow.className = 'opt-btn-arrow';
@@ -210,6 +277,7 @@ export class OptBtnGroups {
btnEl.appendChild(arrow);
}
// 绑定事件
btnEl.addEventListener('click', () => this.handleClick(button));
btnEl.addEventListener('mouseenter', () => this.handleMouseEnter(button, btnEl));
btnEl.addEventListener('mouseleave', () => this.handleMouseLeave());
@@ -220,9 +288,13 @@ export class OptBtnGroups {
return wrapper;
}
/**
* 处理按钮点击事件
*/
private handleClick(button: OptButton): void {
if (button.disabled) return;
// 如果没有子菜单,直接触发
if (!button.children || button.children.length === 0) {
if (button.keepActive) {
const wasActive = this.activeBtnIds.has(button.id);
@@ -242,6 +314,9 @@ export class OptBtnGroups {
}
}
/**
* 处理子菜单项点击事件
*/
private handleSubClick(button: OptButton): void {
if (button.keepActive) {
const wasActive = this.activeBtnIds.has(button.id);
@@ -260,6 +335,9 @@ export class OptBtnGroups {
}
}
/**
* 处理鼠标移入事件(显示菜单)
*/
private handleMouseEnter(button: OptButton, btnEl: HTMLElement): void {
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
@@ -278,12 +356,18 @@ export class OptBtnGroups {
}
}
/**
* 处理鼠标移出事件(隐藏菜单)
*/
private handleMouseLeave(): void {
this.hoverTimeout = window.setTimeout(() => {
this.closeDropdown();
}, 200);
}
/**
* 显示下拉菜单
*/
private showDropdown(button: OptButton, btnEl: HTMLElement): void {
this.closeDropdown();
@@ -291,6 +375,22 @@ export class OptBtnGroups {
const dropdown = document.createElement('div');
dropdown.className = 'opt-btn-dropdown';
// 下拉菜单也应用当前的 CSS 变量样式,因为它们通常挂载在 body 上,所以需要单独设置或者确保能继承
// 简单起见,我们可以直接将容器上的 CSS 变量复制过来,或者设置内联样式
// 更好的是:如果我们在 this.container 上设置 CSS 变量,
// 而 dropdown 挂载在 body 上,它无法继承。
// 所以我们需要将 CSS 变量也应用到 dropdown 上。
const style = dropdown.style;
if (this.options.backgroundColor) style.setProperty('--bim-toolbar-bg', this.options.backgroundColor);
if (this.options.btnBackgroundColor) style.setProperty('--bim-btn-bg', this.options.btnBackgroundColor);
if (this.options.btnHoverColor) style.setProperty('--bim-btn-hover-bg', this.options.btnHoverColor);
if (this.options.btnActiveColor) style.setProperty('--bim-btn-active-bg', this.options.btnActiveColor);
if (this.options.iconColor) style.setProperty('--bim-icon-color', this.options.iconColor);
if (this.options.iconActiveColor) style.setProperty('--bim-icon-active-color', this.options.iconActiveColor);
if (this.options.textColor) style.setProperty('--bim-btn-text-color', this.options.textColor);
if (this.options.textActiveColor) style.setProperty('--bim-btn-text-active-color', this.options.textActiveColor);
const rect = btnEl.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
@@ -305,6 +405,7 @@ export class OptBtnGroups {
}
});
// 保持菜单显<E58D95><E698BE><EFBFBD>
dropdown.addEventListener('mouseenter', () => {
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
@@ -318,6 +419,9 @@ export class OptBtnGroups {
this.dropdownElement = dropdown;
}
/**
* 渲染下拉菜单项
*/
private renderDropdownItem(button: OptButton): HTMLElement {
const item = document.createElement('div');
item.className = 'opt-btn-dropdown-item';
@@ -341,6 +445,9 @@ export class OptBtnGroups {
return item;
}
/**
* 关闭所有下拉菜单
*/
private closeDropdown(): void {
if (this.dropdownElement) {
this.dropdownElement.remove();
@@ -355,6 +462,9 @@ export class OptBtnGroups {
});
}
/**
* 更新按钮的激活状态样式
*/
private updateButtonState(buttonId: string): void {
const btnEl = this.btnRefs.get(buttonId);
if (btnEl) {
@@ -366,14 +476,53 @@ export class OptBtnGroups {
}
}
/**
* 获取图标 SVG 字符串
*/
private getIcon(icon?: string): string {
return icon || this.DEFAULT_ICON;
}
/**
* 更新按钮可见性
* @param buttonId 按钮ID
* @param visible 是否可见
*/
public updateButtonVisibility(buttonId: string, visible: boolean): void {
if (!this.options.visibility) {
this.options.visibility = {};
}
this.options.visibility[buttonId] = visible;
this.render();
}
/**
* 设置是否显示标签
* @param show 是否显示
*/
public setShowLabel(show: boolean): void {
this.options.showLabel = show;
this.render();
}
/**
* 设置背景颜色 (兼容旧接口)
* @param color CSS 颜色值
*/
public setBackgroundColor(color: string): void {
this.setColors({ backgroundColor: color });
}
/**
* 检查按钮是否可见
*/
private isVisible(id: string): boolean {
return this.options.visibility?.[id] !== false;
}
/**
* 销毁组件,清理资源
*/
public destroy(): void {
this.closeDropdown();
if (this.hoverTimeout) {
@@ -382,6 +531,6 @@ export class OptBtnGroups {
this.container.innerHTML = '';
this.btnRefs.clear();
this.activeBtnIds.clear();
this.groups = []; // 清空数组
this.groups = [];
}
}

View File

@@ -4,40 +4,78 @@ export type ButtonType = 'button' | 'menu';
* 按钮配置接口(用于外部定义按钮)
*/
export interface ButtonConfig {
id: string; // 唯一标识
type: ButtonType; // 按钮类型
label: string; // 按钮文字
icon?: string; // SVG 图标(内联 SVG 字符串)
keepActive?: boolean; // 是否保持激活状态(默认 false
disabled?: boolean; // 是否禁用
onClick?: (button: OptButton) => void; // 点击回调
children?: ButtonConfig[]; // 子按钮配置(可选,用于菜单按钮)
/** 唯一标识 */
id: string;
/** 按钮类型:普通按钮或菜单按钮 */
type: ButtonType;
/** 按钮显示文字 */
label: string;
/** SVG 图标(内联 SVG 字符串) */
icon?: string;
/** 是否保持激活状态(默认 false */
keepActive?: boolean;
/** 是否禁用 */
disabled?: boolean;
/** 点击回调函数 */
onClick?: (button: OptButton) => void;
/** 子按钮配置(可选,用于菜单按钮) */
children?: ButtonConfig[];
groupId?: string; // 所属组ID
parentId?: string; // 父按钮ID如果是子按钮
/** 所属组ID */
groupId?: string;
/** 父按钮ID如果是子按钮则必填 */
parentId?: string;
}
/**
* 操作按钮接口(内部使用,继承配置)
*/
export interface OptButton extends ButtonConfig {
children?: OptButton[]; // 内部使用的子按钮列表
/** 内部使用的子按钮列表 */
children?: OptButton[];
}
/**
* 按钮组接口
*/
export interface ButtonGroup {
/** 组 ID */
id: string;
/** 组内按钮列表 */
buttons: OptButton[];
}
/**
* 工具栏颜色配置接口
*/
export interface ToolbarColors {
/** 工具栏背景颜色 */
backgroundColor?: string;
/** 按钮默认背景颜色 */
btnBackgroundColor?: string;
/** 按钮 Hover 背景颜色 */
btnHoverColor?: string;
/** 按钮激活状态背景颜色 */
btnActiveColor?: string;
/** 图标默认颜色 */
iconColor?: string;
/** 图标激活/Hover 颜色 */
iconActiveColor?: string;
/** 文字默认颜色 */
textColor?: string;
/** 文字激活/Hover 颜色 */
textActiveColor?: string;
}
/**
* OptBtnGroups 配置选项
*/
export interface OptBtnGroupsOptions {
export interface OptBtnGroupsOptions extends ToolbarColors {
/** 容器元素或 ID */
container: HTMLElement | string;
/** 是否显示标签 */
showLabel?: boolean;
/** 按钮可见性配置 Map */
visibility?: Record<string, boolean>;
}
@@ -45,7 +83,10 @@ export interface OptBtnGroupsOptions {
* 点击事件载荷
*/
export interface ClickPayload {
/** 被点击的按钮对象 */
button: OptButton;
/** 触发的动作类型 */
action: 'activate' | 'deactivate' | 'trigger';
/** 当前激活状态 */
isActive?: boolean;
}