diff --git a/AI_COLLABORATION.md b/AI_COLLABORATION.md index d9af2fd..83eca78 100644 --- a/AI_COLLABORATION.md +++ b/AI_COLLABORATION.md @@ -536,7 +536,9 @@ engine.toolbar.setButtonVisibility('my-button', false); | `ToolbarManager` | `src/managers/toolbar-manager.ts` | 管理底部工具栏 | `BimComponent` | | `ButtonGroupManager` | `src/managers/button-group-manager.ts` | 管理通用按钮组 | `BimComponent` | | `EngineManager` | `src/managers/engine-manager.ts` | 管理 3D 引擎 | `BimComponent` | -| `RightKeyManager` | `src/managers/right-key-manager.ts` | 管理右键菜单 (Context Menu)。直接使用 `MenuItemConfig` 接口配置 | `BimComponent` | +| `RightKeyManager` | `src/managers/right-key-manager.ts` | 管理右键菜单 (Context Menu) | `BimComponent` | +| `TreeManager` | `src/managers/tree-manager.ts` | 管理树组件实例 | `BimComponent` | +| `ModelTreeManager` | `src/managers/model-tree-manager.ts` | 模型树业务管理器 (组合 Dialog 和 Tree) | `BimComponent` | ### 4.2 组件类清单 @@ -549,6 +551,7 @@ engine.toolbar.setButtonVisibility('my-button', false); | `Engine` | `src/components/engine/index.ts` | 3D 引擎组件 | `IBimComponent` | | `BimRightKey` | `src/components/right-key/index.ts` | 右键浮层容器 | `IBimComponent` | | `BimMenu` | `src/components/menu/index.ts` | 通用菜单列表 | `IBimComponent` | +| `BimTree` | `src/components/tree/index.ts` | 通用树形组件 | `IBimComponent` | ### 4.3 服务类清单 @@ -573,6 +576,11 @@ interface EngineEvents { 'engine:model-loaded': { url: string }; 'engine:object-clicked': { objectId: string; position: { x: number, y: number, z: number } }; + // 树组件事件 + 'ui:tree-node-check': { id: string; checked: boolean; node: any }; + 'ui:tree-node-select': { id: string; selected: boolean; node: any }; + 'ui:tree-node-expand': { id: string; expanded: boolean }; + // 系统事件 'sys:theme-changed': { theme: string }; 'sys:locale-changed': { locale: string }; diff --git a/demo-vue/src/App.vue b/demo-vue/src/App.vue index 2515865..b1f13bc 100644 --- a/demo-vue/src/App.vue +++ b/demo-vue/src/App.vue @@ -20,6 +20,8 @@ + + @@ -181,6 +183,67 @@ const openRedDialog = () => { }); }; +const openTreeDialog = () => { + if (!engine.value || !engine.value.modelTree) return; + + // 1. 构造树数据 + const treeData = [ + { + id: 'root', + label: 'tree.modelStruct', // 翻译键 + expanded: true, + children: [ + { + id: 'level-1', + label: 'tree.floor1', + expanded: true, + children: [ + { id: 'l1-wall', label: 'tree.wall', checked: true }, + { id: 'l1-col', label: 'tree.column' }, + { id: 'l1-win', label: 'tree.window' }, + { id: 'l1-door', label: 'tree.door' } + ] + }, + { + id: 'level-2', + label: 'tree.floor2', + children: [ + { id: 'l2-wall', label: 'tree.wall' }, + { id: 'l2-col', label: 'tree.column' } + ] + } + ] + } + ]; + + // 2. 使用高级 API + engine.value.modelTree.showStructTree(treeData); +}; + +const openSimpleTreeDialog = () => { + if (!engine.value || !engine.value.modelTree) return; + + const treeData = [ + { + id: 'menu-root', + label: 'menu.home', + expanded: true, + icon: '', + children: [ + { id: 'm-info', label: 'menu.info' }, + { + id: 'm-struct', label: 'tree.modelStruct', children: [ + { id: 'm-w', label: 'tree.wall' }, + { id: 'm-c', label: 'tree.column' } + ] + } + ] + } + ]; + + engine.value.modelTree.showSimpleTree(treeData, 'Simple Tree'); +}; + // --- 工具栏操作 --- const toggleToolbar = () => { if (!engine.value || !engine.value.toolbar) return; diff --git a/demo/index.html b/demo/index.html index 80d470f..615186e 100644 --- a/demo/index.html +++ b/demo/index.html @@ -132,6 +132,8 @@ + + @@ -239,6 +241,69 @@ }); } + // --- 树组件测试 --- + function openTreeDialog() { + if (!engine || !engine.modelTree) return; + + // 1. 构造树数据 + const treeData = [ + { + id: 'root', + label: 'tree.modelStruct', + expanded: true, + children: [ + { + id: 'level-1', + label: 'tree.floor1', + expanded: true, + children: [ + { id: 'l1-wall', label: 'tree.wall', checked: true }, + { id: 'l1-col', label: 'tree.column' }, + { id: 'l1-win', label: 'tree.window' }, + { id: 'l1-door', label: 'tree.door' } + ] + }, + { + id: 'level-2', + label: 'tree.floor2', + children: [ + { id: 'l2-wall', label: 'tree.wall' }, + { id: 'l2-col', label: 'tree.column' } + ] + } + ] + } + ]; + + // 2. 使用高级 API 直接显示 + engine.modelTree.showStructTree(treeData); + } + + // --- 纯树测试 (无复选框) --- + function openSimpleTreeDialog() { + if (!engine || !engine.modelTree) return; + + // 简单数据 + const treeData = [ + { + id: 'menu-root', + label: 'menu.home', + expanded: true, + icon: '', + children: [ + { id: 'm-info', label: 'menu.info' }, + { id: 'm-struct', label: 'tree.modelStruct', children: [ + { id: 'm-w', label: 'tree.wall' }, + { id: 'm-c', label: 'tree.column' } + ]} + ] + } + ]; + + // 使用高级 API + engine.modelTree.showSimpleTree(treeData, 'Simple Tree'); + } + // --- 工具栏操作 --- function toggleToolbar() { if (!engine || !engine.toolbar) return; diff --git a/docs/components/tree.md b/docs/components/tree.md new file mode 100644 index 0000000..c9a074a --- /dev/null +++ b/docs/components/tree.md @@ -0,0 +1,84 @@ +# Tree 组件文档 + +## 1. 组件概述 +**BimTree** 是一个通用的树形组件,支持多级嵌套、复选框、图标和自定义内容。它通常用于展示模型结构、文件目录或层级列表。 +该组件设计为被包裹在容器(如 Dialog)中,并通过 `TreeManager` 进行创建和管理。 + +## 2. 组件类 API (BimTree) +`src/components/tree/index.ts` + +### 2.1 核心方法 +* `checkNode(id: string, checked: boolean)`: 勾选/取消勾选指定节点。支持父子联动。 +* `checkAll(checked: boolean)`: 全选或全不选。 +* `expandNode(id: string, expanded: boolean)`: 展开/折叠指定节点。 +* `expandAll(expanded: boolean)`: 展开/折叠所有层级。 +* `getCheckedNodes(includeHalfChecked?: boolean)`: 获取当前所有被勾选的节点配置列表。 +* `getNode(id: string)`: 获取指定 ID 的节点实例。 + +## 3. Manager API (TreeManager) +`src/managers/tree-manager.ts` + +* `create(options: TreeOptions)`: 创建并返回一个新的 BimTree 实例。 + * `options`: 参见 `TreeOptions` 类型定义。 + +## 4. 类型定义 +`src/components/tree/types.ts` + +```typescript +interface TreeNodeConfig { + id: string; + label: string; // 翻译键 + children?: TreeNodeConfig[]; + checked?: boolean; + expanded?: boolean; + disabled?: boolean; + icon?: string; + data?: any; // 业务数据 +} + +interface TreeOptions { + data: TreeNodeConfig[]; + checkable?: boolean; // 是否显示复选框 + checkStrictly?: boolean; // 是否父子联动 (默认 true) + defaultExpandAll?: boolean; + indent?: number; +} +``` + +## 5. UI 结构与样式 +* **容器**: `.bim-tree` (Flex column) +* **节点**: `.bim-tree-node` + * **内容行**: `.bim-tree-node-content` (Flex row, align-center) + * **箭头**: `.bim-tree-switcher` (SVG, rotate transform) + * **复选框**: `.bim-tree-checkbox` (自定义样式, support indeterminate) + * **图标**: `.bim-tree-icon` + * **文本**: `.bim-tree-title` + * **子容器**: `.bim-tree-children` (padding-left 缩进) + +## 6. 逻辑流程 +1. **初始化**: + * 根据 `data` 递归创建 `BimTreeNode` 实例。 + * 建立 `id -> Node` 的 Map 索引。 + * 订阅主题和语言变更。 +2. **联动逻辑 (Check Cascade)**: + * **向下**: 父节点状态变更 -> 递归强制设置所有子节点。 + * **向上**: 子节点状态变更 -> 冒泡检查兄弟节点状态 -> 更新父节点 (Checked/Unchecked/Indeterminate)。 +3. **事件**: + * 点击复选框 -> 更新状态 -> 触发联动 -> 发送 `ui:tree-node-check`。 + * 点击内容 -> 发送 `ui:tree-node-select`。 + +## 7. 国际化支持 +* 节点 `label` 必须是翻译键。 +* 组件订阅 `localeManager`,语言变更时自动刷新文本。 + +## 8. 使用示例 +```typescript +const tree = engine.tree.create({ + data: [ + { id: 'root', label: 'tree.root', children: [...] } + ], + checkable: true +}); +// 挂载到 DOM +document.body.appendChild(tree.element); +``` diff --git a/src/bim-engine.ts b/src/bim-engine.ts index c3cc72b..4b88d30 100644 --- a/src/bim-engine.ts +++ b/src/bim-engine.ts @@ -3,6 +3,9 @@ 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 { TreeManager } from './managers/tree-manager'; +import { RightKeyManager } from './managers/right-key-manager'; +import { ModelTreeManager } from './managers/model-tree-manager'; import type { EngineOptions, ModelLoadOptions } from './components/engine'; import { localeManager } from './services/locale'; import { themeManager } from './services/theme'; @@ -21,6 +24,9 @@ export class BimEngine extends EventEmitter { public buttonGroup: ButtonGroupManager | null = null; // 通用 public dialog: DialogManager | null = null; public engine: EngineManager | null = null; // 3D 引擎管理器 + public tree: TreeManager | null = null; // 树组件管理器 + public rightKey: RightKeyManager | null = null; // 右键菜单管理器 + public modelTree: ModelTreeManager | null = null; // 模型树业务管理器 public get localeManager() { return localeManager; } public get themeManager() { return themeManager; } @@ -74,6 +80,10 @@ 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.tree = new TreeManager(this, this.wrapper); + this.rightKey = new RightKeyManager(this, this.wrapper); + this.modelTree = new ModelTreeManager(this); + // 初始主题 this.updateTheme(themeManager.getTheme()); // 订阅主题变化 @@ -95,6 +105,9 @@ export class BimEngine extends EventEmitter { this.buttonGroup?.destroy(); this.engine?.destroy(); this.dialog?.destroy(); + this.tree?.destroy(); + this.rightKey?.destroy(); + this.modelTree?.destroy(); this.container.innerHTML = ''; this.clear(); } diff --git a/src/components/tree/index.css b/src/components/tree/index.css new file mode 100644 index 0000000..2bfc39b --- /dev/null +++ b/src/components/tree/index.css @@ -0,0 +1,161 @@ +/* 树容器 */ +.bim-tree { + width: 100%; + overflow: auto; + font-size: 14px; + color: var(--bim-ui_text_primary, #333); + user-select: none; /* 防止双击选中文字 */ +} + +/* 节点行容器 */ +.bim-tree-node { + display: flex; + flex-direction: column; +} + +/* 节点内容行 (Flex 布局) */ +.bim-tree-node-content { + display: flex; + align-items: center; + height: 32px; /* 标准行高 */ + cursor: pointer; + transition: background-color 0.2s; + border-radius: 4px; + padding-right: 8px; +} + +.bim-tree-node-content:hover { + background-color: var(--bim-ui_bg_hover, rgba(0, 0, 0, 0.05)); +} + +/* 展开/折叠箭头 */ +.bim-tree-switcher { + width: 24px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--bim-ui_text_secondary, #999); + transition: transform 0.2s; + flex-shrink: 0; +} + +.bim-tree-switcher svg { + width: 12px; + height: 12px; + fill: currentColor; + transition: transform 0.2s; +} + +.bim-tree-switcher.is-expanded svg { + transform: rotate(90deg); +} + +.bim-tree-switcher.is-hidden { + visibility: hidden; /*叶子节点占位但不显示*/ +} + +/* 复选框 */ +.bim-tree-checkbox { + width: 16px; + height: 16px; + border: 1px solid var(--bim-ui_border_color, #d9d9d9); + border-radius: 2px; + margin-right: 8px; + background-color: var(--bim-ui_bg_color, #fff); + position: relative; + cursor: pointer; + flex-shrink: 0; + transition: all 0.2s; +} + +.bim-tree-checkbox:hover { + border-color: var(--bim-primary_color, #1890ff); +} + +/* 选中状态 */ +.bim-tree-checkbox.is-checked { + background-color: var(--bim-primary_color, #1890ff); + border-color: var(--bim-primary_color, #1890ff); +} + +.bim-tree-checkbox.is-checked::after { + content: ''; + position: absolute; + top: 1px; + left: 4px; + width: 5px; + height: 9px; + border: 2px solid #fff; + border-top: 0; + border-left: 0; + transform: rotate(45deg); +} + +/* 半选状态 */ +.bim-tree-checkbox.is-indeterminate { + background-color: var(--bim-ui_bg_color, #fff); + border-color: var(--bim-primary_color, #1890ff); +} + +.bim-tree-checkbox.is-indeterminate::after { + content: ''; + position: absolute; + top: 6px; + left: 3px; + width: 8px; + height: 2px; + background-color: var(--bim-primary_color, #1890ff); +} + +/* 禁用状态 */ +.bim-tree-node.is-disabled .bim-tree-checkbox { + background-color: #f5f5f5; + border-color: #d9d9d9; + cursor: not-allowed; +} + +.bim-tree-node.is-disabled .bim-tree-checkbox.is-checked { + background-color: #d9d9d9; +} + +.bim-tree-node.is-disabled .bim-tree-node-content { + color: var(--bim-ui_text_disabled, #ccc); + cursor: not-allowed; +} + +/* 图标 */ +.bim-tree-icon { + width: 16px; + height: 16px; + margin-right: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.bim-tree-icon img, +.bim-tree-icon svg { + width: 100%; + height: 100%; +} + +/* 文本 */ +.bim-tree-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 子节点容器 */ +.bim-tree-children { + display: none; + overflow: hidden; +} + +.bim-tree-children.is-visible { + display: block; +} diff --git a/src/components/tree/index.ts b/src/components/tree/index.ts new file mode 100644 index 0000000..a422309 --- /dev/null +++ b/src/components/tree/index.ts @@ -0,0 +1,218 @@ +import { IBimComponent } from '../../types/component'; +import { ThemeConfig } from '../../themes/types'; +import { localeManager } from '../../services/locale'; +import { themeManager } from '../../services/theme'; +import { TreeOptions, TreeNodeConfig, TreeNodeCheckState } from './types'; +import { BimTreeNode } from './tree-node'; +import './index.css'; + +// 定义辅助事件发射器接口 (由于 BimTree 通常作为 Manager 的一部分或独立使用) +// 为了方便,这里我们假设它会被 TreeManager 管理,TreeManager 会处理事件发射 +// 但 BimTree 本身也需要一个方式通知 Manager。 +// 我们可以通过构造函数传入 event bus 适配器,或者直接使用 CustomEvent。 +// 更好的方式:BimTree 提供 onEvent 回调。 + +export class BimTree implements IBimComponent { + public element: HTMLElement; + private options: TreeOptions; + private nodeMap: Map = new Map(); + private rootNodes: BimTreeNode[] = []; + + // 订阅清理函数 + private unsubscribeLocale: (() => void) | null = null; + private unsubscribeTheme: (() => void) | null = null; + + // 事���回调 (由 Manager 注入) + public onNodeCheck?: (node: BimTreeNode) => void; + public onNodeSelect?: (node: BimTreeNode) => void; + public onNodeExpand?: (node: BimTreeNode) => void; + + constructor(options: TreeOptions) { + this.options = { + checkable: true, + checkStrictly: true, + indent: 24, + defaultExpandAll: false, + ...options + }; + this.element = document.createElement('div'); + this.element.className = 'bim-tree'; + } + + public init(): void { + this.render(); + + // 订阅系统事件 + this.unsubscribeLocale = localeManager.subscribe(() => this.setLocales()); + this.unsubscribeTheme = themeManager.subscribe((theme) => this.setTheme(theme)); + } + + /** + * 设置主题 + */ + public setTheme(theme: ThemeConfig): void { + 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_text_secondary', theme.textSecondary || '#999'); + style.setProperty('--bim-ui_border_color', theme.border); + style.setProperty('--bim-ui_bg_hover', theme.componentHover); + style.setProperty('--bim-primary_color', theme.primary); + // style.setProperty('--bim-ui_text_disabled', theme.textDisabled); // 如果 ThemeConfig 有这个字段 + } + + /** + * 响应语言变更 + */ + public setLocales(): void { + this.nodeMap.forEach(node => node.updateLabel()); + } + + public destroy(): void { + if (this.unsubscribeLocale) { + this.unsubscribeLocale(); + this.unsubscribeLocale = null; + } + if (this.unsubscribeTheme) { + this.unsubscribeTheme(); + this.unsubscribeTheme = null; + } + this.rootNodes.forEach(node => node.destroy()); + this.rootNodes = []; + this.nodeMap.clear(); + this.element.remove(); + } + + /** + * 核心渲染逻辑 + */ + private render(): void { + this.element.innerHTML = ''; + this.nodeMap.clear(); + this.rootNodes = []; + + this.options.data.forEach(config => { + this.createNodeRecursively(config, null); + }); + + // 如果设置了 defaultExpandAll,展开所有 + if (this.options.defaultExpandAll) { + this.expandAll(true); + } + } + + /** + * 递归创建节点 + */ + private createNodeRecursively(config: TreeNodeConfig, parent: BimTreeNode | null) { + const node = new BimTreeNode(config, this.options, { + onExpand: (n) => { if (this.onNodeExpand) this.onNodeExpand(n); }, + onCheck: (n) => this.handleNodeCheck(n), + onClick: (n) => { if (this.onNodeSelect) this.onNodeSelect(n); } + }); + + this.nodeMap.set(config.id, node); + + if (parent) { + parent.appendChild(node); + } else { + this.rootNodes.push(node); + this.element.appendChild(node.element); + } + + if (config.children && config.children.length > 0) { + config.children.forEach(childConfig => { + this.createNodeRecursively(childConfig, node); + }); + } + + // 如果是初始化渲染,需要处理 checkStrictly 的向上联动(因为数据里可能只给了子节点 checked,父节点没给) + // 这里做一个简单的后处理:如果 checkStrictly 开启,且当前节点 checked,则触发一次联动 + // 注意:这可能会导致性能问题,优化做法是在所有节点创建完后统一计算一次状态 + } + + /** + * 处理节点勾选逻辑 (核心算法) + */ + private handleNodeCheck(node: BimTreeNode) { + const isChecked = node.checkState === TreeNodeCheckState.Checked; + + // 1. 触发外部回调 (Event) + if (this.onNodeCheck) this.onNodeCheck(node); + + // 2. 如果不联动,直接返回 + if (this.options.checkStrictly === false) return; + + // 3. 联动逻辑 + // 3.1 向下级联 (Cascade Down): 父变子全变 + const updateChildren = (n: BimTreeNode, state: TreeNodeCheckState) => { + n.children.forEach(child => { + if (child.config.disabled) return; // 跳过禁用节点 + child.setChecked(state, false); // 不再触发事件,只更新状态 UI + updateChildren(child, state); + }); + }; + + // 当前节点是 Checked 或 Unchecked,子节点跟随 + if (isChecked) { + updateChildren(node, TreeNodeCheckState.Checked); + } else { + updateChildren(node, TreeNodeCheckState.Unchecked); + } + + // 3.2 向上冒泡 (Bubble Up): 子变父更新 + let current = node.parent; + while (current) { + if (current.config.disabled) { + current = current.parent; + continue; + } + + const children = current.children; + const allChecked = children.every(c => c.checkState === TreeNodeCheckState.Checked); + const allUnchecked = children.every(c => c.checkState === TreeNodeCheckState.Unchecked); + + if (allChecked) { + current.setChecked(TreeNodeCheckState.Checked, false); + } else if (allUnchecked) { + current.setChecked(TreeNodeCheckState.Unchecked, false); + } else { + current.setChecked(TreeNodeCheckState.Indeterminate, false); + } + + current = current.parent; + } + } + + // ================== Public APIs ================== + + public getNode(id: string): BimTreeNode | undefined { + return this.nodeMap.get(id); + } + + public checkNode(id: string, checked: boolean) { + const node = this.nodeMap.get(id); + if (node) { + node.setChecked(checked ? TreeNodeCheckState.Checked : TreeNodeCheckState.Unchecked, true); + // 手动调用联动处理,因为 setChecked 的 fireEvent 只触发回调,不包含内部逻辑调用? + // 不,我们在 createNodeRecursively 里绑定的 onCheck 就是 handleNodeCheck + // 所以只要 fireEvent=true,就会触发 handleNodeCheck,进而触发联动。 + } + } + + public expandAll(expanded: boolean) { + this.nodeMap.forEach(node => node.toggleExpand(expanded)); + } + + public getCheckedNodes(includeHalfChecked: boolean = false): TreeNodeConfig[] { + const result: TreeNodeConfig[] = []; + this.nodeMap.forEach(node => { + if (node.checkState === TreeNodeCheckState.Checked) { + result.push(node.config); + } else if (includeHalfChecked && node.checkState === TreeNodeCheckState.Indeterminate) { + result.push(node.config); + } + }); + return result; + } +} diff --git a/src/components/tree/tree-node.ts b/src/components/tree/tree-node.ts new file mode 100644 index 0000000..e9733f0 --- /dev/null +++ b/src/components/tree/tree-node.ts @@ -0,0 +1,239 @@ +import { TreeNodeConfig, TreeNodeCheckState, TreeOptions } from './types'; +import { t } from '../../services/locale'; + +/** + * 树节点类 + * 负责渲染单个节点、处理交互和递归 + */ +export class BimTreeNode { + public config: TreeNodeConfig; + public element: HTMLElement; + public children: BimTreeNode[] = []; + public parent: BimTreeNode | null = null; + public checkState: TreeNodeCheckState = TreeNodeCheckState.Unchecked; + + // UI Elements + private contentEl!: HTMLElement; + private switcherEl!: HTMLElement; + private checkboxEl: HTMLElement | null = null; + private titleEl!: HTMLElement; + private childrenContainer!: HTMLElement; + + // 外部回调 + private onExpandChange: (node: BimTreeNode) => void; + private onCheckChange: (node: BimTreeNode) => void; + private onNodeClick: (node: BimTreeNode) => void; + + constructor( + config: TreeNodeConfig, + options: TreeOptions, + callbacks: { + onExpand: (n: BimTreeNode) => void, + onCheck: (n: BimTreeNode) => void, + onClick: (n: BimTreeNode) => void + } + ) { + this.config = config; + this.onExpandChange = callbacks.onExpand; + this.onCheckChange = callbacks.onCheck; + this.onNodeClick = callbacks.onClick; + + // 初始化状态 + this.checkState = config.checked ? TreeNodeCheckState.Checked : TreeNodeCheckState.Unchecked; + + this.element = this.createDom(options); + } + + /** + * 创建节点 DOM + */ + private createDom(options: TreeOptions): HTMLElement { + const nodeEl = document.createElement('div'); + nodeEl.className = 'bim-tree-node'; + if (this.config.disabled) nodeEl.classList.add('is-disabled'); + + // 1. 内容行 + this.contentEl = document.createElement('div'); + this.contentEl.className = 'bim-tree-node-content'; + + // 计算缩进 (由父级传入或在 Tree 中统一处理样式,这里使用 padding-left 动态计算较复杂, + // 我们改用 Flex + 占位符 或者直接在 Tree 递归时处理。 + // 实际上,递归渲染时只需把 children 放在一个有 padding-left 的容器里即可,CSS 中已经处理吗? + // 检查 CSS: .bim-tree-children 没有 padding-left。 + // 我们采用在 CSS 中给 children 加 padding-left 的方式最简单,或者动态设置 style. + // 为了灵活性,这里采用动态设置 contentEl 的 paddingLeft + // 但这需要知道层级 depth。为了简单,我们在 renderChildren 时设置容器样式。 + + // 1.1 展开/折叠箭头 + this.switcherEl = document.createElement('span'); + this.switcherEl.className = 'bim-tree-switcher'; + // 默认右箭头 SVG + this.switcherEl.innerHTML = ``; + + const hasChildren = this.config.children && this.config.children.length > 0; + if (!hasChildren) { + this.switcherEl.classList.add('is-hidden'); + } else if (this.config.expanded) { + this.switcherEl.classList.add('is-expanded'); + } + + this.switcherEl.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleExpand(); + }); + + this.contentEl.appendChild(this.switcherEl); + + // 1.2 复选框 (可选) + if (options.checkable !== false) { + this.checkboxEl = document.createElement('span'); + this.checkboxEl.className = 'bim-tree-checkbox'; + this.updateCheckboxUI(); + + this.checkboxEl.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.config.disabled) return; + this.toggleCheck(); + }); + this.contentEl.appendChild(this.checkboxEl); + } + + // 1.3 图标 (可选) + if (this.config.icon) { + const iconEl = document.createElement('span'); + iconEl.className = 'bim-tree-icon'; + iconEl.innerHTML = this.config.icon.includes('`; + this.contentEl.appendChild(iconEl); + } + + // 1.4 文本 + this.titleEl = document.createElement('span'); + this.titleEl.className = 'bim-tree-title'; + this.updateLabel(); // 设置文本 + this.contentEl.appendChild(this.titleEl); + + // 绑定整行点击 + this.contentEl.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.config.disabled) return; + this.onNodeClick(this); + }); + + nodeEl.appendChild(this.contentEl); + + // 2. 子节点容器 + this.childrenContainer = document.createElement('div'); + this.childrenContainer.className = 'bim-tree-children'; + // 设置缩进 + const indent = options.indent || 24; + this.childrenContainer.style.paddingLeft = `${indent}px`; // 每一级子���器左移 + + if (this.config.expanded && hasChildren) { + this.childrenContainer.classList.add('is-visible'); + } + nodeEl.appendChild(this.childrenContainer); + + return nodeEl; + } + + /** + * 更新显示文本 (国际化支持) + */ + public updateLabel() { + if (this.titleEl) { + this.titleEl.textContent = t(this.config.label); + } + } + + /** + * 切换展开状态 + */ + public toggleExpand(force?: boolean) { + if (this.config.children?.length === 0) return; + + const newState = force !== undefined ? force : !this.config.expanded; + this.config.expanded = newState; + + if (newState) { + this.switcherEl.classList.add('is-expanded'); + this.childrenContainer.classList.add('is-visible'); + } else { + this.switcherEl.classList.remove('is-expanded'); + this.childrenContainer.classList.remove('is-visible'); + } + + // 触发回调 + if (force === undefined) { // 只有用户交互才触发回调,防止初始化时无限循环 + this.onExpandChange(this); + } + } + + /** + * 切换选中状态 (用户点击) + */ + public toggleCheck() { + // 如果当前是半选,点击变全选;如果全选,点击��未选;如果未选,点击变全选 + // 简化逻辑:只要不是 Checked,点击都变 Checked;如果是 Checked,变 Unchecked + const newChecked = this.checkState !== TreeNodeCheckState.Checked; + this.setChecked(newChecked ? TreeNodeCheckState.Checked : TreeNodeCheckState.Unchecked, true); + } + + /** + * 设置选中状态 (API调用或联动) + * @param state 新状态 + * @param fireEvent 是否触发事件 + */ + public setChecked(state: TreeNodeCheckState, fireEvent: boolean = false) { + if (this.checkState === state) return; + + this.checkState = state; + this.config.checked = (state === TreeNodeCheckState.Checked); + + this.updateCheckboxUI(); + + if (fireEvent) { + this.onCheckChange(this); + } + } + + /** + * 更新复选框 UI 样式 + */ + public updateCheckboxUI() { + if (!this.checkboxEl) return; + + this.checkboxEl.classList.remove('is-checked', 'is-indeterminate'); + + if (this.checkState === TreeNodeCheckState.Checked) { + this.checkboxEl.classList.add('is-checked'); + } else if (this.checkState === TreeNodeCheckState.Indeterminate) { + this.checkboxEl.classList.add('is-indeterminate'); + } + } + + /** + * 添加子节点实例 + */ + public appendChild(childNode: BimTreeNode) { + childNode.parent = this; + this.children.push(childNode); + this.childrenContainer.appendChild(childNode.element); + + // 如果之前是隐藏的箭头,现在有了子节点,需要显示出来 + if (this.children.length === 1) { + this.switcherEl.classList.remove('is-hidden'); + } + } + + /** + * 销毁 + */ + public destroy() { + this.children.forEach(c => c.destroy()); + this.children = []; + this.element.remove(); + this.parent = null; + } +} diff --git a/src/components/tree/types.ts b/src/components/tree/types.ts new file mode 100644 index 0000000..f28f941 --- /dev/null +++ b/src/components/tree/types.ts @@ -0,0 +1,64 @@ +/** + * 节点勾选状态枚举 + */ +export enum TreeNodeCheckState { + Unchecked = 0, + Checked = 1, + Indeterminate = 2 // 半选 +} + +/** + * 树节点配置接口 + */ +export interface TreeNodeConfig { + /** 唯一标识符 */ + id: string; + + /** 显示文本的翻译键 */ + label: string; + + /** 节点图标 (SVG string 或 URL) */ + icon?: string; + + /** 子节点列表 */ + children?: TreeNodeConfig[]; + + /** 初始展开状态 (默认 false) */ + expanded?: boolean; + + /** 初始选中状态 (默认 false) */ + checked?: boolean; + + /** 是否禁用 (默认 false) */ + disabled?: boolean; + + /** 自定义业务数据 */ + data?: any; + + /** 是否是叶子节点 (用于异步加载场景,暂���接口) */ + isLeaf?: boolean; +} + +/** + * 树组件配置选项 + */ +export interface TreeOptions { + /** 树的数据源 */ + data: TreeNodeConfig[]; + + /** 是否显示复选框 (默认 true) */ + checkable?: boolean; + + /** + * 父子节点选中状态是否关联 (默认 true) + * true: 选中父选子,子全选自动选父 + * false: 独立选中 + */ + checkStrictly?: boolean; + + /** 默认展开所有节点 (默认 false) */ + defaultExpandAll?: boolean; + + /** 缩进宽度 (像素,默认 24) */ + indent?: number; +} diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 6f1ac8b..130c090 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -22,7 +22,16 @@ export const enUS: TranslationDictionary = { testContent: '
This is a draggable and resizable dialog.

