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