Files
bim_engine/src/managers/setting-dialog-manager.ts

1202 lines
45 KiB
TypeScript

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<string>();
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 = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"></path>
<path d="M19 6l-1 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1L5 6"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
`;
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<void> {
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<void> {
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);
}
}