import './index.css'; import type { DialogOptions } from './index.type'; import type { ThemeConfig } from '../../themes/types'; import { IBimComponent } from '../../types/component'; import { themeManager } from '../../services/theme'; import { t, localeManager } from '../../services/locale'; /** * 通用弹窗组件类 * 支持拖拽、缩放、自定义内容和位置。 */ export class BimDialog implements IBimComponent { private element: HTMLElement; private options: DialogOptions; private container: HTMLElement; private header: HTMLElement; private contentArea: HTMLElement; private _isDestroyed = false; private _isInitialized = false; private unsubscribeTheme: (() => void) | null = null; private unsubscribeLocale: (() => void) | null = null; // 性能优化:用于存储 requestAnimationFrame 的 ID private rafId: number | null = null; /** * 构造函数 * @param options 弹窗配置选项 */ constructor(options: DialogOptions) { // 合并默认配置 this.options = { title: 'Dialog', width: 300, height: 'auto', position: 'center', draggable: true, resizable: false, minWidth: 200, minHeight: 100, ...options }; this.container = options.container; // 创建 DOM 结构 this.element = this.createDom(); this.header = this.element.querySelector('.bim-dialog-header') as HTMLElement; this.contentArea = this.element.querySelector('.bim-dialog-content') as HTMLElement; // 自动初始化 (为了兼容现有逻辑) this.init(); } /** * 设置主题 * @param theme 全局主题配置 */ public setTheme(theme: ThemeConfig) { const style = this.element.style; if (!this.options.backgroundColor) style.setProperty('--bim-dialog-bg', theme.bgElevated); if (!this.options.headerBackgroundColor) style.setProperty('--bim-dialog-header-bg', theme.bgInset); if (!this.options.titleColor) style.setProperty('--bim-dialog-title-color', theme.textPrimary); if (!this.options.textColor) style.setProperty('--bim-dialog-text-color', theme.textSecondary); if (!this.options.borderColor) style.setProperty('--bim-dialog-border-color', theme.borderDefault); style.setProperty('--bim-bg-elevated', theme.bgElevated); style.setProperty('--bim-bg-inset', theme.bgInset); 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-shadow-lg', theme.shadowLg); } /** * 初始化组件功能 (接口实现) */ public init() { if (this._isInitialized) return; this.container.appendChild(this.element); // 必须先挂载才能计算尺寸进行定位 this.initPosition(); if (this.options.draggable) { this.initDrag(); } if (this.options.resizable) { this.initResize(); } this._isInitialized = true; // 调用弹窗开启后回调 if (this.options.onOpen) { this.options.onOpen(); } // 自动订阅主题变更 this.unsubscribeTheme = themeManager.subscribe((theme) => { this.setTheme(theme); }); // 自动订阅语言变更 this.unsubscribeLocale = localeManager.subscribe(() => { this.setLocales(); }); } public setLocales(): void { if (this.options.title) { const titleEl = this.header.querySelector('.bim-dialog-title'); if (titleEl) { titleEl.textContent = t(this.options.title); } } } /** * 创建弹窗的 DOM 结构 */ private createDom(): HTMLElement { const el = document.createElement('div'); el.className = 'bim-dialog'; if (this.options.id) el.id = this.options.id; // 应用颜色配置到 CSS 变量 const style = el.style; if (this.options.backgroundColor) style.setProperty('--bim-dialog-bg', this.options.backgroundColor); if (this.options.headerBackgroundColor) style.setProperty('--bim-dialog-header-bg', this.options.headerBackgroundColor); if (this.options.titleColor) style.setProperty('--bim-dialog-title-color', this.options.titleColor); if (this.options.textColor) style.setProperty('--bim-dialog-text-color', this.options.textColor); if (this.options.borderColor) style.setProperty('--bim-dialog-border-color', this.options.borderColor); // 设置初始尺寸 this.setSize(el, this.options.width, this.options.height); // 确保最小尺寸生效 if (this.options.minWidth) el.style.minWidth = `${this.options.minWidth}px`; // 创建标题栏 (Header) const header = document.createElement('div'); header.className = 'bim-dialog-header'; if (this.options.draggable) header.classList.add('draggable'); const title = document.createElement('span'); title.className = 'bim-dialog-title'; title.textContent = this.options.title ? t(this.options.title) : ''; const closeBtn = document.createElement('span'); closeBtn.className = 'bim-dialog-close'; closeBtn.innerHTML = '×'; // 修复 TS 报错:去掉未使用的参数 e closeBtn.onclick = () => { this.close(); }; header.appendChild(title); header.appendChild(closeBtn); // 创建内容区域 (Content) const content = document.createElement('div'); content.className = 'bim-dialog-content'; if (typeof this.options.content === 'string') { content.innerHTML = this.options.content; } else if (this.options.content instanceof HTMLElement) { content.appendChild(this.options.content); } el.appendChild(header); el.appendChild(content); // 如果允许缩放,创建缩放手柄 if (this.options.resizable) { const resizeHandle = document.createElement('div'); resizeHandle.className = 'bim-dialog-resize-handle'; el.appendChild(resizeHandle); } // ==================== 事件拦截核心逻辑 ==================== // 定义阻断逻辑:只阻止冒泡,不阻止捕获,也不阻止默认行为(除非显式阻止) const stopPropagation = (e: Event) => { e.stopPropagation(); }; // 现代浏览器和 3D 引擎 (Three.js/Cesium) 交互事件 const events = [ 'click', 'dblclick', 'contextmenu', 'wheel', 'mousedown', 'mouseup', 'mousemove', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'pointerup', 'pointermove', 'pointerenter', 'pointerleave', 'pointerover', 'pointerout' ]; // 绑定监听器 (默认冒泡阶段) // 这样内部元素(如关闭按钮)先触发,然后冒泡到这里被拦截,不再传给地图 events.forEach(eventType => { el.addEventListener(eventType, stopPropagation, { passive: false }); }); return el; } /** * 设置元素尺寸 */ private setSize(el: HTMLElement, width?: number | string, height?: number | string) { if (width !== undefined) { if (width === 'auto' || width === 'fit-content') { el.style.width = width; } else { el.style.width = typeof width === 'number' ? `${width}px` : width; } } if (height !== undefined) { if (height === 'auto' || height === 'fit-content') { el.style.height = height; } else { el.style.height = typeof height === 'number' ? `${height}px` : height; } } } /** * 根据内容自动调整弹窗宽度 * @param recenter 是否重新计算定位(例如保持居中),默认 true */ public fitWidth(recenter: boolean = false) { // 1. 设置为 fit-content 以获取自然宽度,高度保持不变 this.element.style.width = 'fit-content'; // 2. 如果需要重新定位 if (recenter) { this.initPosition(); } } /** * 根据内容自动调整弹窗高度 * * 设计说明: * - 主要用于“内容展开/收起”场景(比如测量面板展开后,Dialog 高度跟随变化) * - 默认不改变用户拖拽后的当前位置,只做边界夹紧,避免弹窗超出容器 * * @param recenter 是否根据 options.position 重新定位(默认 false) */ public fitHeight(recenter: boolean = false) { // 1) 先让高度由内容自然撑开,便于测量真实高度 this.element.style.height = 'auto'; // 2) 获取自然高度并做约束(最小高度 + 不超过容器) const naturalHeight = this.element.getBoundingClientRect().height; const minHeight = this.options.minHeight ?? 100; const containerHeight = this.container.clientHeight || 0; // 如果容器高度不可用,至少保证最小高度 let targetHeight = Math.max(minHeight, naturalHeight); // 约束最大高度:不超过容器高度(避免完全溢出) if (containerHeight > 0) { targetHeight = Math.min(targetHeight, containerHeight); } this.element.style.height = `${targetHeight}px`; // 3) 定位修正:recenter 则重新按 position 计算,否则只做边界夹紧 if (recenter) { this.initPosition(); } else { this.clampToContainer(); } } /** * 边界夹紧:保持当前 left/top 不变的前提下,确保弹窗不超出容器 * 说明:用于 fitHeight / fitWidth 后的“尺寸变化”场景,避免弹窗被裁切。 */ private clampToContainer(): void { const containerW = this.container.clientWidth; const containerH = this.container.clientHeight; const elW = this.element.offsetWidth; const elH = this.element.offsetHeight; // 当前 left/top(优先从 style 读取,避免 NaN) const currentLeft = this.element.offsetLeft; const currentTop = this.element.offsetTop; const maxLeft = Math.max(0, containerW - elW); const maxTop = Math.max(0, containerH - elH); const nextLeft = Math.max(0, Math.min(currentLeft, maxLeft)); const nextTop = Math.max(0, Math.min(currentTop, maxTop)); this.element.style.left = `${nextLeft}px`; this.element.style.top = `${nextTop}px`; } /** * 初始化弹窗位置 */ private initPosition() { const pos = this.options.position; const elRect = this.element.getBoundingClientRect(); // 计算相对父容器的定位 let left = 0; let top = 0; const pW = this.container.clientWidth; const pH = this.container.clientHeight; const elW = elRect.width; const elH = elRect.height; if (typeof pos === 'object' && 'x' in pos) { left = pos.x; top = pos.y; } else { switch (pos) { case 'center': left = (pW - elW) / 2; top = (pH - elH) / 2; break; case 'top-left': left = 0; top = 0; break; case 'top-center': left = (pW - elW) / 2; top = 0; break; case 'top-right': left = pW - elW; top = 0; break; case 'left-center': left = 0; top = (pH - elH) / 2; break; case 'right-center': left = pW - elW; top = (pH - elH) / 2; break; case 'bottom-left': left = 0; top = pH - elH; break; case 'bottom-center': left = (pW - elW) / 2; top = pH - elH; break; case 'bottom-right': left = pW - elW; top = pH - elH; break; default: left = (pW - elW) / 2; top = (pH - elH) / 2; } } left = Math.max(0, Math.min(left, pW - elW)); top = Math.max(0, Math.min(top, pH - elH)); this.element.style.left = `${left}px`; this.element.style.top = `${top}px`; } /** * 初始化拖拽功能 (性能优化 + 解决粘手) */ private initDrag() { let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; let containerW = 0; let containerH = 0; let elW = 0; let elH = 0; const onMouseDown = (e: MouseEvent) => { e.preventDefault(); // 阻止默认行为(如选中文本),非常重要,防止卡顿 e.stopPropagation(); // 阻止传递给 Three.js startX = e.clientX; startY = e.clientY; startLeft = this.element.offsetLeft; startTop = this.element.offsetTop; // 缓存尺寸,减少 reflow containerW = this.container.clientWidth; containerH = this.container.clientHeight; elW = this.element.offsetWidth; elH = this.element.offsetHeight; // 关键:使用 capture: true // 确保即使 createDom 阻止了冒泡,document 也能在捕获阶段收到事件 document.addEventListener('mousemove', onMouseMove, { capture: true }); document.addEventListener('mouseup', onMouseUp, { capture: true }); }; const onMouseMove = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); // 节流优化:使用 requestAnimationFrame if (this.rafId) return; this.rafId = requestAnimationFrame(() => { const dx = e.clientX - startX; const dy = e.clientY - startY; let newLeft = startLeft + dx; let newTop = startTop + dy; const maxLeft = containerW - elW; const maxTop = containerH - elH; newLeft = Math.max(0, Math.min(newLeft, maxLeft)); newTop = Math.max(0, Math.min(newTop, maxTop)); this.element.style.left = `${newLeft}px`; this.element.style.top = `${newTop}px`; this.rafId = null; }); }; const onMouseUp = () => { if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } // 移除监听 document.removeEventListener('mousemove', onMouseMove, { capture: true }); document.removeEventListener('mouseup', onMouseUp, { capture: true }); }; this.header.addEventListener('mousedown', onMouseDown); } /** * 初始化缩放功能 (性能优化 + 解决粘手) */ private initResize() { const handle = this.element.querySelector('.bim-dialog-resize-handle') as HTMLElement; if (!handle) return; let startX = 0; let startY = 0; let startW = 0; let startH = 0; const onMouseDown = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); startX = e.clientX; startY = e.clientY; startW = this.element.offsetWidth; startH = this.element.offsetHeight; // 关键:使用 capture: true document.addEventListener('mousemove', onMouseMove, { capture: true }); document.addEventListener('mouseup', onMouseUp, { capture: true }); }; const onMouseMove = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (this.rafId) return; this.rafId = requestAnimationFrame(() => { const dx = e.clientX - startX; const dy = e.clientY - startY; const newW = Math.max(this.options.minWidth || 100, startW + dx); const newH = Math.max(this.options.minHeight || 50, startH + dy); this.element.style.width = `${newW}px`; this.element.style.height = `${newH}px`; this.rafId = null; }); }; const onMouseUp = () => { if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } document.removeEventListener('mousemove', onMouseMove, { capture: true }); document.removeEventListener('mouseup', onMouseUp, { capture: true }); }; handle.addEventListener('mousedown', onMouseDown); } /** * 动态设置内容 * @param content 内容元素或 HTML 字符串 */ public setContent(content: HTMLElement | string) { this.contentArea.innerHTML = ''; if (typeof content === 'string') { this.contentArea.innerHTML = content; } else { this.contentArea.appendChild(content); } } /** * 关闭弹窗并销毁 */ public close() { if (this._isDestroyed) return; // 清理可能存在的动画帧,防止报错 if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } if (this.unsubscribeTheme) { this.unsubscribeTheme(); this.unsubscribeTheme = null; } if (this.unsubscribeLocale) { this.unsubscribeLocale(); this.unsubscribeLocale = null; } this.element.remove(); this._isDestroyed = true; if (this.options.onClose) { this.options.onClose(); } } /** * 销毁组件 (接口实现) */ public destroy() { this.close(); } }