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

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