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