Refactor: modularize toolbar buttons, integrate config, and fix layout positioning

This commit is contained in:
yuding
2025-12-03 15:46:18 +08:00
parent 5aeb381add
commit 14ac91aa6e
22 changed files with 1290 additions and 51 deletions

View File

@@ -1,24 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BIM Engine SDK Demo</title>
<script src="./dist/bim-engine-sdk.umd.js"></script>
</head>
<body>
<div id="app" style="width: 100%; height: 300px; border: 1px dashed #ccc;"></div>
<body>
<div id="app" style="width: 1000px; height: 500px; border: 1px dashed #ccc;"></div>
<script>
// 等待 SDK 加载
window.onload = () => {
if (window.BimEngineSDK) {
// 这里的 BimEngineSDK 是 UMD 的全局变量名(由 vite.config.ts 中的 build.lib.name 定义)
// 如果是 export default可能直接挂载在 window.BimEngineSDK或者需要 .default取决于打包格式。
// 但我们在 index.ts 中使用了具名导出 export { BimEngine },所以应该是 window.BimEngineSDK.BimEngine
const Engine = window.BimEngineSDK.BimEngine;
if (window.LyzBimEngineSDK) {
const Engine = window.LyzBimEngineSDK.BimEngine;
try {
const instance = new Engine(document.getElementById('app'));
console.log('Engine initialized:', instance);
@@ -31,4 +27,5 @@
};
</script>
</body>
</html>

View File

@@ -1,20 +1,210 @@
class e {
(function(){"use strict";try{if(typeof document<"u"){var o=document.createElement("style");o.appendChild(document.createTextNode(".bim-engine-wrapper{position:relative;width:100%;height:100%;font-family:sans-serif;color:#333;padding:20px;background-color:#e16969;border-radius:8px;border:1px solid #e0e0e0;box-sizing:border-box}.bim-engine-opt-btn-container{position:absolute;bottom:20px;left:50%;transform:translate(-50%);z-index:100}.toolbar-container{display:flex;align-items:center;max-width:100%;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none}.toolbar-container::-webkit-scrollbar{display:none}.opt-btn-group{overflow:hidden;display:flex;align-items:center;flex-shrink:0;background-color:#111111e0;border-radius:4px;padding:4px 8px}.has-divider{margin-right:16px}.opt-btn-wrapper{position:relative}.opt-btn{display:flex;flex-direction:column;align-items:center;justify-content:center;width:50px;min-height:50px;padding:4px;cursor:pointer;color:#ccc;transition:all .2s;border-bottom:2px solid transparent}.opt-btn:hover{background-color:#444;color:#fff}.opt-btn.active{background-color:#ffffff26;color:#fff;border-bottom:2px solid #fff}.opt-btn.disabled{opacity:.5;cursor:not-allowed}.opt-btn-icon{width:32px;height:32px;display:flex;align-items:center;justify-content:center;flex-shrink:0}.opt-btn-icon svg{width:100%;height:100%}.opt-btn-label{font-size:10px;margin-top:2px}.opt-btn-arrow{font-size:8px;position:absolute;top:2px;right:2px;opacity:.6;transition:transform .2s ease}.opt-btn-arrow.rotated{transform:rotate(180deg)}.opt-btn.no-label .opt-btn-arrow{top:2px;right:2px}.opt-btn-dropdown{position:fixed;transform:translate(-50%,-100%);background-color:#111111e0;border-radius:4px;overflow:hidden;box-shadow:0 4px 12px #0000004d;min-width:50px;z-index:9999;display:flex;flex-direction:column}.opt-btn-dropdown-item{display:flex;flex-direction:column;align-items:center;justify-content:center;color:#b3b4b4;cursor:pointer;transition:background .2s;white-space:nowrap;min-width:50px;min-height:50px;padding:4px}.opt-btn-dropdown-item:last-child{border-bottom:none}.opt-btn-dropdown-item:hover{background-color:#444;color:#fff}.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}.opt-btn.no-label .opt-btn-icon{width:32px;height:32px}")),document.head.appendChild(o)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})();
class a {
container;
constructor(n) {
const i = typeof n == "string" ? document.getElementById(n) : n;
if (!i) throw new Error("Container not found");
this.container = i, this.init();
options;
// 改用 Array 存储 Group方便控制顺序
groups = [];
activeBtnIds = /* @__PURE__ */ new Set();
btnRefs = /* @__PURE__ */ new Map();
dropdownElement = null;
hoverTimeout = null;
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>';
constructor(t) {
const e = typeof t.container == "string" ? document.getElementById(t.container) : t.container;
if (!e) throw new Error("Container not found");
this.container = e, this.options = {
showLabel: !0,
visibility: {},
...t
}, this.initContainer();
}
initContainer() {
this.container.innerHTML = "";
}
/**
* 添加按钮组
* @param groupId 组ID
* @param beforeGroupId 在哪个组之前插入(可选),不传则插入到最后
*/
addGroup(t, e) {
if (this.groups.some((n) => n.id === t)) {
console.warn("Group " + t + " already exists");
return;
}
const i = { id: t, buttons: [] };
if (e) {
const n = this.groups.findIndex((s) => s.id === e);
n !== -1 ? this.groups.splice(n, 0, i) : (console.warn(`Target group ${e} not found, appending ${t} to end.`), this.groups.push(i));
} else
this.groups.push(i);
}
/**
* 添加按钮
* @param config 按钮配置(必须包含 groupId可选包含 parentId
*/
addButton(t) {
const { groupId: e, parentId: i } = t;
if (!e)
throw new Error(`Button ${t.id} config must contain 'groupId'`);
const n = this.groups.find((o) => o.id === e);
if (!n)
throw new Error(`Group ${e} not found. Please call addGroup first.`);
const s = {
...t,
children: t.children || []
};
if (i) {
const o = this.findButton(n.buttons, i);
if (!o)
throw new Error(`Parent button ${i} not found in group ${e}`);
o.children || (o.children = []), o.children.push(s);
} else
n.buttons.push(s);
}
findButton(t, e) {
for (const i of t) {
if (i.id === e) return i;
if (i.children) {
const n = this.findButton(i.children, e);
if (n) return n;
}
}
}
async init() {
const { homeButton: t } = await import("./index-CAJWny5G.mjs"), { locationButton: e } = await import("./index-C12x1apF.mjs"), { walkMenuButton: i } = await import("./index-Wpi9Br9A.mjs"), { walkPersonButton: n } = await import("./index-BXbORK0j.mjs"), { walkBirdButton: s } = await import("./index-Djlk5GIH.mjs"), { settingButton: o } = await import("./index-DsRG5l_h.mjs"), { infoButton: r } = await import("./index-DvZ5eiUH.mjs");
this.addGroup("group-1"), this.addButton(t), this.addButton(i), this.addButton(n), this.addButton(s), this.addButton(e), this.addGroup("group-2"), this.addButton(o), this.addButton(r), this.render();
}
render() {
this.container.innerHTML = "", this.btnRefs.clear();
const t = document.createElement("div");
t.className = "toolbar-container", this.groups.forEach((e, i) => {
const n = this.renderGroup(e, i, this.groups.length);
t.appendChild(n);
}), this.container.appendChild(t);
}
renderGroup(t, e, i) {
const n = document.createElement("div");
return n.className = "opt-btn-group", e < i - 1 && n.classList.add("has-divider"), t.buttons.forEach((s) => {
if (this.isVisible(s.id)) {
const o = this.renderButton(s);
n.appendChild(o);
}
}), n;
}
renderButton(t) {
const e = document.createElement("div");
e.className = "opt-btn-wrapper";
const i = document.createElement("div");
i.className = "opt-btn", this.activeBtnIds.has(t.id) && i.classList.add("active"), t.disabled && i.classList.add("disabled"), this.options.showLabel || (i.classList.add("no-label"), t.label && (i.title = t.label));
const n = document.createElement("div");
if (n.className = "opt-btn-icon", n.innerHTML = this.getIcon(t.icon), i.appendChild(n), this.options.showLabel && t.label) {
const s = document.createElement("span");
s.className = "opt-btn-label", s.textContent = t.label, i.appendChild(s);
}
if (t.children && t.children.length > 0) {
const s = document.createElement("span");
s.className = "opt-btn-arrow", s.textContent = "▼", i.appendChild(s);
}
return i.addEventListener("click", () => this.handleClick(t)), i.addEventListener("mouseenter", () => this.handleMouseEnter(t, i)), i.addEventListener("mouseleave", () => this.handleMouseLeave()), this.btnRefs.set(t.id, i), e.appendChild(i), e;
}
handleClick(t) {
t.disabled || (!t.children || t.children.length === 0) && (t.keepActive && (this.activeBtnIds.has(t.id) ? this.activeBtnIds.delete(t.id) : this.activeBtnIds.add(t.id), this.updateButtonState(t.id)), this.closeDropdown(), t.onClick && t.onClick(t));
}
handleSubClick(t) {
t.keepActive && (this.activeBtnIds.has(t.id) ? this.activeBtnIds.delete(t.id) : this.activeBtnIds.add(t.id), this.updateButtonState(t.id)), this.closeDropdown(), t.onClick && t.onClick(t);
}
handleMouseEnter(t, e) {
if (this.hoverTimeout && (clearTimeout(this.hoverTimeout), this.hoverTimeout = null), t.children && t.children.length > 0) {
this.showDropdown(t, e);
const i = e.querySelector(".opt-btn-arrow");
i && i.classList.add("rotated");
} else
this.closeDropdown();
}
handleMouseLeave() {
this.hoverTimeout = window.setTimeout(() => {
this.closeDropdown();
}, 200);
}
showDropdown(t, e) {
if (this.closeDropdown(), !t.children) return;
const i = document.createElement("div");
i.className = "opt-btn-dropdown";
const n = e.getBoundingClientRect(), s = n.left + n.width / 2;
i.style.top = n.top - 8 + "px", i.style.left = s + "px", t.children.forEach((o) => {
if (this.isVisible(o.id)) {
const r = this.renderDropdownItem(o);
i.appendChild(r);
}
}), i.addEventListener("mouseenter", () => {
this.hoverTimeout && (clearTimeout(this.hoverTimeout), this.hoverTimeout = null);
}), i.addEventListener("mouseleave", () => this.handleMouseLeave()), document.body.appendChild(i), this.dropdownElement = i;
}
renderDropdownItem(t) {
const e = document.createElement("div");
e.className = "opt-btn-dropdown-item";
const i = document.createElement("div");
if (i.className = "opt-btn-icon small", i.innerHTML = this.getIcon(t.icon), e.appendChild(i), this.options.showLabel) {
const n = document.createElement("span");
n.textContent = t.label, e.appendChild(n);
}
return e.addEventListener("click", (n) => {
n.stopPropagation(), this.handleSubClick(t);
}), e;
}
closeDropdown() {
this.dropdownElement && (this.dropdownElement.remove(), this.dropdownElement = null), this.btnRefs.forEach((t) => {
const e = t.querySelector(".opt-btn-arrow");
e && e.classList.remove("rotated");
});
}
updateButtonState(t) {
const e = this.btnRefs.get(t);
e && (this.activeBtnIds.has(t) ? e.classList.add("active") : e.classList.remove("active"));
}
getIcon(t) {
return t || this.DEFAULT_ICON;
}
isVisible(t) {
return this.options.visibility?.[t] !== !1;
}
destroy() {
this.closeDropdown(), this.hoverTimeout && clearTimeout(this.hoverTimeout), this.container.innerHTML = "", this.btnRefs.clear(), this.activeBtnIds.clear(), this.groups = [];
}
}
class c {
container;
optBtnGroups = null;
constructor(t) {
const e = typeof t == "string" ? document.getElementById(t) : t;
if (!e) throw new Error("Container not found");
this.container = e, this.init();
}
init() {
this.container.innerHTML = `
<div style="font-family: sans-serif; color: #333;">
<h1>BimEngine</h1>
<p>这是一个纯 TypeScript 组件入口。</p>
</div>
`;
this.container.innerHTML = "";
const t = document.createElement("div");
t.className = "bim-engine-wrapper";
const e = document.createElement("h1");
e.textContent = "BimEngine", e.className = "bim-engine-title";
const i = document.createElement("p");
i.textContent = "这是一个使用BIM-ENGINE。", i.className = "bim-engine-desc";
const n = document.createElement("div");
n.id = "opt-btn-groups", n.className = "bim-engine-opt-btn-container", t.appendChild(e), t.appendChild(i), t.appendChild(n), this.container.appendChild(t), this.initOptBtnGroups(n);
}
initOptBtnGroups(t) {
this.optBtnGroups = new a({
container: t,
showLabel: !0
}), this.optBtnGroups.init().catch((e) => {
console.error("Failed to initialize OptBtnGroups:", e);
});
}
destroy() {
this.optBtnGroups && (this.optBtnGroups.destroy(), this.optBtnGroups = null), this.container.innerHTML = "";
}
}
export {
e as BimEngine
c as BimEngine,
a as OptBtnGroups
};
//# sourceMappingURL=bim-engine-sdk.es.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

94
dist/index.d.ts vendored
View File

@@ -1,7 +1,101 @@
export declare class BimEngine {
private container;
private optBtnGroups;
constructor(container: HTMLElement | string);
private init;
private initOptBtnGroups;
destroy(): void;
}
/**
* 按钮配置接口(用于外部定义按钮)
*/
declare interface ButtonConfig {
id: string;
type: ButtonType;
label: string;
icon?: string;
keepActive?: boolean;
disabled?: boolean;
onClick?: (button: OptButton) => void;
children?: ButtonConfig[];
groupId?: string;
parentId?: string;
}
/**
* 按钮组接口
*/
export declare interface ButtonGroup {
id: string;
buttons: OptButton[];
}
declare type ButtonType = 'button' | 'menu';
/**
* 点击事件载荷
*/
export declare interface ClickPayload {
button: OptButton;
action: 'activate' | 'deactivate' | 'trigger';
isActive?: boolean;
}
export declare class OptBtnGroups {
private container;
private options;
private groups;
private activeBtnIds;
private btnRefs;
private dropdownElement;
private hoverTimeout;
private readonly DEFAULT_ICON;
constructor(options: OptBtnGroupsOptions);
private initContainer;
/**
* 添加按钮组
* @param groupId 组ID
* @param beforeGroupId 在哪个组之前插入(可选),不传则插入到最后
*/
addGroup(groupId: string, beforeGroupId?: string): void;
/**
* 添加按钮
* @param config 按钮配置(必须包含 groupId可选包含 parentId
*/
addButton(config: ButtonConfig): void;
private findButton;
init(): Promise<void>;
render(): void;
private renderGroup;
private renderButton;
private handleClick;
private handleSubClick;
private handleMouseEnter;
private handleMouseLeave;
private showDropdown;
private renderDropdownItem;
private closeDropdown;
private updateButtonState;
private getIcon;
private isVisible;
destroy(): void;
}
/**
* OptBtnGroups 配置选项
*/
export declare interface OptBtnGroupsOptions {
container: HTMLElement | string;
showLabel?: boolean;
visibility?: Record<string, boolean>;
}
/**
* 操作按钮接口(内部使用,继承配置)
*/
export declare interface OptButton extends ButtonConfig {
children?: OptButton[];
}
export { }

160
docs/OptBtnGroups.md Normal file
View File

@@ -0,0 +1,160 @@
# OptBtnGroups 组件使用文档
## 📋 组件简介
`OptBtnGroups` 是一个纯 TypeScript 实现的操作按钮组组件,使用原生 DOM API 开发,无任何框架依赖。
## 🎯 核心特性
- ✅ 纯 TypeScript + 原生 DOM API
- ✅ 多组分隔,独立背景块
- ✅ 二级下拉菜单
- ✅ 多选激活状态
- ✅ 可控的标签显示
- ✅ 按钮显隐控制
- ✅ SVG 图标支持
- ✅ 响应式横向滚动
## 🚀 快速开始
### 1. 安装/引入
```html
<!-- UMD 方式 -->
<script src="./dist/bim-engine-sdk.umd.js"></script>
<script>
const { OptBtnGroups } = window.BimEngineSDK;
</script>
```
```typescript
// ES Module 方式
import { OptBtnGroups } from 'bim-engine-sdk';
```
### 2. 基础使用
```typescript
const optBtnGroups = new OptBtnGroups({
container: 'btn-container', // 容器 ID 或 HTMLElement
showLabel: true, // 显示文字标签
onClick: (payload) => {
console.log('点击了按钮:', payload.button.label);
console.log('操作类型:', payload.action);
if (payload.isActive !== undefined) {
console.log('激活状态:', payload.isActive);
}
}
});
```
### 3. 控制显隐
```typescript
new OptBtnGroups({
container: 'btn-container',
visibility: {
'1-1': true, // 显示
'3-2-1': false // 隐藏
},
onClick: (payload) => console.log(payload)
});
```
## 📝 API 文档
### 构造函数参数
```typescript
interface OptBtnGroupsOptions {
container: HTMLElement | string; // 必需:容器
showLabel?: boolean; // 可选:显示标签(默认 true
visibility?: Record<string, boolean>; // 可选:显隐控制
onClick?: (payload: ClickPayload) => void; // 可选:点击回调
}
```
### 点击事件载荷
```typescript
interface ClickPayload {
button: OptButton; // 被点击的按钮
action: 'trigger' | 'activate' | 'deactivate'; // 操作类型
isActive?: boolean; // 激活状态(仅 keepActive 按钮有值)
}
```
- `trigger`: 触发型操作(无状态保持)
- `activate`: 激活按钮
- `deactivate`: 取消激活
### 方法
```typescript
// 销毁组件
optBtnGroups.destroy();
```
## 🛠️ 二次开发
### 修改内置按钮
编辑 `src/OptBtnGroups.ts` 中的 `defaultGroups`:
```typescript
private readonly defaultGroups: ButtonGroup[] = [
{
id: 'my-group',
buttons: [
{
id: 'home',
label: '首页',
icon: '<svg viewBox="0 0 24 24">...</svg>',
keepActive: true
}
]
}
];
```
### 修改样式
编辑 `src/OptBtnGroups.css`:
```css
.opt-btn {
width: 50px; /* 按钮宽度 */
min-height: 50px; /* 最小高度 */
}
.opt-btn.active {
background-color: rgba(255, 255, 255, 0.15); /* 激活背景 */
border-bottom: 2px solid #fff; /* 底部指示条 */
}
```
## 📦 构建
```bash
# 安装依赖
npm install
# 开发模式
npm run dev
# 构建生产版本
npm run build
```
## 🎨 示例
查看 `demo-optbtn.html` 获取完整示例。
## 🤝 贡献
修改组件时请遵循:
1. 保持中文注释
2. 使用原生 DOM API无框架依赖
3. TypeScript 严格模式
4. 测试多种使用场景

View File

@@ -1,19 +0,0 @@
export class BimEngine {
private container: HTMLElement;
constructor(container: HTMLElement | string) {
const el = typeof container === 'string' ? document.getElementById(container) : container;
if (!el) throw new Error('Container not found');
this.container = el;
this.init();
}
private init() {
this.container.innerHTML = `
<div style="font-family: sans-serif; color: #333;">
<h1>BimEngine</h1>
<p>这是一个纯 TypeScript 组件入口。</p>
</div>
`;
}
}

26
src/bim-engine.css Normal file
View File

@@ -0,0 +1,26 @@
.bim-engine-wrapper {
position: relative;
/* 添加相对定位作为参照物 */
width: 100%;
height: 100%;
font-family: sans-serif;
color: #333;
padding: 20px;
background-color: #e16969;
border-radius: 8px;
border: 1px solid #e0e0e0;
box-sizing: border-box;
/* 确保 padding 不会撑大容器 */
}
/* ... (中间代码不变) ... */
/* 操作按钮组容器 */
.bim-engine-opt-btn-container {
position: absolute;
/* 改为绝对定位 */
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
}

69
src/bim-engine.ts Normal file
View File

@@ -0,0 +1,69 @@
import './bim-engine.css';
import { OptBtnGroups } from './toolbar';
export class BimEngine {
private container: HTMLElement;
private optBtnGroups: OptBtnGroups | null = null;
constructor(container: HTMLElement | string) {
const el = typeof container === 'string' ? document.getElementById(container) : container;
if (!el) throw new Error('Container not found');
this.container = el;
this.init();
}
private init() {
// 1. 清空容器可能存在的旧内容
this.container.innerHTML = '';
// 2. 创建外层容器 div
const wrapper = document.createElement('div');
wrapper.className = 'bim-engine-wrapper';
// 3. 创建标题 h1
const title = document.createElement('h1');
title.textContent = 'BimEngine';
title.className = 'bim-engine-title';
// 4. 创建段落 p
const desc = document.createElement('p');
desc.textContent = '这是一个使用BIM-ENGINE。';
desc.className = 'bim-engine-desc';
// 6. 创建操作按钮组容器
const btnGroupContainer = document.createElement('div');
btnGroupContainer.id = 'opt-btn-groups';
btnGroupContainer.className = 'bim-engine-opt-btn-container';
// 7. 组装元素
wrapper.appendChild(title);
wrapper.appendChild(desc);
wrapper.appendChild(btnGroupContainer); // 将按钮组放入 wrapper 中
// 8. 挂载到主容器
this.container.appendChild(wrapper);
// 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);
});
}
public destroy() {
if (this.optBtnGroups) {
this.optBtnGroups.destroy();
this.optBtnGroups = null;
}
this.container.innerHTML = '';
}
}

View File

@@ -1,3 +1,4 @@
import { BimEngine } from './BimEngine';
import { BimEngine } from './bim-engine';
export { OptBtnGroups } from './toolbar';
export type { OptButton, ButtonGroup, OptBtnGroupsOptions, ClickPayload } from './toolbar/index.type';
export { BimEngine };

View File

@@ -0,0 +1,16 @@
import type { ButtonConfig } from '../../index.type';
/**
* 首页按钮配置
*/
export const homeButton: ButtonConfig = {
id: 'home',
groupId: 'group-1',
type: 'button',
label: '首页',
icon: '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M4 21V9l8-6l8 6v12h-6v-7h-4v7z"/></svg>',
keepActive: true,
onClick: (button) => {
console.log('首页按钮被点击:', button.id);
}
};

View File

@@ -0,0 +1,16 @@
import type { ButtonConfig } from '../../index.type';
/**
* 定位按钮配置
*/
export const infoButton: ButtonConfig = {
id: 'info',
groupId: 'group-2',
type: 'button',
label: '信息',
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>',
keepActive: false,
onClick: (button) => {
console.log('信息按钮被点击:', button.id);
}
};

View File

@@ -0,0 +1,16 @@
import type { ButtonConfig } from '../../index.type';
/**
* 定位按钮配置
*/
export const locationButton: ButtonConfig = {
id: 'location',
groupId: 'group-1',
type: 'button',
label: '定位',
icon: '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M9 13h2v-2.75h2V13h2V8.25l-3-2l-3 2zm3 9q-4.025-3.425-6.012-6.362T4 10.2q0-3.75 2.413-5.975T12 2t5.588 2.225T20 10.2q0 2.5-1.987 5.438T12 22"/></svg>',
keepActive: false,
onClick: (button) => {
console.log('定位按钮被点击:', button.id);
}
};

View File

@@ -0,0 +1,16 @@
import type { ButtonConfig } from '../../index.type';
/**
* 定位按钮配置
*/
export const settingButton: ButtonConfig = {
id: 'setting',
groupId: 'group-2',
type: 'button',
label: '设置',
icon: '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zM11 20h1.975l.35-2.65q.775-.2 1.438-.587t1.212-.938l2.475 1.025l.975-1.7l-2.15-1.625q.125-.35.175-.737T17.5 12t-.05-.787t-.175-.738l2.15-1.625l-.975-1.7l-2.475 1.05q-.55-.575-1.212-.962t-1.438-.588L13 4h-1.975l-.35 2.65q-.775.2-1.437.588t-1.213.937L5.55 7.15l-.975 1.7l2.15 1.6q-.125.375-.175.75t-.05.8q0 .4.05.775t.175.75l-2.15 1.625l.975 1.7l2.475-1.05q.55.575 1.213.963t1.437.587zm1.05-4.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5M12 12"/></svg>',
keepActive: false,
onClick: (button) => {
console.log('设置按钮被点击:', button.id);
}
};

View File

@@ -0,0 +1,13 @@
import type { ButtonConfig } from '../../../index.type';
export const walkBirdButton: ButtonConfig = {
id: 'walk-bird',
groupId: 'group-1',
parentId: 'walk',
type: 'button',
label: '鸟瞰漫游',
icon: '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M9 22V8.775q-2.275-.6-3.637-2.512T4 2h2q0 2.075 1.338 3.538T10.75 7h2.5q.75 0 1.4.275t1.175.8L20.35 12.6l-1.4 1.4L15 10.05V22h-2v-6h-2v6zm3-16q-.825 0-1.412-.587T10 4t.588-1.412T12 2t1.413.588T14 4t-.587 1.413T12 6"/></svg>',
onClick: (button) => {
console.log('鸟瞰漫游被点击:', button.id);
}
};

View File

@@ -0,0 +1,16 @@
import type { ButtonConfig } from '../../../index.type';
/**
* 漫游菜单按钮配置
*/
export const walkMenuButton: ButtonConfig = {
id: 'walk',
groupId: 'group-1',
type: 'menu',
label: '漫游',
icon: '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M9 22V8.775q-2.275-.6-3.637-2.512T4 2h2q0 2.075 1.338 3.538T10.75 7h2.5q.75 0 1.4.275t1.175.8L20.35 12.6l-1.4 1.4L15 10.05V22h-2v-6h-2v6zm3-16q-.825 0-1.412-.587T10 4t.588-1.412T12 2t1.413.588T14 4t-.587 1.413T12 6"/></svg>',
keepActive: true,
onClick: (button) => {
console.log('漫游按钮被点击:', button.id);
}
};

View File

@@ -0,0 +1,13 @@
import type { ButtonConfig } from '../../../index.type';
export const walkPersonButton: ButtonConfig = {
id: 'walk-person',
groupId: 'group-1',
parentId: 'walk',
type: 'button',
label: '人视漫游',
icon: '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M9 22V8.775q-2.275-.6-3.637-2.512T4 2h2q0 2.075 1.338 3.538T10.75 7h2.5q.75 0 1.4.275t1.175.8L20.35 12.6l-1.4 1.4L15 10.05V22h-2v-6h-2v6zm3-16q-.825 0-1.412-.587T10 4t.588-1.412T12 2t1.413.588T14 4t-.587 1.413T12 6"/></svg>',
onClick: (button) => {
console.log('人视漫游被点击:', button.id);
}
};

181
src/toolbar/index.css Normal file
View File

@@ -0,0 +1,181 @@
/* 容器样式 */
.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 隐藏滚动条 */
}
/* 按钮组样式 */
.opt-btn-group {
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
flex-shrink: 0;
background-color: rgba(17, 17, 17, 0.88);
/* 每个组独立的背景 */
border-radius: 4px;
padding: 4px 8px;
}
.has-divider {
margin-right: 16px;
/* 增加右边距来分隔组 */
}
/* 按钮包装器 */
.opt-btn-wrapper {
position: relative;
}
/* 按钮样式 */
.opt-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 50px;
min-height: 50px;
padding: 4px;
cursor: pointer;
color: #ccc;
transition: all 0.2s;
border-bottom: 2px solid transparent;
/* 默认透明底边 */
}
.opt-btn:hover {
background-color: #444;
color: #fff;
}
.opt-btn.active {
background-color: rgba(255, 255, 255, 0.15);
/* 白色半透明背景 */
color: #fff;
border-bottom: 2px solid #fff;
/* 纯白色底部横条 */
}
.opt-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 图标样式 */
.opt-btn-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
/* 防止图标被压缩 */
}
.opt-btn-icon svg {
width: 100%;
height: 100%;
}
/* 文字标签样式 */
.opt-btn-label {
font-size: 10px;
margin-top: 2px;
}
/* 下拉箭头样式 */
.opt-btn-arrow {
font-size: 8px;
position: absolute;
top: 2px;
right: 2px;
opacity: 0.6;
transition: transform 0.2s ease;
}
/* 箭头旋转 (菜单展开时) */
.opt-btn-arrow.rotated {
transform: rotate(180deg);
}
/* 无标签模式调整 */
.opt-btn.no-label .opt-btn-arrow {
top: 2px;
right: 2px;
}
/* 下拉菜单样式 */
.opt-btn-dropdown {
position: fixed;
/* 固定定位 */
transform: translate(-50%, -100%);
/* 水平居中并向上移动 */
background-color: rgba(17, 17, 17, 0.88);
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;
}
/* 下拉菜单项样式 */
.opt-btn-dropdown-item {
display: flex;
flex-direction: column;
/* 垂直布局 */
align-items: center;
justify-content: center;
color: #b3b4b4;
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;
/* 移除最后一项的分隔线 */
}
.opt-btn-dropdown-item:hover {
background-color: #444;
color: #fff;
}
.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;
/* 较小的文字 */
}
/* 无标签模式调整 */
.opt-btn.no-label .opt-btn-icon {
width: 32px;
height: 32px;
}

387
src/toolbar/index.ts Normal file
View File

@@ -0,0 +1,387 @@
import './index.css';
import type {
OptButton,
ButtonGroup,
OptBtnGroupsOptions,
ButtonConfig
} from './index.type';
export class OptBtnGroups {
private container: HTMLElement;
private options: OptBtnGroupsOptions;
// 改用 Array 存储 Group方便控制顺序
private groups: ButtonGroup[] = [];
private activeBtnIds: Set<string> = new Set();
private btnRefs: Map<string, HTMLElement> = new Map();
private dropdownElement: HTMLElement | null = null;
private hoverTimeout: number | null = null;
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>';
constructor(options: OptBtnGroupsOptions) {
const el = typeof options.container === 'string'
? document.getElementById(options.container)
: options.container;
if (!el) throw new Error('Container not found');
this.container = el;
this.options = {
showLabel: true,
visibility: {},
...options
};
this.initContainer();
}
private initContainer(): void {
this.container.innerHTML = '';
}
/**
* 添加按钮组
* @param groupId 组ID
* @param beforeGroupId 在哪个组之前插入(可选),不传则插入到最后
*/
public addGroup(groupId: string, beforeGroupId?: string): void {
if (this.groups.some(g => g.id === groupId)) {
console.warn('Group ' + groupId + ' already exists');
return;
}
const newGroup: ButtonGroup = { id: groupId, buttons: [] };
if (beforeGroupId) {
const index = this.groups.findIndex(g => g.id === beforeGroupId);
if (index !== -1) {
this.groups.splice(index, 0, newGroup);
} else {
console.warn(`Target group ${beforeGroupId} not found, appending ${groupId} to end.`);
this.groups.push(newGroup);
}
} else {
this.groups.push(newGroup);
}
}
/**
* 添加按钮
* @param config 按钮配置(必须包含 groupId可选包含 parentId
*/
public addButton(config: ButtonConfig): void {
const { groupId, parentId } = config;
if (!groupId) {
throw new Error(`Button ${config.id} config must contain 'groupId'`);
}
const group = this.groups.find(g => g.id === groupId);
if (!group) {
throw new Error(`Group ${groupId} not found. Please call addGroup first.`);
}
const button: OptButton = {
...config,
children: config.children || []
};
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}`);
}
if (!parentBtn.children) {
parentBtn.children = [];
}
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;
if (btn.children) {
const found = this.findButton(btn.children, id);
if (found) return found;
}
}
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');
const { walkPersonButton } = await import('./buttons/walk/walk-person');
const { walkBirdButton } = await import('./buttons/walk/walk-bird');
const { settingButton } = await import('./buttons/setting');
const { infoButton } = await import('./buttons/info');
// 添加组1
this.addGroup('group-1');
this.addButton(homeButton);
this.addButton(walkMenuButton);
this.addButton(walkPersonButton);
this.addButton(walkBirdButton);
this.addButton(locationButton);
this.addGroup('group-2');
this.addButton(settingButton);
this.addButton(infoButton);
this.render();
}
public render(): void {
this.container.innerHTML = '';
this.btnRefs.clear();
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);
});
this.container.appendChild(wrapper);
}
private renderGroup(group: ButtonGroup, index: number, total: number): HTMLElement {
const groupEl = document.createElement('div');
groupEl.className = 'opt-btn-group';
if (index < total - 1) {
groupEl.classList.add('has-divider');
}
group.buttons.forEach(button => {
if (this.isVisible(button.id)) {
const btnWrapper = this.renderButton(button);
groupEl.appendChild(btnWrapper);
}
});
return groupEl;
}
private renderButton(button: OptButton): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'opt-btn-wrapper';
const btnEl = document.createElement('div');
btnEl.className = 'opt-btn';
if (this.activeBtnIds.has(button.id)) {
btnEl.classList.add('active');
}
if (button.disabled) {
btnEl.classList.add('disabled');
}
if (!this.options.showLabel) {
btnEl.classList.add('no-label');
if (button.label) {
btnEl.title = button.label;
}
}
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';
label.textContent = button.label;
btnEl.appendChild(label);
}
if (button.children && button.children.length > 0) {
const arrow = document.createElement('span');
arrow.className = 'opt-btn-arrow';
arrow.textContent = '▼';
btnEl.appendChild(arrow);
}
btnEl.addEventListener('click', () => this.handleClick(button));
btnEl.addEventListener('mouseenter', () => this.handleMouseEnter(button, btnEl));
btnEl.addEventListener('mouseleave', () => this.handleMouseLeave());
this.btnRefs.set(button.id, btnEl);
wrapper.appendChild(btnEl);
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);
if (wasActive) {
this.activeBtnIds.delete(button.id);
} else {
this.activeBtnIds.add(button.id);
}
this.updateButtonState(button.id);
}
this.closeDropdown();
if (button.onClick) {
button.onClick(button);
}
}
}
private handleSubClick(button: OptButton): void {
if (button.keepActive) {
const wasActive = this.activeBtnIds.has(button.id);
if (wasActive) {
this.activeBtnIds.delete(button.id);
} else {
this.activeBtnIds.add(button.id);
}
this.updateButtonState(button.id);
}
this.closeDropdown();
if (button.onClick) {
button.onClick(button);
}
}
private handleMouseEnter(button: OptButton, btnEl: HTMLElement): void {
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
if (button.children && button.children.length > 0) {
this.showDropdown(button, btnEl);
const arrow = btnEl.querySelector('.opt-btn-arrow');
if (arrow) {
arrow.classList.add('rotated');
}
} else {
this.closeDropdown();
}
}
private handleMouseLeave(): void {
this.hoverTimeout = window.setTimeout(() => {
this.closeDropdown();
}, 200);
}
private showDropdown(button: OptButton, btnEl: HTMLElement): void {
this.closeDropdown();
if (!button.children) return;
const dropdown = document.createElement('div');
dropdown.className = 'opt-btn-dropdown';
const rect = btnEl.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
dropdown.style.top = rect.top - 8 + 'px';
dropdown.style.left = centerX + 'px';
button.children.forEach(subBtn => {
if (this.isVisible(subBtn.id)) {
const item = this.renderDropdownItem(subBtn);
dropdown.appendChild(item);
}
});
dropdown.addEventListener('mouseenter', () => {
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
});
dropdown.addEventListener('mouseleave', () => this.handleMouseLeave());
document.body.appendChild(dropdown);
this.dropdownElement = dropdown;
}
private renderDropdownItem(button: OptButton): HTMLElement {
const item = document.createElement('div');
item.className = 'opt-btn-dropdown-item';
const icon = document.createElement('div');
icon.className = 'opt-btn-icon small';
icon.innerHTML = this.getIcon(button.icon);
item.appendChild(icon);
if (this.options.showLabel) {
const label = document.createElement('span');
label.textContent = button.label;
item.appendChild(label);
}
item.addEventListener('click', (e) => {
e.stopPropagation();
this.handleSubClick(button);
});
return item;
}
private closeDropdown(): void {
if (this.dropdownElement) {
this.dropdownElement.remove();
this.dropdownElement = null;
}
this.btnRefs.forEach(btnEl => {
const arrow = btnEl.querySelector('.opt-btn-arrow');
if (arrow) {
arrow.classList.remove('rotated');
}
});
}
private updateButtonState(buttonId: string): void {
const btnEl = this.btnRefs.get(buttonId);
if (btnEl) {
if (this.activeBtnIds.has(buttonId)) {
btnEl.classList.add('active');
} else {
btnEl.classList.remove('active');
}
}
}
private getIcon(icon?: string): string {
return icon || this.DEFAULT_ICON;
}
private isVisible(id: string): boolean {
return this.options.visibility?.[id] !== false;
}
public destroy(): void {
this.closeDropdown();
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
}
this.container.innerHTML = '';
this.btnRefs.clear();
this.activeBtnIds.clear();
this.groups = []; // 清空数组
}
}

51
src/toolbar/index.type.ts Normal file
View File

@@ -0,0 +1,51 @@
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[]; // 子按钮配置(可选,用于菜单按钮)
groupId?: string; // 所属组ID
parentId?: string; // 父按钮ID如果是子按钮
}
/**
* 操作按钮接口(内部使用,继承配置)
*/
export interface OptButton extends ButtonConfig {
children?: OptButton[]; // 内部使用的子按钮列表
}
/**
* 按钮组接口
*/
export interface ButtonGroup {
id: string;
buttons: OptButton[];
}
/**
* OptBtnGroups 配置选项
*/
export interface OptBtnGroupsOptions {
container: HTMLElement | string;
showLabel?: boolean;
visibility?: Record<string, boolean>;
}
/**
* 点击事件载荷
*/
export interface ClickPayload {
button: OptButton;
action: 'activate' | 'deactivate' | 'trigger';
isActive?: boolean;
}

View File

@@ -16,7 +16,7 @@ export default defineConfig(({ command }) => {
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'BimEngineSDK',
name: 'LyzBimEngineSDK',
fileName: (format) => `bim-engine-sdk.${format}.js`,
},
rollupOptions: {