import { BaseDialogManager } from '../core/base-dialog-manager'; import { ManagerRegistry } from '../core/manager-registry'; import { localeManager, t } from '../services/locale'; import type { EngineSettingPreset, EngineSettings, EngineSettingsPatch, SettingPresetLists } from '../components/engine'; type RenderMode = EngineSettings['render']['mode']; type EnvironmentType = EngineSettings['environment']['type']; type SelectOption = { value: string; label: string; }; const SECTION_LABEL_STYLE = 'font-size: 15px; font-weight: 600; color: var(--bim-text-primary, #0f172a); margin-bottom: 4px;'; const DIVIDER_STYLE = 'height: 1px; background: var(--bim-divider, #e2e8f0); margin: 8px 0;'; const ROW_STYLE = 'display: flex; align-items: center; justify-content: space-between; gap: 12px;'; const SECTION_STACK_STYLE = 'display: flex; flex-direction: column; gap: 6px;'; const SLIDER_VALUE_STYLE = 'font-size: 12px; color: var(--bim-text-secondary, #475569); min-width: 32px; text-align: right;'; const BORDER_COLOR = 'var(--bim-border-default, #e2e8f0)'; const INPUT_BG = 'var(--bim-bg-inset, #f1f5f9)'; const PRIMARY = 'var(--bim-primary, #3b82f6)'; const TEXT_PRIMARY = 'var(--bim-text-primary, #0f172a)'; const TEXT_INVERSE = 'var(--bim-text-inverse, #ffffff)'; const HOVER_BG = 'var(--bim-component-bg-hover, rgba(0,0,0,0.04))'; const DEFAULT_SETTINGS: EngineSettings = { 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, }, }; const EMPTY_PRESET_LISTS: SettingPresetLists = { ground: [], hdr: [], sky: [], }; export class SettingDialogManager extends BaseDialogManager { protected get dialogId() { return 'setting-dialog'; } protected get dialogTitle() { return 'setting.dialogTitle'; } protected get dialogWidth() { return 420; } private settings: EngineSettings = structuredClone(DEFAULT_SETTINGS); private presetList: EngineSettingPreset[] = []; private selectedPresetId: string | null = null; private presetLists: SettingPresetLists = structuredClone(EMPTY_PRESET_LISTS); private savedPosition: { x: number; y: number } | null = null; private savedScrollTop: number | null = null; private isDirty: boolean = false; private saveAsModalEl: HTMLElement | null = null; private deleteConfirmModalEl: HTMLElement | null = null; private unsubscribeLocale: (() => void) | null = null; private static readonly INTERNAL_DEFAULT_PRESET_ID = '__internal-default-preset__'; constructor(registry: ManagerRegistry) { super(registry); } public init(): void { this.ensurePresetListWithInternalDefault(); this.applySelectedPresetFromList(); this.unsubscribeLocale = localeManager.subscribe(() => { this.updateInternalDefaultPresetLabel(); if (this.isOpen()) { this.reopenAtSamePosition(); } }); } public destroy(): void { if (this.unsubscribeLocale) { this.unsubscribeLocale(); this.unsubscribeLocale = null; } this.closeSaveAsPresetModal(); this.closeDeletePresetConfirmModal(); super.destroy(); } private ensurePresetListWithInternalDefault(): void { const builtin = this.createInternalDefaultPreset(); const withoutInternal = this.presetList.filter((item) => item.id !== SettingDialogManager.INTERNAL_DEFAULT_PRESET_ID); this.presetList = [...withoutInternal, builtin]; if (!this.selectedPresetId || !this.presetList.some((item) => item.id === this.selectedPresetId)) { this.selectedPresetId = builtin.id; } } private updateInternalDefaultPresetLabel(): void { const idx = this.presetList.findIndex((item) => item.id === SettingDialogManager.INTERNAL_DEFAULT_PRESET_ID); if (idx < 0) return; this.presetList[idx] = { ...this.presetList[idx], presetName: t('setting.defaultPresetLabel'), }; this.ensurePresetListWithInternalDefault(); } private createInternalDefaultPreset(): EngineSettingPreset { return { id: SettingDialogManager.INTERNAL_DEFAULT_PRESET_ID, presetName: t('setting.defaultPresetLabel'), isDefault: true, settings: structuredClone(DEFAULT_SETTINGS), source: 'sdk-default', readonly: false, }; } public setPresetList(presets: EngineSettingPreset[]): void { this.presetList = this.normalizePresetList(presets); this.ensurePresetListWithInternalDefault(); this.selectedPresetId = this.resolveDefaultPresetId(this.presetList); if (!this.selectedPresetId && this.presetList.length > 0) { this.selectedPresetId = this.presetList[0].id; } this.applySelectedPresetFromList(); this.isDirty = false; this.updateUndoRestoreVisibility(); if (this.isOpen()) { this.reopenAtSamePosition(); } } protected getDialogPosition() { if (this.savedPosition) { const pos = this.savedPosition; this.savedPosition = null; return pos; } const container = this.registry.container; if (!container) return { x: 100, y: 100 }; const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; return { x: (containerWidth - this.dialogWidth) / 2, y: Math.max(20, (containerHeight - 560) / 2), }; } protected createContent(): HTMLElement { this.syncFromEngine(); this.ensureSliderStyle(); const content = document.createElement('div'); content.style.cssText = 'max-height: 520px; display: flex; flex-direction: column;'; const scrollBody = document.createElement('div'); scrollBody.className = 'setting-scroll-body'; scrollBody.style.cssText = 'padding: 14px 16px 12px 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px;'; scrollBody.appendChild(this.createRenderModeSection()); scrollBody.appendChild(this.createSliderSection( t('setting.lightIntensity'), this.settings.render.lightIntensity, 0, 100, (v) => this.updateSettings({ render: { lightIntensity: v } }) )); if (this.settings.render.mode === 'advanced') { scrollBody.appendChild(this.createSliderSection( t('setting.contrast'), this.settings.render.contrast, 0, 100, (v) => this.updateSettings({ render: { contrast: v } }) )); scrollBody.appendChild(this.createSliderSection( t('setting.saturation'), this.settings.render.saturation, 0, 100, (v) => this.updateSettings({ render: { saturation: v } }) )); } scrollBody.appendChild(this.createDivider()); scrollBody.appendChild(this.createDisplaySection()); scrollBody.appendChild(this.createDivider()); scrollBody.appendChild(this.createEnvironmentSection()); const footer = document.createElement('div'); footer.style.cssText = ` padding: 12px 16px 14px 16px; border-top: 1px solid ${BORDER_COLOR}; background: var(--bim-bg-elevated, #fff); position: sticky; bottom: 0; z-index: 1; `; footer.appendChild(this.createBottomActionBar()); content.appendChild(scrollBody); content.appendChild(footer); return content; } private syncFromEngine(): void { const engine = this.engineComponent; if (!engine) return; this.ensurePresetListWithInternalDefault(); this.presetLists = engine.getPresetLists(); this.settings = engine.getSettings(); this.isDirty = this.getCurrentSettingsDirtyState(this.settings); if (this.selectedPresetId === null) { this.selectedPresetId = this.resolveDefaultPresetId(this.presetList); if (!this.selectedPresetId && this.presetList.length > 0) { this.selectedPresetId = this.presetList[0].id; } } } private applySelectedPresetFromList(): void { const preset = this.presetList.find((item) => item.id === this.selectedPresetId) ?? this.presetList.find((item) => item.isDefault) ?? this.presetList[0]; if (!preset) { return; } this.selectedPresetId = preset.id; this.settings = structuredClone(preset.settings); this.isDirty = false; void this.engineComponent?.setSettings(preset.settings); this.updateUndoRestoreVisibility(); } private normalizePresetList(input: EngineSettingPreset[]): EngineSettingPreset[] { const seen = new Set(); const output: EngineSettingPreset[] = []; for (const preset of input) { if (!preset || typeof preset.id !== 'string') continue; const id = preset.id.trim(); if (id === SettingDialogManager.INTERNAL_DEFAULT_PRESET_ID) continue; if (!id || seen.has(id)) continue; seen.add(id); output.push({ id, presetName: preset.presetName?.trim() || id, isDefault: Boolean(preset.isDefault), settings: structuredClone(preset.settings), readonly: Boolean(preset.readonly), source: preset.source, }); } return output; } private resolveDefaultPresetId(presets: EngineSettingPreset[]): string | null { if (presets.length === 0) return null; const marked = presets.find((item) => item.isDefault); return marked?.id ?? null; } private createDivider(): HTMLElement { const div = document.createElement('div'); div.style.cssText = DIVIDER_STYLE; return div; } private createBottomActionBar(): HTMLElement { const wrapper = document.createElement('div'); wrapper.style.cssText = 'display: flex; flex-direction: column; gap: 8px;'; const undoBtn = document.createElement('button'); undoBtn.id = `${this.dialogId}-undo-btn`; undoBtn.type = 'button'; undoBtn.textContent = t('setting.undoChanges'); undoBtn.style.cssText = ` border: none; background: transparent; color: #ef4444; text-align: left; cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; display: ${this.isDirty && this.selectedPresetId ? 'block' : 'none'}; `; undoBtn.addEventListener('click', () => { if (!this.selectedPresetId) return; void this.applyPresetById(this.selectedPresetId); }); wrapper.appendChild(undoBtn); const actions = document.createElement('div'); const canDelete = this.isSelectedPresetDeletable(); actions.style.cssText = canDelete ? 'display: grid; grid-template-columns: minmax(0, 1.55fr) 40px minmax(0, 0.7fr) minmax(0, 0.95fr); gap: 8px; align-items: center;' : 'display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 0.7fr) minmax(0, 0.95fr); gap: 8px; align-items: center;'; actions.appendChild(this.createBottomPresetSelect()); if (canDelete) { actions.appendChild(this.createDeleteIconButton()); } actions.appendChild(this.createFooterActionButton(t('setting.savePreset'), { background: PRIMARY, color: TEXT_INVERSE, border: 'none', onClick: () => this.handleSaveCurrentPreset(), compact: true, })); actions.appendChild(this.createFooterActionButton(t('setting.saveAsNewPreset'), { background: PRIMARY, color: TEXT_INVERSE, border: 'none', onClick: () => this.openSaveAsPresetModal(), compact: true, })); wrapper.appendChild(actions); return wrapper; } private createDeleteIconButton(): HTMLButtonElement { const button = document.createElement('button'); button.type = 'button'; button.title = t('setting.deletePreset'); button.setAttribute('aria-label', t('setting.deletePreset')); button.innerHTML = ` `; button.style.cssText = ` width: 40px; height: 36px; border-radius: 6px; border: 1px solid #fca5a5; background: var(--bim-danger-soft, #fecaca); color: #991b1b; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; `; button.addEventListener('click', () => this.openDeletePresetConfirmModal()); return button; } private isSelectedPresetDeletable(): boolean { if (!this.selectedPresetId) return false; if (this.selectedPresetId === SettingDialogManager.INTERNAL_DEFAULT_PRESET_ID) return false; return this.presetList.some((item) => item.id === this.selectedPresetId); } private createBottomPresetSelect(): HTMLElement { const options: SelectOption[] = []; if (this.presetList.length === 0) { options.push({ value: '', label: t('setting.presetSelectPlaceholder') }); } else { for (const preset of this.presetList) { options.push({ value: preset.id, label: preset.presetName }); } } const selectedExists = this.selectedPresetId ? this.presetList.some((item) => item.id === this.selectedPresetId) : false; if (!selectedExists) { this.selectedPresetId = this.resolveDefaultPresetId(this.presetList) ?? this.presetList[0]?.id ?? null; } return this.createSelect(options, this.selectedPresetId ?? '', (value) => { if (!value) return; void this.applyPresetById(value); }); } private createFooterActionButton( text: string, options: { background: string; color: string; border: string; onClick: () => void; compact?: boolean } ): HTMLButtonElement { const button = document.createElement('button'); button.type = 'button'; button.textContent = text; button.style.cssText = ` height: ${options.compact ? '36px' : '40px'}; border-radius: 6px; cursor: pointer; font-size: ${options.compact ? '13px' : '14px'}; font-weight: 600; padding: 0 8px; white-space: nowrap; background: ${options.background}; color: ${options.color}; border: ${options.border}; `; button.addEventListener('click', options.onClick); return button; } private createRenderModeSection(): HTMLElement { const section = document.createElement('div'); section.style.cssText = SECTION_STACK_STYLE; const label = document.createElement('div'); label.style.cssText = SECTION_LABEL_STYLE; label.textContent = t('setting.renderMode'); section.appendChild(label); const group = document.createElement('div'); group.style.cssText = ` display: flex; gap: 0; border-radius: 6px; overflow: hidden; border: 1px solid ${BORDER_COLOR}; `; const modes: { key: RenderMode; labelKey: 'setting.modes.simple' | 'setting.modes.balance' | 'setting.modes.advanced' }[] = [ { key: 'simple', labelKey: 'setting.modes.simple' }, { key: 'balance', labelKey: 'setting.modes.balance' }, { key: 'advanced', labelKey: 'setting.modes.advanced' }, ]; for (let i = 0; i < modes.length; i++) { const mode = modes[i]; const isActive = mode.key === this.settings.render.mode; const btn = document.createElement('button'); btn.type = 'button'; btn.style.cssText = ` flex: 1; text-align: center; padding: 7px 0; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; background: ${isActive ? PRIMARY : 'transparent'}; color: ${isActive ? TEXT_INVERSE : TEXT_PRIMARY}; border: none; ${i < modes.length - 1 ? `border-right: 1px solid ${BORDER_COLOR};` : ''} `; btn.textContent = t(mode.labelKey); btn.addEventListener('mouseenter', () => { if (!isActive) btn.style.background = HOVER_BG; }); btn.addEventListener('mouseleave', () => { if (!isActive) btn.style.background = 'transparent'; }); btn.addEventListener('click', () => { void this.updateSettings({ render: { mode: mode.key } }, true); }); group.appendChild(btn); } section.appendChild(group); return section; } private createDisplaySection(): HTMLElement { const section = document.createElement('div'); section.style.cssText = SECTION_STACK_STYLE; const label = document.createElement('div'); label.style.cssText = SECTION_LABEL_STYLE; label.textContent = t('setting.displaySection'); section.appendChild(label); section.appendChild(this.createToggleRow( t('setting.edgeLine'), this.settings.display.showEdge, (enabled) => { void this.updateSettings({ display: { showEdge: enabled } }, true); } )); if (this.settings.display.showEdge) { section.appendChild(this.createSliderSection( t('setting.edgeOpacity'), this.settings.display.edgeOpacity, 0, 100, (v) => this.updateSettings({ display: { edgeOpacity: v } }) )); } section.appendChild(this.createToggleRow( t('setting.showGrid'), this.settings.display.showGrid, (enabled) => { void this.updateSettings({ display: { showGrid: enabled } }); } )); section.appendChild(this.createToggleRow( t('setting.showLevel'), this.settings.display.showLevel, (enabled) => { void this.updateSettings({ display: { showLevel: enabled } }); } )); section.appendChild(this.createToggleRow( t('setting.ground'), this.settings.display.showGround, (enabled) => { const nextGroundId = enabled ? (this.settings.display.groundId && this.settings.display.groundId !== '0' ? this.settings.display.groundId : (this.presetLists.ground[0]?.id || '1')) : '0'; void this.updateSettings({ display: { showGround: enabled, groundId: nextGroundId } }); this.reopenAtSamePosition(); } )); if (this.settings.display.showGround) { const groundOptions = this.presetLists.ground.map((item) => ({ value: item.id, label: this.getLocalizedPresetName(item.names, item.id), })); section.appendChild(this.createSelect( groundOptions, this.settings.display.groundId, (value) => { void this.updateSettings({ display: { groundId: value } }); } )); const row = document.createElement('div'); row.style.cssText = `${ROW_STYLE} margin-top: 10px;`; const elevationLabel = document.createElement('span'); elevationLabel.style.cssText = 'font-size: 13px; color: var(--bim-text-secondary, #475569);'; elevationLabel.textContent = t('setting.groundElevation'); row.appendChild(elevationLabel); const inputWrapper = document.createElement('div'); inputWrapper.style.cssText = 'display: flex; align-items: center; gap: 4px;'; const input = document.createElement('input'); input.type = 'number'; input.value = String(this.settings.display.groundHeight); input.style.cssText = ` width: 72px; padding: 4px 8px; border-radius: 4px; border: 1px solid ${BORDER_COLOR}; background: ${INPUT_BG}; color: ${TEXT_PRIMARY}; font-size: 13px; outline: none; text-align: right; `; input.addEventListener('input', () => { const value = Number(input.value); if (!Number.isNaN(value)) { void this.updateSettings({ display: { groundHeight: value } }); } }); const unit = document.createElement('span'); unit.style.cssText = 'font-size: 13px; color: var(--bim-text-secondary, #475569);'; unit.textContent = t('setting.groundElevationUnit'); inputWrapper.appendChild(input); inputWrapper.appendChild(unit); row.appendChild(inputWrapper); section.appendChild(row); } return section; } private createEnvironmentSection(): HTMLElement { const section = document.createElement('div'); section.style.cssText = SECTION_STACK_STYLE; const label = document.createElement('div'); label.style.cssText = SECTION_LABEL_STYLE; label.textContent = t('setting.environment'); section.appendChild(label); const typeSection = document.createElement('div'); typeSection.style.cssText = ` display: flex; gap: 0; border-radius: 6px; overflow: hidden; border: 1px solid ${BORDER_COLOR}; margin-bottom: 10px; `; const types: { key: EnvironmentType; labelKey: 'setting.environmentType.none' | 'setting.environmentType.hdr' | 'setting.environmentType.sky' }[] = [ { key: 'none', labelKey: 'setting.environmentType.none' }, { key: 'hdr', labelKey: 'setting.environmentType.hdr' }, { key: 'sky', labelKey: 'setting.environmentType.sky' }, ]; for (let i = 0; i < types.length; i++) { const item = types[i]; const isActive = this.settings.environment.type === item.key; const button = document.createElement('button'); button.type = 'button'; button.style.cssText = ` flex: 1; text-align: center; padding: 7px 0; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; background: ${isActive ? PRIMARY : 'transparent'}; color: ${isActive ? TEXT_INVERSE : TEXT_PRIMARY}; border: none; ${i < types.length - 1 ? `border-right: 1px solid ${BORDER_COLOR};` : ''} `; button.textContent = t(item.labelKey); button.addEventListener('click', () => { void this.updateSettings({ environment: { type: item.key } }, true); }); typeSection.appendChild(button); } section.appendChild(typeSection); if (this.settings.environment.type === 'hdr') { const hdrOptions = this.presetLists.hdr.map((item) => ({ value: item.id, label: this.getLocalizedPresetName(item.names, item.id), })); section.appendChild(this.createSelect( hdrOptions, this.settings.environment.hdrId, (value) => { void this.updateSettings({ environment: { type: 'hdr', hdrId: value } }); } )); } if (this.settings.environment.type === 'sky') { const skyOptions = this.presetLists.sky.map((item) => ({ value: item.id, label: this.getLocalizedPresetName(item.names, item.id), })); section.appendChild(this.createSelect( skyOptions, this.settings.environment.skyPreset, (value) => { void this.updateSettings({ environment: { type: 'sky', skyPreset: value } }); } )); } return section; } private createSliderSection( labelText: string, value: number, min: number, max: number, onChange: (val: number) => void ): HTMLElement { const section = document.createElement('div'); section.style.cssText = 'margin-bottom: 4px;'; const header = document.createElement('div'); header.style.cssText = `${ROW_STYLE} margin-bottom: 6px;`; const label = document.createElement('span'); label.style.cssText = 'font-size: 13px; color: var(--bim-text-secondary, #475569);'; label.textContent = labelText; header.appendChild(label); const valueDisplay = document.createElement('span'); valueDisplay.style.cssText = SLIDER_VALUE_STYLE; valueDisplay.textContent = String(value); header.appendChild(valueDisplay); section.appendChild(header); const slider = document.createElement('input'); slider.type = 'range'; slider.min = String(min); slider.max = String(max); slider.value = String(value); slider.className = 'bim-setting-slider'; slider.style.cssText = ` width: 100%; height: 4px; -webkit-appearance: none; appearance: none; background: var(--bim-border-strong, #cbd5e1); border-radius: 2px; outline: none; cursor: pointer; `; slider.addEventListener('input', () => { const next = Number(slider.value); valueDisplay.textContent = String(next); onChange(next); }); section.appendChild(slider); return section; } private createToggleRow( labelText: string, enabled: boolean, onChange: (enabled: boolean) => void ): HTMLElement { const row = document.createElement('div'); row.style.cssText = ROW_STYLE; const label = document.createElement('span'); label.style.cssText = 'font-size: 13px; color: var(--bim-text-secondary, #475569);'; label.textContent = labelText; row.appendChild(label); const toggleOffBg = 'var(--bim-border-strong, #cbd5e1)'; const toggleOnBg = PRIMARY; let currentState = enabled; const toggle = document.createElement('div'); toggle.style.cssText = ` width: 40px; height: 22px; border-radius: 11px; cursor: pointer; position: relative; transition: background 0.2s; background: ${currentState ? toggleOnBg : toggleOffBg}; `; const knob = document.createElement('div'); knob.style.cssText = ` width: 18px; height: 18px; border-radius: 50%; background: #fff; position: absolute; top: 2px; transition: left 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.2); left: ${currentState ? '20px' : '2px'}; `; toggle.appendChild(knob); toggle.addEventListener('click', () => { currentState = !currentState; toggle.style.background = currentState ? toggleOnBg : toggleOffBg; knob.style.left = currentState ? '20px' : '2px'; onChange(currentState); }); row.appendChild(toggle); return row; } private createSelect( options: SelectOption[], currentValue: string, onChange: (val: string) => void ): HTMLElement { const select = document.createElement('select'); select.style.cssText = ` width: 100%; padding: 7px 10px; border-radius: 6px; border: 1px solid ${BORDER_COLOR}; background: ${INPUT_BG}; color: ${TEXT_PRIMARY}; font-size: 13px; outline: none; cursor: pointer; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px; `; for (const optionData of options) { const option = document.createElement('option'); option.value = optionData.value; option.textContent = optionData.label; if (optionData.value === currentValue) { option.selected = true; } select.appendChild(option); } select.addEventListener('change', () => onChange(select.value)); select.addEventListener('focus', () => { select.style.borderColor = PRIMARY; }); select.addEventListener('blur', () => { select.style.borderColor = BORDER_COLOR; }); return select; } private async applyPresetById(id: string): Promise { const preset = this.presetList.find((item) => item.id === id); if (!preset) return; this.selectedPresetId = id; await this.engineComponent?.setSettings(preset.settings); this.settings = structuredClone(preset.settings); this.isDirty = false; this.logCurrentSettings('apply-preset'); this.updateUndoRestoreVisibility(); this.emit('setting:preset-changed', { preset: structuredClone(preset), timestamp: Date.now(), }); this.reopenAtSamePosition(); } private async updateSettings(partial: EngineSettingsPatch, rerenderAfterApply: boolean = false): Promise { const merged = this.mergeSettings(this.settings, partial); try { await this.engineComponent?.setSettings(partial); } catch (error) { console.warn('[SettingDialogManager] Failed to apply settings patch, keep local state in sync.', error); } this.settings = merged; this.isDirty = this.getCurrentSettingsDirtyState(merged); this.logCurrentSettings('update-settings'); this.updateUndoRestoreVisibility(); if (rerenderAfterApply) { this.reopenAtSamePosition(); } } private logCurrentSettings(source: string): void { console.log('[SettingDialogManager] current settings', { source, settings: structuredClone(this.settings), }); } private mergeSettings(base: EngineSettings, patch: EngineSettingsPatch): EngineSettings { return { render: { ...base.render, ...patch.render, }, display: { ...base.display, ...patch.display, }, environment: { ...base.environment, ...patch.environment, skyParams: { ...base.environment.skyParams, ...patch.environment?.skyParams, }, }, }; } private getCurrentSettingsDirtyState(current: EngineSettings): boolean { const preset = this.presetList.find((item) => item.id === this.selectedPresetId); if (!preset) return false; return JSON.stringify(preset.settings) !== JSON.stringify(current); } private getLocalizedPresetName(names: string[] | undefined, fallback: string): string { if (!Array.isArray(names) || names.length === 0) { return fallback; } const locale = localeManager.getLocale() as string; if (locale === 'zh-CN') { return names[0] || names[1] || names[2] || fallback; } if (locale === 'zh-TW') { return names[1] || names[0] || names[2] || fallback; } return names[2] || names[0] || names[1] || fallback; } private updateUndoRestoreVisibility(): void { const dialogEl = document.getElementById(this.dialogId); if (!dialogEl) return; const undoBtn = dialogEl.querySelector(`#${this.dialogId}-undo-btn`); if (!(undoBtn instanceof HTMLElement)) return; undoBtn.style.display = this.isDirty && this.selectedPresetId ? 'block' : 'none'; } private handleSaveCurrentPreset(): void { if (!this.selectedPresetId) { window.alert(t('setting.selectPresetFirst')); return; } const index = this.presetList.findIndex((item) => item.id === this.selectedPresetId); if (index < 0) { window.alert(t('setting.selectPresetFirst')); return; } const currentSettings = this.engineComponent?.getSettings() ?? structuredClone(this.settings); const updatedPreset: EngineSettingPreset = { ...this.presetList[index], settings: structuredClone(currentSettings), }; this.presetList = this.presetList.map((item, i) => (i === index ? updatedPreset : item)); this.ensurePresetListWithInternalDefault(); this.settings = structuredClone(currentSettings); this.isDirty = false; this.updateUndoRestoreVisibility(); this.emit('setting:preset-saved', { preset: structuredClone(updatedPreset), currentSettings: structuredClone(currentSettings), timestamp: Date.now(), }); this.reopenAtSamePosition(); } private saveAsPresetWithName(presetName: string): boolean { const trimmedName = presetName.trim(); if (!trimmedName) { window.alert(t('setting.presetNameRequired')); return false; } const duplicate = this.presetList.some((item) => item.presetName.toLowerCase() === trimmedName.toLowerCase()); if (duplicate) { window.alert(t('setting.presetNameDuplicate')); return false; } const currentSettings = this.engineComponent?.getSettings() ?? structuredClone(this.settings); const preset: EngineSettingPreset = { id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, presetName: trimmedName, isDefault: false, settings: structuredClone(currentSettings), source: 'user', readonly: false, }; const withoutInternal = this.presetList.filter((item) => item.id !== SettingDialogManager.INTERNAL_DEFAULT_PRESET_ID); this.presetList = [...withoutInternal, preset]; this.ensurePresetListWithInternalDefault(); this.selectedPresetId = preset.id; this.settings = structuredClone(currentSettings); this.isDirty = false; this.updateUndoRestoreVisibility(); this.emit('setting:preset-saved', { preset: structuredClone(preset), currentSettings: structuredClone(currentSettings), timestamp: Date.now(), }); this.reopenAtSamePosition(); return true; } private openDeletePresetConfirmModal(): void { if (!this.isSelectedPresetDeletable()) { return; } this.closeDeletePresetConfirmModal(); const host = document.getElementById(this.dialogId); if (!host) return; const overlay = document.createElement('div'); overlay.style.cssText = ` position: absolute; inset: 0; background: rgba(15, 23, 42, 0.35); display: flex; align-items: center; justify-content: center; z-index: 21; `; const panel = document.createElement('div'); panel.style.cssText = ` width: min(320px, calc(100% - 24px)); border-radius: 8px; background: var(--bim-bg-elevated, #fff); border: 1px solid ${BORDER_COLOR}; padding: 12px; display: flex; flex-direction: column; gap: 10px; `; const title = document.createElement('div'); title.textContent = t('setting.deleteDialogTitle'); title.style.cssText = 'font-size: 14px; font-weight: 600; color: var(--bim-text-primary, #0f172a);'; const message = document.createElement('div'); message.textContent = t('setting.deleteDialogMessage'); message.style.cssText = 'font-size: 13px; color: var(--bim-text-secondary, #475569);'; const actions = document.createElement('div'); actions.style.cssText = 'display: flex; justify-content: flex-end; gap: 8px;'; const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = t('setting.cancel'); cancel.style.cssText = ` height: 32px; padding: 0 12px; border-radius: 6px; border: 1px solid ${BORDER_COLOR}; background: var(--bim-bg-elevated, #fff); color: ${TEXT_PRIMARY}; cursor: pointer; `; cancel.addEventListener('click', () => this.closeDeletePresetConfirmModal()); const confirm = document.createElement('button'); confirm.type = 'button'; confirm.textContent = t('setting.confirm'); confirm.style.cssText = ` height: 32px; padding: 0 12px; border-radius: 6px; border: none; background: #ef4444; color: ${TEXT_INVERSE}; cursor: pointer; `; confirm.addEventListener('click', () => { this.deleteSelectedPreset(); this.closeDeletePresetConfirmModal(); }); actions.appendChild(cancel); actions.appendChild(confirm); panel.appendChild(title); panel.appendChild(message); panel.appendChild(actions); overlay.appendChild(panel); host.appendChild(overlay); this.deleteConfirmModalEl = overlay; } private closeDeletePresetConfirmModal(): void { if (this.deleteConfirmModalEl) { this.deleteConfirmModalEl.remove(); this.deleteConfirmModalEl = null; } } private deleteSelectedPreset(): void { if (!this.isSelectedPresetDeletable() || !this.selectedPresetId) { return; } const deleted = this.presetList.find((item) => item.id === this.selectedPresetId); if (!deleted) { return; } this.presetList = this.presetList.filter((item) => item.id !== this.selectedPresetId); this.ensurePresetListWithInternalDefault(); this.selectedPresetId = this.resolveDefaultPresetId(this.presetList) ?? this.presetList[0]?.id ?? null; this.applySelectedPresetFromList(); this.emit('setting:preset-deleted', structuredClone(deleted)); this.reopenAtSamePosition(); } private openSaveAsPresetModal(defaultValue?: string): void { this.closeSaveAsPresetModal(); const host = document.getElementById(this.dialogId); if (!host) return; const overlay = document.createElement('div'); overlay.style.cssText = ` position: absolute; inset: 0; background: rgba(15, 23, 42, 0.35); display: flex; align-items: center; justify-content: center; z-index: 20; `; const panel = document.createElement('div'); panel.style.cssText = ` width: min(320px, calc(100% - 24px)); border-radius: 8px; background: var(--bim-bg-elevated, #fff); border: 1px solid ${BORDER_COLOR}; padding: 12px; display: flex; flex-direction: column; gap: 10px; `; const title = document.createElement('div'); title.textContent = t('setting.saveAsDialogTitle'); title.style.cssText = 'font-size: 14px; font-weight: 600; color: var(--bim-text-primary, #0f172a);'; const input = document.createElement('input'); input.type = 'text'; input.value = defaultValue ?? `${t('setting.presetDefaultName')} ${this.presetList.length + 1}`; input.placeholder = t('setting.presetNamePlaceholder'); input.style.cssText = ` width: 100%; height: 34px; padding: 0 10px; border-radius: 6px; border: 1px solid ${BORDER_COLOR}; background: ${INPUT_BG}; color: ${TEXT_PRIMARY}; outline: none; `; const actions = document.createElement('div'); actions.style.cssText = 'display: flex; justify-content: flex-end; gap: 8px;'; const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = t('setting.cancel'); cancel.style.cssText = ` height: 32px; padding: 0 12px; border-radius: 6px; border: 1px solid ${BORDER_COLOR}; background: var(--bim-bg-elevated, #fff); color: ${TEXT_PRIMARY}; cursor: pointer; `; cancel.addEventListener('click', () => this.closeSaveAsPresetModal()); const confirm = document.createElement('button'); confirm.type = 'button'; confirm.textContent = t('setting.confirm'); confirm.style.cssText = ` height: 32px; padding: 0 12px; border-radius: 6px; border: none; background: ${PRIMARY}; color: ${TEXT_INVERSE}; cursor: pointer; `; confirm.addEventListener('click', () => { const ok = this.saveAsPresetWithName(input.value); if (ok) this.closeSaveAsPresetModal(); }); input.addEventListener('keydown', (evt) => { if (evt.key === 'Enter') { const ok = this.saveAsPresetWithName(input.value); if (ok) this.closeSaveAsPresetModal(); } if (evt.key === 'Escape') { this.closeSaveAsPresetModal(); } }); actions.appendChild(cancel); actions.appendChild(confirm); panel.appendChild(title); panel.appendChild(input); panel.appendChild(actions); overlay.appendChild(panel); host.appendChild(overlay); this.saveAsModalEl = overlay; window.requestAnimationFrame(() => input.focus()); } private closeSaveAsPresetModal(): void { if (this.saveAsModalEl) { this.saveAsModalEl.remove(); this.saveAsModalEl = null; } } private reopenAtSamePosition(): void { if (!this.isOpen()) return; const dialogEl = document.getElementById(this.dialogId); const contentEl = this.getScrollableContentElement(); if (dialogEl) { this.savedPosition = { x: dialogEl.offsetLeft, y: dialogEl.offsetTop }; } if (contentEl) { this.savedScrollTop = contentEl.scrollTop; } this.hide(); this.show(); if (this.savedScrollTop !== null) { const restoreTop = this.savedScrollTop; this.savedScrollTop = null; window.requestAnimationFrame(() => { const nextContent = this.getScrollableContentElement(); if (nextContent) { nextContent.scrollTop = restoreTop; } }); } } private getScrollableContentElement(): HTMLElement | null { const dialogEl = document.getElementById(this.dialogId); if (!dialogEl) return null; const scrollEl = dialogEl.querySelector('.setting-scroll-body'); if (!(scrollEl instanceof HTMLElement)) return null; return scrollEl; } private ensureSliderStyle(): void { if (document.querySelector('#bim-setting-slider-style')) return; const style = document.createElement('style'); style.id = 'bim-setting-slider-style'; style.textContent = ` .bim-setting-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--bim-primary, #3b82f6); cursor: pointer; border: 2px solid var(--bim-bg-elevated, #fff); box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .bim-setting-slider::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--bim-primary, #3b82f6); cursor: pointer; border: 2px solid var(--bim-bg-elevated, #fff); box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .bim-setting-slider::-moz-range-track { background: var(--bim-border-strong, #cbd5e1); height: 4px; border-radius: 2px; border: none; } `; document.head.appendChild(style); } }