@preference-sl/pref-viewer 2.18.0-beta.1 → 3.0.0-beta.0

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.
Files changed (35) hide show
  1. package/README.md +267 -31
  2. package/index.d.ts +75 -6
  3. package/package.json +8 -2
  4. package/src/babylonjs-animation-controller.js +18 -10
  5. package/src/babylonjs-animation-opening-menu.js +80 -14
  6. package/src/babylonjs-animation-opening.js +3 -3
  7. package/src/babylonjs-ar-floor-bounds.js +169 -0
  8. package/src/babylonjs-ar-glass.js +128 -0
  9. package/src/babylonjs-ar-placement-feedback.js +449 -0
  10. package/src/babylonjs-ar-placement.js +1038 -0
  11. package/src/babylonjs-camera-wheel-motion.js +35 -2
  12. package/src/babylonjs-controller.js +507 -125
  13. package/src/babylonjs-orbital-pointers-input.js +186 -0
  14. package/src/{render-math.js → babylonjs-render-math.js} +11 -11
  15. package/src/babylonjs-selection-controller.js +4 -4
  16. package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
  17. package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
  18. package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
  19. package/src/babylonjs-view-cube-controller.js +74 -1103
  20. package/src/babylonjs-view-cube-svg-controller.js +1012 -0
  21. package/src/file-storage.js +7 -4
  22. package/src/gltf-resolver.js +13 -8
  23. package/src/localization/translations.js +12 -0
  24. package/src/panzoom-controller.js +4 -4
  25. package/src/pref-viewer-2d.js +2 -2
  26. package/src/pref-viewer-3d.js +55 -9
  27. package/src/pref-viewer-controls-presentation.js +292 -0
  28. package/src/pref-viewer-dialog.js +86 -7
  29. package/src/pref-viewer-menu-3d.js +134 -17
  30. package/src/pref-viewer-style-controller.js +320 -0
  31. package/src/pref-viewer.js +133 -109
  32. package/src/quick-look-action.js +280 -0
  33. package/src/styles.js +702 -98
  34. package/src/babylonjs-view-cube-renderer.js +0 -548
  35. package/src/view-cube-orbit-math.js +0 -199
