2025-12-24 19:02:34 +08:00
|
|
|
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';
|
2025-12-26 17:08:02 +08:00
|
|
|
import { getIcon } from '../../utils/icon-manager';
|
2025-12-24 19:02:34 +08:00
|
|
|
|
|
|
|
|
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<SectionBoxRange>): 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;
|
|
|
|
|
|
2025-12-26 17:08:02 +08:00
|
|
|
const iconMap: Record<string, string> = {
|
|
|
|
|
hide: '隐藏',
|
|
|
|
|
reverse: '反向',
|
|
|
|
|
fit: '适应到模型',
|
|
|
|
|
reset: '重置'
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-24 19:02:34 +08:00
|
|
|
const icon = document.createElement('div');
|
|
|
|
|
icon.className = 'section-box-btn-icon';
|
2025-12-26 17:08:02 +08:00
|
|
|
icon.innerHTML = getIcon(iconMap[type] || type);
|
2025-12-24 19:02:34 +08:00
|
|
|
|
|
|
|
|
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;
|
2026-01-22 11:29:51 +08:00
|
|
|
style.setProperty('--bim-bg-inset', theme.bgInset ?? '#152232');
|
|
|
|
|
style.setProperty('--bim-bg-elevated', theme.bgElevated ?? '#1f2d3e');
|
|
|
|
|
style.setProperty('--bim-border-default', theme.borderDefault ?? '#334155');
|
|
|
|
|
style.setProperty('--bim-border-strong', theme.borderStrong ?? '#475569');
|
|
|
|
|
style.setProperty('--bim-divider', theme.divider ?? '#334155');
|
|
|
|
|
style.setProperty('--bim-primary', theme.primary ?? '#3b82f6');
|
|
|
|
|
style.setProperty('--bim-primary-subtle', theme.primarySubtle ?? 'rgba(59, 130, 246, 0.15)');
|
|
|
|
|
style.setProperty('--bim-icon-default', theme.iconDefault ?? '#ffffff');
|
|
|
|
|
style.setProperty('--bim-text-primary', theme.textPrimary ?? '#ffffff');
|
|
|
|
|
style.setProperty('--bim-component-bg-hover', theme.componentBgHover ?? 'rgba(248, 250, 252, 0.06)');
|
2025-12-24 19:02:34 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public destroy(): void {
|
|
|
|
|
this.unsubscribeLocale?.();
|
|
|
|
|
this.unsubscribeTheme?.();
|
|
|
|
|
if (this.element && this.element.parentElement) {
|
|
|
|
|
this.element.parentElement.removeChild(this.element);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|