Try dragging the title bar or resizing from the bottom-right corner.
', }, menu: { - info: "info", - home: "home", + info: 'Info', + home: 'Home', + }, + tree: { + modelStruct: 'Model Structure', + floor1: 'Level 1', + floor2: 'Level 2', + wall: 'Walls', + column: 'Columns', + window: 'Windows', + door: 'Doors', } }; diff --git a/src/locales/types.ts b/src/locales/types.ts index f442c24..f96ce24 100644 --- a/src/locales/types.ts +++ b/src/locales/types.ts @@ -26,6 +26,15 @@ export interface TranslationDictionary { menu: { info: string; home: string; + }; + tree: { + modelStruct: string; + floor1: string; + floor2: string; + wall: string; + column: string; + window: string; + door: string; } } diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index e858a42..891c3a7 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -22,7 +22,16 @@ export const zhCN: TranslationDictionary = { testContent: '
这是一个 可拖拽可缩放 的弹窗。

你可以尝试拖动标题栏,或者拖动右下角改变大小。
', }, menu: { - info: "信息", - home: "首页" + info: '信息', + home: '首页', + }, + tree: { + modelStruct: '模型结构', + floor1: '一楼', + floor2: '二楼', + wall: '墙体', + column: '柱子', + window: '窗户', + door: '门', } }; diff --git a/src/managers/model-tree-manager.ts b/src/managers/model-tree-manager.ts new file mode 100644 index 0000000..3a65bdc --- /dev/null +++ b/src/managers/model-tree-manager.ts @@ -0,0 +1,76 @@ +import { BimComponent } from '../core/component'; +import { BimTree } from '../components/tree/index'; +import { BimDialog } from '../components/dialog/index'; +import { TreeNodeConfig } from '../components/tree/types'; + +/** + * 模型树业务管理器 + * 负责组装 Tree 和 Dialog 组件,提供开箱即用的业务弹窗 + */ +export class ModelTreeManager extends BimComponent { + + /** + * 显示带复选框的模型结构树弹窗 + * @param data 树节点数据 + * @param title 弹窗标题 (翻译键) + */ + public showStructTree(data: TreeNodeConfig[], title: string = 'tree.modelStruct'): { tree: BimTree, dialog: BimDialog } { + // 1. 创建 Tree 实例 + const tree = this.engine.tree!.create({ + data: data, + checkable: true, + checkStrictly: true, + defaultExpandAll: true + }); + + // 2. 创建 Dialog 实例 + const dialog = this.engine.dialog!.create({ + title: title, + width: 300, + height: 400, + content: tree.element, + position: 'left-center', + resizable: true, + // 关键:绑定生命周期 + onClose: () => { + tree.destroy(); + } + }); + + return { tree, dialog }; + } + + /** + * 显示简单的树形弹窗 (无复选框) + * @param data 树节点数据 + * @param title 弹窗标题 (翻译键) + */ + public showSimpleTree(data: TreeNodeConfig[], title: string = 'menu.home'): { tree: BimTree, dialog: BimDialog } { + // 1. 创建 Tree 实例 + const tree = this.engine.tree!.create({ + data: data, + checkable: false, + defaultExpandAll: true + }); + + // 2. 创建 Dialog 实例 + const dialog = this.engine.dialog!.create({ + title: title, + width: 250, + height: 300, + content: tree.element, + position: 'center', + resizable: true, + onClose: () => { + tree.destroy(); + } + }); + + return { tree, dialog }; + } + + public destroy(): void { + // 具体的 Tree 和 Dialog 实例由它们各自的生命周期管理 + // 这里不需要销毁它们,除非我们持有了全局引用 + } +} diff --git a/src/managers/tree-manager.ts b/src/managers/tree-manager.ts new file mode 100644 index 0000000..eef8c2d --- /dev/null +++ b/src/managers/tree-manager.ts @@ -0,0 +1,57 @@ +import { BimComponent } from '../core/component'; +import { BimTree } from '../components/tree/index'; +import { TreeOptions } from '../components/tree/types'; +import type { BimEngine } from '../bim-engine'; + +/** + * 树组件管理器 + * 负责创建和管理 BimTree 实例 + */ +export class TreeManager extends BimComponent { + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(engine: BimEngine, _container: HTMLElement) { + super(engine); + } + + /** + * 创建一个新的树组件实例 + * @param options 配置选项 + */ + public create(options: TreeOptions): BimTree { + const tree = new BimTree(options); + + // 绑定事件桥接 + tree.onNodeCheck = (node) => { + this.emit('ui:tree-node-check', { + id: node.config.id, + checked: node.config.checked || false, + node: node.config + }); + }; + + tree.onNodeSelect = (node) => { + this.emit('ui:tree-node-select', { + id: node.config.id, + selected: true, + node: node.config + }); + }; + + tree.onNodeExpand = (node) => { + this.emit('ui:tree-node-expand', { + id: node.config.id, + expanded: node.config.expanded || false + }); + }; + + tree.init(); + return tree; + } + + public destroy(): void { + // TreeManager 本身不持有 Tree 实例的强引用列表 + // 实例通常由调用者(如 Dialog)持有并销毁 + // 这里可以做一些全局清理工作 + } +} diff --git a/src/types/events.ts b/src/types/events.ts index 6d610e8..6c40c66 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -5,9 +5,14 @@ export interface EngineEvents { // Engine Events 'engine:model-loaded': { url: string }; - 'engine:object-clicked': { objectId: string; position: { x: number, y: number, z: number } }; - - // System Events + 'engine:object-clicked': { objectId: string; position: { x: number, y: number, z: number } }; + + // 树组件事件 + 'ui:tree-node-check': { id: string; checked: boolean; node: any }; + 'ui:tree-node-select': { id: string; selected: boolean; node: any }; + 'ui:tree-node-expand': { id: string; expanded: boolean }; + + // 系统事件 'sys:theme-changed': { theme: string }; 'sys:locale-changed': { locale: string }; }