@preference-sl/pref-viewer 2.16.0-beta.9 → 2.16.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.
@@ -19,20 +19,24 @@ import { PrefViewer2DStyles } from "./styles.js";
19
19
  * - visible: "true" | "false" (controls visibility and event subscription)
20
20
  *
21
21
  * Public methods
22
- * - async load(svgConfig) : Promise<boolean> — accept SVG input/config and prepare for render
22
+ * - async load(svgConfig) : Promise<{success: boolean, error: Error|null}> — accept SVG input/config and prepare for render
23
23
  * - show(), hide() — toggle visibility and event subscriptions
24
24
  * - zoomIn(), zoomOut(), zoomCenter(), zoomExtentsAll() — pan/zoom controls
25
+ * - applySelectionState(state) — reconcile selectable and selected visual layers
26
+ * - appendSvgNode(id, svgId, svg), removeAppendedSvgNodes() — manage host-provided SVG nodes
25
27
  *
26
- * * Public Properties:
28
+ * Public Properties:
27
29
  * - isInitialized: Indicates whether the component has completed initialization.
28
30
  * - isLoaded: Indicates whether the SVG content is loaded and ready.
29
31
  * - isVisible: Indicates whether the component is currently visible.
30
32
  *
31
33
  * Events
32
- * - "drawing-loading": emitted before loading starts
33
- * - "drawing-loaded": emitted after content and Panzoom are ready
34
- * - "drawing-error": emitted on validation/fetch errors (detail.error: Error)
35
- * - "drawing-zoom-changed": emitted when pan/zoom state changes (detail: { moved, scaled, maximized, minimized })
34
+ * - "pref-viewer-2d-loading": emitted before loading starts. Legacy alias: "drawing-loading"
35
+ * - "pref-viewer-2d-loaded": emitted after content and Panzoom are ready. Legacy alias: "drawing-loaded"
36
+ * - "pref-viewer-2d-error": emitted on validation/fetch errors (detail.error: Error). Legacy alias: "drawing-error"
37
+ * - "pref-viewer-2d-viewport-changed": emitted when pan/zoom state changes
38
+ * (detail: { x, y, scale, moved, scaled, maximized, minimized })
39
+ * - "pref-viewer-2d-element-click": emitted with `{ ids: string[] }` after semantic hit resolution
36
40
  *
37
41
  * Implementation notes
38
42
  * - Keeps internal state in private fields (#svg, #panzoom, #panzoomOptions, ...).
@@ -69,10 +73,24 @@ export default class PrefViewer2D extends HTMLElement {
69
73
  #wrapper = null; // Panzoom element
70
74
  #panzoomController = null; // PanzoomController instance
71
75
 
76
+ #selectionState = {
77
+ selectableIds: [],
78
+ selectedItems: [],
79
+ };
80
+ #selectionNodes = [];
81
+ #appendedSvgElements = [];
82
+
72
83
  #handlers = {
73
84
  onPanzoomChanged: null,
85
+ onElementClick: null,
74
86
  };
75
87
 
88
+ #SELECTABLE_PREFIX = "_SELECTABLE_";
89
+ #SELECTED_PREFIX = "_SELECTED_";
90
+ #SELECTABLE_COLOR = "#FFFF00";
91
+ #SELECTED_COLOR = "#FF0000";
92
+ #COLOR_OPACITY = "0.5";
93
+
76
94
  /**
77
95
  * Create a new instance of the 2D viewer component.
78
96
  * The constructor only calls super(); heavy initialization happens in connectedCallback.
@@ -123,10 +141,15 @@ export default class PrefViewer2D extends HTMLElement {
123
141
  * @returns {void}
124
142
  */
125
143
  connectedCallback() {
126
- this.#createDOMElements();
144
+ if (!this.#wrapper) {
145
+ this.#createDOMElements();
146
+ }
127
147
  this.#setInitialVisibility();
128
- this.#initializePanzoom();
148
+ if (!this.#panzoomController) {
149
+ this.#initializePanzoom();
150
+ }
129
151
  this.#isInitialized = true;
152
+ this.#svg.update = true;
130
153
  this.#loadSVG();
131
154
  }
