@preference-sl/pref-viewer 2.16.0-beta.13 → 2.16.0-beta.18

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.
@@ -27,6 +27,7 @@ import Panzoom from "@panzoom/panzoom/dist/panzoom.es.js";
27
27
  * - wrapper (HTMLElement): The DOM element to apply pan/zoom interactions to.
28
28
  * - options (object): Configuration options for Panzoom (e.g., minScale, maxScale, step).
29
29
  * - changedCallback (function): Optional callback invoked when the pan/zoom state changes.
30
+ * - clickCallback (function): Optional callback invoked with the original event after a gesture is classified as a click.
30
31
  *
31
32
  * Public Methods:
32
33
  * - enable(): Enables the Panzoom instance and initializes it if not already enabled.
@@ -40,7 +41,7 @@ import Panzoom from "@panzoom/panzoom/dist/panzoom.es.js";
40
41
  *
41
42
  * Getters:
42
43
  * - panzoom: Returns the current Panzoom instance or null if not initialized.
43
- * - state: Returns the current pan/zoom state (moved, scaled, maximized, minimized).
44
+ * - state: Returns the current pan/zoom transform and semantic state.
44
45
  *
45
46
  * Private Methods:
46
47
  * - #bindHandlers(): Pre-binds reusable event handlers to preserve stable references.
@@ -81,6 +82,9 @@ export default class PanzoomController {
81
82
  totalCounter: 0,
82
83
  };
83
84
  #state = {
85
+ x: 0,
86
+ y: 0,
87
+ scale: 1,
84
88
  moved: false,
85
89
  scaled: false,
86
90
  maximized: false,
