@preference-sl/pref-viewer 2.18.0 → 3.0.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/README.md +267 -31
- package/index.d.ts +75 -6
- package/package.json +8 -2
- package/src/babylonjs-animation-controller.js +18 -10
- package/src/babylonjs-animation-opening-menu.js +80 -14
- package/src/babylonjs-animation-opening.js +3 -3
- package/src/babylonjs-ar-floor-bounds.js +169 -0
- package/src/babylonjs-ar-glass.js +128 -0
- package/src/babylonjs-ar-placement-feedback.js +449 -0
- package/src/babylonjs-ar-placement.js +1038 -0
- package/src/babylonjs-camera-wheel-motion.js +35 -2
- package/src/babylonjs-controller.js +542 -125
- package/src/babylonjs-orbital-pointers-input.js +186 -0
- package/src/{render-math.js → babylonjs-render-math.js} +11 -11
- package/src/babylonjs-selection-controller.js +4 -4
- package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
- package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
- package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
- package/src/babylonjs-view-cube-controller.js +74 -1103
- package/src/babylonjs-view-cube-svg-controller.js +1012 -0
- package/src/file-storage.js +22 -4
- package/src/gltf-resolver.js +83 -13
- package/src/localization/translations.js +12 -0
- package/src/panzoom-controller.js +4 -4
- package/src/pref-viewer-2d.js +2 -2
- package/src/pref-viewer-3d.js +55 -9
- package/src/pref-viewer-controls-presentation.js +292 -0
- package/src/pref-viewer-dialog.js +86 -7
- package/src/pref-viewer-menu-3d.js +134 -17
- package/src/pref-viewer-style-controller.js +320 -0
- package/src/pref-viewer.js +133 -109
- package/src/quick-look-action.js +291 -0
- package/src/styles.js +702 -98
- package/src/babylonjs-view-cube-renderer.js +0 -548
- package/src/view-cube-orbit-math.js +0 -199
|
@@ -5,6 +5,8 @@ import I18n from "./localization/i18n.js";
|
|
|
5
5
|
/**
|
|
6
6
|
* PrefViewerMenu3D - Localized floating control panel for Babylon.js render settings.
|
|
7
7
|
*
|
|
8
|
+
* Shares its viewer control presentation; supports explicit touch open/close and hover on fine pointers.
|
|
9
|
+
* Owns temporary touch-press feedback until release/cancellation, focus loss or disconnection.
|
|
8
10
|
* Renders a gear-icon toggle button that appears on viewer hover and opens a panel with:
|
|
9
11
|
* - Boolean switches for every boolean render setting (AA, SSAO, IBL, shadows, highlight, etc.).
|
|
10
12
|
* - A hue slider for the highlight color.
|
|
@@ -62,6 +64,54 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
62
64
|
onToggleEnter: null,
|
|
63
65
|
};
|
|
64
66
|
|
|
67
|
+
#touchPressedControl = null;
|
|
68
|
+
#touchPointerId = null;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Marks one enabled touch control while the finger is down, independent of native :active timing.
|
|
72
|
+
* A second touch cancels the current feedback without changing values or preventing native scrolling/input.
|
|
73
|
+
* @param {PointerEvent} event - Pointer down bubbling from this menu.
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
#onTouchPress = event => {
|
|
77
|
+
if (event.pointerType !== "touch") {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (this.#touchPressedControl) {
|
|
81
|
+
this.#clearTouchPress();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const control = event.target.closest?.('button, input[type="range"], .menu-switch');
|
|
85
|
+
if (!control || !this.contains(control) || control.matches(':disabled, [data-disabled]')
|
|
86
|
+
|| control.querySelector('input:disabled')) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.#touchPressedControl = control;
|
|
90
|
+
this.#touchPointerId = event.pointerId;
|
|
91
|
+
control.setAttribute("data-touch-pressed", "");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Releases feedback for the active finger on pointer up or cancellation, including outside the menu.
|
|
96
|
+
* @param {PointerEvent} event - Document-level pointer completion.
|
|
97
|
+
* @returns {void}
|
|
98
|
+
*/
|
|
99
|
+
#onTouchRelease = event => {
|
|
100
|
+
if (event.pointerId === this.#touchPointerId) {
|
|
101
|
+
this.#clearTouchPress();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Removes only this menu's owned touch-press state; also handles window blur and disconnection.
|
|
107
|
+
* @returns {void} Idempotent; does not change focus, settings, click handling or native range dragging.
|
|
108
|
+
*/
|
|
109
|
+
#clearTouchPress = () => {
|
|
110
|
+
this.#touchPressedControl?.removeAttribute("data-touch-pressed");
|
|
111
|
+
this.#touchPressedControl = null;
|
|
112
|
+
this.#touchPointerId = null;
|
|
113
|
+
};
|
|
114
|
+
|
|
65
115
|
#DEFAULT_RENDER_SETTINGS = { ...BabylonJSController.config.render.defaultSettings };
|
|
66
116
|
// Only include boolean settings as switches; scalar settings are handled by sliders.
|
|
67
117
|
#MENU_SWITCHES = Object.keys(this.#DEFAULT_RENDER_SETTINGS).filter(key =>
|
|
@@ -144,7 +194,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
144
194
|
}
|
|
145
195
|
|
|
146
196
|
/**
|
|
147
|
-
* Builds
|
|
197
|
+
* Builds localized toggle/close controls and an initially inert panel with switches and sliders.
|
|
148
198
|
* @private
|
|
149
199
|
* @returns {void}
|
|
150
200
|
*/
|
|
@@ -152,11 +202,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
152
202
|
const wrapper = document.createElement("div");
|
|
153
203
|
wrapper.className = "menu-wrapper";
|
|
154
204
|
wrapper.innerHTML = `
|
|
155
|
-
<button class="menu-toggle" type="button" aria-label="${this.#texts.title}">
|
|
205
|
+
<button class="menu-toggle" type="button" aria-expanded="false" aria-label="${this.#texts.title}">
|
|
156
206
|
<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>
|
|
157
207
|
</button>
|
|
158
|
-
<div class="menu-panel" role="dialog" aria-label="${this.#texts.title}">
|
|
208
|
+
<div class="menu-panel" inert aria-hidden="true" role="dialog" aria-label="${this.#texts.title}">
|
|
159
209
|
<div class="menu-header">
|
|
210
|
+
<button class="menu-close" type="button" aria-label="${this.#texts.actions.close}">×</button>
|
|
160
211
|
<p class="menu-title">${this.#texts.title}</p>
|
|
161
212
|
<p class="menu-subtitle">${this.#texts.subtitle}</p>
|
|
162
213
|
</div>
|
|
@@ -231,7 +282,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
231
282
|
/**
|
|
232
283
|
* Normalizes locale data into a predictable shape consumed by the UI helpers.
|
|
233
284
|
* @private
|
|
234
|
-
* @returns {{title: string, subtitle: string, actions: {play: string, pause: string}, features: object}} Structured copy bundle for titles, actions, and feature labels.
|
|
285
|
+
* @returns {{title: string, subtitle: string, actions: {play: string, pause: string, close: string}, features: object}} Structured copy bundle for titles, actions, and feature labels.
|
|
235
286
|
*/
|
|
236
287
|
#getMenuTexts() {
|
|
237
288
|
const data = this.#getMenuNamespace();
|
|
@@ -241,13 +292,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
241
292
|
actions: {
|
|
242
293
|
play: data.actions?.play || "",
|
|
243
294
|
pause: data.actions?.pause || "",
|
|
295
|
+
close: data.actions?.close || "Close",
|
|
244
296
|
},
|
|
245
297
|
features: data.features || {},
|
|
246
298
|
};
|
|
247
299
|
}
|
|
248
300
|
|
|
249
301
|
/**
|
|
250
|
-
* Replaces all textual UI nodes with the latest localized strings.
|
|
302
|
+
* Replaces all textual UI nodes, including the close action, with the latest localized strings.
|
|
251
303
|
* @private
|
|
252
304
|
* @returns {void}
|
|
253
305
|
*/
|
|
@@ -257,6 +309,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
257
309
|
}
|
|
258
310
|
this.#elements.toggleButton.setAttribute("aria-label", this.#texts.title);
|
|
259
311
|
this.#elements.panel?.setAttribute("aria-label", this.#texts.title);
|
|
312
|
+
this.#elements.closeButton?.setAttribute("aria-label", this.#texts.actions.close);
|
|
260
313
|
if (this.#elements.title) {
|
|
261
314
|
this.#elements.title.textContent = this.#texts.title;
|
|
262
315
|
}
|
|
@@ -308,13 +361,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
308
361
|
}
|
|
309
362
|
|
|
310
363
|
/**
|
|
311
|
-
* Caches
|
|
364
|
+
* Caches toggle/close actions and settings nodes for event binding and UI synchronization.
|
|
312
365
|
* @private
|
|
313
366
|
* @returns {void}
|
|
314
367
|
*/
|
|
315
368
|
#cacheElements() {
|
|
316
369
|
this.#elements.toggleButton = this.querySelector(".menu-toggle");
|
|
317
370
|
this.#elements.panel = this.querySelector(".menu-panel");
|
|
371
|
+
this.#elements.closeButton = this.querySelector(".menu-close");
|
|
318
372
|
this.#elements.title = this.querySelector(".menu-title");
|
|
319
373
|
this.#elements.subtitle = this.querySelector(".menu-subtitle");
|
|
320
374
|
|
|
@@ -348,7 +402,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
348
402
|
}
|
|
349
403
|
|
|
350
404
|
/**
|
|
351
|
-
* Attaches
|
|
405
|
+
* Attaches hover/focus, touch-press feedback, explicit toggle/close and settings listeners.
|
|
406
|
+
* Retains document completion/window blur subscriptions for feedback cleanup during teardown.
|
|
352
407
|
* @private
|
|
353
408
|
* @returns {void}
|
|
354
409
|
*/
|
|
@@ -356,9 +411,37 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
356
411
|
if (!this.#elements.toggleButton || !this.#elements.panel) {
|
|
357
412
|
return;
|
|
358
413
|
}
|
|
359
|
-
|
|
360
|
-
this.#handles.
|
|
414
|
+
/** Opens on hover/focus only for compact fine-pointer presentation. @returns {void} */
|
|
415
|
+
this.#handles.onToggleEnter = () => {
|
|
416
|
+
if (!this.#usesTouchControls()) {
|
|
417
|
+
this.#openMenu();
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
/** Toggles on trusted click/tap without requiring synthetic mouse hover. @returns {void} */
|
|
421
|
+
this.#handles.onToggleClick = () => {
|
|
422
|
+
if (this.#usesTouchControls()) {
|
|
423
|
+
this.hasAttribute("data-open") ? this.#closeMenu() : this.#openMenu();
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
/** Closes explicitly and returns keyboard focus to the trigger. @returns {void} */
|
|
427
|
+
this.#handles.onCloseClick = () => { this.#elements.toggleButton.focus(); this.#closeMenu(); };
|
|
428
|
+
/** Closes the settings panel when Escape is pressed. @param {KeyboardEvent} event - Panel key event. @returns {void} */
|
|
429
|
+
this.#handles.onPanelKeyDown = event => {
|
|
430
|
+
if (event.key === "Escape") {
|
|
431
|
+
this.#handles.onCloseClick();
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
/** Opens settings on actual fine-pointer hover. @returns {void} */
|
|
435
|
+
this.#handles.onPanelEnter = () => {
|
|
436
|
+
if (!this.#usesTouchControls()) {
|
|
437
|
+
this.#openMenu();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
/** Keeps touch panels open on pointer leave. @param {MouseEvent} event - Leave event. @returns {void} */
|
|
361
441
|
this.#handles.onHostLeave = (event) => {
|
|
442
|
+
if (this.#usesTouchControls()) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
362
445
|
if (event.relatedTarget && this.contains(event.relatedTarget)) {
|
|
363
446
|
return;
|
|
364
447
|
}
|
|
@@ -369,6 +452,13 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
369
452
|
this.#handles.onLightingSliderInput = (event) => this.#handleLightingSliderInput(event);
|
|
370
453
|
this.#handles.onLightingPlayClick = () => this.#toggleLightingPlayback();
|
|
371
454
|
|
|
455
|
+
this.addEventListener("pointerdown", this.#onTouchPress);
|
|
456
|
+
this.ownerDocument.addEventListener("pointerup", this.#onTouchRelease);
|
|
457
|
+
this.ownerDocument.addEventListener("pointercancel", this.#onTouchRelease);
|
|
458
|
+
this.ownerDocument.defaultView?.addEventListener("blur", this.#clearTouchPress);
|
|
459
|
+
this.#elements.toggleButton.addEventListener("click", this.#handles.onToggleClick);
|
|
460
|
+
this.#elements.closeButton.addEventListener("click", this.#handles.onCloseClick);
|
|
461
|
+
this.#elements.panel.addEventListener("keydown", this.#handles.onPanelKeyDown);
|
|
372
462
|
this.#elements.toggleButton.addEventListener("mouseenter", this.#handles.onToggleEnter);
|
|
373
463
|
this.#elements.toggleButton.addEventListener("focus", this.#handles.onToggleEnter);
|
|
374
464
|
this.#elements.panel.addEventListener("mouseenter", this.#handles.onPanelEnter);
|
|
@@ -380,7 +470,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
380
470
|
}
|
|
381
471
|
|
|
382
472
|
/**
|
|
383
|
-
* Removes
|
|
473
|
+
* Removes hover, touch feedback, explicit toggle/close, keyboard and settings listeners, guarding partial initialization.
|
|
474
|
+
* Clears owned press state and document/window subscriptions before disconnection.
|
|
384
475
|
* @private
|
|
385
476
|
* @returns {void}
|
|
386
477
|
*/
|
|
@@ -388,6 +479,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
388
479
|
if (!this.#elements.toggleButton) {
|
|
389
480
|
return;
|
|
390
481
|
}
|
|
482
|
+
this.removeEventListener("pointerdown", this.#onTouchPress);
|
|
483
|
+
this.ownerDocument.removeEventListener("pointerup", this.#onTouchRelease);
|
|
484
|
+
this.ownerDocument.removeEventListener("pointercancel", this.#onTouchRelease);
|
|
485
|
+
this.ownerDocument.defaultView?.removeEventListener("blur", this.#clearTouchPress);
|
|
486
|
+
this.#clearTouchPress();
|
|
487
|
+
this.#elements.toggleButton.removeEventListener("click", this.#handles.onToggleClick);
|
|
488
|
+
this.#elements.closeButton?.removeEventListener("click", this.#handles.onCloseClick);
|
|
489
|
+
this.#elements.panel?.removeEventListener("keydown", this.#handles.onPanelKeyDown);
|
|
391
490
|
this.#elements.toggleButton.removeEventListener("mouseenter", this.#handles.onToggleEnter);
|
|
392
491
|
this.#elements.toggleButton.removeEventListener("focus", this.#handles.onToggleEnter);
|
|
393
492
|
this.#elements.panel?.removeEventListener("mouseenter", this.#handles.onPanelEnter);
|
|
@@ -399,24 +498,40 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
399
498
|
}
|
|
400
499
|
|
|
401
500
|
/**
|
|
402
|
-
*
|
|
501
|
+
* Reads inherited presentation state without installing a second capability observer.
|
|
502
|
+
* @private
|
|
503
|
+
* @returns {boolean} True for comfortable controls or an actual primary coarse pointer.
|
|
504
|
+
*/
|
|
505
|
+
#usesTouchControls() {
|
|
506
|
+
const surface = this.closest("[data-controls-density]");
|
|
507
|
+
return surface?.dataset.controlsDensity === "comfortable" || surface?.dataset.controlsTouch === "true";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Opens enabled settings for touch presentation or an actually hovered viewer; exposes panel focus targets.
|
|
403
512
|
* @private
|
|
404
513
|
* @returns {void}
|
|
405
514
|
*/
|
|
406
515
|
#openMenu() {
|
|
407
|
-
if (!this.#isEnabled || !this.hasAttribute("data-viewer-hover")) {
|
|
516
|
+
if (!this.#isEnabled || (!this.#usesTouchControls() && !this.hasAttribute("data-viewer-hover"))) {
|
|
408
517
|
return;
|
|
409
518
|
}
|
|
410
519
|
this.setAttribute("data-open", "true");
|
|
520
|
+
this.#elements.toggleButton?.setAttribute("aria-expanded", "true");
|
|
521
|
+
this.#elements.panel?.removeAttribute("inert");
|
|
522
|
+
this.#elements.panel?.setAttribute("aria-hidden", "false");
|
|
411
523
|
}
|
|
412
524
|
|
|
413
525
|
/**
|
|
414
|
-
* Closes
|
|
526
|
+
* Closes settings, synchronizes expanded state and removes hidden panel controls from keyboard navigation.
|
|
415
527
|
* @private
|
|
416
528
|
* @returns {void}
|
|
417
529
|
*/
|
|
418
530
|
#closeMenu() {
|
|
419
531
|
this.removeAttribute("data-open");
|
|
532
|
+
this.#elements.toggleButton?.setAttribute("aria-expanded", "false");
|
|
533
|
+
this.#elements.panel?.setAttribute("inert", "");
|
|
534
|
+
this.#elements.panel?.setAttribute("aria-hidden", "true");
|
|
420
535
|
}
|
|
421
536
|
|
|
422
537
|
/**
|
|
@@ -748,7 +863,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
748
863
|
/**
|
|
749
864
|
* Sets the locale used for menu labels and helper text.
|
|
750
865
|
* @public
|
|
751
|
-
* @param {string} cultureId - Locale identifier, e.g., "en-EN".
|
|
866
|
+
* @param {string} [cultureId=I18n.DEFAULT_LOCALE] - Locale identifier, e.g., "en-EN".
|
|
752
867
|
* @returns {void}
|
|
753
868
|
*/
|
|
754
869
|
setCulture(cultureId = I18n.DEFAULT_LOCALE) {
|
|
@@ -778,7 +893,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
778
893
|
/**
|
|
779
894
|
* Applies a new render settings snapshot to the UI and clears the applying state.
|
|
780
895
|
* @public
|
|
781
|
-
* @param {object} settings - Render settings snapshot from PrefViewer or BabylonJSController.
|
|
896
|
+
* @param {object} [settings={}] - Render settings snapshot from PrefViewer or BabylonJSController.
|
|
782
897
|
* @returns {void}
|
|
783
898
|
*/
|
|
784
899
|
setSettings(settings = {}) {
|
|
@@ -796,7 +911,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
796
911
|
}
|
|
797
912
|
|
|
798
913
|
/**
|
|
799
|
-
* Receives
|
|
914
|
+
* Receives hover state; pointer leave closes fine-pointer settings while touch panels require explicit close.
|
|
800
915
|
* @public
|
|
801
916
|
* @param {boolean} isHovering - True when the viewer is hovered.
|
|
802
917
|
* @returns {void}
|
|
@@ -809,7 +924,9 @@ export default class PrefViewerMenu3D extends HTMLElement {
|
|
|
809
924
|
this.setAttribute("data-viewer-hover", "true");
|
|
810
925
|
} else {
|
|
811
926
|
this.removeAttribute("data-viewer-hover");
|
|
812
|
-
this.#
|
|
927
|
+
if (!this.#usesTouchControls()) {
|
|
928
|
+
this.#closeMenu();
|
|
929
|
+
}
|
|
813
930
|
}
|
|
814
931
|
}
|
|
815
932
|
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import * as ViewerStyles from "./styles.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Owns one root viewer's validated appearance, explicit patches and optional saved preferences.
|
|
5
|
+
* Projects values onto a shadow-internal boundary; never mutates shared defaults or the public host style.
|
|
6
|
+
* No observers or graphics resources are owned. Reattach after reconnect to replace the owned boundary stylesheet.
|
|
7
|
+
*/
|
|
8
|
+
export default class PrefViewerStyleController {
|
|
9
|
+
static #DEFAULT_PRIMARY = ViewerStyles.PrefViewerViewCubePalette.colors.hover;
|
|
10
|
+
static #FIELDS = Object.freeze({
|
|
11
|
+
colorPrimary: ["color", "--pref-viewer-color-primary"],
|
|
12
|
+
"viewCube.colors.face": ["color", "--pref-viewer-view-cube-fill-color"],
|
|
13
|
+
"viewCube.colors.edge": ["color", "--pref-viewer-view-cube-edge-color"],
|
|
14
|
+
"viewCube.colors.corner": ["color", "--pref-viewer-view-cube-corner-color"],
|
|
15
|
+
"viewCube.colors.border": ["color", "--pref-viewer-view-cube-border-color"],
|
|
16
|
+
"viewCube.colors.text": ["color", "--pref-viewer-view-cube-text-color"],
|
|
17
|
+
"viewCube.colors.hover": ["color", "--pref-viewer-view-cube-hover-color"],
|
|
18
|
+
"viewCube.colors.active": ["color", "--pref-viewer-view-cube-active-color"],
|
|
19
|
+
"viewCube.hoverOpacity": ["opacity", "--pref-viewer-view-cube-hover-opacity"],
|
|
20
|
+
"viewCube.activeOpacity": ["opacity", "--pref-viewer-view-cube-active-opacity"],
|
|
21
|
+
"viewCube.typography.fontFamily": ["family", "--pref-viewer-view-cube-text-font-family"],
|
|
22
|
+
"viewCube.typography.fontSize": ["size", "--pref-viewer-view-cube-text-font-size"],
|
|
23
|
+
"viewCube.typography.fontWeight": ["weight", "--pref-viewer-view-cube-text-font-weight"],
|
|
24
|
+
"viewCube.typography.letterSpacing": ["spacing", "--pref-viewer-view-cube-text-letter-spacing"],
|
|
25
|
+
"viewCube.typography.textTransform": ["transform", "--pref-viewer-view-cube-text-transform"],
|
|
26
|
+
});
|
|
27
|
+
#document;
|
|
28
|
+
#surface = null;
|
|
29
|
+
#boundary = null;
|
|
30
|
+
#primaryColor = PrefViewerStyleController.#DEFAULT_PRIMARY;
|
|
31
|
+
#saved = new Map();
|
|
32
|
+
#explicit = new Map();
|
|
33
|
+
#persistence = { enabled: true, key: ViewerStyles.PREF_VIEWER_STYLES_STORAGE_KEY };
|
|
34
|
+
|
|
35
|
+
/** @param {Document} document - Owning document, used for CSS validation and best-effort origin storage. */
|
|
36
|
+
constructor(document) {
|
|
37
|
+
this.#document = document;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns {object} Detached merged overrides, including the effective primary default; omitted leaves use component defaults. */
|
|
41
|
+
get styles() {
|
|
42
|
+
return { colorPrimary: PrefViewerStyleController.#DEFAULT_PRIMARY, ...PrefViewerStyleController.#expand(this.#values()) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns {string} Opaque sRGB hex primary for Babylon materials; resolved when projecting the current API styles. */
|
|
46
|
+
get primaryColor() {
|
|
47
|
+
return this.#primaryColor;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @returns {{enabled: boolean, key: string}} Detached persistence configuration; keys address this origin's localStorage. */
|
|
51
|
+
get persistence() {
|
|
52
|
+
return { ...this.#persistence };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Configures storage without writing it. An attached viewer reloads the selected key, preserving explicit patches/reset markers.
|
|
57
|
+
* @param {{enabled?: boolean, key?: string}} value - Partial options; invalid leaves are ignored. Disable before insertion to avoid reads.
|
|
58
|
+
* @returns {void} Storage failures leave explicit styles and defaults usable.
|
|
59
|
+
*/
|
|
60
|
+
setPersistence(value) {
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (typeof value.enabled === "boolean") {
|
|
65
|
+
this.#persistence.enabled = value.enabled;
|
|
66
|
+
}
|
|
67
|
+
if (typeof value.key === "string" && value.key.trim()) {
|
|
68
|
+
this.#persistence.key = value.key.trim();
|
|
69
|
+
}
|
|
70
|
+
if (this.#surface) {
|
|
71
|
+
this.#restore();
|
|
72
|
+
this.#project();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Installs the CSS inheritance boundary and restores the selected saved layer without writing preferences.
|
|
78
|
+
* Internal density/layout rules are appended later and override reset tokens at the same specificity.
|
|
79
|
+
* @param {HTMLElement} surface - The root viewer's wrapper inside its ShadowRoot, never the public host.
|
|
80
|
+
* @returns {void} Reattachment releases the previous owned stylesheet and preserves explicit patches.
|
|
81
|
+
*/
|
|
82
|
+
attach(surface) {
|
|
83
|
+
this.#boundary?.remove();
|
|
84
|
+
this.#surface = surface;
|
|
85
|
+
this.#boundary = this.#document.createElement("style");
|
|
86
|
+
const templates = Object.values(ViewerStyles).filter(value => typeof value === "string");
|
|
87
|
+
templates.push(ViewerStyles.PrefViewerViewCubePalette.styles, ViewerStyles.PrefViewerViewCubeTypography.styles);
|
|
88
|
+
const tokens = new Set(templates.join("\n").match(/--[a-zA-Z][\w-]*/g));
|
|
89
|
+
tokens.add("--pref-viewer-view-cube-inset-right");
|
|
90
|
+
this.#boundary.textContent = `.pref-viewer-wrapper {\n all: initial;\n direction: ltr;\n unicode-bidi: isolate;\n${[...tokens].map(token => ` ${token}: initial;`).join("\n")}\n}`;
|
|
91
|
+
surface.prepend(this.#boundary);
|
|
92
|
+
this.#restore();
|
|
93
|
+
this.#project();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Applies a partial patch. Null leaves/groups reset matching saved and explicit customizations; unknown/invalid values are ignored.
|
|
98
|
+
* @param {object} patch - Supported style tree, already parsed by the public facade.
|
|
99
|
+
* @returns {boolean} Whether at least one valid leaf or reset was accepted; accepted patches persist best-effort.
|
|
100
|
+
*/
|
|
101
|
+
update(patch) {
|
|
102
|
+
const changes = this.#normalize(patch);
|
|
103
|
+
if (!changes.size) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
for (const [path, value] of changes) {
|
|
107
|
+
this.#explicit.set(path, value);
|
|
108
|
+
}
|
|
109
|
+
this.#project();
|
|
110
|
+
this.#persist();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Removes only the configured storage entry; current appearance remains intact and a later patch may save it again.
|
|
116
|
+
* @returns {boolean} True on successful removal; false when persistence is disabled or storage is unavailable.
|
|
117
|
+
*/
|
|
118
|
+
clearPersisted() {
|
|
119
|
+
if (!this.#persistence.enabled) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const storage = this.#document.defaultView?.localStorage;
|
|
124
|
+
if (!storage) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
storage.removeItem(this.#persistence.key);
|
|
128
|
+
return true;
|
|
129
|
+
} catch (_error) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @returns {Map<string, string|number>} Effective overrides after applying explicit values and reset markers to stored values. */
|
|
135
|
+
#values() {
|
|
136
|
+
const values = new Map(this.#saved);
|
|
137
|
+
for (const [path, value] of this.#explicit) {
|
|
138
|
+
if (value === null) {
|
|
139
|
+
values.delete(path);
|
|
140
|
+
} else {
|
|
141
|
+
values.set(path, value);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return values;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whitelists known leaves; a null group expands to individual reset markers so later partial changes cannot revive saved siblings.
|
|
149
|
+
* @param {object} input - Candidate style tree; arrays and non-objects are ignored.
|
|
150
|
+
* @returns {Map<string, string|number|null>} Accepted flattened leaves; no caller object references are retained.
|
|
151
|
+
*/
|
|
152
|
+
#normalize(input) {
|
|
153
|
+
const result = new Map();
|
|
154
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
for (const [path, [kind]] of Object.entries(PrefViewerStyleController.#FIELDS)) {
|
|
158
|
+
let value = input;
|
|
159
|
+
for (const segment of path.split(".")) {
|
|
160
|
+
if (value === null) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, segment)) {
|
|
164
|
+
value = undefined;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
value = value[segment];
|
|
168
|
+
}
|
|
169
|
+
if (value === null || this.#valid(kind, value)) {
|
|
170
|
+
result.set(path, typeof value === "string" ? value.trim() : value);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Validates a leaf with native CSS parsing for colors/families and explicit numeric/enum constraints elsewhere.
|
|
178
|
+
* @param {string} kind - Internal schema validation kind.
|
|
179
|
+
* @param {*} value - Candidate leaf; CSS references and inheritance keywords are rejected.
|
|
180
|
+
* @returns {boolean} Whether the value is independent of external CSS and valid for the supported field.
|
|
181
|
+
*/
|
|
182
|
+
#valid(kind, value) {
|
|
183
|
+
if (["opacity", "size", "weight", "spacing"].includes(kind)) {
|
|
184
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (kind === "opacity") {
|
|
188
|
+
return value >= 0 && value <= 1;
|
|
189
|
+
}
|
|
190
|
+
if (kind === "weight") {
|
|
191
|
+
return Number.isInteger(value) && value >= 1 && value <= 1000;
|
|
192
|
+
}
|
|
193
|
+
return kind === "size" ? value > 0 : value >= 0;
|
|
194
|
+
}
|
|
195
|
+
if (typeof value !== "string" || !value.trim() || /[;{}]|\b(?:var|env)\s*\(|\b(?:inherit|initial|unset|revert|revert-layer|currentcolor)\b/i.test(value)) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (kind === "transform") {
|
|
199
|
+
return ["none", "uppercase", "lowercase"].includes(value.trim());
|
|
200
|
+
}
|
|
201
|
+
const style = this.#document.createElement("span").style;
|
|
202
|
+
const property = kind === "color" ? "color" : "font-family";
|
|
203
|
+
style.setProperty(property, value.trim());
|
|
204
|
+
return Boolean(style.getPropertyValue(property));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Expands a whitelist-generated flat map to a detached JSON-safe object.
|
|
209
|
+
* @param {Map<string, string|number>} values - Known schema paths, excluding reset markers.
|
|
210
|
+
* @returns {object} Nested override tree suitable for getters and storage.
|
|
211
|
+
*/
|
|
212
|
+
static #expand(values) {
|
|
213
|
+
const result = {};
|
|
214
|
+
for (const [path, value] of values) {
|
|
215
|
+
const segments = path.split(".");
|
|
216
|
+
let target = result;
|
|
217
|
+
for (const segment of segments.slice(0, -1)) {
|
|
218
|
+
target = target[segment] ??= {};
|
|
219
|
+
}
|
|
220
|
+
target[segments.at(-1)] = value;
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @returns {void} Replaces the saved layer from the configured key; corrupt/unsupported data and inaccessible storage are ignored. */
|
|
226
|
+
#restore() {
|
|
227
|
+
this.#saved.clear();
|
|
228
|
+
if (!this.#persistence.enabled) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const serialized = this.#document.defaultView?.localStorage?.getItem(this.#persistence.key);
|
|
233
|
+
const data = serialized ? JSON.parse(serialized) : null;
|
|
234
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const patch = data.version === 1 ? data.styles : !Object.hasOwn(data, "version") ? { colorPrimary: data.colorPrimary } : null;
|
|
238
|
+
this.#saved = new Map([...this.#normalize(patch)].filter(([, value]) => value !== null));
|
|
239
|
+
} catch (_error) {
|
|
240
|
+
// Storage is optional; explicit styles remain available.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @returns {void} Writes only effective overrides with schema version 1; blocked/full storage never interrupts viewer work. */
|
|
245
|
+
#persist() {
|
|
246
|
+
if (!this.#persistence.enabled) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const styles = PrefViewerStyleController.#expand(this.#values());
|
|
251
|
+
this.#document.defaultView?.localStorage?.setItem(this.#persistence.key, JSON.stringify({ version: 1, styles }));
|
|
252
|
+
} catch (_error) {
|
|
253
|
+
// Rendering and queue processing continue when storage cannot be written.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @returns {void} Projects API values and fixed inherited typography onto the internal wrapper without changing the public host. */
|
|
258
|
+
#project() {
|
|
259
|
+
if (!this.#surface) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const values = this.#values();
|
|
263
|
+
const style = this.#surface.style;
|
|
264
|
+
style.fontFamily = ViewerStyles.PrefViewerStyleVariables.fontFamily;
|
|
265
|
+
style.fontSize = ViewerStyles.PrefViewerStyleVariables.fontSize;
|
|
266
|
+
style.fontWeight = "400";
|
|
267
|
+
style.fontStyle = "normal";
|
|
268
|
+
style.lineHeight = "1.2";
|
|
269
|
+
style.color = "#000000";
|
|
270
|
+
style.letterSpacing = "normal";
|
|
271
|
+
style.wordSpacing = "normal";
|
|
272
|
+
style.textTransform = "none";
|
|
273
|
+
style.textShadow = "none";
|
|
274
|
+
style.textAlign = "start";
|
|
275
|
+
for (const [path, [kind, token]] of Object.entries(PrefViewerStyleController.#FIELDS)) {
|
|
276
|
+
if (values.has(path)) {
|
|
277
|
+
const value = values.get(path);
|
|
278
|
+
style.setProperty(token, ["size", "spacing"].includes(kind) ? `${value}px` : String(value));
|
|
279
|
+
} else {
|
|
280
|
+
style.removeProperty(token);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
this.#primaryColor = this.#resolvePrimary(this.styles.colorPrimary);
|
|
284
|
+
style.setProperty("--pref-viewer-color-primary", this.#primaryColor);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolves a validated CSS color to opaque sRGB for Babylon's hex-only material consumers.
|
|
289
|
+
* Uses computed RGB for common syntaxes and a one-pixel sRGB canvas for modern color functions.
|
|
290
|
+
* @param {string} color - Valid independent CSS color, including named colors and short hex.
|
|
291
|
+
* @returns {string} Six-digit hex, falling back to the default when browser normalization is unavailable.
|
|
292
|
+
*/
|
|
293
|
+
#resolvePrimary(color) {
|
|
294
|
+
const probe = this.#document.createElement("span");
|
|
295
|
+
probe.style.color = color;
|
|
296
|
+
probe.style.display = "none";
|
|
297
|
+
this.#surface.append(probe);
|
|
298
|
+
try {
|
|
299
|
+
const computed = this.#document.defaultView.getComputedStyle(probe).color;
|
|
300
|
+
const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(computed);
|
|
301
|
+
let channels;
|
|
302
|
+
if (rgb) {
|
|
303
|
+
channels = rgb.slice(1, 4).map(Number);
|
|
304
|
+
} else {
|
|
305
|
+
const canvas = this.#document.createElement("canvas");
|
|
306
|
+
canvas.width = canvas.height = 1;
|
|
307
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
308
|
+
if (!context) {
|
|
309
|
+
return PrefViewerStyleController.#DEFAULT_PRIMARY;
|
|
310
|
+
}
|
|
311
|
+
context.fillStyle = color;
|
|
312
|
+
context.fillRect(0, 0, 1, 1);
|
|
313
|
+
channels = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3);
|
|
314
|
+
}
|
|
315
|
+
return `#${channels.map(channel => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")).join("")}`;
|
|
316
|
+
} finally {
|
|
317
|
+
probe.remove();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|