@preference-sl/pref-viewer 2.16.0-beta.14 → 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.
- package/README.md +9 -5
- package/index.d.ts +373 -12
- package/package.json +2 -1
- package/src/babylonjs-controller.js +105 -5
- package/src/panzoom-controller.js +28 -15
- package/src/pref-viewer-2d.js +384 -3
- package/src/pref-viewer.js +79 -11
|
@@ -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.
|
|
@@ -92,6 +93,7 @@ export default class PanzoomController {
|
|
|
92
93
|
#wrapper = null;
|
|
93
94
|
|
|
94
95
|
#changedCallback = null;
|
|
96
|
+
#clickCallback = null;
|
|
95
97
|
#handlers = {
|
|
96
98
|
panzoomStart: null,
|
|
97
99
|
panzoomChange: null,
|
|
@@ -103,10 +105,11 @@ export default class PanzoomController {
|
|
|
103
105
|
wheel: null,
|
|
104
106
|
};
|
|
105
107
|
|
|
106
|
-
constructor(wrapper = null, options = {}, changedCallback = null) {
|
|
108
|
+
constructor(wrapper = null, options = {}, changedCallback = null, clickCallback = null) {
|
|
107
109
|
this.#wrapper = wrapper;
|
|
108
110
|
Object.assign(this.#options, options);
|
|
109
111
|
this.#changedCallback = changedCallback;
|
|
112
|
+
this.#clickCallback = clickCallback;
|
|
110
113
|
this.#bindHandlers();
|
|
111
114
|
}
|
|
112
115
|
|
|
@@ -148,7 +151,7 @@ export default class PanzoomController {
|
|
|
148
151
|
* @returns {{x:number,y:number}|null} Point or null if coordinates not present.
|
|
149
152
|
*/
|
|
150
153
|
#getPointFromEvent(e) {
|
|
151
|
-
if (e
|
|
154
|
+
if (Number.isFinite(e?.clientX) && Number.isFinite(e?.clientY)) {
|
|
152
155
|
return {
|
|
153
156
|
x: e.clientX,
|
|
154
157
|
y: e.clientY,
|
|
@@ -158,6 +161,13 @@ export default class PanzoomController {
|
|
|
158
161
|
}
|
|
159
162
|
}
|
|
160
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
|
+
|
|
161
171
|
/**
|
|
162
172
|
* Update internal pan/zoom state and emit a custom event with current values.
|
|
163
173
|
* If a changed callback is defined, it is invoked with the updated state object.
|
|
@@ -258,7 +268,6 @@ export default class PanzoomController {
|
|
|
258
268
|
* @private
|
|
259
269
|
* @param {CustomEvent} e - Panzoom custom event with originalEvent property.
|
|
260
270
|
* @returns {void}
|
|
261
|
-
* @todo Implement selection/click logic.
|
|
262
271
|
*/
|
|
263
272
|
#onPanzoomEnd(e) {
|
|
264
273
|
this.#pointerEvents.partialCounter--;
|
|
@@ -266,21 +275,21 @@ export default class PanzoomController {
|
|
|
266
275
|
if (this.#pointerEvents.partialCounter > 0) {
|
|
267
276
|
return;
|
|
268
277
|
} else if (this.#pointerEvents.totalCounter > 1) {
|
|
269
|
-
this.#
|
|
270
|
-
this.#pointerEvents.totalCounter = 0;
|
|
278
|
+
this.#resetPointerEvents();
|
|
271
279
|
return;
|
|
272
280
|
}
|
|
273
281
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
const originalEvent = e?.detail?.originalEvent;
|
|
283
|
+
if (!originalEvent) {
|
|
284
|
+
this.#resetPointerEvents();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
278
287
|
const point = this.#getPointFromEvent(originalEvent);
|
|
279
288
|
const distance = this.#getDistanceBetweenPoints(point, this.#pointerEvents.lastPointerDownPosition);
|
|
280
289
|
|
|
281
290
|
let proceedToPick = true;
|
|
282
291
|
|
|
283
|
-
if (!point || distance > this.#interactionOptions.clickThreshold) {
|
|
292
|
+
if (!point || !this.#pointerEvents.lastPointerDownPosition || distance > this.#interactionOptions.clickThreshold) {
|
|
284
293
|
proceedToPick = false;
|
|
285
294
|
}
|
|
286
295
|
|
|
@@ -288,7 +297,7 @@ export default class PanzoomController {
|
|
|
288
297
|
proceedToPick = false;
|
|
289
298
|
}
|
|
290
299
|
|
|
291
|
-
this.#
|
|
300
|
+
this.#resetPointerEvents();
|
|
292
301
|
|
|
293
302
|
if (!proceedToPick) {
|
|
294
303
|
return;
|
|
@@ -298,7 +307,9 @@ export default class PanzoomController {
|
|
|
298
307
|
return;
|
|
299
308
|
}
|
|
300
309
|
|
|
301
|
-
|
|
310
|
+
if (typeof this.#clickCallback === "function") {
|
|
311
|
+
this.#clickCallback(originalEvent);
|
|
312
|
+
}
|
|
302
313
|
}
|
|
303
314
|
|
|
304
315
|
/**
|
|
@@ -308,7 +319,7 @@ export default class PanzoomController {
|
|
|
308
319
|
* @returns {void}
|
|
309
320
|
*/
|
|
310
321
|
#onPanzoomStart(e) {
|
|
311
|
-
const originalEvent = e
|
|
322
|
+
const originalEvent = e?.detail?.originalEvent;
|
|
312
323
|
this.#pointerEvents.partialCounter++;
|
|
313
324
|
this.#pointerEvents.totalCounter++;
|
|
314
325
|
this.#pointerEvents.lastPointerDownPosition = this.#getPointFromEvent(originalEvent);
|
|
@@ -427,13 +438,15 @@ export default class PanzoomController {
|
|
|
427
438
|
if (!this.#wrapper) {
|
|
428
439
|
return;
|
|
429
440
|
}
|
|
441
|
+
this.#disableMouseWheelZoom();
|
|
442
|
+
this.#resetPointerEvents();
|
|
430
443
|
this.#wrapper.parentElement.removeAttribute("tabindex");
|
|
431
444
|
this.#wrapper.removeEventListener("panzoomstart", this.#handlers.panzoomStart);
|
|
432
445
|
this.#wrapper.removeEventListener("panzoomchange", this.#handlers.panzoomChange);
|
|
433
446
|
this.#wrapper.removeEventListener("panzoomend", this.#handlers.panzoomEnd);
|
|
434
447
|
this.#wrapper.parentElement.removeEventListener("keyup", this.#handlers.keyUp);
|
|
435
|
-
this.#wrapper.parentElement.removeEventListener("focus", this.#handlers.focus);
|
|
436
|
-
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);
|
|
437
450
|
this.#wrapper.parentElement.removeEventListener("contextmenu", this.#handlers.contextMenu);
|
|
438
451
|
}
|
|
439
452
|
|
package/src/pref-viewer-2d.js
CHANGED
|
@@ -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.
|
|
@@ -34,6 +36,7 @@ import { PrefViewer2DStyles } from "./styles.js";
|
|
|
34
36
|
* - "pref-viewer-2d-error": emitted on validation/fetch errors (detail.error: Error). Legacy alias: "drawing-error"
|
|
35
37
|
* - "pref-viewer-2d-viewport-changed": emitted when pan/zoom state changes
|
|
36
38
|
* (detail: { x, y, scale, moved, scaled, maximized, minimized })
|
|
39
|
+
* - "pref-viewer-2d-element-click": emitted with `{ ids: string[] }` after semantic hit resolution
|
|
37
40
|
*
|
|
38
41
|
* Implementation notes
|
|
39
42
|
* - Keeps internal state in private fields (#svg, #panzoom, #panzoomOptions, ...).
|
|
@@ -70,10 +73,24 @@ export default class PrefViewer2D extends HTMLElement {
|
|
|
70
73
|
#wrapper = null; // Panzoom element
|
|
71
74
|
#panzoomController = null; // PanzoomController instance
|
|
72
75
|
|
|
76
|
+
#selectionState = {
|
|
77
|
+
selectableIds: [],
|
|
78
|
+
selectedItems: [],
|
|
79
|
+
};
|
|
80
|
+
#selectionNodes = [];
|
|
81
|
+
#appendedSvgElements = [];
|
|
82
|
+
|
|
73
83
|
#handlers = {
|
|
74
84
|
onPanzoomChanged: null,
|
|
85
|
+
onElementClick: null,
|
|
75
86
|
};
|
|
76
87
|
|
|
88
|
+
#SELECTABLE_PREFIX = "_SELECTABLE_";
|
|
89
|
+
#SELECTED_PREFIX = "_SELECTED_";
|
|
90
|
+
#SELECTABLE_COLOR = "#FFFF00";
|
|
91
|
+
#SELECTED_COLOR = "#FF0000";
|
|
92
|
+
#COLOR_OPACITY = "0.5";
|
|
93
|
+
|
|
77
94
|
/**
|
|
78
95
|
* Create a new instance of the 2D viewer component.
|
|
79
96
|
* The constructor only calls super(); heavy initialization happens in connectedCallback.
|
|
@@ -124,10 +141,15 @@ export default class PrefViewer2D extends HTMLElement {
|
|
|
124
141
|
* @returns {void}
|
|
125
142
|
*/
|
|
126
143
|
connectedCallback() {
|
|
127
|
-
this.#
|
|
144
|
+
if (!this.#wrapper) {
|
|
145
|
+
this.#createDOMElements();
|
|
146
|
+
}
|
|
128
147
|
this.#setInitialVisibility();
|
|
129
|
-
this.#
|
|
148
|
+
if (!this.#panzoomController) {
|
|
149
|
+
this.#initializePanzoom();
|
|
150
|
+
}
|
|
130
151
|
this.#isInitialized = true;
|
|
152
|
+
this.#svg.update = true;
|
|
131
153
|
this.#loadSVG();
|
|
132
154
|
}
|
|
133
155
|
|
|
@@ -141,6 +163,8 @@ export default class PrefViewer2D extends HTMLElement {
|
|
|
141
163
|
if (this.#panzoomController) {
|
|
142
164
|
this.#panzoomController.disable();
|
|
143
165
|
}
|
|
166
|
+
this.#isInitialized = false;
|
|
167
|
+
this.#isLoaded = false;
|
|
144
168
|
}
|
|
145
169
|
|
|
146
170
|
/**
|
|
@@ -176,7 +200,13 @@ export default class PrefViewer2D extends HTMLElement {
|
|
|
176
200
|
*/
|
|
177
201
|
#initializePanzoom() {
|
|
178
202
|
this.#handlers.onPanzoomChanged = this.#onPanzoomChanged.bind(this);
|
|
179
|
-
this.#
|
|
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
|
+
);
|
|
180
210
|
}
|
|
181
211
|
|
|
182
212
|
/**
|
|
@@ -206,6 +236,8 @@ export default class PrefViewer2D extends HTMLElement {
|
|
|
206
236
|
this.#panzoomController.disable();
|
|
207
237
|
}
|
|
208
238
|
|
|
239
|
+
this.#clearSelectionLayers();
|
|
240
|
+
this.#removeAppendedSvgNodes();
|
|
209
241
|
this.#wrapper.innerHTML = this.#svg.value;
|
|
210
242
|
|
|
211
243
|
// If SVG is empty, do not initialize Panzoom but don't emit an error
|
|
@@ -259,6 +291,7 @@ export default class PrefViewer2D extends HTMLElement {
|
|
|
259
291
|
this.setAttribute("loaded", "");
|
|
260
292
|
|
|
261
293
|
this.#isLoaded = true;
|
|
294
|
+
this.#reconcileSelectionState();
|
|
262
295
|
this.#subscribeToEvents();
|
|
263
296
|
}
|
|
264
297
|
|
|
@@ -305,6 +338,293 @@ export default class PrefViewer2D extends HTMLElement {
|
|
|
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
|