132
155
 
@@ -140,6 +163,8 @@ export default class PrefViewer2D extends HTMLElement {
140
163
  if (this.#panzoomController) {
141
164
  this.#panzoomController.disable();
142
165
  }
166
+ this.#isInitialized = false;
167
+ this.#isLoaded = false;
143
168
  }
144
169
 
145
170
  /**
@@ -175,7 +200,13 @@ export default class PrefViewer2D extends HTMLElement {
175
200
  */
176
201
  #initializePanzoom() {
177
202
  this.#handlers.onPanzoomChanged = this.#onPanzoomChanged.bind(this);
178
- this.#panzoomController = new PanzoomController(this.#wrapper, undefined, this.#handlers.onPanzoomChanged);
203
+ this.#handlers.onElementClick = this.#onElementClick.bind(this);
204
+ this.#panzoomController = new PanzoomController(
205
+ this.#wrapper,
206
+ undefined,
207
+ this.#handlers.onPanzoomChanged,
208
+ this.#handlers.onElementClick,
209
+ );
179
210
  }
180
211
 
181
212
  /**
@@ -205,6 +236,8 @@ export default class PrefViewer2D extends HTMLElement {
205
236
  this.#panzoomController.disable();
206
237
  }
207
238
 
239
+ this.#clearSelectionLayers();
240
+ this.#removeAppendedSvgNodes();
208
241
  this.#wrapper.innerHTML = this.#svg.value;
209
242
 
210
243
  // If SVG is empty, do not initialize Panzoom but don't emit an error
@@ -235,7 +268,8 @@ export default class PrefViewer2D extends HTMLElement {
235
268
  composed: false, // indicates whether or not the event will propagate across the shadow DOM boundary into the standard DOM
236
269
  detail: detail,
237
270
  };
238
- this.dispatchEvent(new CustomEvent("drawing-error", customEventOptions));
271
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-error", customEventOptions));
272
+ this.dispatchEvent(new CustomEvent("drawing-error", customEventOptions)); // Legacy alias for backward compatibility
239
273
  return detail;
240
274
  }
241
275
 
@@ -250,12 +284,14 @@ export default class PrefViewer2D extends HTMLElement {
250
284
  cancelable: true,
251
285
  composed: false,
252
286
  };
253
- this.dispatchEvent(new CustomEvent("drawing-loaded", customEventOptions));
287
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-loaded", customEventOptions));
288
+ this.dispatchEvent(new CustomEvent("drawing-loaded", customEventOptions)); // Legacy alias for backward compatibility
254
289
 
255
290
  this.removeAttribute("loading");
256
291
  this.setAttribute("loaded", "");
257
292
 
258
293
  this.#isLoaded = true;
294
+ this.#reconcileSelectionState();
259
295
  this.#subscribeToEvents();
260
296
  }
261
297
 
@@ -270,7 +306,8 @@ export default class PrefViewer2D extends HTMLElement {
270
306
  cancelable: true,
271
307
  composed: false,
272
308
  };
273
- this.dispatchEvent(new CustomEvent("drawing-loading", customEventOptions));
309
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-loading", customEventOptions));
310
+ this.dispatchEvent(new CustomEvent("drawing-loading", customEventOptions)); // Legacy alias for backward compatibility
274
311
 
275
312
  this.removeAttribute("loaded");
276
313
  this.setAttribute("loading", "");
@@ -283,12 +320,12 @@ export default class PrefViewer2D extends HTMLElement {
283
320
  }
284
321
 
285
322
  /**
286
- * Handles Panzoom state changes by dispatching a "prefviewer2d-zoomchanged" custom event.
323
+ * Handles Panzoom state changes by dispatching the public "pref-viewer-2d-viewport-changed" event.
287
324
  * @private
288
325
  * @param {object} state - The current Panzoom state object.
289
326
  * @returns {void}
290
327
  * @description
291
- * The event detail contains the Panzoom state: { moved, scaled, maximized, minimized } to provide
328
+ * The event detail contains the Panzoom state: { x, y, scale, moved, scaled, maximized, minimized } to provide
292
329
  * the UI with the enabled/disabled status of the zoomIn, zoomOut, center, and zoomExtentsAll buttons.
293
330
  */
