@preference-sl/pref-viewer 2.13.0-beta.0 → 2.13.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/babylonjs-animation-controller.js +2 -2
- package/src/babylonjs-controller.js +290 -175
- package/src/index.js +2 -0
- package/src/localization/i18n.js +94 -0
- package/src/localization/translations.js +104 -0
- package/src/pref-viewer-3d-data.js +28 -0
- package/src/pref-viewer-3d.js +46 -10
- package/src/pref-viewer-menu-3d.js +557 -0
- package/src/pref-viewer-task.js +1 -0
- package/src/pref-viewer.js +532 -200
- package/src/styles.js +302 -10
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { PrefViewerMenu3DStyles } from "./styles.js";
|
|
2
|
+
import BabylonJSController from "./babylonjs-controller.js";
|
|
3
|
+
import { DEFAULT_LOCALE, resolveLocale, translate } from "./localization/i18n.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PrefViewerMenu3D - Localized floating control surface for Babylon render toggles used by PrefViewer.
|
|
7
|
+
*
|
|
8
|
+
* Capabilities:
|
|
9
|
+
* - Builds an accessible hover/focus-activated panel with switches for AA, SSAO, IBL, and dynamic shadows.
|
|
10
|
+
* - Caches translated copy in `#texts` and listens for culture changes via the `culture` attribute or `setCulture()`.
|
|
11
|
+
* - Tracks applied vs. draft render settings so pending diffs, button enablement, and status text stay in sync.
|
|
12
|
+
* - Emits `pref-viewer-menu-apply` whenever the user confirms toggles, allowing PrefViewer/BabylonJSController to persist.
|
|
13
|
+
* - Shows transient status/error messages and per-switch pending states while operations complete.
|
|
14
|
+
*
|
|
15
|
+
* Public API:
|
|
16
|
+
* - `setSettings(settings)`: hydrates switches with the latest applied render snapshot.
|
|
17
|
+
* - `setApplying(isApplying, hasError?)`: locks the UI and optionally displays errors while async updates run.
|
|
18
|
+
* - `setViewerHover(isHovering)`: opens/closes the panel based on viewer hover state.
|
|
19
|
+
* - `setEnabled(isEnabled)`: hides the menu in 2D mode and clears hover data.
|
|
20
|
+
* - `setCulture(cultureId)`: forces a locale change and re-renders copy from the i18n layer.
|
|
21
|
+
*
|
|
22
|
+
* Lifecycle & Integration:
|
|
23
|
+
* - Automatically creates shadow DOM/UI in `connectedCallback`, wiring hover/focus listeners and ARIA labels.
|
|
24
|
+
* - Exposes a single `culture` observed attribute so hosts can drive localization declaratively.
|
|
25
|
+
*
|
|
26
|
+
* Accessibility:
|
|
27
|
+
* - Uses ARIA labels/live regions for the toggle button, dialog panel, pending indicator, and status slot.
|
|
28
|
+
*/
|
|
29
|
+
export default class PrefViewerMenu3D extends HTMLElement {
|
|
30
|
+
#elements = {
|
|
31
|
+
applyButton: null,
|
|
32
|
+
panel: null,
|
|
33
|
+
pendingLabel: null,
|
|
34
|
+
status: null,
|
|
35
|
+
subtitle: null,
|
|
36
|
+
switches: {},
|
|
37
|
+
switchCopyNodes: {},
|
|
38
|
+
switchWrappers: {},
|
|
39
|
+
title: null,
|
|
40
|
+
toggleButton: null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
#handles = {
|
|
44
|
+
onApplyClick: null,
|
|
45
|
+
onHostLeave: null,
|
|
46
|
+
onPanelEnter: null,
|
|
47
|
+
onSwitchChange: null,
|
|
48
|
+
onToggleEnter: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
#DEFAULT_RENDER_SETTINGS = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
|
|
52
|
+
#MENU_SWITCHES = Object.keys(this.#DEFAULT_RENDER_SETTINGS);
|
|
53
|
+
#appliedSettings = { ...this.#DEFAULT_RENDER_SETTINGS };
|
|
54
|
+
#draftSettings = { ...this.#DEFAULT_RENDER_SETTINGS };
|
|
55
|
+
#statusTimeout = null;
|
|
56
|
+
#isApplying = false;
|
|
57
|
+
#isEnabled = true;
|
|
58
|
+
|
|
59
|
+
#culture = DEFAULT_LOCALE;
|
|
60
|
+
#texts = {};
|
|
61
|
+
|
|
62
|
+
constructor() {
|
|
63
|
+
super();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns the list of attributes to observe for changes.
|
|
68
|
+
* @public
|
|
69
|
+
* @returns {string[]} Array of attribute names to observe.
|
|
70
|
+
*/
|
|
71
|
+
static get observedAttributes() {
|
|
72
|
+
return ["culture"];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reacts to observed attribute mutations, currently handling locale changes.
|
|
77
|
+
* @public
|
|
78
|
+
* @param {string} name - Attribute name that changed.
|
|
79
|
+
* @param {*} _old - Previous attribute value (unused).
|
|
80
|
+
* @param {*} value - New attribute value.
|
|
81
|
+
* @returns {void}
|
|
82
|
+
*/
|
|
83
|
+
attributeChangedCallback(name, _old, value) {
|
|
84
|
+
if (name === "culture") {
|
|
85
|
+
this.#setCultureInternal(value || DEFAULT_LOCALE);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Lifecycle hook invoked when the element is added to the DOM.
|
|
91
|
+
* Builds the internal structure, wires listeners, and syncs switches with current settings.
|
|
92
|
+
* @public
|
|
93
|
+
* @returns {void}
|
|
94
|
+
*/
|
|
95
|
+
connectedCallback() {
|
|
96
|
+
if (!this.#elements.toggleButton) {
|
|
97
|
+
this.#texts = this.#getMenuTexts();
|
|
98
|
+
this.#createDOMElements();
|
|
99
|
+
this.#cacheElements();
|
|
100
|
+
this.#attachEvents();
|
|
101
|
+
this.setSettings(this.#appliedSettings);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Lifecycle hook invoked when the element is removed from the DOM.
|
|
107
|
+
* Cleans up listeners and pending timers to avoid leaks.
|
|
108
|
+
* @public
|
|
109
|
+
* @returns {void}
|
|
110
|
+
*/
|
|
111
|
+
disconnectedCallback() {
|
|
112
|
+
this.#detachEvents();
|
|
113
|
+
if (this.#statusTimeout) {
|
|
114
|
+
clearTimeout(this.#statusTimeout);
|
|
115
|
+
this.#statusTimeout = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Builds the shadow DOM structure for the toggle button, panel, switches, and footer controls.
|
|
121
|
+
* @private
|
|
122
|
+
* @returns {void}
|
|
123
|
+
*/
|
|
124
|
+
#createDOMElements() {
|
|
125
|
+
const wrapper = document.createElement("div");
|
|
126
|
+
wrapper.className = "menu-wrapper";
|
|
127
|
+
wrapper.innerHTML = `
|
|
128
|
+
<button class="menu-toggle" type="button" aria-label="${this.#texts.title}">
|
|
129
|
+
<svg viewBox="0 0 24 24"><path d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10M10,22C9.75,22 9.54,21.82 9.5,21.58L9.13,18.93C8.5,18.68 7.96,18.34 7.44,17.94L4.95,18.95C4.73,19.03 4.46,18.95 4.34,18.73L2.34,15.27C2.21,15.05 2.27,14.78 2.46,14.63L4.57,12.97L4.5,12L4.57,11L2.46,9.37C2.27,9.22 2.21,8.95 2.34,8.73L4.34,5.27C4.46,5.05 4.73,4.96 4.95,5.05L7.44,6.05C7.96,5.66 8.5,5.32 9.13,5.07L9.5,2.42C9.54,2.18 9.75,2 10,2H14C14.25,2 14.46,2.18 14.5,2.42L14.87,5.07C15.5,5.32 16.04,5.66 16.56,6.05L19.05,5.05C19.27,4.96 19.54,5.05 19.66,5.27L21.66,8.73C21.79,8.95 21.73,9.22 21.54,9.37L19.43,11L19.5,12L19.43,13L21.54,14.63C21.73,14.78 21.79,15.05 21.66,15.27L19.66,18.73C19.54,18.95 19.27,19.04 19.05,18.95L16.56,17.95C16.04,18.34 15.5,18.68 14.87,18.93L14.5,21.58C14.46,21.82 14.25,22 14,22H10M11.25,4L10.88,6.61C9.68,6.86 8.62,7.5 7.85,8.39L5.44,7.35L4.69,8.65L6.8,10.2C6.4,11.37 6.4,12.64 6.8,13.8L4.68,15.36L5.43,16.66L7.86,15.62C8.63,16.5 9.68,17.14 10.87,17.38L11.24,20H12.76L13.13,17.39C14.32,17.14 15.37,16.5 16.14,15.62L18.57,16.66L19.32,15.36L17.2,13.81C17.6,12.64 17.6,11.37 17.2,10.2L19.31,8.65L18.56,7.35L16.15,8.39C15.38,7.5 14.32,6.86 13.12,6.62L12.75,4H11.25Z" /></svg>
|
|
130
|
+
</button>
|
|
131
|
+
<div class="menu-panel" role="dialog" aria-label="${this.#texts.title}">
|
|
132
|
+
<div class="menu-header">
|
|
133
|
+
<p class="menu-title">${this.#texts.title}</p>
|
|
134
|
+
<p class="menu-subtitle">${this.#texts.subtitle}</p>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="menu-switches">
|
|
137
|
+
${this.#MENU_SWITCHES.map((key) => this.#renderSwitchRow(key, this.#texts)).join("")}
|
|
138
|
+
</div>
|
|
139
|
+
<div class="menu-footer">
|
|
140
|
+
<span class="menu-pending" aria-live="polite">${this.#texts.pendingLabel}</span>
|
|
141
|
+
<span class="menu-status" aria-live="assertive"></span>
|
|
142
|
+
<button class="menu-apply" type="button" disabled>${this.#texts.actions.apply}</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
`;
|
|
146
|
+
const style = document.createElement("style");
|
|
147
|
+
style.textContent = PrefViewerMenu3DStyles;
|
|
148
|
+
this.append(wrapper, style);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generates the HTML fragment for a single render-setting switch.
|
|
153
|
+
* @private
|
|
154
|
+
* @param {string} key - Render setting identifier (aa, ssao, ibl, etc.).
|
|
155
|
+
* @param {object} copy - Locale-aware copy bundle used for labels.
|
|
156
|
+
* @returns {string} HTML string representing the switch row.
|
|
157
|
+
*/
|
|
158
|
+
#renderSwitchRow(key, copy) {
|
|
159
|
+
const switchCopy = copy.switches?.[key] ?? {};
|
|
160
|
+
const label = switchCopy.label ?? key;
|
|
161
|
+
const helper = switchCopy.helper ?? "";
|
|
162
|
+
return `
|
|
163
|
+
<label class="menu-switch" data-setting="${key}">
|
|
164
|
+
<span class="menu-switch-copy">
|
|
165
|
+
<span class="menu-switch-title">${label}</span>
|
|
166
|
+
<small>${helper}</small>
|
|
167
|
+
</span>
|
|
168
|
+
<span class="menu-switch-control">
|
|
169
|
+
<input type="checkbox" data-setting="${key}">
|
|
170
|
+
<span class="menu-switch-visual" aria-hidden="true"></span>
|
|
171
|
+
</span>
|
|
172
|
+
</label>
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pulls the full translation namespace for the menu using the active culture.
|
|
178
|
+
* @private
|
|
179
|
+
* @returns {object} Resolved translation object or an empty fallback.
|
|
180
|
+
*/
|
|
181
|
+
#getMenuNamespace() {
|
|
182
|
+
const namespace = translate("prefViewer.menu3d", { locale: this.#culture });
|
|
183
|
+
return namespace && typeof namespace === "object" ? namespace : {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Normalizes locale data into a predictable shape consumed by the UI helpers.
|
|
188
|
+
* @private
|
|
189
|
+
* @returns {object} Structured copy bundle for titles, actions, status, and switches.
|
|
190
|
+
*/
|
|
191
|
+
#getMenuTexts() {
|
|
192
|
+
const data = this.#getMenuNamespace();
|
|
193
|
+
return {
|
|
194
|
+
title: data.title || "",
|
|
195
|
+
subtitle: data.subtitle || "",
|
|
196
|
+
pendingLabel: data.pendingLabel || "",
|
|
197
|
+
actions: {
|
|
198
|
+
apply: data.actions?.apply || "",
|
|
199
|
+
applying: data.actions?.applying || "",
|
|
200
|
+
},
|
|
201
|
+
status: {
|
|
202
|
+
error: data.status?.error || "",
|
|
203
|
+
},
|
|
204
|
+
switches: data.switches || {},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Replaces all textual UI nodes with the latest localized strings.
|
|
210
|
+
* @private
|
|
211
|
+
* @returns {void}
|
|
212
|
+
*/
|
|
213
|
+
#refreshLocaleTexts() {
|
|
214
|
+
if (!this.#elements.toggleButton) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this.#elements.toggleButton.setAttribute("aria-label", this.#texts.title);
|
|
218
|
+
this.#elements.panel?.setAttribute("aria-label", this.#texts.title);
|
|
219
|
+
if (this.#elements.title) {
|
|
220
|
+
this.#elements.title.textContent = this.#texts.title;
|
|
221
|
+
}
|
|
222
|
+
if (this.#elements.subtitle) {
|
|
223
|
+
this.#elements.subtitle.textContent = this.#texts.subtitle;
|
|
224
|
+
}
|
|
225
|
+
if (this.#elements.pendingLabel) {
|
|
226
|
+
this.#elements.pendingLabel.textContent = this.#texts.pendingLabel;
|
|
227
|
+
}
|
|
228
|
+
if (this.#elements.applyButton) {
|
|
229
|
+
this.#elements.applyButton.textContent = this.#isApplying ? this.#texts.actions.applying : this.#texts.actions.apply;
|
|
230
|
+
}
|
|
231
|
+
Object.entries(this.#elements.switchCopyNodes).forEach(([key, nodes]) => {
|
|
232
|
+
if (!nodes) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const switchCopy = this.#texts.switches?.[key] ?? {};
|
|
236
|
+
nodes.title && (nodes.title.textContent = switchCopy.label ?? key);
|
|
237
|
+
nodes.helper && (nodes.helper.textContent = switchCopy.helper ?? "");
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolves and applies a locale internally, updating texts when it changes.
|
|
243
|
+
* @private
|
|
244
|
+
* @param {string} [cultureId=DEFAULT_LOCALE] - Desired locale identifier.
|
|
245
|
+
* @returns {string} The applied locale.
|
|
246
|
+
*/
|
|
247
|
+
#setCultureInternal(cultureId = DEFAULT_LOCALE) {
|
|
248
|
+
const resolved = resolveLocale(cultureId);
|
|
249
|
+
if (resolved === this.#culture) {
|
|
250
|
+
return this.#culture;
|
|
251
|
+
}
|
|
252
|
+
this.#culture = resolved;
|
|
253
|
+
this.#texts = this.#getMenuTexts();
|
|
254
|
+
this.#refreshLocaleTexts();
|
|
255
|
+
return this.#culture;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Caches references to frequently used DOM nodes for faster lookups.
|
|
260
|
+
* @private
|
|
261
|
+
* @returns {void}
|
|
262
|
+
*/
|
|
263
|
+
#cacheElements() {
|
|
264
|
+
this.#elements.toggleButton = this.querySelector(".menu-toggle");
|
|
265
|
+
this.#elements.panel = this.querySelector(".menu-panel");
|
|
266
|
+
this.#elements.title = this.querySelector(".menu-title");
|
|
267
|
+
this.#elements.subtitle = this.querySelector(".menu-subtitle");
|
|
268
|
+
this.#elements.applyButton = this.querySelector(".menu-apply");
|
|
269
|
+
this.#elements.pendingLabel = this.querySelector(".menu-pending");
|
|
270
|
+
this.#elements.status = this.querySelector(".menu-status");
|
|
271
|
+
this.#MENU_SWITCHES.forEach((key) => {
|
|
272
|
+
this.#elements.switches[key] = this.querySelector(`input[data-setting="${key}"]`);
|
|
273
|
+
const wrapper = this.querySelector(`.menu-switch[data-setting="${key}"]`);
|
|
274
|
+
this.#elements.switchWrappers[key] = wrapper;
|
|
275
|
+
this.#elements.switchCopyNodes[key] = {
|
|
276
|
+
title: wrapper ? wrapper.querySelector(".menu-switch-title") : null,
|
|
277
|
+
helper: wrapper ? wrapper.querySelector("small") : null,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Attaches all interactive event listeners for hover, focus, switch changes, and apply clicks.
|
|
284
|
+
* @private
|
|
285
|
+
* @returns {void}
|
|
286
|
+
*/
|
|
287
|
+
#attachEvents() {
|
|
288
|
+
if (!this.#elements.toggleButton || !this.#elements.panel) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
this.#handles.onToggleEnter = () => this.#openMenu();
|
|
292
|
+
this.#handles.onPanelEnter = () => this.#openMenu();
|
|
293
|
+
this.#handles.onHostLeave = (event) => {
|
|
294
|
+
if (event.relatedTarget && this.contains(event.relatedTarget)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.#closeMenu();
|
|
298
|
+
};
|
|
299
|
+
this.#handles.onSwitchChange = (event) => this.#handleSwitchChange(event);
|
|
300
|
+
this.#handles.onApplyClick = () => this.#emitApply();
|
|
301
|
+
|
|
302
|
+
this.#elements.toggleButton.addEventListener("mouseenter", this.#handles.onToggleEnter);
|
|
303
|
+
this.#elements.toggleButton.addEventListener("focus", this.#handles.onToggleEnter);
|
|
304
|
+
this.#elements.panel.addEventListener("mouseenter", this.#handles.onPanelEnter);
|
|
305
|
+
this.addEventListener("mouseleave", this.#handles.onHostLeave);
|
|
306
|
+
Object.values(this.#elements.switches).forEach((input) => input?.addEventListener("change", this.#handles.onSwitchChange));
|
|
307
|
+
this.#elements.applyButton.addEventListener("click", this.#handles.onApplyClick);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Removes previously attached event listeners, guarding against partially initialized states.
|
|
312
|
+
* @private
|
|
313
|
+
* @returns {void}
|
|
314
|
+
*/
|
|
315
|
+
#detachEvents() {
|
|
316
|
+
if (!this.#elements.toggleButton) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this.#elements.toggleButton.removeEventListener("mouseenter", this.#handles.onToggleEnter);
|
|
320
|
+
this.#elements.toggleButton.removeEventListener("focus", this.#handles.onToggleEnter);
|
|
321
|
+
this.#elements.panel?.removeEventListener("mouseenter", this.#handles.onPanelEnter);
|
|
322
|
+
this.removeEventListener("mouseleave", this.#handles.onHostLeave);
|
|
323
|
+
Object.values(this.#elements.switches).forEach((input) => input?.removeEventListener("change", this.#handles.onSwitchChange));
|
|
324
|
+
this.#elements.applyButton?.removeEventListener("click", this.#handles.onApplyClick);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Opens the menu panel when the viewer is hovered and the menu is enabled.
|
|
329
|
+
* @private
|
|
330
|
+
* @returns {void}
|
|
331
|
+
*/
|
|
332
|
+
#openMenu() {
|
|
333
|
+
if (!this.#isEnabled || !this.hasAttribute("data-viewer-hover")) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
this.setAttribute("data-open", "true");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Closes the menu panel and removes its open state attribute.
|
|
341
|
+
* @private
|
|
342
|
+
* @returns {void}
|
|
343
|
+
*/
|
|
344
|
+
#closeMenu() {
|
|
345
|
+
this.removeAttribute("data-open");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Handles switch changes by updating the draft settings and recomputing pending indicators.
|
|
350
|
+
* @private
|
|
351
|
+
* @param {Event} event - Change event triggered by a switch input.
|
|
352
|
+
* @returns {void}
|
|
353
|
+
*/
|
|
354
|
+
#handleSwitchChange(event) {
|
|
355
|
+
const key = event.currentTarget?.dataset?.setting;
|
|
356
|
+
if (!key) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.#draftSettings[key] = event.currentTarget.checked;
|
|
360
|
+
this.#updatePendingState();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Emits the apply event with the current draft settings when changes are pending.
|
|
365
|
+
* @private
|
|
366
|
+
* @returns {void}
|
|
367
|
+
*/
|
|
368
|
+
#emitApply() {
|
|
369
|
+
if (this.#isApplying || !this.#hasPendingChanges()) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const detail = { settings: { ...this.#draftSettings } };
|
|
373
|
+
this.dispatchEvent(new CustomEvent("pref-viewer-menu-apply", { detail, bubbles: true, composed: true }));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Synchronizes checkbox states with the draft settings snapshot.
|
|
378
|
+
* @private
|
|
379
|
+
* @returns {void}
|
|
380
|
+
*/
|
|
381
|
+
#updateSwitches() {
|
|
382
|
+
Object.entries(this.#elements.switches).forEach(([key, input]) => {
|
|
383
|
+
if (input) {
|
|
384
|
+
input.checked = Boolean(this.#draftSettings[key]);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Toggles pending labels, apply button state, and per-switch markers based on outstanding changes.
|
|
391
|
+
* @private
|
|
392
|
+
* @returns {void}
|
|
393
|
+
*/
|
|
394
|
+
#updatePendingState() {
|
|
395
|
+
const pendingKeys = this.#getPendingKeys();
|
|
396
|
+
const pending = pendingKeys.length > 0;
|
|
397
|
+
if (this.#elements.pendingLabel) {
|
|
398
|
+
this.#elements.pendingLabel.hidden = !pending;
|
|
399
|
+
}
|
|
400
|
+
if (!this.#isApplying && this.#elements.applyButton) {
|
|
401
|
+
this.#elements.applyButton.disabled = !pending;
|
|
402
|
+
}
|
|
403
|
+
this.#updateSwitchPendingIndicators(pendingKeys);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Calculates which render keys differ between draft and applied snapshots.
|
|
408
|
+
* @private
|
|
409
|
+
* @returns {string[]} Keys currently pending application.
|
|
410
|
+
*/
|
|
411
|
+
#getPendingKeys() {
|
|
412
|
+
return Object.keys(this.#DEFAULT_RENDER_SETTINGS).filter((key) => this.#draftSettings[key] !== this.#appliedSettings[key]);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Convenience helper to check if any pending changes exist.
|
|
417
|
+
* @private
|
|
418
|
+
* @returns {boolean}
|
|
419
|
+
*/
|
|
420
|
+
#hasPendingChanges() {
|
|
421
|
+
return this.#getPendingKeys().length > 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Adds or removes the `data-pending` attribute on switch wrappers to visually flag unsaved toggles.
|
|
426
|
+
* @private
|
|
427
|
+
* @param {string[]} pendingKeys - Keys currently pending.
|
|
428
|
+
* @returns {void}
|
|
429
|
+
*/
|
|
430
|
+
#updateSwitchPendingIndicators(pendingKeys = []) {
|
|
431
|
+
Object.entries(this.#elements.switchWrappers).forEach(([key, wrapper]) => {
|
|
432
|
+
if (!wrapper) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
wrapper.toggleAttribute("data-pending", pendingKeys.includes(key));
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Shows a transient status message in the footer and schedules it to auto-clear.
|
|
441
|
+
* @private
|
|
442
|
+
* @param {string} message - The status text to display.
|
|
443
|
+
* @returns {void}
|
|
444
|
+
*/
|
|
445
|
+
#setStatusMessage(message) {
|
|
446
|
+
if (!this.#elements.status) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.#elements.status.textContent = message;
|
|
450
|
+
if (message) {
|
|
451
|
+
this.setAttribute("data-status", "visible");
|
|
452
|
+
} else {
|
|
453
|
+
this.removeAttribute("data-status");
|
|
454
|
+
}
|
|
455
|
+
if (this.#statusTimeout) {
|
|
456
|
+
clearTimeout(this.#statusTimeout);
|
|
457
|
+
this.#statusTimeout = null;
|
|
458
|
+
}
|
|
459
|
+
if (message) {
|
|
460
|
+
const schedule = typeof window !== "undefined" ? window.setTimeout : setTimeout;
|
|
461
|
+
this.#statusTimeout = schedule(() => {
|
|
462
|
+
this.#elements.status.textContent = "";
|
|
463
|
+
this.removeAttribute("data-status");
|
|
464
|
+
this.#statusTimeout = null;
|
|
465
|
+
}, 4000);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* ---------------------------
|
|
471
|
+
* Public methods
|
|
472
|
+
* ---------------------------
|
|
473
|
+
*/
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Locks or unlocks the menu while changes are applied and updates the footer status text.
|
|
477
|
+
* @public
|
|
478
|
+
* @param {boolean} isApplying - Whether an apply operation is currently running.
|
|
479
|
+
* @param {boolean} [hasError=false] - Optional flag to show an error message.
|
|
480
|
+
* @returns {void}
|
|
481
|
+
*/
|
|
482
|
+
setApplying(isApplying, hasError = false) {
|
|
483
|
+
this.#isApplying = isApplying;
|
|
484
|
+
this.toggleAttribute("data-applying", isApplying);
|
|
485
|
+
if (this.#elements.applyButton) {
|
|
486
|
+
this.#elements.applyButton.textContent = isApplying ? this.#texts.actions.applying : this.#texts.actions.apply;
|
|
487
|
+
this.#elements.applyButton.disabled = isApplying || !this.#hasPendingChanges();
|
|
488
|
+
}
|
|
489
|
+
if (hasError) {
|
|
490
|
+
this.#setStatusMessage(this.#texts.status.error);
|
|
491
|
+
} else if (!isApplying) {
|
|
492
|
+
this.#setStatusMessage("");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Sets the locale used for menu labels and helper text.
|
|
498
|
+
* @public
|
|
499
|
+
* @param {string} cultureId - Locale identifier, e.g., "en-EN".
|
|
500
|
+
* @returns {void}
|
|
501
|
+
*/
|
|
502
|
+
setCulture(cultureId = DEFAULT_LOCALE) {
|
|
503
|
+
const resolved = resolveLocale(cultureId);
|
|
504
|
+
const applied = this.#setCultureInternal(resolved);
|
|
505
|
+
if (this.getAttribute("culture") !== applied) {
|
|
506
|
+
this.setAttribute("culture", applied);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Enables or disables the menu entirely, hiding it when 3D mode is unavailable.
|
|
512
|
+
* @public
|
|
513
|
+
* @param {boolean} isEnabled - True to show the menu, false to hide and reset hover state.
|
|
514
|
+
* @returns {void}
|
|
515
|
+
*/
|
|
516
|
+
setEnabled(isEnabled) {
|
|
517
|
+
this.#isEnabled = Boolean(isEnabled);
|
|
518
|
+
this.toggleAttribute("hidden", !this.#isEnabled);
|
|
519
|
+
if (!this.#isEnabled) {
|
|
520
|
+
this.removeAttribute("data-viewer-hover");
|
|
521
|
+
this.#closeMenu();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Applies a new set of render settings to the UI, resetting draft state and pending markers.
|
|
527
|
+
* @public
|
|
528
|
+
* @param {object} settings - Render settings snapshot from PrefViewer.
|
|
529
|
+
* @returns {void}
|
|
530
|
+
*/
|
|
531
|
+
setSettings(settings = {}) {
|
|
532
|
+
this.#appliedSettings = { ...this.#DEFAULT_RENDER_SETTINGS, ...settings };
|
|
533
|
+
this.#draftSettings = { ...this.#appliedSettings };
|
|
534
|
+
this.#updateSwitches();
|
|
535
|
+
this.#updatePendingState();
|
|
536
|
+
this.setApplying(false);
|
|
537
|
+
this.#setStatusMessage("");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Receives viewer hover state so the menu only opens when the user interacts with the 3D viewport.
|
|
542
|
+
* @public
|
|
543
|
+
* @param {boolean} isHovering - True when the viewer is hovered.
|
|
544
|
+
* @returns {void}
|
|
545
|
+
*/
|
|
546
|
+
setViewerHover(isHovering) {
|
|
547
|
+
if (!this.#isEnabled) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (isHovering) {
|
|
551
|
+
this.setAttribute("data-viewer-hover", "true");
|
|
552
|
+
} else {
|
|
553
|
+
this.removeAttribute("data-viewer-hover");
|
|
554
|
+
this.#closeMenu();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|