Files
bim_engine/src/components/radial-toolbar/index.ts

475 lines
18 KiB
TypeScript
Raw Normal View History

import './index.css';
import type { RadialToolbarOptions, RadialMenuItem } from './types';
import type { ThemeConfig } from '../../themes/types';
import { IBimComponent } from '../../types/component';
import { getIcon } from '../../utils/icon-manager';
import { themeManager } from '../../services/theme';
import { localeManager, t } from '../../services/locale';
export class RadialToolbar implements IBimComponent {
private container: HTMLElement;
private wrapper: HTMLElement;
private mainButton: HTMLButtonElement;
private items: RadialMenuItem[] = [];
private itemElements: HTMLButtonElement[] = [];
private itemButtonMap: Map<string, HTMLButtonElement> = new Map();
private toggleStateMap: Map<string, boolean> = new Map();
private isActive = false;
private timer: number | null = null;
private unsubscribeTheme: (() => void) | null = null;
private unsubscribeLocale: (() => void) | null = null;
private readonly mainButtonLabel: string;
private readonly onMainButtonClick?: () => void;
private pointerX = Number.NaN;
private pointerY = Number.NaN;
private maxInteractiveRadius = 220;
private readonly closeDelay: number;
private readonly itemsPerRing: number;
// 主按钮直径px
private readonly MAIN_BUTTON_SIZE = 60;
// 子按钮直径px
private readonly SUB_BUTTON_SIZE = 46;
// 第一环“子按钮中心”到“主按钮中心”的距离px
// 不做防重叠兜底,允许在小半径时出现重叠。
private readonly BASE_RADIUS = 80;
// 多环时相邻两环的中心半径差px
private readonly RING_GAP = 40;
// 扇形展开角度范围:当前 180~270实际就是 90 度)
private readonly FAN_START_DEG = 170;
private readonly FAN_END_DEG = 280;
// 扇形边缘留白px防止按钮贴边或裁切
private readonly CANVAS_PADDING = 28;
constructor(options: RadialToolbarOptions) {
this.container = options.container;
this.items = options.items === undefined ? this.createDefaultItems() : [...options.items];
this.mainButtonLabel = options.mainButtonLabel ?? 'toolbar.home';
this.onMainButtonClick = options.onMainButtonClick;
this.itemsPerRing = Math.max(3, options.itemsPerRing ?? 5);
this.closeDelay = Math.max(100, options.closeDelay ?? 260);
this.wrapper = this.createWrapper();
this.mainButton = this.createMainButton(options.mainButtonIcon);
this.wrapper.appendChild(this.mainButton);
this.container.appendChild(this.wrapper);
this.renderItems();
this.updateLayoutMetrics();
this.bindEvents();
this.setTheme(themeManager.getTheme());
this.updateLocales();
this.unsubscribeTheme = themeManager.subscribe((theme) => {
this.setTheme(theme);
});
this.unsubscribeLocale = localeManager.subscribe(() => {
this.updateLocales();
});
}
private createDefaultItems(): RadialMenuItem[] {
return [
{ id: 'zoom', label: 'toolbar.zoomBox', icon: getIcon('框选放大') },
{ id: 'measure', label: 'toolbar.measure', icon: getIcon('测量') },
{ id: 'section', label: 'toolbar.section', icon: getIcon('剖切') },
{ id: 'walk', label: 'toolbar.walk', icon: getIcon('漫游') },
{ id: 'setting', label: 'toolbar.setting', icon: getIcon('设置') }
];
}
private createWrapper(): HTMLElement {
const el = document.createElement('div');
el.className = 'radial-toolbar-wrapper';
return el;
}
private createMainButton(icon?: string): HTMLButtonElement {
const btn = document.createElement('button');
btn.className = 'radial-main-btn';
btn.type = 'button';
btn.innerHTML = icon ?? getIcon('主视角');
return btn;
}
private bindEvents(): void {
this.mainButton.addEventListener('mouseenter', this.handlePointerEnter);
this.mainButton.addEventListener('mouseleave', this.handlePointerLeave);
this.mainButton.addEventListener('click', this.handleMainButtonClick);
document.addEventListener('click', this.handleDocumentClick);
document.addEventListener('mousemove', this.handleDocumentMouseMove);
document.addEventListener('mouseleave', this.handleDocumentMouseLeave);
document.addEventListener('visibilitychange', this.handleVisibilityChange);
window.addEventListener('blur', this.handleWindowBlur);
}
private readonly handleDocumentMouseMove = (event: MouseEvent): void => {
this.pointerX = event.clientX;
this.pointerY = event.clientY;
};
private readonly handleDocumentMouseLeave = (): void => {
this.pointerX = Number.NaN;
this.pointerY = Number.NaN;
this.collapse();
};
private readonly handleVisibilityChange = (): void => {
if (!document.hidden) {
return;
}
this.pointerX = Number.NaN;
this.pointerY = Number.NaN;
this.collapse();
};
private readonly handleWindowBlur = (): void => {
this.pointerX = Number.NaN;
this.pointerY = Number.NaN;
this.collapse();
};
private readonly handlePointerEnter = (): void => {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.expand();
};
private readonly handlePointerLeave = (event: MouseEvent): void => {
const relatedTarget = event.relatedTarget;
if (relatedTarget instanceof Node && this.wrapper.contains(relatedTarget)) {
return;
}
this.scheduleCollapse();
};
private readonly handleMainButtonClick = (event: MouseEvent): void => {
event.stopPropagation();
if (this.onMainButtonClick) {
this.onMainButtonClick();
return;
}
if (this.isActive) {
this.collapse();
return;
}
this.expand();
};
private readonly handleDocumentClick = (event: MouseEvent): void => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (!this.wrapper.contains(target)) {
this.collapse();
}
};
private renderItems(): void {
this.itemElements.forEach((el) => el.remove());
this.itemElements = [];
this.itemButtonMap.clear();
this.toggleStateMap.clear();
this.items.forEach((item, index) => {
this.toggleStateMap.set(item.id, Boolean(item.isActive));
const btn = this.createSubButton(item, index);
this.wrapper.insertBefore(btn, this.mainButton);
this.itemElements.push(btn);
this.itemButtonMap.set(item.id, btn);
});
this.updateItemPositions();
}
private createSubButton(item: RadialMenuItem, index: number): HTMLButtonElement {
const btn = document.createElement('button');
btn.className = 'radial-sub-btn';
btn.type = 'button';
btn.dataset.index = String(index);
this.applyItemActiveClass(btn, item);
const iconContainer = document.createElement('span');
iconContainer.className = 'radial-sub-btn-icon';
if (item.icon) {
iconContainer.innerHTML = item.icon;
} else {
iconContainer.textContent = this.getFallbackLabel(item.label);
}
btn.appendChild(iconContainer);
btn.addEventListener('mouseenter', this.handlePointerEnter);
btn.addEventListener('mouseleave', this.handlePointerLeave);
btn.addEventListener('click', (event) => {
event.stopPropagation();
if (item.isToggle) {
const current = this.toggleStateMap.get(item.id) ?? false;
const next = !current;
this.toggleStateMap.set(item.id, next);
item.isActive = next;
this.applyItemActiveClass(btn, item);
item.onToggle?.(next, item);
this.collapse();
return;
}
item.onClick?.(item);
this.collapse();
});
return btn;
}
private updateItemPositions(): void {
const total = this.itemElements.length;
const fanSpan = this.FAN_END_DEG - this.FAN_START_DEG;
this.itemElements.forEach((btn, globalIndex) => {
const ringIndex = Math.floor(globalIndex / this.itemsPerRing);
const ringStart = ringIndex * this.itemsPerRing;
const ringCount = Math.min(this.itemsPerRing, total - ringStart);
const ringLocalIndex = globalIndex - ringStart;
const ratio = ringCount === 1 ? 0.5 : ringLocalIndex / (ringCount - 1);
const angleDeg = this.FAN_START_DEG + fanSpan * ratio;
const angleRad = (angleDeg * Math.PI) / 180;
const radius = this.getBaseRadius(ringCount) + ringIndex * this.RING_GAP;
const x = Math.cos(angleRad) * radius;
const y = Math.sin(angleRad) * radius;
const openDelay = (ringLocalIndex + ringIndex * 0.5) * 0.045;
const closeDelay = (ringCount - 1 - ringLocalIndex + ringIndex * 0.4) * 0.032;
btn.style.setProperty('--rt-x', `${x.toFixed(2)}px`);
btn.style.setProperty('--rt-y', `${y.toFixed(2)}px`);
btn.style.setProperty('--rt-open-delay', `${openDelay.toFixed(3)}s`);
btn.style.setProperty('--rt-close-delay', `${closeDelay.toFixed(3)}s`);
});
}
private updateLayoutMetrics(): void {
const total = this.items.length;
const ringCount = Math.max(1, Math.ceil(total / this.itemsPerRing));
const maxItemsPerRing = Math.max(1, Math.min(total, this.itemsPerRing));
const maxRadius = this.getBaseRadius(maxItemsPerRing) + (ringCount - 1) * this.RING_GAP;
const size = Math.ceil(maxRadius + this.MAIN_BUTTON_SIZE + this.SUB_BUTTON_SIZE + this.CANVAS_PADDING * 2);
this.maxInteractiveRadius = maxRadius + this.SUB_BUTTON_SIZE * 0.7;
this.wrapper.style.width = `${size}px`;
this.wrapper.style.height = `${size}px`;
this.wrapper.style.setProperty('--rt-main-size', `${this.MAIN_BUTTON_SIZE}px`);
this.wrapper.style.setProperty('--rt-sub-size', `${this.SUB_BUTTON_SIZE}px`);
this.wrapper.style.setProperty('--rt-main-offset', `${(this.MAIN_BUTTON_SIZE - this.SUB_BUTTON_SIZE) / 2}px`);
}
private getBaseRadius(_itemsInRing: number): number {
return this.BASE_RADIUS;
}
private scheduleCollapse(): void {
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = window.setTimeout(() => {
this.timer = null;
if (this.isPointerInsideToolbar()) {
if (this.isActive) {
this.scheduleCollapse();
}
return;
}
this.collapse();
}, this.closeDelay);
}
private isPointerInsideToolbar(): boolean {
if (this.mainButton.matches(':hover')) {
return true;
}
if (this.itemElements.some((button) => button.matches(':hover'))) {
return true;
}
return this.isPointerInsideFanRegion();
}
private isPointerInsideFanRegion(): boolean {
if (!Number.isFinite(this.pointerX) || !Number.isFinite(this.pointerY)) {
return false;
}
const mainRect = this.mainButton.getBoundingClientRect();
const centerX = mainRect.left + mainRect.width / 2;
const centerY = mainRect.top + mainRect.height / 2;
const dx = this.pointerX - centerX;
const dy = this.pointerY - centerY;
const distance = Math.hypot(dx, dy);
if (distance <= this.MAIN_BUTTON_SIZE / 2) {
return true;
}
if (distance > this.maxInteractiveRadius) {
return false;
}
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
if (angle < 0) {
angle += 360;
}
if (this.FAN_START_DEG <= this.FAN_END_DEG) {
return angle >= this.FAN_START_DEG && angle <= this.FAN_END_DEG;
}
return angle >= this.FAN_START_DEG || angle <= this.FAN_END_DEG;
}
private expand(): void {
if (this.isActive || this.items.length === 0) {
return;
}
this.isActive = true;
this.wrapper.classList.add('is-active');
}
private collapse(): void {
if (!this.isActive) {
return;
}
this.isActive = false;
this.wrapper.classList.remove('is-active');
}
private updateLocales(): void {
const mainLabel = t(this.mainButtonLabel);
this.mainButton.title = mainLabel;
this.mainButton.setAttribute('aria-label', mainLabel);
this.itemElements.forEach((el, index) => {
const item = this.items[index];
if (!item) {
return;
}
const text = t(item.label);
el.title = text;
el.setAttribute('aria-label', text);
this.applyItemActiveClass(el, item);
if (!item.icon) {
const iconEl = el.querySelector('.radial-sub-btn-icon');
if (iconEl) {
iconEl.textContent = this.getFallbackLabel(item.label);
}
}
});
}
private applyItemActiveClass(button: HTMLButtonElement, item: RadialMenuItem): void {
if (!item.isToggle) {
button.classList.remove('is-active');
button.dataset.active = 'false';
return;
}
const active = this.toggleStateMap.get(item.id) ?? Boolean(item.isActive);
button.classList.toggle('is-active', active);
button.dataset.active = active ? 'true' : 'false';
}
private getFallbackLabel(label: string): string {
const translated = t(label).trim();
if (!translated) {
return '?';
}
return translated.charAt(0).toUpperCase();
}
public setTheme(theme: ThemeConfig): void {
this.wrapper.classList.remove('theme-light', 'theme-dark');
this.wrapper.classList.add(`theme-${theme.name}`);
const style = this.wrapper.style;
style.setProperty('--bim-primary', theme.primary);
style.setProperty('--bim-primary-hover', theme.primaryHover);
style.setProperty('--bim-primary-active', theme.primaryActive);
style.setProperty('--bim-text-inverse', theme.textInverse);
style.setProperty('--bim-icon-inverse', theme.iconInverse);
style.setProperty('--bim-shadow-glow', theme.shadowGlow);
const isDark = theme.name === 'dark';
style.setProperty('--rt-main-bg', isDark ? 'rgba(55, 68, 86, 0.92)' : theme.floatingBtnBg);
style.setProperty('--rt-main-bg-hover', isDark ? 'rgba(66, 82, 104, 0.96)' : theme.floatingBtnBgHover);
style.setProperty('--rt-main-border', isDark ? 'rgba(117, 133, 154, 0.56)' : theme.floatingBtnBorder);
style.setProperty('--rt-main-shadow', isDark ? '0 2px 8px rgba(15, 23, 42, 0.32), 0 4px 12px rgba(15, 23, 42, 0.24)' : theme.floatingBtnShadow);
style.setProperty('--rt-main-shadow-hover', isDark ? '0 4px 12px rgba(15, 23, 42, 0.38), 0 6px 20px rgba(15, 23, 42, 0.3)' : theme.floatingBtnShadowHover);
style.setProperty('--rt-main-icon', isDark ? '#e2e8f0' : theme.floatingIconColor);
style.setProperty('--rt-main-icon-hover', isDark ? '#f8fafc' : theme.floatingIconColorHover);
style.setProperty('--rt-sub-bg', isDark ? 'rgba(55, 68, 86, 0.92)' : theme.floatingBtnBg);
style.setProperty('--rt-sub-bg-hover', isDark ? 'rgba(66, 82, 104, 0.96)' : theme.floatingBtnBgHover);
style.setProperty('--rt-sub-border', isDark ? 'rgba(117, 133, 154, 0.56)' : theme.floatingBtnBorder);
style.setProperty('--rt-sub-shadow', isDark ? '0 2px 8px rgba(15, 23, 42, 0.32), 0 4px 12px rgba(15, 23, 42, 0.24)' : theme.floatingBtnShadow);
style.setProperty('--rt-sub-shadow-hover', isDark ? '0 4px 12px rgba(15, 23, 42, 0.38), 0 6px 20px rgba(15, 23, 42, 0.3)' : theme.floatingBtnShadowHover);
style.setProperty('--rt-sub-icon', isDark ? '#e2e8f0' : theme.floatingIconColor);
style.setProperty('--rt-sub-icon-hover', isDark ? '#f8fafc' : theme.floatingIconColorHover);
}
public setItemActive(id: string, active: boolean): void {
const item = this.items.find((entry) => entry.id === id);
if (!item || !item.isToggle) {
return;
}
item.isActive = active;
this.toggleStateMap.set(id, active);
const button = this.itemButtonMap.get(id);
if (button) {
this.applyItemActiveClass(button, item);
}
}
public init(): void { }
public setLocales(): void {
this.updateLocales();
}
public destroy(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.unsubscribeTheme) {
this.unsubscribeTheme();
this.unsubscribeTheme = null;
}
if (this.unsubscribeLocale) {
this.unsubscribeLocale();
this.unsubscribeLocale = null;
}
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('mousemove', this.handleDocumentMouseMove);
document.removeEventListener('mouseleave', this.handleDocumentMouseLeave);
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('blur', this.handleWindowBlur);
this.mainButton.removeEventListener('mouseenter', this.handlePointerEnter);
this.mainButton.removeEventListener('mouseleave', this.handlePointerLeave);
this.mainButton.removeEventListener('click', this.handleMainButtonClick);
this.itemElements.forEach((button) => {
button.removeEventListener('mouseenter', this.handlePointerEnter);
button.removeEventListener('mouseleave', this.handlePointerLeave);
});
if (this.wrapper.parentNode) {
this.wrapper.parentNode.removeChild(this.wrapper);
}
}
}