294
331
  #onPanzoomChanged(state) {
@@ -298,18 +335,294 @@ export default class PrefViewer2D extends HTMLElement {
298
335
  composed: false,
299
336
  detail: state,
300
337
  };
301
- customEventOptions.detail.desde2d = "true";
302
- this.dispatchEvent(new CustomEvent("drawing-zoom-changed", customEventOptions));
338
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-viewport-changed", customEventOptions));
303
339
  }
304
340
 
305
341
  /**
306
- * Subscribe DOM events required by Panzoom and keyboard interactions.
342
+ * Resolve a classified click to semantic SVG ids and notify the root component.
307
343
  * @private
344
+ * @param {Event} event - Original pointer event supplied by Panzoom.
308
345
  * @returns {void}
309
- composed: true,
310
- detail: state,
311
- })
312
- );
346
+ */
347
+ #onElementClick(event) {
348
+ const composedTarget = typeof event?.composedPath === "function"
349
+ ? event.composedPath()[0]
350
+ : null;
351
+ const ids = this.#resolveSemanticIds(composedTarget ?? event?.target);
352
+ if (ids.length === 0) {
353
+ return;
354
+ }
355
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-element-click", {
356
+ bubbles: true,
357
+ cancelable: true,
358
+ composed: false,
359
+ detail: { ids },
360
+ }));
361
+ }
362
+
363
+ /**
364
+ * Return the group that owns drawable PrefCAD elements.
365
+ * @private
366
+ * @returns {SVGElement|null}
367
+ */
368
+ #getSvgRoot() {
369
+ const svg = this.#wrapper?.querySelector("svg");
370
+ return svg?.firstElementChild ?? svg ?? null;
371
+ }
372
+
373
+ /**
374
+ * Resolve href values used by SVG `<use>` and paint references.
375
+ * @private
376
+ * @param {Element|null} node - SVG node.
377
+ * @returns {string|null}
378
+ */
379
+ #getHrefId(node) {
380
+ const href = node?.href?.baseVal
381
+ ?? node?.getAttribute?.("href")
382
+ ?? node?.getAttribute?.("xlink:href");
383
+ if (typeof href !== "string" || href.length === 0) {
384
+ return null;
385
+ }
386
+ if (href.startsWith("#")) {
387
+ return href.substring(1) || null;
388
+ }
389
+ const urlMatch = /^Url\(#(.+)\)$/i.exec(href);
390
+ return urlMatch?.[1] ?? null;
391
+ }
392
+
393
+ /**
394
+ * Resolve visual clones, direct ids and SVG references to business ids.
395
+ * @private
396
+ * @param {EventTarget|null} target - Original event target.
397
+ * @returns {string[]}
398
+ */
399
+ #resolveSemanticIds(target) {
400
+ if (!(target instanceof Element)) {
401
+ return [];
402
+ }
403
+
404
+ const visualNode = target.closest("[data-pref-viewer-semantic-id]");
405
+ let id = visualNode?.getAttribute("data-pref-viewer-semantic-id") ?? target.id ?? null;
406
+
407
+ if (!id && target.correspondingUseElement?.id) {
408
+ id = target.correspondingUseElement.id;
409
+ }
410
+ if (!id) {
411
+ id = this.#getHrefId(target);
412
+ }
413
+ if (!id) {
414
+ const drawingRoot = this.#getSvgRoot();
415
+ let ancestor = target.parentElement;
416
+ while (ancestor && ancestor !== drawingRoot) {
417
+ if (ancestor.id) {
418
+ id = ancestor.id;
419
+ break;
420
+ }
421
+ ancestor = ancestor.parentElement;
422
+ }
423
+ }
424
+ if (!id) {
425
+ return [];
426
+ }
427
+
428
+ if (id.startsWith(this.#SELECTABLE_PREFIX)) {
429
+ id = id.substring(this.#SELECTABLE_PREFIX.length);
430
+ } else if (id.startsWith(this.#SELECTED_PREFIX)) {
431
+ id = id.substring(this.#SELECTED_PREFIX.length);
432
+ }
433
+
434
+ if (!id.startsWith("ParametricMaterial")) {
435
+ return [id];
436
+ }
437
+
438
+ const group = this.#findSvgElementsById(id).find((node) => node.localName === "g");
439
+ const childIds = Array.from(group?.children ?? [])
440
+ .map((node) => node.id)
441
+ .filter((childId) => typeof childId === "string" && childId.length > 0);
442
+ return childIds.length > 0 ? childIds : [id];
443
+ }
444
+
445
+ /**
446
+ * Find supported drawing elements by exact id without CSS-selector escaping.
447
+ * @private
448
+ * @param {string} id - Semantic SVG id.
449
+ * @returns {Element[]}
450
+ */
451
+ #findSvgElementsById(id) {
452
+ const root = this.#getSvgRoot();
453
+ if (!root || typeof id !== "string") {
454
+ return [];
455
+ }
456
+ const elements = [root, ...root.querySelectorAll("path, use, g")];
457
+ return elements.filter((node) => node.id === id && !node.hasAttribute("data-pref-viewer-selection-layer"));
458
+ }
459
+
460
+ /**
461
+ * Locate the definition referenced by an SVG node.
462
+ * @private
463
+ * @param {Element} node - Referencing node.
464
+ * @returns {Element|null}
465
+ */
466
+ #getReferencedNode(node) {
467
+ const id = this.#getHrefId(node);
468
+ if (!id) {
469
+ return null;
470
+ }
471
+ const svg = this.#wrapper?.querySelector("svg");
472
+ return Array.from(svg?.querySelectorAll("[id]") ?? []).find((candidate) => candidate.id === id) ?? null;
473
+ }
474
+
475
+ /**
476
+ * Update a cloned `<use>` reference while supporting current browser SVG APIs.
477
+ * @private
478
+ * @param {Element} node - Cloned SVG node.
479
+ * @param {string} value - New href.
480
+ * @returns {void}
481
+ */
482
+ #setHref(node, value) {
483
+ node.setAttribute("href", value);
484
+ if (node.href?.baseVal !== undefined) {
485
+ node.href.baseVal = value;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Register and append a visual selection clone.
491
+ * @private
492
+ * @param {Element} node - Clone to append.
493
+ * @param {string} semanticId - Business id represented by the clone.
494
+ * @param {"selectable"|"selected"|"definition"} layer - Layer type.
495
+ * @param {Element|null} parent - Optional parent; defaults to drawing root.
496
+ * @returns {Element|null}
497
+ */
498
+ #appendSelectionNode(node, semanticId, layer, parent = null) {
499
+ const destination = parent ?? this.#getSvgRoot();
500
+ if (!destination || !node) {
501
+ return null;
502
+ }
503
+ node.setAttribute("data-pref-viewer-selection-layer", layer);
504
+ node.setAttribute("data-pref-viewer-semantic-id", semanticId);
505
+ destination.appendChild(node);
506
+ this.#selectionNodes.push(node);
507
+ return node;
508
+ }
509
+
510
+ /**
511
+ * Create the legacy-equivalent red clones for one source node.
512
+ * @private
513
+ * @param {Element} source - Source SVG node.
514
+ * @param {string} semanticId - Highlight id.
515
+ * @returns {Element[]}
516
+ */
517
+ #createSelectedClones(source, semanticId) {
518
+ const referred = this.#getReferencedNode(source);
519
+ const referredChildren = Array.from(referred?.childNodes ?? []).filter((node) => node.attributes);
520
+ const hatchChild = referredChildren.find((node) => node.getAttribute("fill")?.includes("Hatch"));
521
+ const delimiterChildren = referredChildren.filter((node) => node.getAttribute("fill") === "none" && node.hasAttribute("stroke"));
522
+
523
+ if (referred && hatchChild) {
524
+ const definition = referred.cloneNode(true);
525
+ const definitionId = `${this.#SELECTED_PREFIX}${this.#getHrefId(source)}`;
526
+ definition.id = definitionId;
527
+ const definitionChild = Array.from(definition.children).find((node) => node.hasAttribute("fill"));
528
+ definitionChild?.setAttribute("fill", this.#SELECTED_COLOR);
529
+ definitionChild?.setAttribute("fill-opacity", this.#COLOR_OPACITY);
530
+ this.#appendSelectionNode(definition, semanticId, "definition", referred.parentNode);
531
+
532
+ const clone = source.cloneNode(true);
533
+ this.#setHref(clone, `#${definitionId}`);
534
+ return [clone];
535
+ }
536
+
537
+ if (referred && delimiterChildren.length > 0) {
538
+ const definition = referred.cloneNode(true);
539
+ const definitionId = `${this.#SELECTED_PREFIX}${this.#getHrefId(source)}`;
540
+ definition.id = definitionId;
541
+ Array.from(definition.childNodes).forEach((node) => {
542
+ if (!node.attributes || node.getAttribute("fill") !== "none" || !node.hasAttribute("stroke")) {
543
+ return;
544
+ }
545
+ node.setAttribute("stroke", this.#SELECTED_COLOR);
546
+ node.setAttribute("stroke-width", "10");
547
+ });
548
+ this.#appendSelectionNode(definition, semanticId, "definition", referred.parentNode);
549
+
550
+ const clone = source.cloneNode(true);
551
+ this.#setHref(clone, `#${definitionId}`);
552
+ return [clone];
553
+ }
554
+
555
+ const parent = source.parentElement;
556
+ if (parent?.id?.startsWith("ParametricMaterial")) {
557
+ const selectedId = `${this.#SELECTED_PREFIX}${semanticId}`;
558
+ const definition = parent.cloneNode(true);
559
+ definition.id = selectedId;
560
+ Array.from(definition.children).forEach((node) => {
561
+ node.removeAttribute("id");
562
+ if (node.getAttribute("fill") === "none" && node.hasAttribute("stroke")) {
563
+ node.style.fill = this.#SELECTED_COLOR;
564
+ node.style.fillOpacity = this.#COLOR_OPACITY;
565
+ }
566
+ });
567
+ this.#appendSelectionNode(definition, semanticId, "definition", parent.parentNode);
568
+
569
+ const uses = Array.from(this.#getSvgRoot()?.querySelectorAll("use") ?? [])
570
+ .filter((node) => this.#getHrefId(node) === parent.id)
571
+ .map((node) => {
572
+ const clone = node.cloneNode(true);
573
+ this.#setHref(clone, `#${selectedId}`);
574
+ return clone;
575
+ });
576
+ if (uses.length > 0) {
577
+ return uses;
578
+ }
579
+ }
580
+
581
+ const clone = source.cloneNode(true);
582
+ clone.style.fill = this.#SELECTED_COLOR;
583
+ clone.style.fillOpacity = this.#COLOR_OPACITY;
584
+ return [clone];
585
+ }
586
+
587
+ /** Remove all visual selection clones and cloned definitions. */
588
+ #clearSelectionLayers() {
589
+ this.#selectionNodes.forEach((node) => node.parentNode?.removeChild(node));
590
+ this.#selectionNodes = [];
591
+ }
592
+
593
+ /** Remove all host-provided SVG nodes and clear their registry. */
594
+ #removeAppendedSvgNodes() {
595
+ this.#appendedSvgElements.forEach((entry) => entry.svg?.parentNode?.removeChild(entry.svg));
596
+ this.#appendedSvgElements = [];
597
+ }
598
+
599
+ /** Rebuild visual layers from the latest semantic snapshot. */
600
+ #reconcileSelectionState() {
601
+ this.#clearSelectionLayers();
602
+ if (!this.#isLoaded || !this.#getSvgRoot()) {
603
+ return;
604
+ }
605
+
606
+ this.#selectionState.selectableIds.forEach((id) => {
607
+ this.#findSvgElementsById(id).forEach((source) => {
608
+ const clone = source.cloneNode(true);
609
+ clone.id = `${this.#SELECTABLE_PREFIX}${id}`;
610
+ clone.style.fill = this.#SELECTABLE_COLOR;
611
+ clone.style.fillOpacity = this.#COLOR_OPACITY;
612
+ this.#appendSelectionNode(clone, id, "selectable");
613
+ });
614
+ });
615
+
616
+ this.#selectionState.selectedItems.forEach((item) => {
617
+ item.highlights.forEach((id) => {
618
+ this.#findSvgElementsById(id).forEach((source) => {
619
+ this.#createSelectedClones(source, id).forEach((clone) => {
620
+ clone.id = `${this.#SELECTED_PREFIX}${id}`;
621
+ this.#appendSelectionNode(clone, id, "selected");
622
+ });
623
+ });
624
+ });
625
+ });
313
626
  }
