@preference-sl/pref-viewer 2.11.0-beta.0 → 2.11.0-beta.10

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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * PrefViewerDialog - Custom Web Component for displaying modal dialogs in PrefViewer.
3
+ *
4
+ * Overview:
5
+ * - Provides a modal dialog overlay for user interactions such as downloads or confirmations.
6
+ * - Handles DOM creation, styling, and open/close logic.
7
+ * - Ensures dialog content is centered and styled appropriately.
8
+ * - Automatically focuses the canvas when closed for improved accessibility.
9
+ *
10
+ * Usage:
11
+ * - Use as a custom HTML element: <pref-viewer-dialog></pref-viewer-dialog>
12
+ * - Call open(title, content, footer) to display the dialog with a header, content, and footer.
13
+ * - Call close() to hide and remove the dialog.
14
+ *
15
+ * Methods:
16
+ * - constructor(): Initializes the custom element.
17
+ * - connectedCallback(): Called when the element is added to the DOM; sets up DOM and styles.
18
+ * - disconnectedCallback(): Called when the element is removed from the DOM; cleans up resources.
19
+ * - open(title, content, footer): Displays the dialog and sets its header, content, and footer.
20
+ * - close(): Hides and removes the dialog, refocuses the canvas if available.
21
+ * - #createDOMElements(): Creates the dialog structure and applies styles.
22
+ */
23
+ export class PrefViewerDialog extends HTMLElement {
24
+ #wrapper = null;
25
+ #header = null;
26
+ #content = null;
27
+ #footer = null;
28
+
29
+ /**
30
+ * Initializes the custom dialog element.
31
+ * Only calls super(); heavy initialization is deferred to connectedCallback.
32
+ */
33
+ constructor() {
34
+ super();
35
+ }
36
+
37
+ /**
38
+ * Called when the element is inserted into the DOM.
39
+ * Sets up the dialog's DOM structure and styles.
40
+ * @returns {void}
41
+ */
42
+ connectedCallback() {
43
+ this.#createDOMElements();
44
+ }
45
+
46
+ /**
47
+ * Called when the element is removed from the DOM.
48
+ * Cleans up resources and event listeners.
49
+ * @returns {void}
50
+ */
51
+ disconnectedCallback() {
52
+ this.removeEventListener("click", this.#closeOnBackdropClick.bind(this));
53
+ }
54
+
55
+ /**
56
+ * Creates the dialog's DOM structure and applies CSS styles.
57
+ * Adds a click handler to close the dialog when clicking outside the content.
58
+ * @private
59
+ * @returns {void}
60
+ */
61
+ #createDOMElements() {
62
+ this.#wrapper = document.createElement("div");
63
+ this.#wrapper.classList.add("dialog-wrapper");
64
+ this.#wrapper.innerHTML = `
65
+ <div class="dialog-header"><h3 class="dialog-header-title"></h3></div>
66
+ <div class="dialog-content"></div>
67
+ <div class="dialog-footer"></div>`;
68
+ this.append(this.#wrapper);
69
+
70
+ const style = document.createElement("style");
71
+ style.textContent = `@import "/dist/css/pref-viewer-dialog.css";`;
72
+ this.append(style);
73
+
74
+ this.addEventListener("click", this.#closeOnBackdropClick.bind(this));
75
+
76
+ this.#header = this.#wrapper.querySelector(".dialog-header");
77
+ this.#content = this.#wrapper.querySelector(".dialog-content");
78
+ this.#footer = this.#wrapper.querySelector(".dialog-footer");
79
+ }
80
+
81
+ #closeOnBackdropClick(event) {
82
+ if (event.target === this) {
83
+ this.close();
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Opens the dialog and sets its header, content, and footer.
89
+ * @param {string} title - The dialog title to display in the header.
90
+ * @param {string} content - The HTML content to display in the dialog body.
91
+ * @param {string} footer - The HTML content to display in the dialog footer (e.g., action buttons).
92
+ * @returns {boolean} True if the dialog was opened, false if no content was provided.
93
+ */
94
+ open(title = "", content = "", footer = "") {
95
+ if (this.#wrapper === null || this.#header === null || this.#content === null || this.#footer === null) {
96
+ return false;
97
+ }
98
+ if (title === "" && content === "" && footer === "") {
99
+ return false;
100
+ }
101
+
102
+ if (title === "") {
103
+ this.#header.style.display = "none";
104
+ }
105
+ if (footer === "") {
106
+ this.#footer.style.display = "none";
107
+ }
108
+
109
+ this.#header.querySelector(".dialog-header-title").innerHTML = title;
110
+ this.#content.innerHTML = content;
111
+ this.#footer.innerHTML = footer;
112
+ this.setAttribute("open", "");
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * Closes and removes the dialog from the DOM.
118
+ * @returns {void}
119
+ */
120
+ close() {
121
+ this.removeAttribute("open");
122
+ const parent = this.getRootNode().host;
123
+ if (parent) {
124
+ // Refocus 3D canvas or 2D component for accessibility
125
+ const canvas = parent.shadowRoot.querySelector("pref-viewer-3d[visible='true'] canvas");
126
+ if (canvas) {
127
+ canvas.focus();
128
+ } else {
129
+ const component2D = parent.shadowRoot.querySelector("pref-viewer-2d[visible='true']");
130
+ if (component2D) {
131
+ component2D.focus();
132
+ }
133
+ }
134
+
135
+ }
136
+ this.#wrapper = this.#header = this.#content = this.#footer = null;
137
+ this.remove();
138
+ }
139
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * PrefViewerTask - Represents a single task or operation to be processed by the PrefViewer.
3
+ *
4
+ * Responsibilities:
5
+ * - Encapsulates the payload and type of a viewer task (e.g., loading a model, updating materials).
6
+ * - Validates the task type against a predefined set of allowed types.
7
+ * - Provides an immutable instance to ensure task integrity.
8
+ *
9
+ * Usage:
10
+ * - Instantiate with a value and a type:
11
+ * new PrefViewerTask(data, PrefViewerTask.Types.Model)
12
+ * - Access the payload via the `value` property and the normalized type via the `type` property.
13
+ *
14
+ * Static Properties:
15
+ * - Types: An object containing all allowed task types as string constants.
16
+ *
17
+ * Constructor:
18
+ * - Validates the type (case-insensitive) and throws a TypeError if invalid.
19
+ * - Freezes the instance to prevent further modification.
20
+ */
21
+ export class PrefViewerTask {
22
+ static Types = Object.freeze({
23
+ Config: "config",
24
+ Drawing: "drawing",
25
+ Environment: "environment",
26
+ Materials: "materials",
27
+ Model: "model",
28
+ Options: "options",
29
+ Visibility: "visibility",
30
+ });
31
+
32
+ /**
33
+ * Creates a new PrefViewerTask instance.
34
+ * Validates that the provided type matches one of the allowed task types (case-insensitive).
35
+ * If the type is invalid, throws a TypeError.
36
+ * The instance is frozen to prevent further modification.
37
+ *
38
+ * @param {*} value - The payload or data associated with the task.
39
+ * @param {string} type - The type of task; must match one of PrefViewerTask.Types values.
40
+ * @throws {TypeError} If the type is not valid.
41
+ */
42
+ constructor(value, type) {
43
+ this.value = value;
44
+
45
+ const t = typeof type === "string" ? type.toLowerCase() : String(type).toLowerCase();
46
+ const allowed = Object.values(PrefViewerTask.Types);
47
+ if (!allowed.includes(t)) {
48
+ throw new TypeError(`PrefViewerTask: invalid type "${type}". Allowed types: ${allowed.join(", ")}`);
49
+ }
50
+ this.type = t;
51
+
52
+ Object.freeze(this);
53
+ }
54
+ }
@@ -0,0 +1,281 @@
1
+ import isSvg from "is-svg";
2
+ import { initDb, loadModel } from "./gltf-storage.js";
3
+
4
+ /**
5
+ * SVGResolver - Utility class for resolving, decoding, and validating SVG resources from multiple sources.
6
+ *
7
+ * Responsibilities:
8
+ * - Handles SVG input from:
9
+ * * IndexedDB records (storage.db, storage.table, storage.id)
10
+ * * remote URLs (storage.url)
11
+ * * data URIs (base64 or plain SVG) (storage.url)
12
+ * * direct SVG strings (storage.url)
13
+ * - Decodes base64 and validates SVG content.
14
+ * - Avoids unnecessary reloads by comparing size and Last-Modified timestamp.
15
+ * - Provides a unified API for consumers to obtain SVG content and metadata.
16
+ *
17
+ * Usage:
18
+ * - Instantiate with an initial SVG state object (required).
19
+ * Example: const resolver = new SVGResolver({ value: "", size: 0, timeStamp: null, update: true });
20
+ * - Call getSVG(svgConfig) to resolve and update the SVG state.
21
+ * Example: await resolver.getSVG({ storage: { url: "data:image/svg+xml;base64,..." } });
22
+ *
23
+ * Public methods:
24
+ * - getSVG(svgConfig, svg?): Resolves and prepares SVG from the given configuration, updating internal and optionally external state.
25
+ *
26
+ * Internal methods:
27
+ * - #initializeStorage(db, table): Ensures IndexedDB store is initialized.
28
+ * - #parseSource(source): Decodes and validates SVG from string or data URI.
29
+ * - #getServerFileMetaData(uri): Gets file size and last modified from server.
30
+ * - #getServerSVG(uri): Downloads SVG text from server.
31
+ *
32
+ * Events:
33
+ * - None (state is updated via returned object).
34
+ *
35
+ * Notes:
36
+ * - The internal SVG state is always kept up-to-date and can be referenced externally if passed in the constructor.
37
+ * - Designed for extensibility and integration with custom storage or fetch logic.
38
+ */
39
+ export default class SVGResolver {
40
+ #svg = {
41
+ value: "",
42
+ size: 0,
43
+ timeStamp: null,
44
+ update: true,
45
+ };
46
+
47
+ /**
48
+ * Create a new SVGResolver instance.
49
+ * @param {{value?:string, size?:number, timeStamp?:string|null, update?:boolean}=} svg - Optional initial SVG state to seed the resolver's internal cache.
50
+ * If omitted, the internal state defaults to: { value: "", size: 0, timeStamp: null, update: true }
51
+ * @description
52
+ * The 'svg' parameter allows you to preload the resolver with a known SVG state that will always be up-to-date since it's a referenced object.
53
+ * If this parameter isn't passed when creating the instance, it must be passed as a parameter in the 'getSVG' public method to keep it updated.
54
+ */
55
+ constructor(svg) {
56
+ if (svg && typeof svg === "object") {
57
+ this.#svg = svg;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Ensure the IndexedDB object store for GLTF/SVG storage is initialized.
63
+ * @private
64
+ * @param {string} db - Database name.
65
+ * @param {string} table - Object store name.
66
+ * @returns {Promise<void>}
67
+ * @description
68
+ * If a global PrefConfigurator.db already references the requested DB and table, no action is taken. Otherwise delegates to initDb to create/open the store.
69
+ */
70
+ async #initializeStorage(db, table) {
71
+ if (typeof window !== "undefined" && window.PrefConfigurator.db && window.PrefConfigurator.db.name === db && window.PrefConfigurator.db.objectStoreNames.contains(table)) {
72
+ return;
73
+ }
74
+ await initDb(db, table);
75
+ }
76
+
77
+ /**
78
+ * Parse an input that may be a plain SVG string or a data URI (possibly base64).
79
+ * @private
80
+ * @param {string} source - Data URI or raw string to decode/check.
81
+ * @returns {{value: string|null, size: number}} value when detected/decoded, and size (length of the text string).
82
+ */
83
+ #parseSource(source) {
84
+ let value = null;
85
+ let size = 0;
86
+
87
+ if (typeof source !== "string") {
88
+ return [value, size];
89
+ }
90
+
91
+ if (isSvg(source)) {
92
+ value = source;
93
+ size = source.length;
94
+ return [value, size];
95
+ }
96
+
97
+ let raw;
98
+ if (source.startsWith("data:")) {
99
+ const commaIndex = source.indexOf(",");
100
+ raw = commaIndex !== -1 ? source.slice(commaIndex + 1) : source;
101
+ } else {
102
+ raw = source;
103
+ }
104
+
105
+ if (isSvg(raw)) {
106
+ size = raw.length;
107
+ value = raw;
108
+ return [value, size];
109
+ }
110
+
111
+ let decoded = "";
112
+ try {
113
+ decoded = atob(raw);
114
+ } catch {
115
+ return [value, size];
116
+ }
117
+
118
+ if (isSvg(decoded)) {
119
+ size = raw.length;
120
+ value = decoded;
121
+ return [value, size];
122
+ }
123
+
124
+ return [value, size];
125
+ }
126
+
127
+ /**
128
+ * Perform a HEAD request to gather server-side file metadata (Content-Length and Last-Modified).
129
+ * @private
130
+ * @param {string} uri - Resource URI to probe.
131
+ * @returns {Promise<{size:number,timeStamp:string|null}>} Object with size (0 when unknown) and ISO timeStamp or null.
132
+ */
133
+ async #getServerFileMetaData(uri) {
134
+ let data = {
135
+ size: 0,
136
+ timeStamp: null,
137
+ };
138
+ return new Promise((resolve) => {
139
+ const xhr = new XMLHttpRequest();
140
+ xhr.open("HEAD", uri, true);
141
+ xhr.responseType = "blob";
142
+ xhr.onload = () => {
143
+ if (xhr.status === 200) {
144
+ const lastModified = xhr.getResponseHeader("Last-Modified");
145
+ data.timeStamp = lastModified ? new Date(lastModified).toISOString() : null;
146
+ const contentLength = xhr.getResponseHeader("Content-Length");
147
+ data.size = contentLength ? parseInt(contentLength, 10) : 0;
148
+ resolve(data);
149
+ } else {
150
+ resolve(data);
151
+ }
152
+ };
153
+ xhr.onerror = () => {
154
+ resolve(data);
155
+ };
156
+ xhr.send();
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Download an SVG document as text from the server.
162
+ * @private
163
+ * @param {string} uri - Resource URI.
164
+ * @returns {Promise<string>} The SVG text or empty string on failure.
165
+ */
166
+ async #getServerSVG(uri) {
167
+ let svg = "";
168
+ return new Promise((resolve) => {
169
+ const xhr = new XMLHttpRequest();
170
+ xhr.open("GET", uri, true);
171
+ xhr.responseType = "text";
172
+ xhr.onload = () => {
173
+ if (xhr.status === 200) {
174
+ svg = xhr.responseText;
175
+ resolve(svg);
176
+ } else {
177
+ resolve(svg);
178
+ }
179
+ };
180
+ xhr.onerror = () => {
181
+ resolve(svg);
182
+ };
183
+ xhr.send();
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Resolve and prepare an SVG from multiple storage forms.
189
+ * @public
190
+ * @param {object|string} svgConfig - Configuration object. Expected shape for storage:
191
+ * { storage: { url: "<url>" } } // remote URL or data URI (base64 or plain)
192
+ * { storage: { db: "<db>", table: "<table>", id: "<id>" } } // IndexedDB record
193
+ * @param {object} [svg] - Optional external SVG state object to seed/update. If provided, the resolver
194
+ * updates this object in-place: { value: string, size: number, timeStamp: string|null, update: boolean }.
195
+ * @returns {Promise<false|{value:string,size:number,timeStamp:string|null,update:boolean}>}
196
+ * - Resolves to false when no update is required or on error.
197
+ * - Resolves to the internal SVG state object when an update was prepared.
198
+ * @description
199
+ * - Accepts SVG (base64 or plain string) from IndexedDB (storage.db/table/id), from a data URI (base64 or plain string) (storage.url),
200
+ * from a remote URL (storage.url) or from direct SVG string (storage.url).
201
+ * - Compares sizes and timestamps to avoid unnecessary reloads.
202
+ * - If the 'svg' parameter was provided when creating the instance, the referenced object will also be updated.
203
+ */
204
+ async getSVG(svgConfig, svg) {
205
+ if (svg && typeof svg === "object") {
206
+ this.#svg = svg;
207
+ }
208
+ const storage = svgConfig?.storage;
209
+ if (!storage) {
210
+ return false;
211
+ }
212
+
213
+ let source = storage.url || null;
214
+ let idbSize = 0;
215
+ let idbTimeStamp = null;
216
+
217
+ // SVG from an entry stored in IndexedDB (storage.db, storage.table, storage.id)
218
+ if (storage.db && storage.table && storage.id) {
219
+ await this.#initializeStorage(storage.db, storage.table);
220
+ const object = await loadModel(storage.id, storage.table);
221
+ source = object.data;
222
+ idbSize = object.size;
223
+ idbTimeStamp = object.timeStamp;
224
+ if (this.#svg.size === idbSize && this.#svg.timeStamp === idbTimeStamp) {
225
+ this.#svg.update = false;
226
+ return false;
227
+ } else {
228
+ this.#svg.update = true;
229
+ }
230
+ }
231
+
232
+ if (!source) {
233
+ this.#svg.update = false;
234
+ return false;
235
+ }
236
+
237
+ let [svgValue, svgSize] = this.#parseSource(source);
238
+
239
+ // SVG decoded successfully from Base64 or plain string, from object stored in IndexedDB (storage.db, storage.table, storage.id) or Base64 data URI (storage.url)
240
+ if (svgValue && svgSize) {
241
+ if (this.#svg.update) {
242
+ // From object stored in IndexedDB (storage.db, storage.table, storage.id)
243
+ this.#svg.value = svgValue;
244
+ this.#svg.size = idbSize;
245
+ this.#svg.timeStamp = idbTimeStamp;
246
+ return this.#svg;
247
+ } else {
248
+ // From Base64 data URI (storage.url)
249
+ if (this.#svg.size === svgSize && this.#svg.timeStamp === null) {
250
+ this.#svg.update = false;
251
+ return false;
252
+ } else {
253
+ this.#svg.size = svgSize;
254
+ this.#svg.timeStamp = null;
255
+ this.#svg.update = true;
256
+ this.#svg.value = svgValue;
257
+ return this.#svg;
258
+ }
259
+ }
260
+ } else {
261
+ // SVG from remote URL (storage.url)
262
+ let fileData = await this.#getServerFileMetaData(source);
263
+ if (this.#svg.size === fileData.size && this.#svg.timeStamp === fileData.timeStamp) {
264
+ this.#svg.update = false;
265
+ return false;
266
+ } else {
267
+ svgValue = await this.#getServerSVG(source);
268
+ if (isSvg(svgValue)) {
269
+ this.#svg.size = fileData.size;
270
+ this.#svg.timeStamp = fileData.timeStamp;
271
+ this.#svg.update = true;
272
+ this.#svg.value = svgValue;
273
+ return this.#svg;
274
+ } else {
275
+ this.#svg.update = false;
276
+ return false;
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }