import './index.css'; import type { ThemeConfig } from '../../themes/types'; import { IBimComponent } from '../../types/component'; import { t } from '../../services/locale'; import { MEASURE_TYPES, type ClearHeightDirection, type ClearHeightSelectType, type MeasureMode } from '../../types/measure'; import { getIcon } from '../../utils/icon-manager'; import type { MeasureConfig, MeasurePrecision, MeasureUnit } from '../measure-panel/types'; const PRIMARY_MODES: MeasureMode[] = ['distance', 'clearHeight', 'clearDistance', 'elevation']; const SECONDARY_MODES: MeasureMode[] = ['point', 'angle', 'area', 'slope']; const CONFIG_CACHE_KEY = 'bim-engine:measure:config'; const DEFAULT_CONFIG: MeasureConfig = { unit: 'mm', precision: 2 }; export interface MeasureDockPanelOptions { defaultMode?: MeasureMode; defaultExpanded?: boolean; defaultClearHeightDirection?: ClearHeightDirection; defaultClearHeightSelectType?: ClearHeightSelectType; onModeChange?: (mode: MeasureMode) => void; onClearAll?: () => void; onSettings?: () => void; onConfigSave?: (config: MeasureConfig) => void; onClearHeightDirectionChange?: (direction: ClearHeightDirection) => void; onClearHeightSelectTypeChange?: (selectType: ClearHeightSelectType) => void; } export class MeasureDockPanel implements IBimComponent { public readonly element: HTMLElement; private readonly options: MeasureDockPanelOptions; private readonly modeButtons: Map = new Map(); private readonly clearBtn: HTMLButtonElement; private readonly expandBtn: HTMLButtonElement; private readonly settingsBtn: HTMLButtonElement; private readonly secondaryRow: HTMLElement; private readonly clearHeightOptions: HTMLElement; private readonly clearHeightDirectionLabel: HTMLElement; private readonly clearHeightSelectTypeLabel: HTMLElement; private readonly directionButtons: Map = new Map(); private readonly selectTypeButtons: Map = new Map(); private readonly mainView: HTMLElement; private readonly settingsView: HTMLElement; private readonly settingsUnitLabel: HTMLElement; private readonly settingsPrecisionLabel: HTMLElement; private readonly settingsUnitSelect: HTMLSelectElement; private readonly settingsPrecisionSelect: HTMLSelectElement; private readonly settingsSaveBtn: HTMLButtonElement; private readonly settingsBackBtn: HTMLButtonElement; private activeMode: MeasureMode; private isExpanded: boolean; private clearHeightDirection: ClearHeightDirection; private clearHeightSelectType: ClearHeightSelectType; private view: 'main' | 'settings' = 'main'; private config: MeasureConfig; private lockedWidthPx: number | null = null; constructor(options: MeasureDockPanelOptions = {}) { this.options = options; this.activeMode = options.defaultMode ?? 'distance'; this.isExpanded = options.defaultExpanded ?? false; this.clearHeightDirection = options.defaultClearHeightDirection ?? 1; this.clearHeightSelectType = options.defaultClearHeightSelectType ?? 'point'; this.config = this.loadConfigFromCache() ?? { ...DEFAULT_CONFIG }; const { root, clearBtn, expandBtn, settingsBtn, secondaryRow, clearHeightOptions, clearHeightDirectionLabel, clearHeightSelectTypeLabel, mainView, settingsView, settingsUnitLabel, settingsPrecisionLabel, settingsUnitSelect, settingsPrecisionSelect, settingsSaveBtn, settingsBackBtn } = this.createDom(); this.element = root; this.clearBtn = clearBtn; this.expandBtn = expandBtn; this.settingsBtn = settingsBtn; this.secondaryRow = secondaryRow; this.clearHeightOptions = clearHeightOptions; this.clearHeightDirectionLabel = clearHeightDirectionLabel; this.clearHeightSelectTypeLabel = clearHeightSelectTypeLabel; this.mainView = mainView; this.settingsView = settingsView; this.settingsUnitLabel = settingsUnitLabel; this.settingsPrecisionLabel = settingsPrecisionLabel; this.settingsUnitSelect = settingsUnitSelect; this.settingsPrecisionSelect = settingsPrecisionSelect; this.settingsSaveBtn = settingsSaveBtn; this.settingsBackBtn = settingsBackBtn; } public init(): void { this.setLocales(); this.syncSettingsFormFromConfig(); this.applyExpandedState(); this.applyClearHeightOptionsState(); this.applyViewState(); this.syncActiveMode(this.activeMode); } public setTheme(theme: ThemeConfig): void { const style = this.element.style; style.setProperty('--bim-text-primary', theme.textPrimary); style.setProperty('--bim-text-secondary', theme.textSecondary); style.setProperty('--bim-text-tertiary', theme.textTertiary); style.setProperty('--bim-border-default', theme.borderDefault); style.setProperty('--bim-border-strong', theme.borderStrong); style.setProperty('--bim-bg-inset', theme.bgInset); style.setProperty('--bim-bg-elevated', theme.bgElevated); style.setProperty('--bim-primary', theme.primary); style.setProperty('--bim-primary-subtle', theme.primarySubtle); style.setProperty('--bim-danger', theme.danger); style.setProperty('--bim-component-bg-hover', theme.componentBgHover); } public setLocales(): void { for (const [mode, btn] of this.modeButtons.entries()) { const text = t(`measure.modes.${mode}`); btn.dataset.tooltip = text; btn.setAttribute('aria-label', text); } const clearText = t('measure.actions.clearAll'); this.clearBtn.dataset.tooltip = clearText; this.clearBtn.setAttribute('aria-label', clearText); const expandText = this.isExpanded ? t('measure.actions.collapse') : t('measure.actions.expand'); delete this.expandBtn.dataset.tooltip; this.expandBtn.setAttribute('aria-label', expandText); const settingsText = t('measure.actions.settings'); this.settingsBtn.dataset.tooltip = settingsText; this.settingsBtn.setAttribute('aria-label', settingsText); this.clearHeightDirectionLabel.textContent = t('measure.clearHeight.direction'); this.clearHeightSelectTypeLabel.textContent = t('measure.clearHeight.selectType'); this.directionButtons.get(0)!.textContent = t('measure.clearHeight.directionDown'); this.directionButtons.get(1)!.textContent = t('measure.clearHeight.directionUp'); this.selectTypeButtons.get('point')!.textContent = t('measure.clearHeight.selectPoint'); this.selectTypeButtons.get('element')!.textContent = t('measure.clearHeight.selectElement'); this.settingsUnitLabel.textContent = t('measure.settings.unit'); this.settingsPrecisionLabel.textContent = t('measure.settings.precision'); this.settingsSaveBtn.textContent = t('measure.settings.save'); this.settingsBackBtn.textContent = t('measure.settings.cancel'); } public switchMode(mode: MeasureMode, triggerCallback: boolean = true): void { this.activeMode = mode; this.syncActiveMode(mode); this.applyClearHeightOptionsState(); this.closeSettingsView(); if (triggerCallback) { this.options.onModeChange?.(mode); } if (mode === 'clearHeight') { this.options.onClearHeightDirectionChange?.(this.clearHeightDirection); this.options.onClearHeightSelectTypeChange?.(this.clearHeightSelectType); } } public destroy(): void { this.element.remove(); } public getConfig(): MeasureConfig { return { ...this.config }; } private createDom(): { root: HTMLElement; clearBtn: HTMLButtonElement; expandBtn: HTMLButtonElement; settingsBtn: HTMLButtonElement; secondaryRow: HTMLElement; clearHeightOptions: HTMLElement; clearHeightDirectionLabel: HTMLElement; clearHeightSelectTypeLabel: HTMLElement; mainView: HTMLElement; settingsView: HTMLElement; settingsUnitLabel: HTMLElement; settingsPrecisionLabel: HTMLElement; settingsUnitSelect: HTMLSelectElement; settingsPrecisionSelect: HTMLSelectElement; settingsSaveBtn: HTMLButtonElement; settingsBackBtn: HTMLButtonElement; } { const root = document.createElement('div'); root.className = 'measure-dock-panel'; const mainView = document.createElement('div'); mainView.className = 'measure-dock-panel-main'; const clearHeightOptions = document.createElement('div'); clearHeightOptions.className = 'measure-dock-clearheight-options'; const directionGroup = document.createElement('div'); directionGroup.className = 'measure-dock-clearheight-group'; const clearHeightDirectionLabel = document.createElement('span'); clearHeightDirectionLabel.className = 'measure-dock-clearheight-label'; const directionButtons = document.createElement('div'); directionButtons.className = 'measure-dock-clearheight-buttons'; const directionDown = this.createClearHeightOptionButton(() => { this.setClearHeightDirection(0); }); const directionUp = this.createClearHeightOptionButton(() => { this.setClearHeightDirection(1); }); this.directionButtons.set(0, directionDown); this.directionButtons.set(1, directionUp); directionButtons.appendChild(directionDown); directionButtons.appendChild(directionUp); directionGroup.appendChild(clearHeightDirectionLabel); directionGroup.appendChild(directionButtons); const selectTypeGroup = document.createElement('div'); selectTypeGroup.className = 'measure-dock-clearheight-group'; const clearHeightSelectTypeLabel = document.createElement('span'); clearHeightSelectTypeLabel.className = 'measure-dock-clearheight-label'; const selectTypeButtons = document.createElement('div'); selectTypeButtons.className = 'measure-dock-clearheight-buttons'; const selectPoint = this.createClearHeightOptionButton(() => { this.setClearHeightSelectType('point'); }); const selectElement = this.createClearHeightOptionButton(() => { this.setClearHeightSelectType('element'); }); this.selectTypeButtons.set('point', selectPoint); this.selectTypeButtons.set('element', selectElement); selectTypeButtons.appendChild(selectPoint); selectTypeButtons.appendChild(selectElement); selectTypeGroup.appendChild(clearHeightSelectTypeLabel); selectTypeGroup.appendChild(selectTypeButtons); clearHeightOptions.appendChild(directionGroup); clearHeightOptions.appendChild(selectTypeGroup); const top = document.createElement('div'); top.className = 'measure-dock-panel-top'; const modeZone = document.createElement('div'); modeZone.className = 'measure-dock-panel-mode-zone'; const primaryRow = document.createElement('div'); primaryRow.className = 'measure-dock-panel-mode-row'; PRIMARY_MODES.forEach((mode) => { primaryRow.appendChild(this.createModeButton(mode)); }); const secondaryRow = document.createElement('div'); secondaryRow.className = 'measure-dock-panel-mode-row measure-dock-panel-mode-row-secondary'; SECONDARY_MODES.forEach((mode) => { secondaryRow.appendChild(this.createModeButton(mode)); }); const clearBtn = this.createIconButton('measure-dock-panel-action-clear', getIcon('delete')); clearBtn.addEventListener('click', () => { this.options.onClearAll?.(); }); const settingsBtn = this.createIconButton('measure-dock-panel-action-settings', getIcon('settings')); settingsBtn.addEventListener('click', () => { this.openSettingsView(); this.options.onSettings?.(); }); primaryRow.appendChild(clearBtn); secondaryRow.appendChild(settingsBtn); modeZone.appendChild(primaryRow); modeZone.appendChild(secondaryRow); const actionZone = document.createElement('div'); actionZone.className = 'measure-dock-panel-actions'; const expandBtn = this.createIconButton('measure-dock-panel-action-expand', getIcon('expand')); expandBtn.addEventListener('click', () => { this.isExpanded = !this.isExpanded; this.applyExpandedState(); this.setLocales(); }); actionZone.appendChild(expandBtn); top.appendChild(modeZone); top.appendChild(actionZone); mainView.appendChild(clearHeightOptions); mainView.appendChild(top); const { settingsView, settingsUnitLabel, settingsPrecisionLabel, settingsUnitSelect, settingsPrecisionSelect, settingsSaveBtn, settingsBackBtn } = this.createSettingsView(); root.appendChild(mainView); root.appendChild(settingsView); return { root, clearBtn, expandBtn, settingsBtn, secondaryRow, clearHeightOptions, clearHeightDirectionLabel, clearHeightSelectTypeLabel, mainView, settingsView, settingsUnitLabel, settingsPrecisionLabel, settingsUnitSelect, settingsPrecisionSelect, settingsSaveBtn, settingsBackBtn }; } private createSettingsView(): { settingsView: HTMLElement; settingsUnitLabel: HTMLElement; settingsPrecisionLabel: HTMLElement; settingsUnitSelect: HTMLSelectElement; settingsPrecisionSelect: HTMLSelectElement; settingsSaveBtn: HTMLButtonElement; settingsBackBtn: HTMLButtonElement; } { const settingsView = document.createElement('div'); settingsView.className = 'measure-dock-panel-settings'; const unitRow = document.createElement('div'); unitRow.className = 'measure-dock-settings-row'; const settingsUnitLabel = document.createElement('span'); settingsUnitLabel.className = 'measure-dock-settings-label'; const settingsUnitSelect = document.createElement('select'); settingsUnitSelect.className = 'measure-dock-settings-select'; ['m', 'cm', 'mm', 'km'].forEach((unit) => { const option = document.createElement('option'); option.value = unit; option.textContent = unit; settingsUnitSelect.appendChild(option); }); unitRow.appendChild(settingsUnitLabel); unitRow.appendChild(settingsUnitSelect); const precisionRow = document.createElement('div'); precisionRow.className = 'measure-dock-settings-row'; const settingsPrecisionLabel = document.createElement('span'); settingsPrecisionLabel.className = 'measure-dock-settings-label'; const settingsPrecisionSelect = document.createElement('select'); settingsPrecisionSelect.className = 'measure-dock-settings-select'; [0, 1, 2, 3].forEach((precision) => { const option = document.createElement('option'); option.value = String(precision); option.textContent = precision === 0 ? '0' : `0.${'0'.repeat(precision)}`; settingsPrecisionSelect.appendChild(option); }); precisionRow.appendChild(settingsPrecisionLabel); precisionRow.appendChild(settingsPrecisionSelect); const actions = document.createElement('div'); actions.className = 'measure-dock-settings-actions'; const settingsSaveBtn = document.createElement('button'); settingsSaveBtn.type = 'button'; settingsSaveBtn.className = 'measure-dock-settings-btn is-save'; settingsSaveBtn.addEventListener('click', () => { this.saveSettings(); }); const settingsBackBtn = document.createElement('button'); settingsBackBtn.type = 'button'; settingsBackBtn.className = 'measure-dock-settings-btn is-back'; settingsBackBtn.addEventListener('click', () => { this.closeSettingsView(); }); actions.appendChild(settingsSaveBtn); actions.appendChild(settingsBackBtn); settingsView.appendChild(unitRow); settingsView.appendChild(precisionRow); settingsView.appendChild(actions); return { settingsView, settingsUnitLabel, settingsPrecisionLabel, settingsUnitSelect, settingsPrecisionSelect, settingsSaveBtn, settingsBackBtn }; } private createClearHeightOptionButton(onClick: () => void): HTMLButtonElement { const button = document.createElement('button'); button.type = 'button'; button.className = 'measure-dock-clearheight-btn'; button.addEventListener('click', onClick); return button; } private createModeButton(mode: MeasureMode): HTMLButtonElement { const button = document.createElement('button'); button.type = 'button'; button.className = 'measure-dock-panel-mode-btn'; button.dataset.mode = mode; button.innerHTML = `${MEASURE_TYPES[mode].icon}`; button.addEventListener('click', () => { this.switchMode(mode); }); this.modeButtons.set(mode, button); return button; } private createIconButton(className: string, iconSvg: string): HTMLButtonElement { const button = document.createElement('button'); button.type = 'button'; button.className = `measure-dock-panel-action-btn ${className}`; button.innerHTML = iconSvg; return button; } private applyExpandedState(): void { this.secondaryRow.style.display = this.isExpanded ? 'grid' : 'none'; this.expandBtn.classList.toggle('is-expanded', this.isExpanded); this.expandBtn.classList.toggle('is-collapsed', !this.isExpanded); } private openSettingsView(): void { this.lockPanelWidth(); this.view = 'settings'; this.syncSettingsFormFromConfig(); this.applyViewState(); } private closeSettingsView(): void { if (this.view !== 'settings') { return; } this.view = 'main'; this.unlockPanelWidth(); this.applyViewState(); } private saveSettings(): void { const nextUnit = this.settingsUnitSelect.value as MeasureUnit; const nextPrecision = Number(this.settingsPrecisionSelect.value) as MeasurePrecision; if (!this.isValidUnit(nextUnit) || !this.isValidPrecision(nextPrecision)) { return; } this.config = { unit: nextUnit, precision: nextPrecision }; this.saveConfigToCache(this.config); this.options.onConfigSave?.(this.getConfig()); this.view = 'main'; this.unlockPanelWidth(); this.applyViewState(); } private lockPanelWidth(): void { const width = this.element.getBoundingClientRect().width; if (width <= 0) { return; } this.lockedWidthPx = Math.ceil(width); this.element.style.width = `${this.lockedWidthPx}px`; } private unlockPanelWidth(): void { this.lockedWidthPx = null; this.element.style.removeProperty('width'); } private syncSettingsFormFromConfig(): void { this.settingsUnitSelect.value = this.config.unit; this.settingsPrecisionSelect.value = String(this.config.precision); } private applyViewState(): void { const showMain = this.view === 'main'; this.mainView.style.display = showMain ? 'block' : 'none'; this.settingsView.style.display = showMain ? 'none' : 'flex'; } private applyClearHeightOptionsState(): void { // this.clearHeightOptions.classList.toggle('is-visible', this.activeMode === 'clearHeight'); this.clearHeightOptions.classList.remove('is-visible'); for (const [direction, button] of this.directionButtons.entries()) { button.classList.toggle('is-active', direction === this.clearHeightDirection); } for (const [selectType, button] of this.selectTypeButtons.entries()) { button.classList.toggle('is-active', selectType === this.clearHeightSelectType); } } private setClearHeightDirection(direction: ClearHeightDirection): void { if (this.clearHeightDirection === direction) { return; } this.clearHeightDirection = direction; this.applyClearHeightOptionsState(); this.options.onClearHeightDirectionChange?.(direction); } private setClearHeightSelectType(selectType: ClearHeightSelectType): void { if (this.clearHeightSelectType === selectType) { return; } this.clearHeightSelectType = selectType; this.applyClearHeightOptionsState(); this.options.onClearHeightSelectTypeChange?.(selectType); } private loadConfigFromCache(): MeasureConfig | null { try { const raw = localStorage.getItem(CONFIG_CACHE_KEY); if (!raw) { return null; } const parsed = JSON.parse(raw) as Partial; if (!parsed || typeof parsed !== 'object') { return null; } if (!this.isValidUnit(parsed.unit) || !this.isValidPrecision(parsed.precision)) { return null; } return { unit: parsed.unit, precision: parsed.precision }; } catch { return null; } } private saveConfigToCache(config: MeasureConfig): void { try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)); } catch { return; } } private isValidUnit(unit: unknown): unit is MeasureUnit { return unit === 'm' || unit === 'cm' || unit === 'mm' || unit === 'km'; } private isValidPrecision(precision: unknown): precision is MeasurePrecision { return precision === 0 || precision === 1 || precision === 2 || precision === 3; } private syncActiveMode(mode: MeasureMode): void { for (const [key, button] of this.modeButtons.entries()) { button.classList.toggle('is-active', key === mode); } } }