@@ -89,6 +93,7 @@ export default class PanzoomController {
89
93
  #wrapper = null;
90
94
 
91
95
  #changedCallback = null;
96
+ #clickCallback = null;
92
97
  #handlers = {
93
98
  panzoomStart: null,
94
99
  panzoomChange: null,
@@ -100,10 +105,11 @@ export default class PanzoomController {
100
105
  wheel: null,
101
106
  };
102
107
 
103
- constructor(wrapper = null, options = {}, changedCallback = null) {
108
+ constructor(wrapper = null, options = {}, changedCallback = null, clickCallback = null) {
104
109
  this.#wrapper = wrapper;
105
110
  Object.assign(this.#options, options);
106
111
  this.#changedCallback = changedCallback;
112
+ this.#clickCallback = clickCallback;
107
113
  this.#bindHandlers();
108
114
  }
109
115
 
@@ -145,7 +151,7 @@ export default class PanzoomController {
145
151
  * @returns {{x:number,y:number}|null} Point or null if coordinates not present.
146
152
  */
147
153
  #getPointFromEvent(e) {
148
- if (e.clientX && e.clientY) {
154
+ if (Number.isFinite(e?.clientX) && Number.isFinite(e?.clientY)) {
149
155
  return {
150
156
  x: e.clientX,
151
157
  y: e.clientY,
@@ -155,6 +161,13 @@ export default class PanzoomController {
155
161
  }
156
162
  }
157
163
 
164
+ /** Reset transient pointer data after a completed or interrupted gesture. */
165
+ #resetPointerEvents() {
166
+ this.#pointerEvents.lastPointerDownPosition = null;
167
+ this.#pointerEvents.partialCounter = 0;
168
+ this.#pointerEvents.totalCounter = 0;
169
+ }
170
+
158
171
  /**
159
172
  * Update internal pan/zoom state and emit a custom event with current values.
160
173
  * If a changed callback is defined, it is invoked with the updated state object.
@@ -164,6 +177,9 @@ export default class PanzoomController {
164
177
  */
165
178
  #checkPanzoomState(transform) {
166
179
  this.#state = {
180
+ x: transform.x,
181
+ y: transform.y,
182
+ scale: transform.scale,
167
183
  moved: transform.x !== 0 || transform.y !== 0,
168
184
  scaled: transform.scale !== 1,
169
185
  maximized: transform.scale === this.#options.maxScale,
@@ -252,7 +268,6 @@ export default class PanzoomController {
252
268
  * @private
253
269
  * @param {CustomEvent} e - Panzoom custom event with originalEvent property.
254
270
  * @returns {void}
255
- * @todo Implement selection/click logic.
256
271
  */
257
272
  #onPanzoomEnd(e) {
258
273
  this.#pointerEvents.partialCounter--;
@@ -260,21 +275,21 @@ export default class PanzoomController {
260
275
  if (this.#pointerEvents.partialCounter > 0) {
261
276
  return;
262
277
  } else if (this.#pointerEvents.totalCounter > 1) {
263
- this.#pointerEvents.partialCounter = 0;
264
- this.#pointerEvents.totalCounter = 0;
278
+ this.#resetPointerEvents();
265
279
  return;
266
280
  }
267
281
 
268
- this.#pointerEvents.partialCounter = 0;
269
- this.#pointerEvents.totalCounter = 0;
270
-
271
- const originalEvent = e.detail.originalEvent;
282
+ const originalEvent = e?.detail?.originalEvent;
283
+ if (!originalEvent) {
284
+ this.#resetPointerEvents();
285
+ return;
286
+ }
272
287
  const point = this.#getPointFromEvent(originalEvent);
273
288
  const distance = this.#getDistanceBetweenPoints(point, this.#pointerEvents.lastPointerDownPosition);
274
289
 
275
290
  let proceedToPick = true;
276
291
 
277
- if (!point || distance > this.#interactionOptions.clickThreshold) {
292
+ if (!point || !this.#pointerEvents.lastPointerDownPosition || distance > this.#interactionOptions.clickThreshold) {
278
293
  proceedToPick = false;
279
294
  }
280
295
 
@@ -282,7 +297,7 @@ export default class PanzoomController {
282
297
  proceedToPick = false;
283
298
  }
284
299
 
285
- this.#pointerEvents.lastPointerDownPosition = null;
300
+ this.#resetPointerEvents();
286
301
 
287
302
  if (!proceedToPick) {
288
303
  return;
@@ -292,7 +307,9 @@ export default class PanzoomController {
292
307
  return;
293
308
  }
294
309
 
295
- // Selection/click logic can be implemented here.
310
+ if (typeof this.#clickCallback === "function") {
311
+ this.#clickCallback(originalEvent);
312
+ }
296
313
  }
297
314
 
298
315
  /**
@@ -302,7 +319,7 @@ export default class PanzoomController {
302
319
  * @returns {void}
303
320
  */
304
321
  #onPanzoomStart(e) {
305
- const originalEvent = e.detail.originalEvent;
322
+ const originalEvent = e?.detail?.originalEvent;
306
323
  this.#pointerEvents.partialCounter++;
307
324
  this.#pointerEvents.totalCounter++;
308
325
  this.#pointerEvents.lastPointerDownPosition = this.#getPointFromEvent(originalEvent);
@@ -421,13 +438,15 @@ export default class PanzoomController {
421
438
  if (!this.#wrapper) {
422
439
  return;
423
440
  }
441
+ this.#disableMouseWheelZoom();
442
+ this.#resetPointerEvents();
424
443
  this.#wrapper.parentElement.removeAttribute("tabindex");
425
444
  this.#wrapper.removeEventListener("panzoomstart", this.#handlers.panzoomStart);
426
445
  this.#wrapper.removeEventListener("panzoomchange", this.#handlers.panzoomChange);
427
446
  this.#wrapper.removeEventListener("panzoomend", this.#handlers.panzoomEnd);
428
447
  this.#wrapper.parentElement.removeEventListener("keyup", this.#handlers.keyUp);
429
- this.#wrapper.parentElement.removeEventListener("focus", this.#handlers.focus);
430
- this.#wrapper.parentElement.removeEventListener("blur", this.#handlers.blur);
448
+ this.#wrapper.parentElement.removeEventListener("focus", this.#handlers.focus, true);
449
+ this.#wrapper.parentElement.removeEventListener("blur", this.#handlers.blur, true);
431
450
  this.#wrapper.parentElement.removeEventListener("contextmenu", this.#handlers.contextMenu);
432
451
  }
433
452
 
@@ -523,7 +542,7 @@ export default class PanzoomController {
523
542
  /**
524
543
  * Returns the current pan/zoom state.
525
544
  * @public
526
- * @returns {object} The state object: { moved, scaled, maximized, minimized }.
545
+ * @returns {object} The state object: { x, y, scale, moved, scaled, maximized, minimized }.
527
546
  */
528
547
  get state() {
529
548
  return this.#state;
@@ -22,6 +22,8 @@ import { PrefViewer2DStyles } from "./styles.js";
22
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
28
  * Public Properties:
27
29
  * - isInitialized: Indicates whether the component has completed initialization.
@@ -32,7 +34,9 @@ import { PrefViewer2DStyles } from "./styles.js";
32
34
  * - "pref-viewer-2d-loading": emitted before loading starts. Legacy alias: "drawing-loading"
33
35
  * - "pref-viewer-2d-loaded": emitted after content and Panzoom are ready. Legacy alias: "drawing-loaded"
34
36
  * - "pref-viewer-2d-error": emitted on validation/fetch errors (detail.error: Error). Legacy alias: "drawing-error"
35
- * - "pref-viewer-2d-viewport-changed": emitted when pan/zoom state changes (detail: { moved, scaled, maximized, minimized })
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
@@ -258,6 +291,7 @@ export default class PrefViewer2D extends HTMLElement {
258
291
  this.setAttribute("loaded", "");
259
292
 
260
293
  this.#isLoaded = true;
294
+ this.#reconcileSelectionState();
261
295
  this.#subscribeToEvents();
262
296
  }
263
297
 
@@ -291,7 +325,7 @@ export default class PrefViewer2D extends HTMLElement {
291
325
  * @param {object} state - The current Panzoom state object.
292
326
  * @returns {void}
293
327
  * @description
294
- * 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
295
329
  * the UI with the enabled/disabled status of the zoomIn, zoomOut, center, and zoomExtentsAll buttons.
296
330
  */
297
331
  #onPanzoomChanged(state) {
@@ -301,10 +335,296 @@ export default class PrefViewer2D extends HTMLElement {
301
335
  composed: false,
302
336
  detail: state,
303
337
  };
304
- customEventOptions.detail.desde2d = "true";
305
338
  this.dispatchEvent(new CustomEvent("pref-viewer-2d-viewport-changed", customEventOptions));
306
339
  }
307
340
 
341
+ /**
342
+ * Resolve a classified click to semantic SVG ids and notify the root component.
343
+ * @private
344
+ * @param {Event} event - Original pointer event supplied by Panzoom.
345
+ * @returns {void}
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
+ });
626
+ }
627
+
308
628
  /**
309
629
  * Subscribe DOM events required by Panzoom and keyboard interactions.
310
630
  * @private
@@ -362,6 +682,67 @@ export default class PrefViewer2D extends HTMLElement {
362
682
  return { success: success, error: null };
363
683
  }
364
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
+
365
746
  /**
366
747
  * Hide the component: set style, attribute and unsubscribe from events.
367
748
  * @public