314
627
 
315
628
  /**
@@ -369,6 +682,67 @@ export default class PrefViewer2D extends HTMLElement {
369
682
  return { success: success, error: null };
370
683
  }
371
684
 
685
+ /**
686
+ * Apply a complete semantic selection snapshot to the current drawing.
687
+ * @public
688
+ * @param {{selectableIds?: string[], selectedItems?: Array<{id: string, highlights?: string[]}>}} state - Selection snapshot.
689
+ * @returns {void}
690
+ */
691
+ applySelectionState(state = {}) {
692
+ const selectableIds = Array.isArray(state.selectableIds)
693
+ ? state.selectableIds.filter((id) => typeof id === "string" && id.length > 0)
694
+ : [];
695
+ const selectedItems = Array.isArray(state.selectedItems)
696
+ ? state.selectedItems
697
+ .map((item) => {
698
+ const id = typeof item === "string" ? item : (item?.id ?? item?.Id);
699
+ if (typeof id !== "string" || id.length === 0) {
700
+ return null;
701
+ }
702
+ const rawHighlights = typeof item === "string" ? [item] : (item.highlights ?? item.Highlights ?? [id]);
703
+ return {
704
+ id,
705
+ highlights: Array.isArray(rawHighlights)
706
+ ? rawHighlights.filter((highlight) => typeof highlight === "string" && highlight.length > 0)
707
+ : [],
708
+ };
709
+ })
710
+ .filter(Boolean)
711
+ : [];
712
+ this.#selectionState = { selectableIds, selectedItems };
713
+ this.#reconcileSelectionState();
714
+ }
715
+
716
+ /**
717
+ * Add a host-owned SVG node to the interactive drawing.
718
+ * @public
719
+ * @param {string} id - Owner id.
720
+ * @param {string} svgId - Stable selector/id used to prevent duplicates.
721
+ * @param {SVGElement} svg - Node to append.
722
+ * @returns {boolean} True when appended.
723
+ */
724
+ appendSvgNode(id, svgId, svg) {
725
+ const root = this.#getSvgRoot();
726
+ if (!root || !svg || this.#appendedSvgElements.some((entry) => entry.svgId === svgId)) {
727
+ return false;
728
+ }
729
+ root.appendChild(svg);
730
+ this.#appendedSvgElements.push({ id, svgId, svg });
731
+ this.#reconcileSelectionState();
732
+ return true;
733
+ }
734
+
735
+ /**
736
+ * Remove every node previously added through appendSvgNode.
737
+ * @public
738
+ * @returns {void}
739
+ */
740
+ removeAppendedSvgNodes() {
741
+ this.#clearSelectionLayers();
742
+ this.#removeAppendedSvgNodes();
743
+ this.#reconcileSelectionState();
744
+ }
745
+
372
746
  /**
373
747
  * Hide the component: set style, attribute and unsubscribe from events.
374
748
  * @public