@preference-sl/pref-viewer 2.16.0 → 2.16.2-beta.0
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 +129 -102
- package/index.d.ts +373 -12
- package/package.json +2 -1
- package/src/babylonjs-animation-opening.js +11 -5
- package/src/babylonjs-controller.js +442 -65
- package/src/panzoom-controller.js +36 -17
- package/src/pref-viewer-2d.js +395 -21
- package/src/pref-viewer-3d.js +140 -85
- package/src/pref-viewer-menu-3d.js +84 -40
- package/src/pref-viewer-task.js +7 -2
- package/src/pref-viewer.js +615 -185
- package/src/styles.js +111 -29
|
@@ -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
|
|
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
|
|
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.#
|
|
264
|
-
this.#pointerEvents.totalCounter = 0;
|
|
278
|
+
this.#resetPointerEvents();
|
|
265
279
|
return;
|
|
266
280
|
}
|
|
267
281
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/pref-viewer-2d.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
* - "
|
|
33
|
-
* - "
|
|
34
|
-
* - "
|
|
35
|
-
* - "
|
|
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.#
|
|
144
|
+
if (!this.#wrapper) {
|
|
145
|
+
this.#createDOMElements();
|
|
146
|
+
}
|
|
127
147
|
this.#setInitialVisibility();
|
|
128
|
-
this.#
|
|
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.#
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
310
|
-
|
|
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
|