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 = new Map(); private toggleStateMap: Map = 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); } } }