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

475 lines
18 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 { 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);
}
}
}