@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.
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 +542 -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 +22 -4
  22. package/src/gltf-resolver.js +83 -13
  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 +291 -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,291 @@
1
+ import I18n from "./localization/i18n.js";
2
+
3
+ /**
4
+ * Owns the localized Quick Look link and serialized USDZ preparation for one viewer.
5
+ * Prepares content before a trusted tap so Safari can navigate natively within the user gesture.
6
+ * Revisions prevent stale exports from becoming usable. Unused Blob URLs are revoked immediately;
7
+ * handed-off URLs survive invalidation/detach until an observed native return or document unload.
8
+ * Call dispose() when the owning viewer is torn down.
9
+ */
10
+ export default class QuickLookAction {
11
+ /**
12
+ * URL fragment appended to the prepared USDZ link; AR Quick Look parses it as viewer options.
13
+ * `allowsContentScaling=0` disables pinch scaling so the model keeps its authored real-world size.
14
+ * @public
15
+ * @static
16
+ * @type {string}
17
+ */
18
+ static QUICK_LOOK_FRAGMENT = "allowsContentScaling=0";
19
+
20
+ #anchor;
21
+ #style;
22
+ #window;
23
+ #urlAPI;
24
+ #canActivate;
25
+ #getCulture;
26
+ #revision = 0;
27
+ #disposed = false;
28
+ #exporter = null;
29
+ #work = Promise.resolve();
30
+ #url = null;
31
+ #handedOff = new Set();
32
+ #retired = new Set();
33
+ #away = false;
34
+ #unsubscribe;
35
+
36
+ /**
37
+ * Creates a hidden AR anchor and subscribes to locale and native-return lifecycle signals.
38
+ * @param {HTMLElement} container - Browser-owned canvas wrapper receiving the link and styles.
39
+ * @param {function(): boolean} [canActivate] - Host visibility/readiness gate; defaults to allowing activation.
40
+ * @param {function(): string} [getCulture] - Host locale provider; defaults to the active I18n locale.
41
+ */
42
+ constructor(container, canActivate = () => true, getCulture = () => I18n.getLocale()) {
43
+ const document = container.ownerDocument;
44
+ this.#window = document.defaultView;
45
+ this.#urlAPI = this.#window.URL;
46
+ this.#canActivate = canActivate;
47
+ this.#getCulture = getCulture;
48
+ this.#style = document.createElement("style");
49
+ this.#style.textContent = `
50
+ .pref-viewer-quick-look { position:absolute; right:20px; bottom:50px; z-index:11; display:block; cursor:pointer; }
51
+ .pref-viewer-quick-look[hidden] { display:none !important; }
52
+ .pref-viewer-quick-look[aria-disabled="true"] { opacity:0.5; cursor:wait; }
53
+ .pref-viewer-quick-look>img { width:100%; height:100%; opacity:0; }
54
+ `;
55
+ this.#anchor = document.createElement("a");
56
+ this.#anchor.className = "babylonVRicon pref-viewer-quick-look";
57
+ this.#anchor.rel = "ar";
58
+ this.#anchor.download = "model.usdz";
59
+ this.#anchor.setAttribute("role", "button");
60
+ this.#anchor.tabIndex = 0;
61
+ this.#anchor.hidden = true;
62
+ const image = document.createElement("img");
63
+ image.alt = "";
64
+ image.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='50'/%3E";
65
+ this.#anchor.append(image); // Safari requires a single img/picture child inside rel=ar.
66
+ this.#anchor.addEventListener("click", this.#onClick);
67
+ this.#anchor.addEventListener("keydown", this.#onKeyDown);
68
+ container.append(this.#style, this.#anchor);
69
+ this.#window.addEventListener("blur", this.#onAway);
70
+ this.#window.addEventListener("focus", this.#onReturn);
71
+ document.addEventListener("visibilitychange", this.#onVisibility);
72
+ this.#unsubscribe = I18n.onLocaleChange(() => this.#label());
73
+ this.#label();
74
+ }
75
+
76
+ /**
77
+ * Updates accessible text and tooltip for the current preparation, ready or retry state.
78
+ * @private
79
+ * @returns {void}
80
+ */
81
+ #label() {
82
+ const key = this.#anchor.dataset.state === "error" ? "retry"
83
+ : this.#url ? "open" : "preparing";
84
+ const text = I18n.translate(`prefViewer.ar.${key}`, { locale: this.#getCulture() });
85
+ this.#anchor.setAttribute("aria-label", text);
86
+ this.#anchor.title = text;
87
+ }
88
+
89
+ /**
90
+ * Allows synchronous native navigation only for current, prepared content and an eligible host.
91
+ * Blocks unavailable activation, retries failed preparation and retains handed-off URLs until return.
92
+ * @private
93
+ * @param {MouseEvent} event - Anchor activation, including keyboard-triggered clicks.
94
+ * @returns {void}
95
+ */
96
+ #onClick = (event) => {
97
+ if (this.#disposed || !this.#canActivate() || !this.#url) {
98
+ event.preventDefault();
99
+ if (!this.#disposed && this.#canActivate() && this.#anchor.dataset.state === "error") {
100
+ this.update(this.#exporter);
101
+ }
102
+ return;
103
+ }
104
+ // Leave native anchor navigation synchronous with the user's gesture.
105
+ // Keep its URL alive until native return is observed, even if the model changes meanwhile.
106
+ this.#handedOff.add(this.#url);
107
+ };
108
+
109
+ /**
110
+ * Maps Space and Enter to the same guarded anchor activation without a second default action.
111
+ * @private
112
+ * @param {KeyboardEvent} event - Key press received by the focusable AR action.
113
+ * @returns {void}
114
+ */
115
+ #onKeyDown = (event) => {
116
+ if (event.key === " " || event.key === "Enter") {
117
+ event.preventDefault();
118
+ this.#anchor.click();
119
+ }
120
+ };
121
+
122
+ /**
123
+ * Records a possible native handoff so a later visible return can release retired URLs.
124
+ * @private
125
+ * @returns {void}
126
+ */
127
+ #onAway = () => { this.#away = true; };
128
+ /**
129
+ * Routes document visibility changes through the same away/return URL-ownership handling.
130
+ * @private
131
+ * @returns {void}
132
+ */
133
+ #onVisibility = () => {
134
+ if (this.#anchor.ownerDocument.visibilityState === "hidden") {
135
+ this.#onAway();
136
+ } else {
137
+ this.#onReturn();
138
+ }
139
+ };
140
+ /**
141
+ * Releases retired handed-off URLs after an observed away/visible-return cycle.
142
+ * Removes deferred return listeners after disposal; a still-current URL remains owned by the link.
143
+ * @private
144
+ * @returns {void}
145
+ */
146
+ #onReturn = () => {
147
+ if (!this.#away || this.#anchor.ownerDocument.visibilityState === "hidden") {
148
+ return;
149
+ }
150
+ this.#away = false;
151
+ for (const url of this.#retired) {
152
+ this.#urlAPI.revokeObjectURL(url);
153
+ }
154
+ this.#retired.clear();
155
+ this.#handedOff.clear();
156
+ if (this.#disposed) {
157
+ this.#removeReturnListeners();
158
+ }
159
+ };
160
+
161
+ /**
162
+ * Detaches window/document return listeners once no retired native content needs them.
163
+ * @private
164
+ * @returns {void}
165
+ */
166
+ #removeReturnListeners() {
167
+ this.#window.removeEventListener("blur", this.#onAway);
168
+ this.#window.removeEventListener("focus", this.#onReturn);
169
+ this.#anchor.ownerDocument.removeEventListener("visibilitychange", this.#onVisibility);
170
+ }
171
+
172
+ /**
173
+ * Refreshes accessible text from the host locale while the action is alive.
174
+ * @public
175
+ * @returns {void}
176
+ */
177
+ refreshCulture() {
178
+ if (!this.#disposed) {
179
+ this.#label();
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Hides the action, invalidates pending exports and releases or retires its prepared Blob URL.
185
+ * Handed-off content stays available for native opening; the exporter must be supplied again.
186
+ * @public
187
+ * @returns {void}
188
+ */
189
+ invalidate() {
190
+ this.#revision += 1;
191
+ this.#anchor.removeAttribute("href");
192
+ this.#anchor.hidden = true;
193
+ this.#anchor.setAttribute("aria-disabled", "true");
194
+ this.#anchor.setAttribute("aria-busy", "false");
195
+ if (this.#url) {
196
+ if (this.#handedOff.has(this.#url)) {
197
+ this.#retired.add(this.#url);
198
+ } else {
199
+ this.#urlAPI.revokeObjectURL(this.#url);
200
+ }
201
+ }
202
+ this.#url = null;
203
+ this.#exporter = null;
204
+ }
205
+
206
+ /**
207
+ * Queues current-model export and exposes only a nonempty ZIP-signature USDZ as a native link.
208
+ * Obsolete revisions are ignored. Export failures are logged and shown as a retry action;
209
+ * they do not reject the returned work promise or expose an older model's content.
210
+ * @public
211
+ * @param {?(function(): Promise<Uint8Array>)} exporter - Current-model serializer, or null to invalidate without preparation.
212
+ * @returns {Promise<void>} Resolves after this queued preparation settles or becomes obsolete.
213
+ */
214
+ update(exporter) {
215
+ if (this.#disposed) {
216
+ return Promise.resolve();
217
+ }
218
+ this.invalidate();
219
+ this.#exporter = exporter;
220
+ if (!exporter) {
221
+ return Promise.resolve();
222
+ }
223
+ const revision = this.#revision;
224
+ this.#anchor.hidden = false;
225
+ this.#anchor.dataset.state = "preparing";
226
+ this.#anchor.setAttribute("aria-busy", "true");
227
+ this.#label();
228
+ // Serialize exports per viewer, dropping obsolete queued revisions before work starts.
229
+ this.#work = this.#work.then(async () => {
230
+ if (this.#disposed || revision !== this.#revision) {
231
+ return;
232
+ }
233
+ try {
234
+ const bytes = await exporter();
235
+ if (this.#disposed || revision !== this.#revision) {
236
+ return;
237
+ }
238
+ // USDZ is a ZIP archive; never expose empty, JSON or glTF results as native AR.
239
+ if (!(bytes instanceof Uint8Array) || bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b || bytes[2] !== 3 || bytes[3] !== 4) {
240
+ throw new Error("PrefViewer: invalid USDZ export");
241
+ }
242
+ const blob = new this.#window.Blob([bytes], {
243
+ type: "model/vnd.usdz+zip"
244
+ });
245
+ this.#url = this.#urlAPI.createObjectURL(blob);
246
+ // Quick Look reads the fragment: content scaling off keeps the authored 1:1 metre scale,
247
+ // matching the Android WebXR path. The bare URL is kept for revocation.
248
+ this.#anchor.href = `${this.#url}#${QuickLookAction.QUICK_LOOK_FRAGMENT}`;
249
+ this.#anchor.dataset.state = "ready";
250
+ this.#anchor.setAttribute("aria-disabled", "false");
251
+ } catch (error) {
252
+ if (this.#disposed || revision !== this.#revision) {
253
+ return;
254
+ }
255
+ console.warn("PrefViewer: failed to prepare AR Quick Look", error);
256
+ this.#anchor.dataset.state = "error";
257
+ this.#anchor.setAttribute("aria-disabled", "false");
258
+ } finally {
259
+ if (!this.#disposed && revision === this.#revision) {
260
+ this.#anchor.setAttribute("aria-busy", "false");
261
+ this.#label();
262
+ }
263
+ }
264
+ });
265
+ return this.#work;
266
+ }
267
+
268
+ /**
269
+ * Idempotently removes owned UI/locale handlers and invalidates queued preparation.
270
+ * Native return listeners remain only while handed-off URLs await return or document unload.
271
+ * @public
272
+ * @returns {void}
273
+ */
274
+ dispose() {
275
+ if (this.#disposed) {
276
+ return;
277
+ }
278
+ this.#disposed = true;
279
+ this.invalidate();
280
+ this.#unsubscribe();
281
+ this.#anchor.removeEventListener("click", this.#onClick);
282
+ this.#anchor.removeEventListener("keydown", this.#onKeyDown);
283
+ this.#anchor.remove();
284
+ this.#style.remove();
285
+ // Used native URLs survive detach until an observed return, or document unload if Safari
286
+ // emits no return signal. Unused URLs and late preparations are released immediately.
287
+ if (this.#retired.size === 0) {
288
+ this.#removeReturnListeners();
289
+ }
290
+ }
291
+ }