feat: 新增底部Dock测量面板与回调联动

This commit is contained in:
yuding
2026-03-30 10:53:39 +08:00
parent c11140f967
commit 2574a11284
42 changed files with 18388 additions and 11404 deletions

View File

@@ -0,0 +1,84 @@
.bottom-dock-stack {
position: absolute;
left: 50%;
bottom: 20px;
transform: translateX(-50%);
width: 0;
height: 0;
z-index: 1000;
pointer-events: none;
}
.bottom-dock-panel {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: fit-content;
max-width: calc(100vw - 40px);
pointer-events: auto;
border-radius: 12px;
border: 1px solid var(--bd-border, rgba(148, 163, 184, 0.35));
background: var(--bd-bg, rgba(255, 255, 255, 0.94));
box-shadow: var(--bd-shadow, 0 2px 8px rgba(15, 23, 42, 0.1));
transition: transform 220ms ease, opacity 200ms ease;
overflow: visible;
}
.bottom-dock-panel.is-entering {
opacity: 0;
}
.bottom-dock-panel.is-leaving {
opacity: 0;
pointer-events: none;
}
.bottom-dock-panel-close {
position: absolute;
top: 5px;
right: 5px;
transform: translate(35%, -35%);
z-index: 2;
width: 16px;
height: 16px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--bd-close-color, #475569);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease;
}
.bottom-dock-panel-close:hover {
background: var(--bd-close-bg-hover, rgba(15, 23, 42, 0.08));
color: var(--bd-close-color-hover, #0f172a);
border-color: var(--bd-close-border-hover, rgba(148, 163, 184, 0.3));
}
.bottom-dock-panel-close:active {
transform: translate(35%, -35%) scale(0.96);
}
.bottom-dock-panel-body {
min-height: 50px;
padding: 10px;
background: var(--bd-body-bg, transparent);
width: fit-content;
max-width: calc(100vw - 40px);
box-sizing: border-box;
border-radius: inherit;
}
.bottom-dock-placeholder {
border-radius: 8px;
border: 1px dashed var(--bd-placeholder-border, rgba(148, 163, 184, 0.55));
padding: 10px;
color: var(--bd-placeholder-text, #64748b);
font-size: 12px;
line-height: 1.5;
text-align: center;
}

View File

@@ -0,0 +1,188 @@
import './index.css';
import type { ThemeConfig } from '../../themes/types';
export interface BottomDockPanelOptions {
id: string;
content: HTMLElement;
closable?: boolean;
onClose: (id: string) => void;
}
interface BottomDockPanelInstance {
id: string;
element: HTMLElement;
bodyElement: HTMLElement;
closeButton: HTMLButtonElement | null;
leaving: boolean;
removeTimer: number | null;
}
export class BottomDockStack {
private readonly container: HTMLElement;
private readonly root: HTMLElement;
private readonly panelGap = 10;
private readonly panelMap: Map<string, BottomDockPanelInstance> = new Map();
private readonly panelOrder: string[] = [];
private readonly resizeObserver: ResizeObserver | null;
private readonly handleWindowResize = () => {
this.reflow();
};
constructor(container: HTMLElement) {
this.container = container;
this.root = document.createElement('div');
this.root.className = 'bottom-dock-stack';
this.container.appendChild(this.root);
this.resizeObserver = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => {
this.reflow();
})
: null;
if (!this.resizeObserver) {
window.addEventListener('resize', this.handleWindowResize);
}
}
public setTheme(theme: ThemeConfig): void {
const style = this.root.style;
style.setProperty('--bd-bg', theme.floatingBtnBg);
style.setProperty('--bd-border', theme.floatingBtnBorder);
style.setProperty('--bd-shadow', theme.floatingBtnShadow);
style.setProperty('--bd-close-color', theme.floatingIconColor);
style.setProperty('--bd-close-color-hover', theme.floatingIconColorHover);
style.setProperty('--bd-close-bg-hover', theme.floatingBtnBgHover);
style.setProperty('--bd-close-border-hover', theme.floatingBtnBorder);
style.setProperty('--bd-body-bg', theme.floatingBtnBg);
style.setProperty('--bd-placeholder-border', theme.borderSubtle);
style.setProperty('--bd-placeholder-text', theme.textSecondary);
}
public hasPanel(id: string): boolean {
return this.panelMap.has(id);
}
public addPanel(options: BottomDockPanelOptions): void {
const existing = this.panelMap.get(options.id);
if (existing && existing.leaving) {
existing.leaving = false;
existing.element.classList.remove('is-leaving');
if (existing.removeTimer) {
clearTimeout(existing.removeTimer);
existing.removeTimer = null;
}
existing.bodyElement.replaceChildren(options.content);
if (!this.panelOrder.includes(options.id)) {
this.panelOrder.push(options.id);
}
this.reflow();
return;
}
if (existing) {
return;
}
const panel = document.createElement('section');
panel.className = 'bottom-dock-panel is-entering';
panel.dataset.panelId = options.id;
let closeButton: HTMLButtonElement | null = null;
if (options.closable !== false) {
closeButton = document.createElement('button');
closeButton.className = 'bottom-dock-panel-close';
closeButton.type = 'button';
closeButton.setAttribute('aria-label', 'close-panel');
closeButton.textContent = '×';
closeButton.addEventListener('click', (event) => {
event.stopPropagation();
options.onClose(options.id);
});
panel.appendChild(closeButton);
}
const body = document.createElement('div');
body.className = 'bottom-dock-panel-body';
body.appendChild(options.content);
panel.appendChild(body);
this.root.appendChild(panel);
this.panelOrder.push(options.id);
this.panelMap.set(options.id, {
id: options.id,
element: panel,
bodyElement: body,
closeButton,
leaving: false,
removeTimer: null
});
this.resizeObserver?.observe(panel);
this.reflow();
requestAnimationFrame(() => {
panel.classList.remove('is-entering');
});
}
public removePanel(id: string): void {
const panel = this.panelMap.get(id);
if (!panel || panel.leaving) {
return;
}
panel.leaving = true;
panel.element.classList.add('is-leaving');
const orderIndex = this.panelOrder.indexOf(id);
if (orderIndex >= 0) {
this.panelOrder.splice(orderIndex, 1);
}
this.reflow();
panel.removeTimer = window.setTimeout(() => {
this.resizeObserver?.unobserve(panel.element);
panel.element.remove();
this.panelMap.delete(id);
panel.removeTimer = null;
}, 220);
}
public createPlaceholderContent(text: string): HTMLElement {
const el = document.createElement('div');
el.className = 'bottom-dock-placeholder';
el.textContent = text;
return el;
}
public destroy(): void {
this.resizeObserver?.disconnect();
window.removeEventListener('resize', this.handleWindowResize);
this.panelMap.clear();
this.panelOrder.length = 0;
this.root.remove();
}
private reflow(): void {
let offset = 0;
let maxWidth = 0;
this.panelOrder.forEach((id) => {
const panel = this.panelMap.get(id);
if (!panel) {
return;
}
panel.element.style.transform = `translateX(-50%) translateY(-${offset}px)`;
offset += panel.element.offsetHeight + this.panelGap;
maxWidth = Math.max(maxWidth, panel.element.offsetWidth);
});
const panelCount = this.panelOrder.length;
const totalHeight = panelCount > 0 ? offset - this.panelGap : 0;
this.root.style.height = `${totalHeight}px`;
this.root.style.width = panelCount > 0 ? `${maxWidth}px` : '0px';
}
}

View File

@@ -1,7 +1,15 @@
import type { ThemeConfig } from '../../themes/types';
import { IBimComponent } from '../../types/component';
import { themeManager } from '../../services/theme';
import type { EngineOptions, ModelLoadOptions, EngineInfo } from './types';
import type {
EngineOptions,
ModelLoadOptions,
EngineInfo,
EngineSettings,
EngineSettingsPatch,
EngineSettingPreset,
SettingPresetLists,
} from './types';
import { type MeasureMode, type ClearHeightDirection, type ClearHeightSelectType } from '../../types/measure';
import type { MeasureUnit, MeasurePrecision } from '../measure-panel/types';
import type { SectionBoxRange } from '../section-box-panel/types';
@@ -11,7 +19,15 @@ import { createEngine as createEngineSDK } from 'iflow-engine-base';
//import { createEngine as createEngineSDK } from '../../../../bim_engine_base/dist/bim-engine-sdk.es';
import "../../../../bim_engine_base/dist/iflow-engine-base.css"
export type { EngineOptions, ModelLoadOptions, EngineInfo };
export type {
EngineOptions,
ModelLoadOptions,
EngineInfo,
EngineSettings,
EngineSettingsPatch,
EngineSettingPreset,
SettingPresetLists,
};
/**
* 创建 Engine 实例的工厂函数
@@ -678,6 +694,254 @@ export class Engine implements IBimComponent {
// ==================== 设置功能 ====================
private normalizePercent(value: number, fallback: number): number {
if (!Number.isFinite(value)) return fallback;
return Math.min(100, Math.max(0, Math.round(value)));
}
private getSettingApi(): Record<string, unknown> | null {
if (!this._isInitialized || !this.engine?.setting) {
return null;
}
return this.engine.setting as Record<string, unknown>;
}
private callSettingMethod<T = unknown>(methodName: string, ...args: unknown[]): T | undefined {
const settingApi = this.getSettingApi();
if (!settingApi) return undefined;
const method = settingApi[methodName];
if (typeof method !== 'function') return undefined;
return (method as (...innerArgs: unknown[]) => T).apply(this.engine.setting, args);
}
private hasSettingMethod(methodName: string): boolean {
const settingApi = this.getSettingApi();
if (!settingApi) return false;
return typeof settingApi[methodName] === 'function';
}
private mergeSettingsWithDefaults(raw: Partial<EngineSettings>): EngineSettings {
const groundId = raw.display?.groundId ?? '0';
const showGround = typeof raw.display?.showGround === 'boolean'
? raw.display.showGround
: groundId !== '0' && groundId !== '';
return {
render: {
mode: raw.render?.mode ?? 'advanced',
contrast: this.normalizePercent(raw.render?.contrast ?? 50, 50),
saturation: this.normalizePercent(raw.render?.saturation ?? 50, 50),
shadowIntensity: this.normalizePercent(raw.render?.shadowIntensity ?? 50, 50),
lightIntensity: this.normalizePercent(raw.render?.lightIntensity ?? 50, 50),
gtaoIntensity: this.normalizePercent(raw.render?.gtaoIntensity ?? 50, 50),
},
display: {
showEdge: Boolean(raw.display?.showEdge),
edgeOpacity: this.normalizePercent(raw.display?.edgeOpacity ?? 30, 30),
showGrid: Boolean(raw.display?.showGrid),
showLevel: Boolean(raw.display?.showLevel),
showGround,
groundId,
groundHeight: Number.isFinite(raw.display?.groundHeight) ? Number(raw.display?.groundHeight) : 0,
},
environment: {
type: raw.environment?.type ?? 'none',
hdrId: raw.environment?.hdrId ?? '0',
hdrIntensity: this.normalizePercent(raw.environment?.hdrIntensity ?? 20, 20),
skyPreset: raw.environment?.skyPreset ?? 'sunrise_clear',
skyParams: raw.environment?.skyParams ?? {},
skyIntensity: this.normalizePercent(raw.environment?.skyIntensity ?? 20, 20),
},
};
}
public getPresetLists(): SettingPresetLists {
const fallback: SettingPresetLists = { ground: [], hdr: [], sky: [] };
const settingApi = this.getSettingApi();
if (!settingApi) return fallback;
const getter = settingApi.getPresetLists;
const raw = typeof getter === 'function'
? (getter.call(this.engine.setting) as Partial<SettingPresetLists> | null)
: null;
const legacyGroundList = this.getGroundList().map((item) => ({
id: item.id,
names: [item.name, item.name, item.name],
}));
const legacyHdrList = this.getHDRBackgroundList().map((item) => ({
id: item.id,
names: [item.name, item.name, item.name],
}));
return {
ground: Array.isArray(raw?.ground) && raw.ground.length > 0 ? raw.ground : legacyGroundList,
hdr: Array.isArray(raw?.hdr) && raw.hdr.length > 0 ? raw.hdr : legacyHdrList,
sky: Array.isArray(raw?.sky) ? raw.sky : [],
};
}
public getSettings(): EngineSettings {
const raw = this.callSettingMethod<Partial<EngineSettings>>('getSettings');
if (raw && typeof raw === 'object') {
return this.mergeSettingsWithDefaults(raw);
}
return {
render: {
mode: this.getRenderMode() as EngineSettings['render']['mode'],
contrast: this.normalizePercent(this.getSceneContrast(), 50),
saturation: this.normalizePercent(this.getSceneSaturation(), 50),
shadowIntensity: 50,
lightIntensity: this.normalizePercent(this.getAmbientLightIntensity(), 50),
gtaoIntensity: 50,
},
display: {
showEdge: this.getModelEdgeActive(),
edgeOpacity: 30,
showGrid: false,
showLevel: false,
showGround: this.getGroundId() !== '0' && this.getGroundId() !== '',
groundId: this.getGroundId() || '0',
groundHeight: this.getGroundElevation(),
},
environment: {
type: this.getHDRBackgroundId() && this.getHDRBackgroundId() !== '0' ? 'hdr' : 'none',
hdrId: this.getHDRBackgroundId() || '0',
hdrIntensity: 20,
skyPreset: 'sunrise_clear',
skyParams: {},
skyIntensity: 20,
},
};
}
public async setSettings(settings: EngineSettingsPatch): Promise<void> {
if (!this._isInitialized || !this.engine?.setting) {
console.warn('[Engine] Cannot set settings: engine not initialized.');
return;
}
if (this.hasSettingMethod('setSettings')) {
try {
const setSettingsResult = this.callSettingMethod('setSettings', settings);
await Promise.resolve(setSettingsResult);
return;
} catch (error) {
console.warn('[Engine] Native setSettings failed, fallback to compatibility mode.', error);
}
}
if (settings.render?.mode) {
this.setRenderMode(settings.render.mode);
}
if (typeof settings.render?.lightIntensity === 'number') {
this.setAmbientLightIntensity(settings.render.lightIntensity);
}
if (typeof settings.render?.contrast === 'number') {
this.setSceneContrast(settings.render.contrast);
}
if (typeof settings.render?.saturation === 'number') {
this.setSceneSaturation(settings.render.saturation);
}
if (typeof settings.display?.showEdge === 'boolean') {
if (settings.display.showEdge) {
this.activeModelEdge();
} else {
this.disActiveModelEdge();
}
}
if (typeof settings.display?.edgeOpacity === 'number') {
this.callSettingMethod('setEdgeOpacity', settings.display.edgeOpacity);
}
if (typeof settings.display?.showGrid === 'boolean') {
this.callSettingMethod('setShowGrid', settings.display.showGrid);
}
if (typeof settings.display?.showLevel === 'boolean') {
this.callSettingMethod('setShowLevel', settings.display.showLevel);
}
if (typeof settings.display?.groundId === 'string') {
this.setGroundId(settings.display.groundId);
}
if (typeof settings.display?.groundHeight === 'number') {
this.setGroundElevation(settings.display.groundHeight);
}
if (typeof settings.environment?.type === 'string') {
const result = this.callSettingMethod('setEnvironmentType', settings.environment.type);
if (result !== undefined) {
await Promise.resolve(result);
} else if (settings.environment.type === 'none') {
this.setHDRBackgroundId('0');
}
}
if (typeof settings.environment?.hdrId === 'string') {
const result = this.callSettingMethod('setHdrId', settings.environment.hdrId);
if (result !== undefined) {
await Promise.resolve(result);
} else {
this.setHDRBackgroundId(settings.environment.hdrId);
}
}
if (typeof settings.environment?.hdrIntensity === 'number') {
this.callSettingMethod('setHdrIntensity', settings.environment.hdrIntensity);
}
if (typeof settings.environment?.skyPreset === 'string') {
this.callSettingMethod('setSkyPreset', settings.environment.skyPreset);
}
if (typeof settings.environment?.skyIntensity === 'number') {
this.callSettingMethod('setSkyIntensity', settings.environment.skyIntensity);
}
if (settings.environment?.skyParams && typeof settings.environment.skyParams === 'object') {
this.callSettingMethod('setSkyParams', settings.environment.skyParams);
}
if (typeof settings.render?.shadowIntensity === 'number') {
this.callSettingMethod('setShadowIntensity', settings.render.shadowIntensity);
}
if (typeof settings.render?.gtaoIntensity === 'number') {
this.callSettingMethod('setGtaoIntensity', settings.render.gtaoIntensity);
}
}
public async resetToDefault(): Promise<void> {
const settingApi = this.getSettingApi();
const resetApi = settingApi?.resetToDefault;
if (typeof resetApi === 'function') {
await Promise.resolve(resetApi.call(this.engine.setting));
return;
}
await this.setSettings({
render: {
mode: 'advanced',
contrast: 50,
saturation: 50,
shadowIntensity: 50,
lightIntensity: 50,
gtaoIntensity: 50,
},
display: {
showEdge: false,
edgeOpacity: 30,
showGrid: false,
showLevel: false,
showGround: false,
groundId: '0',
groundHeight: 0,
},
environment: {
type: 'none',
hdrId: '0',
hdrIntensity: 20,
skyPreset: 'sunrise_clear',
skyParams: {},
skyIntensity: 20,
},
});
}
// ---- 边线 ----
/** 启用模型边线显示 */

View File

@@ -31,3 +31,78 @@ export interface ModelLoadOptions {
/** 模型 ID可选如果不提供则自动生成 */
id?: string;
}
export interface RenderSettings {
mode: 'simple' | 'balance' | 'advanced';
contrast: number;
saturation: number;
shadowIntensity: number;
lightIntensity: number;
gtaoIntensity: number;
}
export interface DisplaySettings {
showEdge: boolean;
edgeOpacity: number;
showGrid: boolean;
showLevel: boolean;
showGround: boolean;
groundId: string;
groundHeight: number;
}
export interface SkyParams {
turbidity?: number;
rayleigh?: number;
mieCoefficient?: number;
mieDirectionalG?: number;
elevation?: number;
azimuth?: number;
exposure?: number;
orthoExposureScale?: number;
cloudCoverage?: number;
cloudDensity?: number;
cloudElevation?: number;
showSunDisc?: boolean;
}
export interface EnvironmentSettings {
type: 'none' | 'hdr' | 'sky';
hdrId: string;
hdrIntensity: number;
skyPreset: string;
skyParams: SkyParams;
skyIntensity: number;
}
export interface EngineSettings {
render: RenderSettings;
display: DisplaySettings;
environment: EnvironmentSettings;
}
export interface EngineSettingsPatch {
render?: Partial<RenderSettings>;
display?: Partial<DisplaySettings>;
environment?: Partial<EnvironmentSettings>;
}
export interface EngineSettingPreset {
id: string;
presetName: string;
isDefault: boolean;
settings: EngineSettings;
readonly?: boolean;
source?: 'sdk-default' | 'external' | 'user';
}
export interface PresetListItem {
id: string;
names: string[];
}
export interface SettingPresetLists {
ground: PresetListItem[];
hdr: PresetListItem[];
sky: PresetListItem[];
}

View File

@@ -0,0 +1,335 @@
.measure-dock-panel {
width: fit-content;
max-width: 100%;
padding: 0;
border-radius: 8px;
border: none;
background: color-mix(in srgb, var(--bim-bg-elevated, #e8ecf2) 92%, #ffffff 8%);
box-sizing: border-box;
color: var(--bim-text-secondary, #475569);
font-size: 13px;
line-height: 1.5;
}
.measure-dock-panel-main {
display: block;
}
.measure-dock-panel-settings {
display: none;
flex-direction: column;
gap: 6px;
min-height: 74px;
box-sizing: border-box;
}
.measure-dock-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.measure-dock-settings-label {
color: var(--bim-text-secondary, #64748b);
font-size: 12px;
}
.measure-dock-settings-select {
width: 88px;
height: 24px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.36);
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 90%, #ffffff 10%);
color: var(--bim-text-primary, #0f172a);
font-size: 12px;
padding: 0 6px;
box-sizing: border-box;
}
.measure-dock-settings-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.measure-dock-settings-btn {
height: 24px;
min-width: 52px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.36);
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 90%, #ffffff 10%);
color: var(--bim-text-secondary, #64748b);
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.measure-dock-settings-btn.is-save {
border-color: color-mix(in srgb, var(--bim-primary, #4f88ff) 46%, transparent 54%);
color: color-mix(in srgb, var(--bim-primary, #4f88ff) 78%, #6f9dff 22%);
}
.measure-dock-panel-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.measure-dock-clearheight-options {
display: none;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
padding: 8px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 90%, #ffffff 10%);
}
.measure-dock-clearheight-options.is-visible {
display: flex;
}
.measure-dock-clearheight-group {
display: flex;
align-items: center;
gap: 8px;
}
.measure-dock-clearheight-label {
min-width: 60px;
color: var(--bim-text-secondary, #64748b);
font-size: 12px;
}
.measure-dock-clearheight-buttons {
display: flex;
gap: 8px;
}
.measure-dock-clearheight-btn {
height: 28px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.36);
background: color-mix(in srgb, var(--bim-bg-elevated, #f8fafc) 88%, #ffffff 12%);
color: var(--bim-text-secondary, #64748b);
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: all 0.15s ease;
}
.measure-dock-clearheight-btn:hover {
border-color: rgba(148, 163, 184, 0.56);
}
.measure-dock-clearheight-btn.is-active {
border-color: color-mix(in srgb, var(--bim-primary, #4f88ff) 70%, #9db9ff 30%);
background: color-mix(in srgb, var(--bim-primary-subtle, rgba(96, 140, 255, 0.18)) 72%, #ffffff 28%);
color: color-mix(in srgb, var(--bim-primary, #4f88ff) 78%, #6f9dff 22%);
}
.measure-dock-panel-mode-zone {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.measure-dock-panel-mode-row {
display: grid;
grid-template-columns: repeat(5, 32px);
gap: 10px;
}
.measure-dock-panel-mode-row-secondary {
display: none;
}
.measure-dock-panel-mode-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 92%, #ffffff 8%);
color: color-mix(in srgb, var(--bim-text-secondary, #64748b) 94%, #475569 6%);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
box-sizing: border-box;
cursor: pointer;
transition: all 0.15s ease;
}
.measure-dock-panel-mode-icon {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.measure-dock-panel-mode-icon svg {
width: 100%;
height: 100%;
fill: currentColor;
}
.measure-dock-panel-mode-btn:hover {
border-color: rgba(148, 163, 184, 0.5);
background: color-mix(in srgb, var(--bim-component-bg-hover, #dce5f2) 64%, #ffffff 36%);
}
.measure-dock-panel-mode-btn.is-active {
border-color: color-mix(in srgb, var(--bim-primary, #4f88ff) 70%, #9db9ff 30%);
background: color-mix(in srgb, var(--bim-primary-subtle, rgba(96, 140, 255, 0.18)) 72%, #ffffff 28%);
color: color-mix(in srgb, var(--bim-primary, #4f88ff) 78%, #6f9dff 22%);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bim-primary, #4f88ff) 35%, transparent 65%);
}
.measure-dock-panel-actions {
flex: 0 0 auto;
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
}
.measure-dock-panel-action-btn {
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 92%, #ffffff 8%);
color: var(--bim-text-secondary, #475569);
padding: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.measure-dock-panel-action-btn:hover {
border-color: rgba(148, 163, 184, 0.5);
color: var(--bim-text-primary, #0f172a);
}
.measure-dock-panel-action-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.measure-dock-panel-action-expand {
width: 16px;
height: 32px;
}
.measure-dock-panel-action-expand.is-expanded {
height: 74px;
}
.measure-dock-panel-action-expand.is-collapsed {
height: 32px;
}
.measure-dock-panel-action-clear {
background: color-mix(in srgb, var(--bim-danger, #ef4444) 16%, #ffffff 84%);
border-color: color-mix(in srgb, var(--bim-danger, #ef4444) 26%, transparent 74%);
color: var(--bim-danger, #ef4444);
}
.measure-dock-panel-action-clear:hover {
border-color: color-mix(in srgb, var(--bim-danger, #ef4444) 40%, transparent 60%);
color: var(--bim-danger, #ef4444);
}
.measure-dock-panel-action-settings {
color: color-mix(in srgb, var(--bim-text-primary, #0f172a) 86%, #000 14%);
}
.measure-dock-panel-mode-row .measure-dock-panel-action-btn {
border-radius: 8px;
}
.measure-dock-panel-action-expand svg {
transition: transform 0.15s ease;
}
.measure-dock-panel-action-expand.is-expanded svg {
transform: rotate(180deg);
}
.measure-dock-panel [data-tooltip] {
position: relative;
}
.measure-dock-panel [data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 6px);
transform: translateX(-50%);
padding: 4px 8px;
border-radius: 6px;
background: rgba(15, 23, 42, 0.92);
color: #f8fafc;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
pointer-events: none;
opacity: 0;
visibility: hidden;
z-index: 8;
transition: opacity 60ms ease;
}
.measure-dock-panel [data-tooltip]:hover::after,
.measure-dock-panel [data-tooltip]:focus-visible::after {
opacity: 1;
visibility: visible;
}
@media (max-width: 720px) {
.measure-dock-panel-top {
flex-direction: column;
}
.measure-dock-clearheight-group {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.measure-dock-panel-actions {
width: 100%;
flex-direction: row;
justify-content: flex-end;
}
.measure-dock-panel-action-btn {
width: 32px;
height: 32px;
}
.measure-dock-panel-mode-btn {
width: 32px;
height: 32px;
}
.measure-dock-panel-action-expand {
width: 16px;
height: 32px;
}
.measure-dock-panel-action-expand.is-expanded {
height: 74px;
}
}

View File

@@ -0,0 +1,582 @@
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<MeasureMode, HTMLButtonElement> = 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<ClearHeightDirection, HTMLButtonElement> = new Map();
private readonly selectTypeButtons: Map<ClearHeightSelectType, HTMLButtonElement> = 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 = `<span class="measure-dock-panel-mode-icon">${MEASURE_TYPES[mode].icon}</span>`;
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<MeasureConfig>;
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);
}
}
}

View File

@@ -0,0 +1,28 @@
import type { ManagerRegistry } from '../../../core/manager-registry';
import type { RadialMenuItem } from '../types';
import { getIcon } from '../../../utils/icon-manager';
export const createMeasureRadialButton = (registry: ManagerRegistry): RadialMenuItem => {
return {
id: 'measure',
label: 'toolbar.measure',
icon: getIcon('测量'),
isToggle: true,
isActive: registry.bottomDock?.isOpen('measure') ?? false,
onToggle: (next) => {
const dock = registry.bottomDock;
if (!dock) {
console.warn('[RadialToolbar] bottom dock not initialized: measure');
return;
}
if (next) {
dock.open('measure');
} else {
dock.close('measure');
}
console.log(`[RadialToolbar] 测量${next ? '已打开' : '已关闭'}`);
}
};
};

View File

@@ -0,0 +1,28 @@
import type { ManagerRegistry } from '../../../core/manager-registry';
import type { RadialMenuItem } from '../types';
import { getIcon } from '../../../utils/icon-manager';
export const createSectionRadialButton = (registry: ManagerRegistry): RadialMenuItem => {
return {
id: 'section',
label: 'toolbar.section',
icon: getIcon('剖切'),
isToggle: true,
isActive: registry.bottomDock?.isOpen('section') ?? false,
onToggle: (next) => {
const dock = registry.bottomDock;
if (!dock) {
console.warn('[RadialToolbar] bottom dock not initialized: section');
return;
}
if (next) {
dock.open('section');
} else {
dock.close('section');
}
console.log(`[RadialToolbar] 剖切${next ? '已打开' : '已关闭'}`);
}
};
};

View File

@@ -0,0 +1,15 @@
import type { ManagerRegistry } from '../../../core/manager-registry';
import type { RadialMenuItem } from '../types';
import { getIcon } from '../../../utils/icon-manager';
export const createSettingRadialButton = (registry: ManagerRegistry): RadialMenuItem => {
return {
id: 'setting',
label: 'toolbar.setting',
icon: getIcon('设置'),
onClick: () => {
registry.setting?.toggle();
console.log(`[RadialToolbar] 设置${registry.setting?.isOpen() ? '已打开' : '已关闭'}`);
}
};
};

View File

@@ -0,0 +1,28 @@
import type { ManagerRegistry } from '../../../core/manager-registry';
import type { RadialMenuItem } from '../types';
import { getIcon } from '../../../utils/icon-manager';
export const createWalkRadialButton = (registry: ManagerRegistry): RadialMenuItem => {
return {
id: 'walk',
label: 'toolbar.walk',
icon: getIcon('漫游'),
isToggle: true,
isActive: registry.bottomDock?.isOpen('walk') ?? false,
onToggle: (next) => {
const dock = registry.bottomDock;
if (!dock) {
console.warn('[RadialToolbar] bottom dock not initialized: walk');
return;
}
if (next) {
dock.open('walk');
} else {
dock.close('walk');
}
console.log(`[RadialToolbar] 漫游${next ? '已打开' : '已关闭'}`);
}
};
};

View File

@@ -0,0 +1,112 @@
.radial-toolbar-wrapper {
position: absolute;
right: 20px;
bottom: 20px;
z-index: 1000;
pointer-events: none;
}
.radial-main-btn,
.radial-sub-btn {
border: 1px solid transparent;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
pointer-events: auto;
outline: none;
transition: transform 0.22s ease, opacity 0.22s ease, box-shadow 0.22s ease, background-color 0.22s ease, color 0.22s ease;
}
.radial-main-btn {
position: absolute;
right: 0;
bottom: 0;
width: var(--rt-main-size, 70px);
height: var(--rt-main-size, 70px);
background: var(--rt-main-bg, rgba(255, 255, 255, 0.92));
border-color: var(--rt-main-border, rgba(203, 213, 225, 0.65));
box-shadow: var(--rt-main-shadow, 0 4px 12px rgba(0, 0, 0, 0.12));
color: var(--rt-main-icon, #334155);
}
.radial-main-btn:hover {
transform: translateZ(0) scale(1.04);
background: var(--rt-main-bg-hover, #ffffff);
box-shadow: var(--rt-main-shadow-hover, 0 6px 20px rgba(0, 0, 0, 0.14));
color: var(--rt-main-icon-hover, #1e293b);
}
.radial-main-btn:active {
transform: translateZ(0) scale(0.97);
}
.radial-main-btn svg {
width: 34px;
height: 34px;
}
.radial-sub-btn {
position: absolute;
right: var(--rt-main-offset, 10px);
bottom: var(--rt-main-offset, 10px);
width: var(--rt-sub-size, 50px);
height: var(--rt-sub-size, 50px);
background: var(--rt-sub-bg, rgba(255, 255, 255, 0.9));
border-color: var(--rt-sub-border, rgba(203, 213, 225, 0.65));
box-shadow: var(--rt-sub-shadow, 0 2px 8px rgba(0, 0, 0, 0.1));
color: var(--rt-sub-icon, #334155);
opacity: 0;
transform: translate(0, 0) scale(0.76);
transform-origin: center;
--rt-delay-current: var(--rt-close-delay, 0s);
transition-delay: var(--rt-delay-current);
}
.radial-toolbar-wrapper.is-active .radial-sub-btn {
--rt-delay-current: var(--rt-open-delay, 0s);
opacity: 1;
transform: translate(var(--rt-x), var(--rt-y)) scale(1);
}
.radial-sub-btn:hover {
background: var(--rt-sub-bg-hover, var(--bim-primary, #2563eb));
box-shadow: var(--rt-sub-shadow-hover, 0 4px 14px rgba(0, 0, 0, 0.16));
color: var(--rt-sub-icon-hover, var(--bim-text-inverse, #ffffff));
}
.radial-sub-btn.is-active,
.radial-sub-btn[data-active="true"] {
background: var(--bim-primary, #2563eb);
border: 1px solid var(--bim-primary-active, #1d4ed8);
box-shadow: var(--bim-shadow-glow, 0 0 0 2px rgba(37, 99, 235, 0.28));
color: var(--bim-text-inverse, #ffffff);
}
.radial-sub-btn.is-active:hover,
.radial-sub-btn[data-active="true"]:hover {
background: var(--bim-primary-hover, #3b82f6);
}
.radial-sub-btn.is-active .radial-sub-btn-icon,
.radial-sub-btn[data-active="true"] .radial-sub-btn-icon {
color: var(--bim-icon-inverse, #ffffff);
}
.radial-sub-btn:active {
transform: translate(var(--rt-x), var(--rt-y)) scale(0.94);
}
.radial-sub-btn-icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.radial-sub-btn-icon svg {
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,474 @@
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);
}
}
}

View File

@@ -0,0 +1,19 @@
export interface RadialMenuItem {
id: string;
label: string;
icon?: string;
onClick?: (item: RadialMenuItem) => void;
isToggle?: boolean;
isActive?: boolean;
onToggle?: (nextActive: boolean, item: RadialMenuItem) => void;
}
export interface RadialToolbarOptions {
container: HTMLElement;
items?: RadialMenuItem[];
mainButtonIcon?: string;
mainButtonLabel?: string;
onMainButtonClick?: () => void;
itemsPerRing?: number;
closeDelay?: number;
}