@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.
@@ -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
+ }
@@ -21,6 +21,7 @@
21
21
  export default class PrefViewerTask {
22
22
  static Types = Object.freeze({
23
23
  Config: "config",
24
+ Culture: "culture",
24
25
  Drawing: "drawing",
25
26
  Environment: "environment",
26
27
  Materials: "materials",