import './index.css'; import type { ThemeConfig } from '../../themes/types'; import { IBimComponent } from '../../types/component'; import { localeManager, t } from '../../services/locale'; import { themeManager } from '../../services/theme'; import type { SectionBoxPanelOptions, SectionBoxRange } from './types'; import { getIcon } from '../../utils/icon-manager'; const DEFAULT_RANGE: SectionBoxRange = { x: { min: 0, max: 100 }, y: { min: 0, max: 100 }, z: { min: 0, max: 100 } }; export class SectionBoxPanel implements IBimComponent { public element!: HTMLElement; private options: SectionBoxPanelOptions; private isHidden: boolean = false; private isReversed: boolean = false; private range: SectionBoxRange; private hideBtn!: HTMLButtonElement; private reverseBtn!: HTMLButtonElement; private fitBtn!: HTMLButtonElement; private resetBtn!: HTMLButtonElement; private hideLabelEl!: HTMLElement; private reverseLabelEl!: HTMLElement; private fitLabelEl!: HTMLElement; private resetLabelEl!: HTMLElement; private xLabelEl!: HTMLElement; private yLabelEl!: HTMLElement; private zLabelEl!: HTMLElement; private unsubscribeLocale: (() => void) | null = null; private unsubscribeTheme: (() => void) | null = null; private xSlider!: HTMLElement; private ySlider!: HTMLElement; private zSlider!: HTMLElement; private xMinHandle!: HTMLElement; private xMaxHandle!: HTMLElement; private yMinHandle!: HTMLElement; private yMaxHandle!: HTMLElement; private zMinHandle!: HTMLElement; private zMaxHandle!: HTMLElement; private dragState: { isDragging: boolean; axis: 'x' | 'y' | 'z' | null; handleType: 'min' | 'max' | null; pointerId: number | null; } = { isDragging: false, axis: null, handleType: null, pointerId: null }; constructor(options: SectionBoxPanelOptions = {}) { this.options = options; this.isHidden = options.defaultHidden ?? false; this.isReversed = options.defaultReversed ?? false; this.range = JSON.parse(JSON.stringify(options.defaultRange ?? DEFAULT_RANGE)); } public init(): void { this.element = this.createPanel(); this.unsubscribeLocale = localeManager.subscribe(() => this.setLocales()); this.unsubscribeTheme = themeManager.subscribe((theme) => this.setTheme(theme)); this.setLocales(); this.setTheme(themeManager.getTheme()); this.updateButtonStates(); this.updateAllSlidersUI(); this.setupDragListeners(); } // --- Public APIs --- public setHiddenState(isHidden: boolean): void { this.isHidden = isHidden; this.updateButtonStates(); } public getHiddenState(): boolean { return this.isHidden; } public setReversedState(isReversed: boolean): void { this.isReversed = isReversed; this.updateButtonStates(); } public getReversedState(): boolean { return this.isReversed; } public setRange(range: Partial): void { if (range.x) this.range.x = { ...this.range.x, ...range.x }; if (range.y) this.range.y = { ...this.range.y, ...range.y }; if (range.z) this.range.z = { ...this.range.z, ...range.z }; this.updateAllSlidersUI(); } public getRange(): SectionBoxRange { return JSON.parse(JSON.stringify(this.range)); } public reset(): void { this.isHidden = this.options.defaultHidden ?? false; this.isReversed = this.options.defaultReversed ?? false; this.range = JSON.parse(JSON.stringify(this.options.defaultRange ?? DEFAULT_RANGE)); this.updateButtonStates(); this.updateAllSlidersUI(); this.options.onReset?.(); this.options.onRangeChange?.(this.range); } // --- Private Setup --- private createPanel(): HTMLElement { const panel = document.createElement('div'); panel.className = 'section-box-panel'; const buttonsContainer = document.createElement('div'); buttonsContainer.className = 'section-box-row-buttons'; this.hideBtn = this.createButton('hide', t('sectionBox.actions.hide'), () => { this.isHidden = !this.isHidden; this.updateButtonStates(); this.options.onHideToggle?.(this.isHidden); }, 'hide'); this.reverseBtn = this.createButton('reverse', t('sectionBox.actions.reverse'), () => { this.isReversed = !this.isReversed; this.updateButtonStates(); this.options.onReverseToggle?.(this.isReversed); }, 'reverse'); this.fitBtn = this.createButton('fit', t('sectionBox.actions.fitToModel'), () => { this.options.onFitToModel?.(); }, 'fit'); this.resetBtn = this.createButton('reset', t('sectionBox.actions.reset'), () => this.reset(), 'reset'); [this.hideBtn, this.reverseBtn, this.fitBtn, this.resetBtn].forEach(btn => buttonsContainer.appendChild(btn)); const slidersContainer = document.createElement('div'); slidersContainer.className = 'section-box-sliders'; slidersContainer.addEventListener('pointerdown', (e) => e.stopPropagation()); this.xSlider = this.createSlider('x', t('sectionBox.axes.x')); this.ySlider = this.createSlider('y', t('sectionBox.axes.y')); this.zSlider = this.createSlider('z', t('sectionBox.axes.z')); [this.xSlider, this.ySlider, this.zSlider].forEach(s => slidersContainer.appendChild(s)); panel.appendChild(buttonsContainer); panel.appendChild(slidersContainer); return panel; } private createButton(type: string, label: string, onClick: () => void, ref?: string): HTMLButtonElement { const btn = document.createElement('button'); btn.className = 'section-box-btn'; btn.title = label; const iconMap: Record = { hide: '隐藏', reverse: '反向', fit: '适应到模型', reset: '重置' }; const icon = document.createElement('div'); icon.className = 'section-box-btn-icon'; icon.innerHTML = getIcon(iconMap[type] || type); const labelEl = document.createElement('div'); labelEl.className = 'section-box-btn-label'; labelEl.textContent = label; if (ref === 'hide') this.hideLabelEl = labelEl; else if (ref === 'reverse') this.reverseLabelEl = labelEl; else if (ref === 'fit') this.fitLabelEl = labelEl; else if (ref === 'reset') this.resetLabelEl = labelEl; btn.appendChild(icon); btn.appendChild(labelEl); btn.addEventListener('click', onClick); return btn; } private createSlider(axis: 'x' | 'y' | 'z', label: string): HTMLElement { const slider = document.createElement('div'); slider.className = 'section-box-slider'; const labelEl = document.createElement('div'); labelEl.className = 'section-box-slider-label'; labelEl.textContent = label; if (axis === 'x') this.xLabelEl = labelEl; else if (axis === 'y') this.yLabelEl = labelEl; else this.zLabelEl = labelEl; const track = document.createElement('div'); track.className = 'section-box-slider-track'; const range = document.createElement('div'); range.className = 'section-box-slider-range'; const minHandle = document.createElement('div'); minHandle.className = 'section-box-slider-handle'; minHandle.setAttribute('data-axis', axis); minHandle.setAttribute('data-handle', 'min'); const maxHandle = document.createElement('div'); maxHandle.className = 'section-box-slider-handle'; maxHandle.setAttribute('data-axis', axis); maxHandle.setAttribute('data-handle', 'max'); track.append(range, minHandle, maxHandle); slider.append(labelEl, track); if (axis === 'x') { this.xMinHandle = minHandle; this.xMaxHandle = maxHandle; } else if (axis === 'y') { this.yMinHandle = minHandle; this.yMaxHandle = maxHandle; } else { this.zMinHandle = minHandle; this.zMaxHandle = maxHandle; } return slider; } private setupDragListeners(): void { const handles = [this.xMinHandle, this.xMaxHandle, this.yMinHandle, this.yMaxHandle, this.zMinHandle, this.zMaxHandle]; handles.forEach(handle => { handle.addEventListener('pointerdown', (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); // 核心锁定:确保后续所有移动事件都只发给这个手柄 handle.setPointerCapture(e.pointerId); this.dragState = { isDragging: true, axis: handle.getAttribute('data-axis') as 'x' | 'y' | 'z', handleType: handle.getAttribute('data-handle') as 'min' | 'max', pointerId: e.pointerId }; handle.classList.add('dragging'); (handle.closest('.section-box-slider') as HTMLElement).style.zIndex = '100'; }); handle.addEventListener('pointermove', (e: PointerEvent) => { if (this.dragState.isDragging && this.dragState.pointerId === e.pointerId) { this.onDragging(e); } }); const stop = (e: PointerEvent) => { if (this.dragState.isDragging && this.dragState.pointerId === e.pointerId) { handle.releasePointerCapture(e.pointerId); (handle.closest('.section-box-slider') as HTMLElement).style.zIndex = ''; handle.classList.remove('dragging'); this.dragState.isDragging = false; this.dragState.pointerId = null; } }; handle.addEventListener('pointerup', stop); handle.addEventListener('pointercancel', stop); }); } private onDragging(e: PointerEvent): void { const { axis, handleType } = this.dragState; if (!axis || !handleType) return; const sliderEl = axis === 'x' ? this.xSlider : (axis === 'y' ? this.ySlider : this.zSlider); const track = sliderEl.querySelector('.section-box-slider-track') as HTMLElement; const rect = track.getBoundingClientRect(); let percentage = ((e.clientX - rect.left) / rect.width) * 100; percentage = Math.max(0, Math.min(100, percentage)); const current = this.range[axis]; if (handleType === 'min') { current.min = Math.min(percentage, current.max); } else { current.max = Math.max(percentage, current.min); } this.updateSliderUI(axis); this.options.onRangeChange?.(this.range); } private updateSliderUI(axis: 'x' | 'y' | 'z'): void { const range = this.range[axis]; const minH = axis === 'x' ? this.xMinHandle : (axis === 'y' ? this.yMinHandle : this.zMinHandle); const maxH = axis === 'x' ? this.xMaxHandle : (axis === 'y' ? this.yMaxHandle : this.zMaxHandle); const slider = axis === 'x' ? this.xSlider : (axis === 'y' ? this.ySlider : this.zSlider); const rangeEl = slider.querySelector('.section-box-slider-range') as HTMLElement; minH.style.left = `${range.min}%`; maxH.style.left = `${range.max}%`; rangeEl.style.left = `${range.min}%`; rangeEl.style.width = `${range.max - range.min}%`; minH.setAttribute('data-value', Math.round(range.min).toString()); maxH.setAttribute('data-value', Math.round(range.max).toString()); } private updateAllSlidersUI(): void { ['x', 'y', 'z'].forEach((a: any) => this.updateSliderUI(a)); } private updateButtonStates(): void { if (this.hideBtn) this.hideBtn.classList.toggle('active', this.isHidden); if (this.reverseBtn) this.reverseBtn.classList.toggle('active', this.isReversed); } public setLocales(): void { if (!this.hideLabelEl) return; this.hideLabelEl.textContent = t('sectionBox.actions.hide'); this.reverseLabelEl.textContent = t('sectionBox.actions.reverse'); this.fitLabelEl.textContent = t('sectionBox.actions.fitToModel'); this.resetLabelEl.textContent = t('sectionBox.actions.reset'); this.xLabelEl.textContent = t('sectionBox.axes.x'); this.yLabelEl.textContent = t('sectionBox.axes.y'); this.zLabelEl.textContent = t('sectionBox.axes.z'); this.hideBtn.title = t('sectionBox.actions.hide'); this.reverseBtn.title = t('sectionBox.actions.reverse'); this.fitBtn.title = t('sectionBox.actions.fitToModel'); this.resetBtn.title = t('sectionBox.actions.reset'); } public setTheme(theme: ThemeConfig): void { if (!this.element) return; const style = this.element.style; style.setProperty('--bim-section-box-btn-bg', theme.componentBackground ?? 'rgba(255, 255, 255, 0.06)'); style.setProperty('--bim-section-box-btn-hover', theme.componentHover ?? 'rgba(255, 255, 255, 0.10)'); style.setProperty('--bim-section-box-btn-active', theme.componentActive ?? 'rgba(255, 255, 255, 0.14)'); style.setProperty('--bim-primary-color', theme.primary ?? '#1890ff'); style.setProperty('--bim-icon-color', theme.icon ?? '#ccc'); style.setProperty('--bim-text-color', theme.textSecondary ?? 'rgba(255, 255, 255, 0.90)'); style.setProperty('--bim-text-active-color', theme.textPrimary ?? '#fff'); } public destroy(): void { this.unsubscribeLocale?.(); this.unsubscribeTheme?.(); if (this.element && this.element.parentElement) { this.element.parentElement.removeChild(this.element); } } }