@preference-sl/pref-viewer 2.11.0-beta.0 → 2.11.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.
@@ -0,0 +1,441 @@
1
+ import SVGResolver from "./svg-resolver.js";
2
+ import PanzoomController from "./panzoom-controller.js";
3
+
4
+ /**
5
+ * PrefViewer2D - Custom element for rendering and interacting with 2D SVG content.
6
+ *
7
+ * Summary
8
+ * - Renders an SVG string into an internal wrapper element and provides pan/zoom interactions using the Panzoom library.
9
+ * - Supports SVG input from:
10
+ * * data URI (storage.url) (base64 or plain string)
11
+ * * IndexedDB record (base64 or plain string) (storage.db, storage.table, storage.id)
12
+ * * remote URL (storage.url)
13
+ * * direct SVG strings (storage.url)
14
+ * - Avoids unnecessary reloads by comparing stored size and Last-Modified timestamp.
15
+ *
16
+ * Public attributes
17
+ * - svg: string (setting triggers load)
18
+ * - visible: "true" | "false" (controls visibility and event subscription)
19
+ *
20
+ * Public methods
21
+ * - async load(svgConfig) : Promise<boolean> — accept SVG input/config and prepare for render
22
+ * - show(), hide() — toggle visibility and event subscriptions
23
+ * - zoomIn(), zoomOut(), zoomCenter(), zoomExtentsAll() — pan/zoom controls
24
+ *
25
+ * Events
26
+ * - "prefviewer2d-loading": emitted before loading starts
27
+ * - "prefviewer2d-loaded": emitted after content and Panzoom are ready
28
+ * - "prefviewer2d-error": emitted on validation/fetch errors (detail.error: Error)
29
+ * - "prefviewer2d-zoomchanged": emitted when pan/zoom state changes (detail: { moved, scaled, maximized, minimized })
30
+ *
31
+ * Implementation notes
32
+ * - Keeps internal state in private fields (#svg, #panzoom, #panzoomOptions, ...).
33
+ * - Validates SVG content with is-svg and decodes base64 with atob.
34
+ * - Performs HEAD requests to obtain Content-Length and Last-Modified for remote URLs.
35
+ * - Focus is set on the component's parent to enable keyboard shortcuts without Panzoom focus issues.
36
+ *
37
+ * Usage examples
38
+ * - load plain SVG: el.load({ storage: { url: '<svg...>' } }) or el.load('<svg...>');
39
+ * - load from IndexedDB: el.load({ storage: { db: 'db', table: 'tbl', id: 'recId' } });
40
+ *
41
+ * Notes on extensibility
42
+ * - Modeled for easy addition of other input sources or interaction modes (e.g., selection callbacks).
43
+ */
44
+ export class PrefViewer2D extends HTMLElement {
45
+ // State flags
46
+ #isInitialized = false;
47
+ #isLoaded = false;
48
+ #isVisible = false;
49
+
50
+ #options = {
51
+ resetOnReload: true,
52
+ };
53
+
54
+ // Current SVG state
55
+ #svg = {
56
+ value: "",
57
+ size: 0,
58
+ timeStamp: null,
59
+ update: true,
60
+ };
61
+
62
+ #svgResolver = null; // SVGResolver instance
63
+ #wrapper = null; // Panzoom element
64
+ #panzoomController = null; // PanzoomController instance
65
+
66
+ /**
67
+ * Create a new instance of the 2D viewer component.
68
+ * The constructor only calls super(); heavy initialization happens in connectedCallback.
69
+ */
70
+ constructor() {
71
+ super();
72
+ }
73
+
74
+ /**
75
+ * Attributes observed by the custom element.
76
+ * @returns {string[]} Array of attribute names that trigger attributeChangedCallback.
77
+ */
78
+ static get observedAttributes() {
79
+ return ["svg", "visible"];
80
+ }
81
+
82
+ /**
83
+ * Handle attribute changes.
84
+ * @param {string} name - Attribute name.
85
+ * @param {string|null} _old - Previous attribute value (unused).
86
+ * @param {string|null} value -New attribute value.
87
+ * @returns {void}
88
+ * @description
89
+ * - "svg": triggers load of new SVG content.
90
+ * - "visible": shows or hides the component according to the attribute value ("true"/"false").
91
+ */
92
+ attributeChangedCallback(name, _old, value) {
93
+ switch (name) {
94
+ case "svg":
95
+ this.load(value);
96
+ break;
97
+ case "visible":
98
+ if (_old === value) {
99
+ return;
100
+ }
101
+ if (value === "true" && this.#isVisible === false) {
102
+ this.show();
103
+ } else if (value === "false" && this.#isVisible === true) {
104
+ this.hide();
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Called when the element is inserted into the DOM.
111
+ * Sets up DOM nodes, initial visibility and starts loading the SVG if provided.
112
+ * @returns {void}
113
+ */
114
+ connectedCallback() {
115
+ this.#createDOMElements();
116
+ this.#setInitialVisibility();
117
+ this.#initializePanzoom();
118
+ this.#isInitialized = true;
119
+ this.#loadSVG();
120
+ }
121
+
122
+ disconnectedCallback() {
123
+ if (this.#panzoomController) {
124
+ this.#panzoomController.disable();
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Create DOM structure and basic styles used by the component.
130
+ * @private
131
+ * @returns {void}
132
+ */
133
+ #createDOMElements() {
134
+ this.#wrapper = document.createElement("div");
135
+ this.append(this.#wrapper);
136
+ const style = document.createElement("style");
137
+ style.textContent = `pref-viewer-2d[visible="true"] { display: block; } pref-viewer-2d[visible="false"] { display: none; } pref-viewer-2d, pref-viewer-2d > div, pref-viewer-2d > div > svg { width: 100%; height: 100%; display: block; position: relative; outline: none; box-sizing: border-box; } pref-viewer-2d > div > svg { padding: 10px; }`;
138
+ this.append(style);
139
+ }
140
+
141
+ /**
142
+ * Set initial visibility based on inline style and the visible attribute.
143
+ * @private
144
+ * @returns {void}
145
+ * @description If the "visible" attribute is not present, defaults to visible.
146
+ */
147
+ #setInitialVisibility() {
148
+ const visible = !this.hasAttribute("visible") || this.getAttribute("visible") === "true";
149
+ visible ? this.show() : this.hide();
150
+ }
151
+
152
+ /**
153
+ * Initializes the PanzoomController instance for the SVG wrapper element.
154
+ * Sets up the controller with default options and binds the state change callback.
155
+ * @private
156
+ * @returns {void}
157
+ */
158
+ #initializePanzoom() {
159
+ this.#panzoomController = new PanzoomController(this.#wrapper, undefined, this.#onPanzoomChanged.bind(this));
160
+ }
161
+
162
+ /**
163
+ * Render the current SVG into the wrapper and (re)initialize the Panzoom instance as required.
164
+ * @private
165
+ * @returns {boolean} Returns false when no SVG should be loaded or when validation fails; otherwise performs rendering and returns true.
166
+ * @description
167
+ * Behavior:
168
+ * - If this.#svg.update === false the method returns false (no work needed).
169
+ * - SVG is not validated here because it is already handled by SVGResolver.
170
+ * - Calls #onLoading() before making DOM changes and #onLoaded() after Panzoom is ready.
171
+ * - When options.resetOnReload is true (or there is no SVG value) it destroys the existing Panzoom
172
+ * instance so a fresh one is created for the new content.
173
+ * - If the SVG content is empty the method sets loaded state and returns false (no Panzoom initialization).
174
+ */
175
+ #loadSVG() {
176
+ if (!this.#svgResolver) {
177
+ this.#svgResolver = new SVGResolver(this.#svg);
178
+ }
179
+ if (this.#svg.update === false) {
180
+ return false;
181
+ }
182
+
183
+ this.#onLoading();
184
+
185
+ if (this.#options.resetOnReload || !this.#svg.value) {
186
+ this.#panzoomController.disable();
187
+ }
188
+
189
+ this.#wrapper.innerHTML = this.#svg.value;
190
+
191
+ // If SVG is empty, do not initialize Panzoom but don't emit an error
192
+ if (!this.#svg.value) {
193
+ this.#onLoaded();
194
+ return false;
195
+ }
196
+
197
+ if (!this.#panzoomController.panzoom) {
198
+ this.#panzoomController.enable();
199
+ }
200
+
201
+ this.#onLoaded();
202
+ return true;
203
+ }
204
+
205
+ /**
206
+ * Handle invalid or unsupported SVG input. Emits a custom error event.
207
+ * @private
208
+ * @returns {void}
209
+ */
210
+ #onError() {
211
+ const error = "PrefViewer2D: Error in SVG provided for loading. Ensure the SVG is a URL to an SVG file, a string containing a SVG, or a string containing a base64-encoded SVG.";
212
+ this.dispatchEvent(
213
+ new CustomEvent("prefviewer2d-error", {
214
+ bubbles: true,
215
+ cancelable: false,
216
+ composed: true,
217
+ detail: { error: new Error(error) },
218
+ })
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Called when SVG and Panzoom are ready; sets loaded flag and subscribes to events.
224
+ * @private
225
+ * @returns {void}
226
+ */
227
+ #onLoaded() {
228
+ this.dispatchEvent(
229
+ new CustomEvent("prefviewer2d-loaded", {
230
+ bubbles: true,
231
+ cancelable: false,
232
+ composed: true,
233
+ })
234
+ );
235
+
236
+ this.removeAttribute("loading");
237
+ this.setAttribute("loaded", "");
238
+
239
+ this.#isLoaded = true;
240
+ this.#subscribeToEvents();
241
+ }
242
+
243
+ /**
244
+ * Called before loading starts; clears loaded flag and unsubscribes from events.
245
+ * @private
246
+ * @returns {void}
247
+ */
248
+ #onLoading() {
249
+ this.dispatchEvent(
250
+ new CustomEvent("prefviewer2d-loading", {
251
+ bubbles: true,
252
+ cancelable: false,
253
+ composed: true,
254
+ })
255
+ );
256
+
257
+ this.removeAttribute("loaded");
258
+ this.setAttribute("loading", "");
259
+
260
+ if (this.#isLoaded === true) {
261
+ this.#isLoaded = false;
262
+ }
263
+
264
+ this.#unsubscribeToEvents();
265
+ }
266
+
267
+ /**
268
+ * Handles Panzoom state changes by dispatching a "prefviewer2d-zoomchanged" custom event.
269
+ * @private
270
+ * @param {object} state - The current Panzoom state object.
271
+ * @returns {void}
272
+ * @description
273
+ * The event detail contains the Panzoom state: { moved, scaled, maximized, minimized } to provide
274
+ * the UI with the enabled/disabled status of the zoomIn, zoomOut, center, and zoomExtentsAll buttons.
275
+ */
276
+ #onPanzoomChanged(state) {
277
+ this.dispatchEvent(
278
+ new CustomEvent("prefviewer2d-zoomchanged", {
279
+ bubbles: true,
280
+ cancelable: false,
281
+ composed: true,
282
+ detail: state,
283
+ })
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Subscribe DOM events required by Panzoom and keyboard interactions.
289
+ * @private
290
+ * @returns {void}
291
+ * @description
292
+ * Adds listeners only when Panzoom exists and component is visible.
293
+ * Delegates event binding to PanzoomController instance.
294
+ */
295
+ #subscribeToEvents() {
296
+ if (!this.#panzoomController || !this.#isVisible) {
297
+ return;
298
+ }
299
+ this.#panzoomController.enableEvents();
300
+ }
301
+
302
+ /**
303
+ * Unsubscribe previously attached DOM events.
304
+ * @private
305
+ * @returns {void}
306
+ * @description
307
+ * Delegates event binding to PanzoomController instance.
308
+ */
309
+ #unsubscribeToEvents() {
310
+ if (!this.#panzoomController) {
311
+ return;
312
+ }
313
+ this.#panzoomController.disableEvents();
314
+ }
315
+
316
+ /**
317
+ * ---------------------------
318
+ * Public methods
319
+ * ---------------------------
320
+ */
321
+
322
+ /**
323
+ * Load a new SVG resource into the viewer.
324
+ * @public
325
+ * @param {object} svgConfig - SVG configuration. An object with a `storage` property describing the source:
326
+ * { storage: { url: "<url>" } }
327
+ * or { storage: { db: "<db>", table: "<table>", id: "<id>" } }
328
+ * @returns {Promise<boolean|void>}
329
+ * - Resolves to false when the input is invalid or the SVG could not be retrieved/decoded, or when there is no update required (cached copy unchanged).
330
+ * - Resolves to true when the SVG was accepted and the component has started rendering.
331
+ */
332
+ async load(svgConfig) {
333
+ if (!svgConfig || !(await this.#svgResolver.getSVG(svgConfig))) {
334
+ this.#onError();
335
+ return false;
336
+ }
337
+ this.#isInitialized && this.#loadSVG();
338
+ return true;
339
+ }
340
+
341
+ /**
342
+ * Hide the component: set style, attribute and unsubscribe from events.
343
+ * @public
344
+ * @returns {void}
345
+ */
346
+ hide() {
347
+ this.#isVisible = false;
348
+ this.setAttribute("visible", "false");
349
+ this.#unsubscribeToEvents();
350
+ }
351
+
352
+ /**
353
+ * Show the component: set style, attribute and subscribe to events.
354
+ * @public
355
+ * @returns {void}
356
+ */
357
+ show() {
358
+ this.#isVisible = true;
359
+ this.setAttribute("visible", "true");
360
+ this.#subscribeToEvents();
361
+ }
362
+
363
+ /**
364
+ * Pans the SVG view to the center without changing the zoom level.
365
+ * Delegates the pan action to the PanzoomController instance.
366
+ * @public
367
+ * @returns {void}
368
+ */
369
+ zoomCenter() {
370
+ if (!this.#panzoomController) {
371
+ return;
372
+ }
373
+ this.#panzoomController.pan(0, 0);
374
+ }
375
+
376
+ /**
377
+ * Resets pan and zoom so the entire SVG drawing fits within the available space.
378
+ * Delegates the reset action to the PanzoomController instance.
379
+ * @public
380
+ * @returns {void}
381
+ */
382
+ zoomExtentsAll() {
383
+ if (!this.#panzoomController) {
384
+ return;
385
+ }
386
+ this.#panzoomController.reset();
387
+ }
388
+
389
+ /**
390
+ * Zoom in the SVG view one step.
391
+ * Delegates the zoom-in action to the PanzoomController instance.
392
+ * @public
393
+ * @returns {void}
394
+ */
395
+ zoomIn() {
396
+ if (!this.#panzoomController) {
397
+ return;
398
+ }
399
+ this.#panzoomController.zoomIn();
400
+ }
401
+
402
+ /**
403
+ * Zoom out the SVG view one step.
404
+ * Delegates the zoom-out action to the PanzoomController instance.
405
+ * @public
406
+ * @returns {void}
407
+ */
408
+ zoomOut() {
409
+ if (!this.#panzoomController) {
410
+ return;
411
+ }
412
+ this.#panzoomController.zoomOut();
413
+ }
414
+
415
+ /**
416
+ * Indicates whether the component has completed its initialization.
417
+ * @public
418
+ * @returns {boolean} True if the component is initialized; otherwise, false.
419
+ */
420
+ get initialized() {
421
+ return this.#isInitialized;
422
+ }
423
+
424
+ /**
425
+ * Indicates whether the SVG content is loaded and ready.
426
+ * @public
427
+ * @returns {boolean} True if the SVG is loaded; otherwise, false.
428
+ */
429
+ get loaded() {
430
+ return this.#isLoaded;
431
+ }
432
+
433
+ /**
434
+ * Indicates whether the component is currently visible.
435
+ * @public
436
+ * @returns {boolean} True if the component is visible; otherwise, false.
437
+ */
438
+ get visible() {
439
+ return this.#isVisible;
440
+ }
441
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * ContainerData - Represents the state and metadata of a asset container in the 3D viewer (e.g., model, environment, materials).
3
+ *
4
+ * Responsibilities:
5
+ * - Tracks container name, show (if must be visible), visibility (if currently visible), size, and timestamp.
6
+ * - Manages update state for asynchronous operations (pending, success, etc.).
7
+ * - Provides methods to reset, set pending, and set success states.
8
+ *
9
+ * Usage:
10
+ * - Instantiate with a name: new ContainerData("model")
11
+ * - Use setPending(), setSuccess(), and reset() to manage update state.
12
+ * - Access status via isPending, isSuccess, isVisible getters.
13
+ */
14
+ export class ContainerData {
15
+ constructor(name = "") {
16
+ this.name = name;
17
+ this.show = true;
18
+ this.size = 0;
19
+ this.timeStamp = null;
20
+ this.visible = false;
21
+ // Set initial information about ongoing update
22
+ this.reset();
23
+ }
24
+ reset() {
25
+ this.update = {
26
+ pending: false,
27
+ storage: null,
28
+ success: false,
29
+ show: null,
30
+ size: 0,
31
+ timeStamp: null,
32
+ };
33
+ }
34
+ setSuccess(success = false) {
35
+ if (success) {
36
+ this.update.success = true;
37
+ this.show = this.update.show !== null ? this.update.show : this.show;
38
+ this.size = this.update.size;
39
+ this.timeStamp = this.update.timeStamp;
40
+ } else {
41
+ this.update.success = false;
42
+ }
43
+ }
44
+ setPending(storage = null, show) {
45
+ this.reset();
46
+ this.update.pending = true;
47
+ this.update.storage = storage;
48
+ this.update.success = false;
49
+ this.update.show = show !== undefined ? show : this.update.show;
50
+ }
51
+ setPendingCacheData(size = 0, timeStamp = null) {
52
+ this.update.size = size;
53
+ this.update.timeStamp = timeStamp;
54
+ }
55
+ get isPending() {
56
+ return this.update.pending === true;
57
+ }
58
+ get isSuccess() {
59
+ return this.update.success === true;
60
+ }
61
+ get isVisible() {
62
+ return this.visible === true;
63
+ }
64
+ get mustBeShown() {
65
+ return this.show === true;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * MaterialData - Represents the state and metadata of a material in the 3D viewer.
71
+ *
72
+ * Responsibilities:
73
+ * - Tracks material name, value, node names, and node prefixes.
74
+ * - Manages update state for asynchronous operations (pending, success, etc.).
75
+ * - Provides methods to reset, set pending, and set success states.
76
+ *
77
+ * Usage:
78
+ * - Instantiate with a name and optional value: new MaterialData("innerWall", value, nodeNames, nodePrefixes)
79
+ * - Use setPending(), setSuccess(), and reset() to manage update state.
80
+ * - Access status via isPending, isSuccess getters.
81
+ */
82
+ export class MaterialData {
83
+ constructor(name = "", value = null, nodeNames = [], nodePrefixes = []) {
84
+ this.name = name;
85
+ this.nodeNames = nodeNames;
86
+ this.nodePrefixes = nodePrefixes;
87
+ this.value = value;
88
+ // Set initial information about ongoing update
89
+ this.reset();
90
+ }
91
+ reset() {
92
+ this.update = {
93
+ pending: false,
94
+ success: false,
95
+ value: null,
96
+ };
97
+ }
98
+ setSuccess(success = false) {
99
+ if (success) {
100
+ this.update.success = true;
101
+ this.value = this.update.value;
102
+ } else {
103
+ this.update.success = false;
104
+ }
105
+ }
106
+ setPending(value = null) {
107
+ this.reset();
108
+ this.update.pending = true;
109
+ this.update.success = false;
110
+ this.update.value = value;
111
+ }
112
+ setPendingWithCurrent() {
113
+ this.setPending(this.value);
114
+ }
115
+ get isPending() {
116
+ return this.update.pending === true;
117
+ }
118
+ get isSuccess() {
119
+ return this.update.success === true;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * CameraData - Represents the state and metadata of a camera in the 3D viewer.
125
+ *
126
+ * Responsibilities:
127
+ * - Tracks camera name, value, and locked status.
128
+ * - Manages update state for asynchronous operations (pending, success, etc.).
129
+ * - Provides methods to reset, set pending, and set success states.
130
+ *
131
+ * Usage:
132
+ * - Instantiate with a name and optional value: new CameraData("camera", value, locked)
133
+ * - Use setPending(), setSuccess(), and reset() to manage update state.
134
+ * - Access status via isPending, isSuccess getters.
135
+ */
136
+ export class CameraData {
137
+ constructor(name = "", value = null, locked = true) {
138
+ this.name = name;
139
+ this.value = value;
140
+ this.locked = locked;
141
+ // Set initial information about ongoing update
142
+ this.reset();
143
+ }
144
+ reset() {
145
+ this.update = {
146
+ pending: false,
147
+ success: false,
148
+ value: null,
149
+ };
150
+ }
151
+ setSuccess(success = false) {
152
+ if (success) {
153
+ this.update.success = true;
154
+ this.value = this.update.value;
155
+ } else {
156
+ this.update.success = false;
157
+ }
158
+ }
159
+ setPending(value = undefined, locked = true) {
160
+ this.reset();
161
+ if (value === undefined) {
162
+ return;
163
+ }
164
+ if (value === null) {
165
+ locked = false;
166
+ }
167
+ this.update.pending = true;
168
+ this.update.success = false;
169
+ this.update.value = value;
170
+ this.update.locked = locked;
171
+ }
172
+ get isPending() {
173
+ return this.update.pending === true;
174
+ }
175
+ get isSuccess() {
176
+ return this.update.success === true;
177
+ }
178
+ }