1202 lines
45 KiB
TypeScript
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);
|
|
}
|
|
}
|