Files
bim_engine/src/components/button-group/index.ts
yuding 2395dff81e refactor(components): accept registry in menu, engine, walk-path, button-group
Menu button factories, Engine component, WalkPathPanel, and BimButtonGroup
now accept registry via options/parameters. Engine component adds public
resize() method wrapping EngineKernel.handleWindowResize().

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 10:09:01 +08:00

816 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import './index.css';
import type {
OptButton,
ButtonGroup,
ButtonGroupOptions,
ButtonConfig,
ButtonGroupColors
} from './index.type';
import { t, localeManager } from '../../services/locale';
import { themeManager } from '../../services/theme';
import type { ThemeConfig } from '../../themes/types';
import { IBimComponent } from '../../types/component';
import type { EngineEvents } from '../../types/events';
export class BimButtonGroup implements IBimComponent {
private container: HTMLElement;
protected options: ButtonGroupOptions;
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 customColors: Set<keyof ButtonGroupColors> = new Set();
private unsubscribeLocale: (() => void) | null = null;
private unsubscribeTheme: (() => void) | 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: ButtonGroupOptions) {
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: {},
direction: 'row', // 默认横向
position: 'static', // 默认静态定位
align: 'vertical', // 默认图标在上
expand: 'down', // 默认向下展开
...options
};
// 记录初始传入的自定义颜色
const colorKeys: (keyof ButtonGroupColors)[] = [
'backgroundColor', 'btnBackgroundColor', 'btnHoverColor',
'btnActiveColor', 'iconColor', 'iconActiveColor',
'textColor', 'textActiveColor'
];
colorKeys.forEach(key => {
if (options[key]) {
this.customColors.add(key);
}
});
this.initContainer();
this.applyStyles();
}
protected emit<K extends keyof EngineEvents>(event: K, payload: EngineEvents[K]) {
this.options.registry?.emit(event, payload);
}
private initContainer(): void {
this.container.innerHTML = '';
this.container.classList.add('bim-btn-group-root');
if (this.options.direction === 'column') {
this.container.classList.add('dir-column');
} else {
this.container.classList.add('dir-row');
}
if (this.options.className) {
this.container.classList.add(this.options.className);
}
if (this.options.type && this.options.type !== 'default') {
this.container.classList.add(`type-${this.options.type}`);
}
this.updatePosition();
// 添加事件拦截,防止点击穿透到 3D 引擎
this.setupEventInterception(this.container);
}
/**
* 设置事件拦截,防止事件冒泡到下层元素(如 3D 引擎)
*/
private setupEventInterception(el: HTMLElement): void {
const stopPropagation = (e: Event) => {
e.stopPropagation();
};
const events = [
'click', 'dblclick', 'contextmenu', 'wheel',
'mousedown', 'mouseup', 'mousemove',
'touchstart', 'touchend', 'touchmove',
'pointerdown', 'pointerup', 'pointermove', 'pointerenter', 'pointerleave', 'pointerover', 'pointerout'
];
events.forEach(eventType => {
el.addEventListener(eventType, stopPropagation, { passive: false });
});
}
private updatePosition() {
const pos = this.options.position;
const style = this.container.style;
style.top = ''; style.bottom = ''; style.left = ''; style.right = ''; style.transform = '';
if (pos === 'static') {
this.container.classList.add('static');
return;
}
this.container.classList.remove('static');
this.container.style.position = 'absolute';
if (typeof pos === 'object' && 'x' in pos) {
style.left = `${pos.x}px`;
style.top = `${pos.y}px`;
} else {
const margin = '20px';
switch (pos) {
case 'top-left':
style.top = margin; style.left = margin;
break;
case 'top-center':
style.top = margin; style.left = '50%'; style.transform = 'translateX(-50%)';
break;
case 'top-right':
style.top = margin; style.right = margin;
break;
case 'bottom-left':
style.bottom = margin; style.left = margin;
break;
case 'bottom-center':
style.bottom = margin; style.left = '50%'; style.transform = 'translateX(-50%)';
break;
case 'bottom-right':
style.bottom = margin; style.right = margin;
break;
case 'left-center':
style.left = margin; style.top = '50%'; style.transform = 'translateY(-50%)';
break;
case 'right-center':
style.right = margin; style.top = '50%'; style.transform = 'translateY(-50%)';
break;
case 'center':
style.top = '50%'; style.left = '50%'; style.transform = 'translate(-50%, -50%)';
break;
}
}
}
/**
* 应用样式到容器
*/
private applyStyles(): void {
const style = this.container.style;
if (this.options.backgroundColor) style.setProperty('--bim-btn-group-section-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);
// 同步更新所有已存在的dropdown元素的CSS变量dropdown被添加到body无法继承容器的CSS变量
const dropdowns = document.querySelectorAll('.opt-btn-dropdown');
dropdowns.forEach(dropdown => {
const dropdownStyle = (dropdown as HTMLElement).style;
if (this.options.iconColor) dropdownStyle.setProperty('--bim-icon-color', this.options.iconColor);
if (this.options.iconActiveColor) dropdownStyle.setProperty('--bim-icon-active-color', this.options.iconActiveColor);
if (this.options.textColor) dropdownStyle.setProperty('--bim-btn-text-color', this.options.textColor);
if (this.options.textActiveColor) dropdownStyle.setProperty('--bim-btn-text-active-color', this.options.textActiveColor);
if (this.options.btnBackgroundColor) dropdownStyle.setProperty('--bim-btn-bg', this.options.btnBackgroundColor);
if (this.options.btnHoverColor) dropdownStyle.setProperty('--bim-btn-hover-bg', this.options.btnHoverColor);
if (this.options.btnActiveColor) dropdownStyle.setProperty('--bim-btn-active-bg', this.options.btnActiveColor);
});
}
/**
* 设置主题的primary颜色用于边框等
*/
private setPrimaryColor(color: string): void {
this.container.style.setProperty('--bim-primary-color', color);
// 同步更新所有dropdowndropdown被添加到body无法继承容器的CSS变量
const dropdowns = document.querySelectorAll('.opt-btn-dropdown');
dropdowns.forEach(dropdown => {
(dropdown as HTMLElement).style.setProperty('--bim-primary-color', color);
});
}
/**
* 设置主题颜色
* 只会应用到没有被用户自定义的颜色属性上
*/
public setTheme(theme: ThemeConfig): void {
const themeColors: ButtonGroupColors = {
backgroundColor: theme.bgElevated,
btnBackgroundColor: theme.componentBg,
btnHoverColor: theme.componentBgHover,
btnActiveColor: theme.componentBgActive,
iconColor: theme.iconDefault,
iconActiveColor: theme.iconActive,
textColor: theme.textSecondary,
textActiveColor: theme.textPrimary
};
Object.entries(themeColors).forEach(([key, value]) => {
const colorKey = key as keyof ButtonGroupColors;
if (!this.customColors.has(colorKey)) {
this.options[colorKey] = value;
}
});
this.container.classList.remove('theme-dark', 'theme-light');
this.container.classList.add(`theme-${theme.name}`);
this.applyStyles();
this.setPrimaryColor(theme.primary);
this.applyThemeCssVars(theme);
}
/**
* 应用主题系统的 CSS 变量到容器
* 供 glass-pill 等样式变体使用
*/
private applyThemeCssVars(theme: ThemeConfig): void {
const style = this.container.style;
style.setProperty('--bim-primary', theme.primary);
style.setProperty('--bim-primary-hover', theme.primaryHover);
style.setProperty('--bim-primary-active', theme.primaryActive);
style.setProperty('--bim-bg-glass', theme.bgGlass);
style.setProperty('--bim-bg-glass-blur', theme.bgGlassBlur);
style.setProperty('--bim-bg-elevated', theme.bgElevated);
style.setProperty('--bim-bg-overlay', theme.bgOverlay);
style.setProperty('--bim-bg-inset', theme.bgInset);
style.setProperty('--bim-text-primary', theme.textPrimary);
style.setProperty('--bim-text-secondary', theme.textSecondary);
style.setProperty('--bim-text-tertiary', theme.textTertiary);
style.setProperty('--bim-text-inverse', theme.textInverse);
style.setProperty('--bim-icon-default', theme.iconDefault);
style.setProperty('--bim-icon-hover', theme.iconHover);
style.setProperty('--bim-icon-active', theme.iconActive);
style.setProperty('--bim-icon-inverse', theme.iconInverse);
style.setProperty('--bim-border-default', theme.borderDefault);
style.setProperty('--bim-border-subtle', theme.borderSubtle);
style.setProperty('--bim-component-bg-hover', theme.componentBgHover);
style.setProperty('--bim-component-bg-active', theme.componentBgActive);
style.setProperty('--bim-shadow-sm', theme.shadowSm);
style.setProperty('--bim-shadow-md', theme.shadowMd);
style.setProperty('--bim-shadow-lg', theme.shadowLg);
style.setProperty('--bim-shadow-glow', theme.shadowGlow);
style.setProperty('--bim-floating-bg', theme.floatingBg);
style.setProperty('--bim-floating-border', theme.floatingBorder);
style.setProperty('--bim-floating-shadow', theme.floatingShadow);
style.setProperty('--bim-floating-btn-bg', theme.floatingBtnBg);
style.setProperty('--bim-floating-btn-border', theme.floatingBtnBorder);
style.setProperty('--bim-floating-btn-shadow', theme.floatingBtnShadow);
style.setProperty('--bim-floating-btn-bg-hover', theme.floatingBtnBgHover);
style.setProperty('--bim-floating-btn-shadow-hover', theme.floatingBtnShadowHover);
style.setProperty('--bim-floating-icon-color', theme.floatingIconColor);
style.setProperty('--bim-floating-icon-color-hover', theme.floatingIconColorHover);
this.syncDropdownCssVars(theme);
}
/**
* 同步 CSS 变量到所有 dropdown 元素
* dropdown 被添加到 body无法继承容器的 CSS 变量
*/
private syncDropdownCssVars(theme: ThemeConfig): void {
const dropdowns = document.querySelectorAll('.opt-btn-dropdown');
dropdowns.forEach(dropdown => {
const style = (dropdown as HTMLElement).style;
style.setProperty('--bim-primary', theme.primary);
style.setProperty('--bim-bg-overlay', theme.bgOverlay);
style.setProperty('--bim-border-default', theme.borderDefault);
style.setProperty('--bim-text-primary', theme.textPrimary);
style.setProperty('--bim-text-inverse', theme.textInverse);
style.setProperty('--bim-shadow-lg', theme.shadowLg);
style.setProperty('--bim-floating-btn-bg', theme.floatingBtnBg);
style.setProperty('--bim-floating-btn-bg-hover', theme.floatingBtnBgHover);
style.setProperty('--bim-floating-icon-color', theme.floatingIconColor);
});
}
/**
* 直接设置颜色(强制覆盖)
* 设置的颜色会被标记为自定义,后续的 setTheme 不会覆盖它们
*/
public setColors(colors: ButtonGroupColors): void {
// 更新 options
this.options = { ...this.options, ...colors };
// 标记这些颜色为自定义
Object.keys(colors).forEach(key => {
this.customColors.add(key as keyof ButtonGroupColors);
});
this.applyStyles();
}
public async init(): Promise<void> {
this.render();
// 自动订阅语言变更
this.unsubscribeLocale = localeManager.subscribe(() => {
this.setLocales();
});
// 自动订阅主题变更
this.unsubscribeTheme = themeManager.subscribe((theme) => {
this.setTheme(theme);
});
}
public setLocales(): void {
this.render();
}
public addGroup(groupId: string, beforeGroupId?: string): void {
if (this.groups.some(g => g.id === groupId)) return;
const newGroup: ButtonGroup = { id: groupId, buttons: [] };
if (beforeGroupId) {
const index = this.groups.findIndex(g => g.id === beforeGroupId);
index !== -1 ? this.groups.splice(index, 0, newGroup) : this.groups.push(newGroup);
} else {
this.groups.push(newGroup);
}
}
public addButton(config: ButtonConfig): void {
const { groupId, parentId } = config;
const group = this.groups.find(g => g.id === groupId);
if (!group) return;
const button: OptButton = { ...config, children: config.children || [] };
if (parentId) {
const parentBtn = this.findButton(group.buttons, parentId);
if (parentBtn) {
if (!parentBtn.children) parentBtn.children = [];
parentBtn.children.push(button);
}
} else {
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 render(): void {
this.container.innerHTML = '';
this.btnRefs.clear();
this.groups.forEach((group, index) => {
const groupElement = this.renderGroup(group, index, this.groups.length);
this.container.appendChild(groupElement);
});
}
private renderGroup(group: ButtonGroup, index: number, total: number): HTMLElement {
const groupEl = document.createElement('div');
groupEl.className = 'bim-btn-group-section';
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';
// 初始化时根据 button 自身的属性同步 active 状态
if (button.isActive) {
this.activeBtnIds.add(button.id);
}
// 按钮优先使用自己的 align否则使用全局配置默认为 vertical
const align = button.align || this.options.align || 'vertical';
if (align === 'horizontal') {
btnEl.classList.add('align-horizontal');
} else {
btnEl.classList.add('align-vertical');
}
if (this.activeBtnIds.has(button.id)) btnEl.classList.add('active');
if (button.disabled) btnEl.classList.add('disabled');
// 判断是否显示 label
const hasLabel = this.options.showLabel && button.label;
if (!hasLabel) {
btnEl.classList.add('no-label');
// 当不显示 label 时,添加 title 属性作为 tooltip
if (button.label) {
btnEl.title = t(button.label);
}
}
// 应用按钮的自定义样式
const iconSize = button.iconSize || 32;
const minWidth = button.minWidth || 50;
btnEl.style.minWidth = `${minWidth}px`;
const icon = document.createElement('div');
icon.className = 'opt-btn-icon';
icon.style.width = `${iconSize}px`;
icon.style.height = `${iconSize}px`;
icon.innerHTML = this.getIcon(button.icon);
btnEl.appendChild(icon);
// 创建文字和箭头的容器,确保它们始终在一起(无论主轴是横是竖)
const textWrapper = document.createElement('div');
textWrapper.className = 'opt-btn-text-wrapper';
if (this.options.showLabel && button.label) {
const label = document.createElement('span');
label.className = 'opt-btn-label';
label.textContent = t(button.label);
textWrapper.appendChild(label);
}
// TODO: 暂时隐藏下拉箭头
// if (button.children && button.children.length > 0) {
// const arrow = document.createElement('span');
// arrow.className = 'opt-btn-arrow';
// arrow.textContent = '▼';
// textWrapper.appendChild(arrow);
// }
// 只有当有内容时才添加 wrapper
if (textWrapper.hasChildNodes()) {
btnEl.appendChild(textWrapper);
}
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;
}
/**
* 设置按钮的激活状态
* @param id 按钮 ID
* @param active 可选,如果不传则切换(toggle)当前状态
*/
public setBtnActive(id: string, active?: boolean): void {
const button = this.findButtonById(id);
if (!button) return;
// 确定最终状态
const newState = active !== undefined ? active : !this.activeBtnIds.has(id);
if (newState) {
this.activeBtnIds.add(id);
} else {
this.activeBtnIds.delete(id);
}
// 同步对象状态并更新 DOM
button.isActive = newState;
this.updateButtonState(id);
}
private handleClick(button: OptButton): void {
if (button.disabled) return;
if (!button.children || button.children.length === 0) {
if (button.keepActive) {
// 1) 先切换自身激活状态onClick 里通常会根据 isActive 决定“打开/关闭”逻辑)
const wasActive = this.activeBtnIds.has(button.id);
const newState = !wasActive;
this.setBtnActive(button.id, newState);
// 2) 互斥逻辑:仅在“本次切换为激活”时触发
// - 一级按钮:同 groupId 下其它一级按钮互斥
// - 二级按钮:同 groupId + 同 parentId 下其它二级按钮互斥
// - 被关闭的按钮需要触发 onClick用于执行关闭逻辑
if (newState && button.exclusive && button.groupId) {
this.deactivateExclusiveSiblings(button);
}
}
this.closeDropdown();
if (button.onClick) button.onClick(button);
}
}
/**
* 互斥关闭同范围内的其它已激活按钮,并触发它们的 onClick
* @param button 当前被激活的按钮
*/
private deactivateExclusiveSiblings(button: OptButton): void {
const group = this.groups.find(g => g.id === button.groupId);
if (!group) return;
// 二级按钮:同 groupId + 同 parentId
if (button.parentId) {
const parent = this.findButton(group.buttons, button.parentId);
const siblings = parent?.children || [];
for (const sib of siblings) {
if (!sib) continue;
if (sib.id === button.id) continue;
if (sib.parentId !== button.parentId) continue;
if (sib.groupId !== button.groupId) continue;
if (this.activeBtnIds.has(sib.id)) {
this.setBtnActive(sib.id, false);
// 触发被关闭按钮的 onClick此时 sib.isActive 已经同步为 false
if (sib.onClick) sib.onClick(sib);
}
}
return;
}
// 一级按钮:同 groupId 下其它一级按钮(不包含二级)
for (const sib of group.buttons) {
if (sib.id === button.id) continue;
if (sib.groupId !== button.groupId) continue;
if (sib.parentId) continue; // 只处理一级按钮
if (this.activeBtnIds.has(sib.id)) {
this.setBtnActive(sib.id, false);
if (sib.onClick) sib.onClick(sib);
}
}
}
private handleMouseEnter(button: OptButton, btnEl: HTMLElement): void {
if (this.hoverTimeout) clearTimeout(this.hoverTimeout);
if (button.children && button.children.length > 0) {
this.showDropdown(button, btnEl);
} 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';
if (this.options.backgroundColor) dropdown.style.setProperty('--bim-toolbar-bg', this.options.backgroundColor);
// 将主题CSS变量复制到dropdown元素上因为dropdown被添加到body无法继承容器的CSS变量
const dropdownStyle = dropdown.style;
if (this.options.iconColor) dropdownStyle.setProperty('--bim-icon-color', this.options.iconColor);
if (this.options.iconActiveColor) dropdownStyle.setProperty('--bim-icon-active-color', this.options.iconActiveColor);
if (this.options.textColor) dropdownStyle.setProperty('--bim-btn-text-color', this.options.textColor);
if (this.options.textActiveColor) dropdownStyle.setProperty('--bim-btn-text-active-color', this.options.textActiveColor);
if (this.options.btnBackgroundColor) dropdownStyle.setProperty('--bim-btn-bg', this.options.btnBackgroundColor);
if (this.options.btnHoverColor) dropdownStyle.setProperty('--bim-btn-hover-bg', this.options.btnHoverColor);
if (this.options.btnActiveColor) dropdownStyle.setProperty('--bim-btn-active-bg', this.options.btnActiveColor);
// 获取按钮的位置信息
const btnRect = btnEl.getBoundingClientRect();
const expand = this.options.expand || 'down';
// 根据主按钮组的方向设置下拉菜单的布局方向
if (this.options.direction === 'row') {
dropdown.style.flexDirection = 'column'; // 横向按钮组,菜单纵向排列
} else {
dropdown.style.flexDirection = 'row'; // 纵向按钮组,菜单横向排列
}
if (this.options.type && this.options.type !== 'default') {
dropdown.classList.add(`type-${this.options.type}`);
}
const currentTheme = themeManager.getTheme();
dropdown.classList.add(`theme-${currentTheme.name}`);
dropdownStyle.setProperty('--bim-primary', currentTheme.primary);
dropdownStyle.setProperty('--bim-bg-overlay', currentTheme.bgOverlay);
dropdownStyle.setProperty('--bim-border-default', currentTheme.borderDefault);
dropdownStyle.setProperty('--bim-text-primary', currentTheme.textPrimary);
dropdownStyle.setProperty('--bim-text-inverse', currentTheme.textInverse);
dropdownStyle.setProperty('--bim-shadow-lg', currentTheme.shadowLg);
dropdownStyle.setProperty('--bim-floating-btn-bg', currentTheme.floatingBtnBg);
dropdownStyle.setProperty('--bim-floating-btn-bg-hover', currentTheme.floatingBtnBgHover);
dropdownStyle.setProperty('--bim-floating-icon-color', currentTheme.floatingIconColor);
document.body.appendChild(dropdown);
// 添加事件拦截
this.setupEventInterception(dropdown);
// 添加菜单项
button.children.forEach(subBtn => {
if (this.isVisible(subBtn.id)) {
const item = this.renderDropdownItem(subBtn);
dropdown.appendChild(item);
}
});
// 获取下拉菜单的实际尺寸
const dropdownRect = dropdown.getBoundingClientRect();
if (expand === 'up') {
// 向上展开,与按钮水平居中对齐
dropdown.style.bottom = (window.innerHeight - btnRect.top + 8) + 'px';
dropdown.style.left = (btnRect.left + (btnRect.width - dropdownRect.width) / 2) + 'px';
} else if (expand === 'down') {
// 向下展开,与按钮水平居中对齐
dropdown.style.top = (btnRect.bottom + 8) + 'px';
dropdown.style.left = (btnRect.left + (btnRect.width - dropdownRect.width) / 2) + 'px';
} else if (expand === 'right') {
// 向右展开,与按钮垂直居中对齐
dropdown.style.top = (btnRect.top + (btnRect.height - dropdownRect.height) / 2) + 'px';
dropdown.style.left = (btnRect.right + 8) + 'px';
} else if (expand === 'left') {
// 向左展开,与按钮垂直居中对齐
dropdown.style.top = (btnRect.top + (btnRect.height - dropdownRect.height) / 2) + 'px';
dropdown.style.right = (window.innerWidth - btnRect.left + 8) + 'px';
}
dropdown.addEventListener('mouseenter', () => { if (this.hoverTimeout) clearTimeout(this.hoverTimeout); });
dropdown.addEventListener('mouseleave', () => this.handleMouseLeave());
this.dropdownElement = dropdown;
}
private renderDropdownItem(button: OptButton): HTMLElement {
const item = document.createElement('div');
item.className = 'opt-btn-dropdown-item';
// 应用按钮的 align 设置,默认为 horizontal图标在左
const align = button.align || 'horizontal';
if (align === 'horizontal') {
item.classList.add('align-horizontal');
} else {
item.classList.add('align-vertical');
}
// 二级菜单项的 active 状态渲染(修复 keepActive 在二级按钮“看起来不生效”的问题)
// 说明:
// - keepActive 的状态会记录在 activeBtnIds / button.isActive 上
// - 下拉菜单每次打开都会重新渲染,因此必须在这里同步一次 active 样式
if (this.activeBtnIds.has(button.id) || button.isActive) {
item.classList.add('active');
}
// 应用按钮的自定义样式
const iconSize = button.iconSize || 32; // 二级菜单默认图标更小
const minWidth = button.minWidth; // 不设置默认值,让下拉菜单项保持紧凑
if (minWidth) {
item.style.minWidth = `${minWidth}px`;
}
const icon = document.createElement('div');
icon.className = 'opt-btn-icon';
icon.style.width = `${iconSize}px`;
icon.style.height = `${iconSize}px`;
icon.innerHTML = this.getIcon(button.icon);
item.appendChild(icon);
// 只有在 showLabel 为 true 时才显示 label
if (this.options.showLabel && button.label) {
const label = document.createElement('span');
label.className = 'opt-btn-dropdown-label';
label.textContent = t(button.label);
item.appendChild(label);
} else if (button.label) {
// 当不显示 label 时,添加 title 属性作为 tooltip
item.title = t(button.label);
}
item.addEventListener('click', (e) => { e.stopPropagation(); this.handleClick(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; }
public updateButtonVisibility(id: string, visible: boolean): void {
if (!this.options.visibility) this.options.visibility = {};
this.options.visibility[id] = visible;
this.render();
}
public setShowLabel(show: boolean): void {
this.options.showLabel = show;
this.updateLabelsVisibility();
}
private updateLabelsVisibility(): void {
this.btnRefs.forEach((btnEl, buttonId) => {
// 查找按钮配置
const button = this.findButtonById(buttonId);
if (!button) return;
const hasLabel = this.options.showLabel && button.label;
// 更新 no-label 类和 title 属性
if (hasLabel) {
btnEl.classList.remove('no-label');
// 显示标签时,移除 title避免重复显示
btnEl.removeAttribute('title');
} else {
btnEl.classList.add('no-label');
// 隐藏标签时,添加 title 作为 tooltip
if (button.label) {
btnEl.title = t(button.label);
}
}
});
}
private findButtonById(id: string): OptButton | undefined {
for (const group of this.groups) {
const found = this.findButton(group.buttons, id);
if (found) return found;
}
return undefined;
}
public setBackgroundColor(color: string): void { this.setColors({ backgroundColor: color }); }
private isVisible(id: string): boolean { return this.options.visibility?.[id] !== false; }
public setType(type: 'default' | 'glass-pill'): void {
this.container.classList.remove('type-default', 'type-glass-pill');
this.options.type = type;
if (type && type !== 'default') {
this.container.classList.add(`type-${type}`);
}
this.render();
}
public getType(): 'default' | 'glass-pill' {
return this.options.type || 'default';
}
public destroy(): void {
if (this.unsubscribeLocale) {
this.unsubscribeLocale();
this.unsubscribeLocale = null;
}
if (this.unsubscribeTheme) {
this.unsubscribeTheme();
this.unsubscribeTheme = null;
}
this.closeDropdown();
this.container.innerHTML = '';
this.btnRefs.clear();
}
}