@preference-sl/pref-viewer 2.10.0 → 2.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.10.0",
3
+ "version": "2.11.0-beta.0",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -37,7 +37,8 @@
37
37
  "@babylonjs/core": "^8.31.3",
38
38
  "@babylonjs/loaders": "^8.31.3",
39
39
  "@babylonjs/serializers": "^8.31.3",
40
- "babylonjs-gltf2interface": "^8.31.3"
40
+ "babylonjs-gltf2interface": "^8.31.3",
41
+ "idb": "^8.0.3"
41
42
  },
42
43
  "devDependencies": {
43
44
  "esbuild": "^0.25.10",
@@ -0,0 +1,288 @@
1
+ /**
2
+ * FileStore class to manage file storage in IndexedDB using a promise-based wrapper around the IndexedDB API.
3
+ * @see {@link https://github.com/jakearchibald/idb|GitHub - jakearchibald/idb: IndexedDB, but with promises}
4
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API|IndexedDB API - MDN}
5
+ * @see {@link https://www.npmjs.com/package/idb|idb - npm}
6
+ */
7
+
8
+ import { openDB } from "idb";
9
+
10
+ export class FileStorage {
11
+ #supported = "indexedDB" in window && openDB; // true if IndexedDB is available in the browser and the idb helper is loaded
12
+ #dbVersion = 1; // single DB version; cache is managed per-file
13
+ #db = undefined; // IndexedDB database handle
14
+
15
+ constructor(dbName = "FilesDB", osName = "FilesObjectStore") {
16
+ this.dbName = dbName; // database name
17
+ this.osName = osName; // object store name used for file cache
18
+ }
19
+
20
+ /**
21
+ * Create the object store if it does not exist.
22
+ * @param {IDBDatabase} db IndexedDB database instance
23
+ */
24
+ #createObjectStore = (db, osName) => {
25
+ if (!db.objectStoreNames.contains(osName)) {
26
+ db.createObjectStore(osName);
27
+ }
28
+ };
29
+
30
+ /**
31
+ * Open the IndexedDB database (creating it if needed) and ensure the object store for files exists.
32
+ * @returns {Promise<IDBDatabase|null>} Resolves with the opened database or null if IndexedDB is not supported or opening fails.
33
+ */
34
+ #openDB = async () => {
35
+ if (!this.#supported) {
36
+ return null;
37
+ }
38
+ const self = this;
39
+ try {
40
+ return await openDB(this.dbName, this.#dbVersion, {
41
+ upgrade(db) {
42
+ self.#createObjectStore(db, self.osName);
43
+ },
44
+ });
45
+ } catch (error) {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Download the file from the server (forced download).
52
+ * @param {String} uri File URI
53
+ * @returns {Promise<{blob: Blob, timeStamp: string}|undefined>} Resolves with:
54
+ * - { blob, timeStamp }: object with the downloaded Blob and the Last-Modified timestamp (ISO string) on success
55
+ * - undefined: if the download fails (non-200 status or network error)
56
+ */
57
+ #getServerFile = async (uri) => {
58
+ let file = undefined;
59
+ return new Promise((resolve) => {
60
+ const xhr = new XMLHttpRequest();
61
+ xhr.open("GET", uri, true);
62
+ xhr.responseType = "blob";
63
+ xhr.onload = () => {
64
+ if (xhr.status === 200) {
65
+ const blob = xhr.response;
66
+ const timeStamp = new Date(xhr.getResponseHeader("Last-Modified")).toISOString();
67
+ file = { blob: blob, timeStamp: timeStamp };
68
+ resolve(file);
69
+ } else {
70
+ resolve(file);
71
+ }
72
+ };
73
+ xhr.onerror = () => {
74
+ resolve(file);
75
+ };
76
+ xhr.send();
77
+ });
78
+ };
79
+
80
+ /**
81
+ * Get the Last-Modified timestamp of the file on the server without downloading its content.
82
+ * @param {String} uri File URI
83
+ * @returns {Promise<string|null>} Resolves with:
84
+ * - ISO 8601 string from the Last-Modified header if available
85
+ * - null if the header is not present or on error
86
+ */
87
+ #getServerFileTimeStamp = async (uri) => {
88
+ let timeStamp = null;
89
+ return new Promise((resolve) => {
90
+ const xhr = new XMLHttpRequest();
91
+ xhr.open("HEAD", uri, true);
92
+ xhr.responseType = "blob";
93
+ xhr.onload = () => {
94
+ if (xhr.status === 200) {
95
+ timeStamp = new Date(xhr.getResponseHeader("Last-Modified")).toISOString();
96
+ resolve(timeStamp);
97
+ } else {
98
+ resolve(timeStamp);
99
+ }
100
+ };
101
+ xhr.onerror = () => {
102
+ resolve(timeStamp);
103
+ };
104
+ xhr.send();
105
+ });
106
+ };
107
+
108
+ /**
109
+ * Get the size of the file on the server without downloading its content.
110
+ * @param {String} uri File URI
111
+ * @returns {Promise<number>} Promise that resolves with:
112
+ * - number: Content-Length in bytes when the HEAD request succeeds and the header is present
113
+ * - 0: when the header is missing or the request fails
114
+ */
115
+ #getServerFileSize = async (uri) => {
116
+ let size = 0;
117
+ return new Promise((resolve) => {
118
+ const xhr = new XMLHttpRequest();
119
+ xhr.open("HEAD", uri, true);
120
+ xhr.responseType = "blob";
121
+ xhr.onload = () => {
122
+ if (xhr.status === 200) {
123
+ size = parseInt(xhr.getResponseHeader("Content-Length"));
124
+ resolve(size);
125
+ } else {
126
+ resolve(size);
127
+ }
128
+ };
129
+ xhr.onerror = () => {
130
+ resolve(size);
131
+ };
132
+ xhr.send();
133
+ });
134
+ };
135
+
136
+ /**
137
+ * Stores a file record in IndexedDB under the provided key (uri).
138
+ * @param {Object|Blob} file The value to store (for example an object {blob, timeStamp} or a Blob).
139
+ * @param {String} uri Key to use for storage (the original URI).
140
+ * @returns {Promise<any|undefined>} Resolves with the value returned by the underlying idb store.put (usually the record key — string or number)
141
+ * - resolves to the put result on success
142
+ * - resolves to undefined if the database could not be opened
143
+ * - rejects if the underlying put operation throws
144
+ */
145
+ #putFile = async (file, uri) => {
146
+ if (this.#db === undefined) {
147
+ this.#db = await this.#openDB();
148
+ }
149
+ if (this.#db === null) {
150
+ return undefined;
151
+ }
152
+ const transation = this.#db.transaction(this.osName, "readwrite");
153
+ const store = transation.objectStore(this.osName);
154
+ return store.put(file, uri);
155
+ };
156
+
157
+ /**
158
+ * Retrieve a file record from IndexedDB.
159
+ * @param {String} uri File URI
160
+ * @returns {Promise<{blob: Blob, timeStamp: string}|undefined|false>} Resolves with:
161
+ * - {blob, timeStamp}: object with the Blob and ISO timestamp if the entry exists in IndexedDB
162
+ * - undefined: if there is no stored entry for that URI
163
+ * - false: if the database could not be opened (IndexedDB unsupported or open failure)
164
+ */
165
+ #getFile = async (uri) => {
166
+ if (this.#db === undefined) {
167
+ this.#db = await this.#openDB();
168
+ }
169
+ if (this.#db === null) {
170
+ return false;
171
+ }
172
+ const transation = this.#db.transaction(this.osName, "readonly");
173
+ const store = transation.objectStore(this.osName);
174
+ return store.get(uri);
175
+ };
176
+
177
+ /**
178
+ * Get a URL to the file: either an object URL for the cached Blob, or the original URI if the server copy is available and IndexedDB isn't supported.
179
+ * @param {String} uri File URI
180
+ * @returns {Promise<string|boolean>} Resolves with:
181
+ * - object URL string for the cached Blob
182
+ * - original URI string if IndexedDB unsupported but server file exists
183
+ * - false if the file is not available on the server
184
+ */
185
+ getURL = async (uri) => {
186
+ if (!this.#supported) {
187
+ return (await this.#getServerFileTimeStamp(uri)) ? uri : false;
188
+ }
189
+ const storedFile = await this.get(uri);
190
+ return storedFile ? URL.createObjectURL(storedFile.blob) : (await this.#getServerFileTimeStamp(uri)) ? uri : false;
191
+ };
192
+
193
+ /**
194
+ * Get the cached or server Last-Modified timestamp for a file.
195
+ * @param {String} uri File URI
196
+ * @returns {Promise<string|null>} Resolves with:
197
+ * - ISO 8601 string: Last-Modified timestamp from the cached record (when using IndexedDB) or from the server HEAD response
198
+ * - null: when no timestamp is available or on error
199
+ */
200
+ getTimeStamp = async (uri) => {
201
+ if (!this.#supported) {
202
+ const serverFileTimeStamp = await this.#getServerFileTimeStamp(uri);
203
+ return serverFileTimeStamp ? serverFileTimeStamp : null;
204
+ }
205
+ const storedFile = await this.get(uri);
206
+ return storedFile ? storedFile.timeStamp : null;
207
+ };
208
+
209
+ /**
210
+ * Get the cached or server size of a file in bytes.
211
+ * @param {String} uri File URI
212
+ * @returns {Promise<number|null>} Resolves with:
213
+ * - number: size in bytes when available (from cache or server)
214
+ * - null: when size is not available or on error
215
+ */
216
+ getSize = async (uri) => {
217
+ if (!this.#supported) {
218
+ const serverFileSize = await this.#getServerFileSize(uri);
219
+ return serverFileSize ? serverFileSize : null;
220
+ }
221
+ const storedFile = await this.get(uri);
222
+ return storedFile ? storedFile.blob.size : null;
223
+ };
224
+
225
+ /**
226
+ * Retrieve the Blob for a file from cache or server.
227
+ * @param {String} uri File URI
228
+ * @returns {Promise<Blob|false>} Resolves with:
229
+ * - Blob: the file blob when available (from cache or server)
230
+ * - false: when the file cannot be retrieved
231
+ */
232
+ getBlob = async (uri) => {
233
+ if (!this.#supported) {
234
+ const serverFile = await this.#getServerFile(uri);
235
+ return serverFile ? serverFile.blob : false;
236
+ }
237
+ const storedFile = await this.get(uri);
238
+ return storedFile ? storedFile.blob : false;
239
+ };
240
+
241
+ /**
242
+ * Get the file Blob from IndexedDB if stored; otherwise download and store it, then return the Blob.
243
+ * @param {String} uri File URI
244
+ * @returns {Promise<Blob|undefined|false>} Resolves with:
245
+ * - Blob of the stored file
246
+ * - undefined if the download fails
247
+ * - false if uri is falsy
248
+ * @description
249
+ * The method controls the cache version of the file. If the server copy has a different timestamp than the stored one,
250
+ * the file is re-downloaded from the server and stored again in IndexedDB.
251
+ * If the server file is not available at that time to check its age but is stored, the stored file is returned.
252
+ */
253
+ get = async (uri) => {
254
+ if (!uri) {
255
+ return false;
256
+ }
257
+ if (!this.#supported) {
258
+ return await this.#getServerFile(uri);
259
+ }
260
+ let storedFile = await this.#getFile(uri);
261
+ const serverFileTimeStamp = storedFile ? await this.#getServerFileTimeStamp(uri) : 0;
262
+ const storedFileTimeStamp = storedFile ? storedFile.timeStamp : 0;
263
+ if (!storedFile || (storedFile && serverFileTimeStamp !== null && serverFileTimeStamp !== storedFileTimeStamp)) {
264
+ const fileToStore = await this.#getServerFile(uri);
265
+ if (fileToStore && !!(await this.#putFile(fileToStore, uri))) {
266
+ storedFile = await this.#getFile(uri);
267
+ } else {
268
+ storedFile = fileToStore;
269
+ }
270
+ }
271
+ return storedFile;
272
+ };
273
+
274
+ /**
275
+ * Store the file in IndexedDB.
276
+ * @param {String} uri File URI
277
+ * @returns {Promise<boolean>} Resolves with:
278
+ * - true if the file was stored successfully
279
+ * - false if IndexedDB is not supported or storing failed
280
+ */
281
+ put = async (uri) => {
282
+ if (!this.#supported) {
283
+ return false;
284
+ }
285
+ const fileToStore = await this.#getServerFile(uri);
286
+ return fileToStore ? !!(await this.#putFile(fileToStore, uri)) : false;
287
+ };
288
+ }
package/src/index.js CHANGED
@@ -45,6 +45,7 @@ import { USDZExportAsync, GLTF2Export } from "@babylonjs/serializers";
45
45
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression";
46
46
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression";
47
47
  import { initDb, loadModel } from "./gltf-storage.js";
48
+ import { FileStorage } from "./file-storage.js";
48
49
 
49
50
  class PrefViewerTask {
50
51
  static Types = Object.freeze({
@@ -65,9 +66,7 @@ class PrefViewerTask {
65
66
  const t = typeof type === "string" ? type.toLowerCase() : String(type).toLowerCase();
66
67
  const allowed = Object.values(PrefViewerTask.Types);
67
68
  if (!allowed.includes(t)) {
68
- throw new TypeError(
69
- `PrefViewerTask: invalid type "${type}". Allowed types: ${allowed.join(", ")}`
70
- );
69
+ throw new TypeError(`PrefViewerTask: invalid type "${type}". Allowed types: ${allowed.join(", ")}`);
71
70
  }
72
71
  this.type = t;
73
72
 
@@ -80,6 +79,7 @@ class PrefViewer extends HTMLElement {
80
79
  loaded = false;
81
80
  loading = false;
82
81
  #taskQueue = [];
82
+ #fileStorage = new FileStorage("PrefViewer", "Files");
83
83
 
84
84
  #data = {
85
85
  containers: {
@@ -306,7 +306,7 @@ class PrefViewer extends HTMLElement {
306
306
  tried: toLoadDetail,
307
307
  success: loadedDetail,
308
308
  };
309
-
309
+
310
310
  this.dispatchEvent(
311
311
  new CustomEvent("scene-loaded", {
312
312
  bubbles: true,
@@ -318,7 +318,7 @@ class PrefViewer extends HTMLElement {
318
318
 
319
319
  await this.#scene.whenReadyAsync();
320
320
  this.#engine.runRenderLoop(this.#renderLoop);
321
-
321
+
322
322
  this.#resetChangedFlags();
323
323
 
324
324
  if (this.hasAttribute("loading")) {
@@ -803,6 +803,94 @@ class PrefViewer extends HTMLElement {
803
803
  return true;
804
804
  }
805
805
 
806
+ /**
807
+ * Replace internal URIs in a glTF AssetContainer JSON with URLs pointing to files stored in IndexedDB.
808
+ * @param {JSON} assetContainerJSON AssetContainer in glTF (JSON) (modified in-place).
809
+ * @param {URL} [assetContainerURL] Optional URL of the AssetContainer. Used as the base path to resolve relative URIs.
810
+ * @returns {Promise<void>} Resolves when all applicable URIs have been resolved/replaced.
811
+ * @description
812
+ * - When provided, assetContainerURL is used as the base path for other scene files (binary buffers and all images).
813
+ * If not provided (null/undefined), it is because it is the assetContainer of the model or materials whose URIs are absolute.
814
+ * - According to the glTF 2.0 spec, only items inside the "buffers" and "images" arrays may have a "uri" property.
815
+ * - Data URIs (embedded base64) are ignored and left unchanged.
816
+ * - Matching asset URIs are normalized (backslashes converted to forward slashes) and passed to the FileStorage layer
817
+ * to obtain a usable URL (object URL or cached URL).
818
+ * - The function performs replacements in parallel and waits for all lookups to complete.
819
+ * - The JSON is updated in-place with the resolved URLs.
820
+ * @see {@link https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#uris|glTF™ 2.0 Specification - URIs}
821
+ */
822
+ async #replaceSceneURIAsync(assetContainerJSON, assetContainerURL) {
823
+ if (!assetContainerJSON) {
824
+ return;
825
+ }
826
+
827
+ let sceneURLBase = assetContainerURL;
828
+
829
+ if (typeof assetContainerURL === "string") {
830
+ const lastIndexOfSlash = assetContainerURL.lastIndexOf("/");
831
+ if (lastIndexOfSlash !== -1) {
832
+ sceneURLBase = assetContainerURL.substring(0, lastIndexOfSlash + 1);
833
+ }
834
+ }
835
+
836
+ const arrayOfAssetsWithURI = [];
837
+
838
+ /**
839
+ * Check whether a value is a syntactically absolute URL.
840
+ * @param {string} url Value to test.
841
+ * @returns {boolean} True when `url` is a string that can be parsed by the global URL constructor (i.e. a syntactically absolute URL); false otherwise.
842
+ * @description
843
+ * - Returns false for non-string inputs.
844
+ * - Uses the browser's URL parser, so protocol-relative URLs ("//host/...") and relative paths are considered non-absolute.
845
+ * - This is a syntactic check only — it does not perform network requests or validate reachability/CORS.
846
+ */
847
+ var isURLAbsolute = function (url) {
848
+ if (typeof url !== "string") {
849
+ return false;
850
+ }
851
+ try {
852
+ new URL(url);
853
+ return true;
854
+ } catch {
855
+ return false;
856
+ }
857
+ };
858
+
859
+ /**
860
+ * Collect asset entries that have an external URI (non-data URI) and store a normalized absolute/relative-resolved URI for later replacement.
861
+ * @param {Object} asset glTF asset entry (an element of buffers[] or images[]).
862
+ * @param {number} index Index of the asset within its parent array.
863
+ * @param {Array} array Reference to the parent array (buffers or images).
864
+ * @returns {void} Side-effect: pushes a record into arrayOfAssetsWithURI when applicable { parent: <array>, index: <number>, uri: <string> }.
865
+ */
866
+ var saveAssetData = function (asset, index, array) {
867
+ if (asset.uri && !asset.uri.startsWith("data:")) {
868
+ const assetData = {
869
+ parent: array,
870
+ index: index,
871
+ uri: `${!isURLAbsolute(asset.uri) && sceneURLBase ? sceneURLBase : ""}${asset.uri}`.replace(/\\\\|\\|\/\\/g, "/"),
872
+ };
873
+ arrayOfAssetsWithURI.push(assetData);
874
+ }
875
+ };
876
+
877
+ if (assetContainerJSON.buffers) {
878
+ assetContainerJSON.buffers.forEach((asset, index, array) => saveAssetData(asset, index, array));
879
+ }
880
+ if (assetContainerJSON.images) {
881
+ assetContainerJSON.images.forEach((asset, index, array) => saveAssetData(asset, index, array));
882
+ }
883
+
884
+ // Replace parallel URIs so that if files are not stored yet, they are downloaded in parallel
885
+ const promisesArray = arrayOfAssetsWithURI.map(async (asset) => {
886
+ const uri = await this.#fileStorage.getURL(asset.uri);
887
+ if (uri) {
888
+ asset.parent[asset.index].uri = uri;
889
+ }
890
+ });
891
+ await Promise.all(promisesArray);
892
+ }
893
+
806
894
  async #loadAssetContainer(container) {
807
895
  let storage = container?.storage;
808
896
 
@@ -832,9 +920,15 @@ class PrefViewer extends HTMLElement {
832
920
 
833
921
  let { blob, extension, size } = this.#decodeBase64(source);
834
922
  if (blob && extension) {
835
- file = new File([blob], `${container.name}${extension}`, {
836
- type: blob.type,
837
- });
923
+ if ((container.name === "model" || container.name === "materials") && extension === ".gltf") {
924
+ const assetContainerJSON = JSON.parse(await blob.text());
925
+ await this.#replaceSceneURIAsync(assetContainerJSON, source);
926
+ source = `data:${JSON.stringify(assetContainerJSON)}`;
927
+ } else {
928
+ file = new File([blob], `${container.name}${extension}`, {
929
+ type: blob.type,
930
+ });
931
+ }
838
932
  if (!container.changed.pending) {
839
933
  if (container.timeStamp === null && container.size === size) {
840
934
  container.changed = { pending: false, success: false };
@@ -846,12 +940,20 @@ class PrefViewer extends HTMLElement {
846
940
  } else {
847
941
  const extMatch = source.match(/\.(gltf|glb)(\?|#|$)/i);
848
942
  extension = extMatch ? `.${extMatch[1].toLowerCase()}` : ".gltf";
849
- const [fileSize, fileTimeStamp] = await this.#getServerFileDataHeader(source);
943
+ let [fileSize, fileTimeStamp] = [await this.#fileStorage.getSize(source), await this.#fileStorage.getTimeStamp(source)];
850
944
  if (container.size === fileSize && container.timeStamp === fileTimeStamp) {
851
945
  container.changed = { pending: false, success: false };
852
946
  return false;
853
947
  } else {
854
948
  container.changed = { pending: true, size: fileSize, success: false, timeStamp: fileTimeStamp };
949
+ if (extension === ".gltf") {
950
+ const assetContainerBlob = await this.#fileStorage.getBlob(source);
951
+ const assetContainerJSON = JSON.parse(await assetContainerBlob.text());
952
+ await this.#replaceSceneURIAsync(assetContainerJSON, source);
953
+ source = `data:${JSON.stringify(assetContainerJSON)}`;
954
+ } else {
955
+ source = await this.#fileStorage.getURL(source);
956
+ }
855
957
  }
856
958
  }
857
959
 
@@ -863,7 +965,7 @@ class PrefViewer extends HTMLElement {
863
965
  compileMaterials: true,
864
966
  loadAllMaterials: true,
865
967
  loadOnlyMaterials: container.name === "materials",
866
- preprocessUrlAsync: this.#transformUrl,
968
+ //preprocessUrlAsync: this.#transformUrl,
867
969
  },
868
970
  },
869
971
  };
@@ -1050,7 +1152,7 @@ class PrefViewer extends HTMLElement {
1050
1152
  if (this.#checkMaterialsChanged(options)) {
1051
1153
  someSetted = someSetted || this.#setOptionsMaterials();
1052
1154
  }
1053
-
1155
+
1054
1156
  await this.#setStatusLoaded();
1055
1157
 
1056
1158
  return someSetted;