@@ -0,0 +1,292 @@
1
+ import { ViewerControlsPresentationStyles } from "./styles.js";
2
+
3
+ /**
4
+ * Owns control presentation for one viewer surface, shared by its descendant overlays.
5
+ * Dialogs inherit the same density and size tokens as the render, playback and AR controls.
6
+ * Resolves explicit density or primary-pointer capability without using DPR or changing the page viewport.
7
+ * Publishes scoped CSS state and component dimensions; dispose before removing the owning surface.
8
+ * Observes the active SVG/Canvas widget size plus overlay geometry/visibility and reserves playback space only for horizontally overlapping AR actions.
9
+ * Moves render settings immediately left of the upper-right ViewCube when their normal right-side position is too low or intersects AR.
10
+ */
11
+ export default class PrefViewerControlsPresentation {
12
+ #surface;
13
+ #getPreference;
14
+ #onChange;
15
+ #media;
16
+ #observer;
17
+ #mutationObserver;
18
+ #observedControls = new Set();
19
+ #layoutPending = false;
20
+ #style;
21
+ #disposed = false;
22
+ #key = "";
23
+
24
+ /**
25
+ * Creates presentation styles and subscribes once to capability and component-size changes.
26
+ * Shares the resize observer with overlay controls and watches their insertion and visibility changes.
27
+ * Missing browser APIs fall back to compact auto presentation and current measured dimensions.
28
+ * @param {HTMLElement} surface - Scoped ancestor of the controls; nested viewers inherit its CSS state.
29
+ * @param {function(): ?string} getPreference - Current controls-density attribute, read on every refresh.
30
+ * @param {function(): void} [onChange] - Geometry refresh callback; defaults to a no-op.
31
+ */
32
+ constructor(surface, getPreference, onChange = () => {}) {
33
+ this.#surface = surface;
34
+ this.#getPreference = getPreference;
35
+ this.#onChange = onChange;
36
+ const view = surface.ownerDocument.defaultView;
37
+ this.#style = surface.ownerDocument.createElement("style");
38
+ this.#style.textContent = ViewerControlsPresentationStyles;
39
+ surface.append(this.#style);
40
+ this.#media = view?.matchMedia?.("(pointer: coarse)");
41
+ if (this.#media?.addEventListener) {
42
+ this.#media.addEventListener("change", this.#onMediaChange);
43
+ } else {
44
+ this.#media?.addListener?.(this.#onMediaChange);
45
+ }
46
+ const Observer = view?.ResizeObserver ?? globalThis.ResizeObserver;
47
+ this.#observer = Observer ? new Observer(this.#onResize) : null;
48
+ if (view?.MutationObserver) {
49
+ this.#mutationObserver = new view.MutationObserver(this.#onControlsChanged);
50
+ this.#mutationObserver.observe(surface, {
51
+ subtree: true, childList: true, attributes: true,
52
+ attributeFilter: ["class", "style", "hidden", "visible"],
53
+ });
54
+ }
55
+ this.refresh();
56
+ this.#observer?.observe(surface);
57
+ }
58
+
59
+ /**
60
+ * Re-evaluates the attribute/capability and measures the surface without changing model or camera state.
61
+ * @returns {void} No-op after disposal; notifies the geometry owner only for effective changes.
62
+ */
63
+ refresh() {
64
+ this.#update(this.#surface.getBoundingClientRect());
65
+ }
66
+
67
+ /**
68
+ * Re-evaluates automatic presentation when the primary pointing device changes.
69
+ * @private
70
+ * @returns {void}
71
+ */
72
+ #onMediaChange = () => { this.refresh(); };
73
+
74
+ /**
75
+ * Consumes the owning surface's observed content size, including hidden-to-visible and iframe resizes.
76
+ * Overlay resize entries also refresh the conditional playback clearance.
77
+ * @private
78
+ * @param {ResizeObserverEntry[]} entries - Resize entries; unrelated entries are ignored.
79
+ * @returns {void}
80
+ */
81
+ #onResize = entries => {
82
+ const entry = entries.find(item => item.target === this.#surface);
83
+ if (entry) {
84
+ this.#update(entry.contentRect);
85
+ }
86
+ this.#scheduleLayout();
87
+ };
88
+
89
+ /**
90
+ * Schedules layout for inserted/removed overlays or changes affecting their visibility and dimensions.
91
+ * Tracks widget insertion/replacement plus settings/AR geometry, ignoring unrelated model/UI and SVG-polygon geometry mutations.
92
+ * @private
93
+ * @param {MutationRecord[]} records - Mutations within this presentation surface.
94
+ * @returns {void}
95
+ */
96
+ #onControlsChanged = records => {
97
+ const selector = ".pref-viewer-view-cube, .babylonVRicon, .animation-menu, .xr-button-overlay, pref-viewer-3d, pref-viewer-menu-3d, .menu-toggle";
98
+ if (records.some(record => record.target === this.#surface
99
+ || record.target.matches?.(selector)
100
+ || [...record.addedNodes, ...record.removedNodes].some(node => node.matches?.(selector) || node.querySelector?.(selector)))) {
101
+ this.#scheduleLayout();
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Coalesces control mutations/resizes into one layout measurement after the current DOM update.
107
+ * @private
108
+ * @returns {void} Queued work becomes a no-op after disposal.
109
+ */
110
+ #scheduleLayout() {
111
+ if (this.#disposed || this.#layoutPending) {
112
+ return;
113
+ }
114
+ this.#layoutPending = true;
115
+ Promise.resolve().then(() => {
116
+ this.#layoutPending = false;
117
+ if (!this.#disposed) {
118
+ this.#layoutControls();
119
+ }
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Measures visible AR actions and offsets centered playback only where their horizontal bounds intersect.
125
+ * Publishes the measured active SVG/Canvas cube width for side-menu placement; clears it when no cube is visible.
126
+ * Also updates render-menu side from its independent normal right-side position.
127
+ * Applies the same clearance rule in compact and comfortable presentation, using actual control dimensions.
128
+ * Uses CSS-pixel geometry in the menu's positioning container, including scaled embedding surfaces.
129
+ * Owns the playback-bottom property and control resize subscriptions until removal or disposal.
130
+ * @private
131
+ * @returns {void}
132
+ */
133
+ #layoutControls() {
134
+ const controls = new Set(this.#surface.querySelectorAll(".pref-viewer-view-cube, .babylonVRicon, .animation-menu, pref-viewer-menu-3d, pref-viewer-menu-3d .menu-toggle"));
135
+ for (const control of this.#observedControls) {
136
+ if (!controls.has(control)) {
137
+ this.#observer?.unobserve(control);
138
+ control.style.removeProperty("--pref-viewer-controls-playback-bottom");
139
+ control.removeAttribute("data-controls-menu-side");
140
+ }
141
+ }
142
+ for (const control of controls) {
143
+ if (!this.#observedControls.has(control)) {
144
+ this.#observer?.observe(control);
145
+ }
146
+ }
147
+ this.#observedControls = controls;
148
+ const view = this.#surface.ownerDocument.defaultView;
149
+ const cube = this.#surface.querySelector(".pref-viewer-view-cube:not([hidden])");
150
+ const cubeRect = cube?.getBoundingClientRect();
151
+ const surfaceRect = this.#surface.getBoundingClientRect();
152
+ const scale = this.#surface.clientWidth > 0 ? surfaceRect.width / this.#surface.clientWidth : 1;
153
+ const cubeSize = cubeRect?.width > 0 ? `${cubeRect.width / (scale || 1)}px` : null;
154
+ if (cubeSize) {
155
+ if (this.#surface.style.getPropertyValue("--pref-viewer-controls-cube-actual-size") !== cubeSize) {
156
+ this.#surface.style.setProperty("--pref-viewer-controls-cube-actual-size", cubeSize);
157
+ }
158
+ } else {
159
+ this.#surface.style.removeProperty("--pref-viewer-controls-cube-actual-size");
160
+ }
161
+ const tokens = view.getComputedStyle(this.#surface);
162
+ const edge = parseFloat(tokens.getPropertyValue("--pref-viewer-controls-edge-spacing")) || 16;
163
+ const gap = parseFloat(tokens.getPropertyValue("--pref-viewer-controls-overlay-gap")) || 12;
164
+ const buttons = [...controls].filter(control => control.matches(".babylonVRicon"))
165
+ .filter(control => view.getComputedStyle(control).visibility !== "hidden" && view.getComputedStyle(control).opacity !== "0")
166
+ .map(control => control.getBoundingClientRect()).filter(rect => rect.width > 0 && rect.height > 0);
167
+ for (const menu of [...controls].filter(control => control.matches(".animation-menu"))) {
168
+ const rect = menu.getBoundingClientRect();
169
+ const container = menu.offsetParent ?? this.#surface;
170
+ const containerRect = container.getBoundingClientRect();
171
+ const scale = container.clientWidth > 0 ? containerRect.width / container.clientWidth : 1;
172
+ let bottom = edge;
173
+ if (rect.width > 0) {
174
+ for (const button of buttons) {
175
+ if (rect.right + gap * scale > button.left && rect.left - gap * scale < button.right) {
176
+ bottom = Math.max(bottom, (containerRect.bottom - button.top) / (scale || 1) + gap);
177
+ }
178
+ }
179
+ }
180
+ const value = `${bottom}px`;
181
+ if (menu.style.getPropertyValue("--pref-viewer-controls-playback-bottom") !== value) {
182
+ menu.style.setProperty("--pref-viewer-controls-playback-bottom", value);
183
+ }
184
+ }
185
+ this.#layoutRenderMenus(controls, buttons, edge, gap);
186
+ }
187
+
188
+ /**
189
+ * Chooses settings alignment from the normal right-side rectangle, independently of its current placement beside the cube.
190
+ * Moves the trigger/panel to the upper right, immediately left of the cube, when it cannot fit below the cube or intersects visible AR clearance.
191
+ * Comparing a stable right-side candidate prevents resize/mutation feedback from oscillating between sides.
192
+ * @private
193
+ * @param {Set<HTMLElement>} controls - Currently observed overlay elements, including settings hosts/triggers.
194
+ * @param {DOMRect[]} buttons - Nonempty visible AR rectangles in viewport CSS coordinates.
195
+ * @param {number} edge - Normal component edge spacing in unscaled CSS pixels.
196
+ * @param {number} gap - Minimum gap between trigger and AR, in unscaled CSS pixels.
197
+ * @returns {void} Publishes only an owned beside-cube attribute; stylesheet owns positioning and panel alignment.
198
+ */
199
+ #layoutRenderMenus(controls, buttons, edge, gap) {
200
+ const view = this.#surface.ownerDocument.defaultView;
201
+ const surface = this.#surface.getBoundingClientRect();
202
+ const scaleX = this.#surface.clientWidth > 0 ? surface.width / this.#surface.clientWidth : 1;
203
+ const scaleY = this.#surface.clientHeight > 0 ? surface.height / this.#surface.clientHeight : 1;
204
+ for (const menu of [...controls].filter(control => control.matches("pref-viewer-menu-3d"))) {
205
+ const toggle = menu.querySelector(".menu-toggle");
206
+ const rect = toggle?.getBoundingClientRect();
207
+ let moveBesideCube = false;
208
+ if (rect?.width > 0 && rect.height > 0 && surface.width > 0 && surface.height > 0 && scaleX > 0 && scaleY > 0) {
209
+ const style = view.getComputedStyle(menu);
210
+ const margin = parseFloat(style.getPropertyValue("--toggle-margin")) || edge;
211
+ const top = parseFloat(style.getPropertyValue("--pref-viewer-render-menu-top")) || margin;
212
+ const right = surface.right - margin * scaleX;
213
+ const candidate = { right, left: right - rect.width, top: surface.top + top * scaleY,
214
+ bottom: surface.top + top * scaleY + rect.height };
215
+ moveBesideCube = candidate.bottom + margin * scaleY > surface.bottom
216
+ || buttons.some(button => candidate.left < button.right + gap * scaleX
217
+ && candidate.right > button.left - gap * scaleX
218
+ && candidate.top < button.bottom + gap * scaleY
219
+ && candidate.bottom > button.top - gap * scaleY);
220
+ }
221
+ if (moveBesideCube) {
222
+ if (menu.getAttribute("data-controls-menu-side") !== "beside-cube") {
223
+ menu.setAttribute("data-controls-menu-side", "beside-cube");
224
+ }
225
+ } else {
226
+ menu.removeAttribute("data-controls-menu-side");
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Publishes density, touch capability, narrow layout and measured dimensions on this surface only.
233
+ * Invalid or missing preferences use auto; explicit density never overrides actual touch availability.
234
+ * @private
235
+ * @param {{width: number, height: number}} rect - Current dimensions in CSS pixels.
236
+ * @returns {void}
237
+ */
238
+ #update(rect) {
239
+ if (this.#disposed) {
240
+ return;
241
+ }
242
+ this.#scheduleLayout();
243
+ const preference = this.#getPreference();
244
+ const touch = this.#media?.matches === true;
245
+ const density = ["compact", "comfortable"].includes(preference) ? preference : touch ? "comfortable" : "compact";
246
+ const width = Math.max(0, Math.round(rect.width || this.#surface.clientWidth || 0));
247
+ const height = Math.max(0, Math.round(rect.height || this.#surface.clientHeight || 0));
248
+ const narrow = width > 0 && width < 480;
249
+ const key = `${density},${touch},${width},${height}`;
250
+ if (key === this.#key) {
251
+ return;
252
+ }
253
+ this.#key = key;
254
+ this.#surface.dataset.controlsDensity = density;
255
+ this.#surface.dataset.controlsTouch = String(touch);
256
+ this.#surface.dataset.controlsNarrow = String(narrow);
257
+ this.#surface.style.setProperty("--pref-viewer-controls-height", `${height}px`);
258
+ this.#surface.style.setProperty("--pref-viewer-controls-width", `${width}px`);
259
+ this.#onChange();
260
+ }
261
+
262
+ /**
263
+ * Releases media/resize subscriptions and removes only this instance's presentation state/styles.
264
+ * Disconnects control mutations and removes owned playback offsets/settings-side attributes from observed overlays.
265
+ * @returns {void} Idempotent; queued observer callbacks cannot change the detached surface.
266
+ */
267
+ dispose() {
268
+ if (this.#disposed) {
269
+ return;
270
+ }
271
+ this.#disposed = true;
272
+ this.#observer?.disconnect();
273
+ this.#mutationObserver?.disconnect();
274
+ for (const control of this.#observedControls) {
275
+ control.style.removeProperty("--pref-viewer-controls-playback-bottom");
276
+ control.removeAttribute("data-controls-menu-side");
277
+ }
278
+ this.#observedControls.clear();
279
+ if (this.#media?.removeEventListener) {
280
+ this.#media.removeEventListener("change", this.#onMediaChange);
281
+ } else {
282
+ this.#media?.removeListener?.(this.#onMediaChange);
283
+ }
284
+ this.#style.remove();
285
+ for (const name of ["data-controls-density", "data-controls-touch", "data-controls-narrow"]) {
286
+ this.#surface.removeAttribute(name);
287
+ }
288
+ this.#surface.style.removeProperty("--pref-viewer-controls-height");
289
+ this.#surface.style.removeProperty("--pref-viewer-controls-width");
290
+ this.#surface.style.removeProperty("--pref-viewer-controls-cube-actual-size");
291
+ }
292
+ }
@@ -6,6 +6,8 @@ import { PrefViewerDialogStyles } from "./styles.js";
6
6
  * Overview:
7
7
  * - Provides a modal dialog overlay for user interactions such as downloads or confirmations.
8
8
  * - Handles DOM creation, styling, and open/close logic.
9
+ * - Owns temporary touch feedback for dynamic controls, including associated radio/checkbox labels.
10
+ * Releases press state/listeners on completion, cancellation, blur, content replacement or removal.
9
11
  * - Ensures dialog content is centered and styled appropriately.
10
12
  * - Automatically focuses the canvas when closed for improved accessibility.
11
13
  *
@@ -28,6 +30,68 @@ export default class PrefViewerDialog extends HTMLElement {
28
30
  #content = null;
29
31
  #footer = null;
30
32
 
33
+ #touchPressedControl = null;
34
+ #touchPointerId = null;
35
+
36
+ /**
37
+ * Marks an enabled control during touch-down without relying on delayed native :active styling.
38
+ * Resolves nested or for-associated labels to their input; ignores labels pointing outside this dialog.
39
+ * Pointer input also clears owned keyboard modality; a second touch clears feedback.
40
+ * Native clicks, selection, text entry, ranges and scrolling stay untouched.
41
+ * @param {PointerEvent} event - Pointer down bubbling from dynamic dialog content or footer.
42
+ * @returns {void}
43
+ */
44
+ #onTouchPress = event => {
45
+ this.removeAttribute("data-keyboard-navigation");
46
+ if (event.pointerType !== "touch") {
47
+ return;
48
+ }
49
+ if (this.#touchPressedControl) {
50
+ this.#clearTouchPress();
51
+ return;
52
+ }
53
+ const target = event.target.closest?.('button, input, select, textarea, label, a[href]');
54
+ const control = target?.matches("label") ? target.control : target;
55
+ if (!control || !this.contains(control) || control.matches(':disabled, [type="hidden"], [aria-disabled="true"]')) {
56
+ return;
57
+ }
58
+ this.#touchPressedControl = control;
59
+ this.#touchPointerId = event.pointerId;
60
+ control.setAttribute("data-touch-pressed", "");
61
+ };
62
+
63
+ /**
64
+ * Clears feedback when the owning finger finishes or is cancelled, even outside this dialog.
65
+ * @param {PointerEvent} event - Document-level pointer completion.
66
+ * @returns {void}
67
+ */
68
+ #onTouchRelease = event => {
69
+ if (event.pointerId === this.#touchPointerId) {
70
+ this.#clearTouchPress();
71
+ }
72
+ };
73
+
74
+ /**
75
+ * Removes only owned press state on completion, blur, content replacement or teardown.
76
+ * @returns {void} Idempotent; preserves values, focus and native control behavior.
77
+ */
78
+ #clearTouchPress = () => {
79
+ this.#touchPressedControl?.removeAttribute("data-touch-pressed");
80
+ this.#touchPressedControl = null;
81
+ this.#touchPointerId = null;
82
+ };
83
+
84
+ /**
85
+ * Keeps keyboard focus visible where mobile WebKit does not expose :focus-visible for radio arrow navigation.
86
+ * @param {KeyboardEvent} event - Navigation key bubbling from a dialog control; native handling is preserved.
87
+ * @returns {void} Publishes scoped modality only for Tab and arrow keys; pointer input resets it.
88
+ */
89
+ #onKeyboardNavigation = event => {
90
+ if (event.key === "Tab" || event.key.startsWith("Arrow")) {
91
+ this.setAttribute("data-keyboard-navigation", "");
92
+ }
93
+ };
94
+
31
95
  #handles = {
32
96
  onBackdropClick: null,
33
97
  };
@@ -42,21 +106,34 @@ export default class PrefViewerDialog extends HTMLElement {
42
106
 
43
107
  /**
44
108
  * Called when the element is inserted into the DOM.
45
- * Sets up the dialog's DOM structure and styles.
109
+ * Sets up the dialog's DOM structure/styles and subscribes to transient touch feedback.
110
+ * Owns keyboard navigation feedback and document completion/window blur listeners until disconnection.
46
111
  * @returns {void}
47
112
  */
48
- connectedCallback() {
113
+ connectedCallback() {
49
114
  this.#handles.onBackdropClick = this.#onBackdropClick.bind(this);
50
115
  this.#createDOMElements();
116
+ this.addEventListener("pointerdown", this.#onTouchPress);
117
+ this.addEventListener("keydown", this.#onKeyboardNavigation);
118
+ this.ownerDocument.addEventListener("pointerup", this.#onTouchRelease);
119
+ this.ownerDocument.addEventListener("pointercancel", this.#onTouchRelease);
120
+ this.ownerDocument.defaultView?.addEventListener("blur", this.#clearTouchPress);
51
121
  }
52
122
 
53
123
  /**
54
124
  * Called when the element is removed from the DOM.
55
- * Cleans up resources and event listeners.
125
+ * Cleans up backdrop and touch listeners, including document/window subscriptions and owned press state.
56
126
  * @returns {void}
57
127
  */
58
128
  disconnectedCallback() {
59
129
  this.removeEventListener("click", this.#handles.onBackdropClick);
130
+ this.removeEventListener("pointerdown", this.#onTouchPress);
131
+ this.removeEventListener("keydown", this.#onKeyboardNavigation);
132
+ this.removeAttribute("data-keyboard-navigation");
133
+ this.ownerDocument.removeEventListener("pointerup", this.#onTouchRelease);
134
+ this.ownerDocument.removeEventListener("pointercancel", this.#onTouchRelease);
135
+ this.ownerDocument.defaultView?.removeEventListener("blur", this.#clearTouchPress);
136
+ this.#clearTouchPress();
60
137
  }
61
138
 
62
139
  /**
@@ -98,10 +175,10 @@ export default class PrefViewerDialog extends HTMLElement {
98
175
  }
99
176
 
100
177
  /**
101
- * Opens the dialog and sets its header, content, and footer.
102
- * @param {string} title - The dialog title to display in the header.
103
- * @param {string} content - The HTML content to display in the dialog body.
104
- * @param {string} footer - The HTML content to display in the dialog footer (e.g., action buttons).
178
+ * Opens the dialog and replaces its header, content, and footer, clearing press/keyboard feedback on replaced controls.
179
+ * @param {string} [title=""] - The dialog title to display in the header.
180
+ * @param {string} [content=""] - The HTML content to display in the dialog body.
181
+ * @param {string} [footer=""] - The HTML content to display in the dialog footer (e.g., action buttons).
105
182
  * @returns {boolean} True if the dialog was opened, false if no content was provided.
106
183
  */
107
184
  open(title = "", content = "", footer = "") {
@@ -112,6 +189,8 @@ export default class PrefViewerDialog extends HTMLElement {
112
189
  return false;
113
190
  }
114
191
 
192
+ this.#clearTouchPress();
193
+ this.removeAttribute("data-keyboard-navigation");
115
194
  if (title === "") {
116
195
  this.#header.style.display = "none";
117
196
  }