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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ import "@panzoom/panzoom";
2
+
3
+ /**
4
+ * PanzoomController - Encapsulates the logic for managing pan and zoom interactions on a given DOM element.
5
+ *
6
+ * Responsibilities:
7
+ * - Provides an interface to enable, disable, and control pan/zoom interactions.
8
+ * - Manages event listeners for mouse, keyboard, and touch interactions.
9
+ * - Tracks the current pan/zoom state and invokes a callback when the state changes.
10
+ * - Supports zooming in/out, panning, resetting, and mouse wheel zoom.
11
+ *
12
+ * Usage:
13
+ * - Instantiate the controller with a DOM element and optional configuration:
14
+ * const controller = new PanzoomController(wrapperElement, options, stateChangeCallback);
15
+ * - Use public methods like `enable()`, `zoomIn()`, `pan(x, y)`, etc., to control interactions.
16
+ * - Call `disable()` to remove all event listeners and destroy the Panzoom instance.
17
+ *
18
+ * Constructor Parameters:
19
+ * - `wrapper` (HTMLElement): The DOM element to apply pan/zoom interactions to.
20
+ * - `options` (object): Configuration options for Panzoom (e.g., minScale, maxScale, step).
21
+ * - `changedCallback` (function): Optional callback invoked when the pan/zoom state changes.
22
+ *
23
+ * Public Methods:
24
+ * - `enable()`: Enables the Panzoom instance and initializes it if not already enabled.
25
+ * - `disable()`: Disables the Panzoom instance and removes all event listeners.
26
+ * - `enableEvents()`: Attaches all required event listeners for interactions.
27
+ * - `disableEvents()`: Removes all previously attached event listeners.
28
+ * - `pan(x, y)`: Pans the view to the specified coordinates.
29
+ * - `reset()`: Resets pan and zoom to fit the entire content within the available space.
30
+ * - `zoomIn(focal?)`: Zooms in by one step, optionally centered on a focal point.
31
+ * - `zoomOut(focal?)`: Zooms out by one step, optionally centered on a focal point.
32
+ *
33
+ * Getters:
34
+ * - `panzoom`: Returns the current Panzoom instance or `null` if not initialized.
35
+ * - `state`: Returns the current pan/zoom state (e.g., moved, scaled, maximized, minimized).
36
+ *
37
+ * Private Methods:
38
+ * - `#checkPanzoomState(transform)`: Updates the internal state and invokes the state change callback.
39
+ * - `#resetTransform()`: Resets the transform applied to the wrapper element.
40
+ * - `#setFocus()`: Sets focus on the parent element to enable keyboard interactions.
41
+ * - `#onPanzoomStart(e)`: Handles the start of a pan/zoom interaction.
42
+ * - `#onPanzoomChange(e)`: Handles changes in pan/zoom state.
43
+ * - `#onPanzoomEnd(e)`: Handles the end of a pan/zoom interaction.
44
+ * - `#onZoomWithWheel(e)`: Handles zoom interactions via the mouse wheel.
45
+ * - `#enableMouseWheelZoom()`: Enables zooming with the mouse wheel.
46
+ * - `#disableMouseWheelZoom()`: Disables zooming with the mouse wheel.
47
+ * - `#getDistanceBetweenPoints(point1, point2)`: Calculates the Euclidean distance between two points.
48
+ * - `#getPointFromEvent(e)`: Extracts client coordinates from a mouse or touch event.
49
+ *
50
+ * Notes:
51
+ * - Designed to work with the Panzoom library for managing pan/zoom interactions.
52
+ * - Provides a clean separation of concerns by encapsulating all pan/zoom logic in a single class.
53
+ * - Can be extended or customized by overriding methods or providing additional options.
54
+ */
55
+ export default class PanzoomController {
56
+ #changedCallback = null;
57
+ #interactionOptions = {
58
+ clickThreshold: 6, // Threshold to avoid false clicks on elements when panning or zooming
59
+ };
60
+ #options = {
61
+ canvas: true,
62
+ minScale: 0.1,
63
+ maxScale: 10,
64
+ step: 0.3,
65
+ cursor: "default",
66
+ disablePan: false,
67
+ disableZoom: false,
68
+ };
69
+ #panzoom = null; // Panzoom Object
70
+ #pointerEvents = {
71
+ lastPointerDownPosition: null,
72
+ partialCounter: 0,
73
+ totalCounter: 0,
74
+ };
75
+ #state = {
76
+ moved: false,
77
+ scaled: false,
78
+ maximized: false,
79
+ minimized: false,
80
+ };
81
+ #wrapper = null;
82
+
83
+ constructor(wrapper = null, options = {}, changedCallback = null) {
84
+ this.#wrapper = wrapper;
85
+ Object.assign(this.#options, options);
86
+ this.#changedCallback = changedCallback;
87
+ }
88
+
89
+ /**
90
+ * Calculate Euclidean distance between two points {x,y}.
91
+ * @private
92
+ * @param {{x:number,y:number}} point1
93
+ * @param {{x:number,y:number}} point2
94
+ * @returns {number} Distance in pixels (0 when points are invalid).
95
+ */
96
+ #getDistanceBetweenPoints(point1, point2) {
97
+ let distance = 0;
98
+ if (point1 && point2) {
99
+ distance = Math.sqrt(Math.pow(point2.x - point1.x, 2) + Math.pow(point2.y - point1.y, 2));
100
+ }
101
+ return distance;
102
+ }
103
+
104
+ /**
105
+ * Extract client coordinates from a mouse/touch event.
106
+ * @private
107
+ * @param {Event} e - Mouse or touch event.
108
+ * @returns {{x:number,y:number}|null} Point or null if coordinates not present.
109
+ */
110
+ #getPointFromEvent(e) {
111
+ if (e.clientX && e.clientY) {
112
+ return {
113
+ x: e.clientX,
114
+ y: e.clientY,
115
+ };
116
+ } else {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Update internal pan/zoom state and emit a custom event with current values.
123
+ * If a changed callback is defined, it is invoked with the updated state object.
124
+ * @private
125
+ * @param {{scale:number,x:number,y:number}} transform - Current transform values.
126
+ * @returns {void}
127
+ */
128
+ #checkPanzoomState(transform) {
129
+ this.#state = {
130
+ moved: transform.x !== 0 || transform.y !== 0,
131
+ scaled: transform.scale !== 1,
132
+ maximized: transform.scale === this.#options.maxScale,
133
+ minimized: transform.scale === this.#options.minScale,
134
+ };
135
+
136
+ if (this.#changedCallback && typeof this.#changedCallback === "function") {
137
+ this.#changedCallback(this.#state);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Remove any transform applied to the wrapper (reset pan/zoom). Resets internal Panzoom state accordingly.
143
+ * @private
144
+ * @returns {void}
145
+ */
146
+ #resetTransform() {
147
+ const transform = {
148
+ scale: 1,
149
+ x: 0,
150
+ y: 0,
151
+ };
152
+ if (this.#wrapper) {
153
+ this.#wrapper.style.removeProperty("transform");
154
+ }
155
+ this.#checkPanzoomState(transform);
156
+ }
157
+
158
+ /**
159
+ * Set focus on the viewer's parent element to enable keyboard interactions.
160
+ * @private
161
+ * @returns {void}
162
+ * @description Focus is set on the parent element because when Panzoom had focus with a positive Y translation (part of the drawing outside the parent container), the Y translation was reset.
163
+ */
164
+ #setFocus() {
165
+ if (this.#wrapper) {
166
+ this.#wrapper.parentElement.focus();
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Contextmenu handler to prevent the browser's default context menu.
172
+ * @private
173
+ * @param {Event} e - Contextmenu event.
174
+ * @returns {void}
175
+ */
176
+ #onContextMenu(e) {
177
+ e.preventDefault();
178
+ }
179
+
180
+ /**
181
+ * Keyboard handler to support '+' and '-' shortcuts for zoom.
182
+ * @private
183
+ * @param {KeyboardEvent} e - Key event.
184
+ * @returns {void}
185
+ */
186
+ #onKeyUp(e) {
187
+ // '+' zoom in
188
+ if (e.key === "+") {
189
+ this.zoomIn();
190
+ }
191
+ // '-' zoom out
192
+ if (e.key === "-") {
193
+ this.zoomOut();
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Panzoom "change" (pan, zoom, reset) event handler. Updates internal transform state.
199
+ * @private
200
+ * @param {CustomEvent} e - Panzoom custom event with detail.scale/x/y
201
+ * @returns {void}
202
+ * @description This event is not fired when options.setTransform is called directly.
203
+ */
204
+ #onPanzoomChange(e) {
205
+ const transform = {
206
+ scale: e.detail.scale,
207
+ x: e.detail.x,
208
+ y: e.detail.y,
209
+ };
210
+ this.#checkPanzoomState(transform);
211
+ }
212
+
213
+ /**
214
+ * Panzoom "end" event handler. Determines whether the gesture should be considered a click (for selection) or a pan/zoom gesture and acts accordingly.
215
+ * @private
216
+ * @param {CustomEvent} e - Panzoom custom event with originalEvent property.
217
+ * @returns {void}
218
+ * @todo Implement selection/click logic.
219
+ */
220
+ #onPanzoomEnd(e) {
221
+ this.#pointerEvents.partialCounter--;
222
+
223
+ if (this.#pointerEvents.partialCounter > 0) {
224
+ return;
225
+ } else if (this.#pointerEvents.totalCounter > 1) {
226
+ this.#pointerEvents.partialCounter = 0;
227
+ this.#pointerEvents.totalCounter = 0;
228
+ return;
229
+ }
230
+
231
+ this.#pointerEvents.partialCounter = 0;
232
+ this.#pointerEvents.totalCounter = 0;
233
+
234
+ const originalEvent = e.detail.originalEvent;
235
+ const point = this.#getPointFromEvent(originalEvent);
236
+ const distance = this.#getDistanceBetweenPoints(point, this.#pointerEvents.lastPointerDownPosition);
237
+
238
+ let proceedToPick = true;
239
+
240
+ if (!point || distance > this.#interactionOptions.clickThreshold) {
241
+ proceedToPick = false;
242
+ }
243
+
244
+ if (originalEvent.button !== 0) {
245
+ proceedToPick = false;
246
+ }
247
+
248
+ this.#pointerEvents.lastPointerDownPosition = null;
249
+
250
+ if (!proceedToPick) {
251
+ return;
252
+ }
253
+
254
+ if (!originalEvent.target) {
255
+ return;
256
+ }
257
+
258
+ // Selection/click logic can be implemented here.
259
+ }
260
+
261
+ /**
262
+ * Panzoom "start" event handler. Store pointer state and set focus.
263
+ * @private
264
+ * @param {CustomEvent} e - Panzoom custom event with originalEvent property.
265
+ * @returns {void}
266
+ */
267
+ #onPanzoomStart(e) {
268
+ const originalEvent = e.detail.originalEvent;
269
+ this.#pointerEvents.partialCounter++;
270
+ this.#pointerEvents.totalCounter++;
271
+ this.#pointerEvents.lastPointerDownPosition = this.#getPointFromEvent(originalEvent);
272
+ this.#setFocus();
273
+ }
274
+
275
+ /**
276
+ * Delegate wheel events to Panzoom zoom handler.
277
+ * @private
278
+ * @param {WheelEvent} e - Wheel event.
279
+ * @returns {void}
280
+ */
281
+ #onZoomWithWheel(e) {
282
+ if (!this.#panzoom) {
283
+ return;
284
+ }
285
+ this.#panzoom.zoomWithWheel(e);
286
+ }
287
+
288
+ /**
289
+ * Disable mouse wheel zoom by removing the wheel event listener from the parent element.
290
+ * @private
291
+ * @returns {void}
292
+ */
293
+ #disableMouseWheelZoom() {
294
+ if (!this.#panzoom) {
295
+ return;
296
+ }
297
+ this.#wrapper.parentElement.removeEventListener("wheel", this.#onZoomWithWheel.bind(this));
298
+ }
299
+
300
+ /**
301
+ * Enable mouse wheel zoom by adding the wheel event listener to the parent element.
302
+ * @private
303
+ * @returns {void}
304
+ * @description
305
+ * Removes any previous wheel event listener before adding a new one to avoid duplicates.
306
+ */
307
+ #enableMouseWheelZoom() {
308
+ if (!this.#panzoom) {
309
+ return;
310
+ }
311
+ this.#disableMouseWheelZoom();
312
+ this.#wrapper.parentElement.addEventListener("wheel", this.#onZoomWithWheel.bind(this));
313
+ }
314
+
315
+ /**
316
+ * ---------------------------
317
+ * Public methods
318
+ * ---------------------------
319
+ */
320
+
321
+ /**
322
+ * Enables the Panzoom instance for the wrapper element.
323
+ * Initializes Panzoom with the provided options if not already enabled.
324
+ * @public
325
+ * @returns {void}
326
+ */
327
+ enable() {
328
+ if (!this.#wrapper) {
329
+ return;
330
+ }
331
+ if (!this.#panzoom) {
332
+ this.#panzoom = Panzoom(this.#wrapper, this.#options);
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Disables the Panzoom instance and removes all related event listeners.
338
+ * Destroys the Panzoom object and resets transforms.
339
+ * @public
340
+ * @returns {void}
341
+ */
342
+ disable() {
343
+ if (!this.#panzoom) {
344
+ return;
345
+ }
346
+ this.disableEvents();
347
+ this.#panzoom.destroy();
348
+ this.#panzoom = null;
349
+ this.#resetTransform();
350
+ }
351
+
352
+ /**
353
+ * Subscribes all required Panzoom and interaction event listeners.
354
+ * Adds listeners for panzoom events, keyboard shortcuts, mouse wheel zoom, and context menu.
355
+ * Sets focus and enables keyboard navigation.
356
+ * @public
357
+ * @returns {void}
358
+ */
359
+ enableEvents() {
360
+ if (!this.#panzoom) {
361
+ return;
362
+ }
363
+
364
+ this.disableEvents();
365
+
366
+ this.#wrapper.addEventListener("panzoomstart", this.#onPanzoomStart.bind(this));
367
+ this.#wrapper.addEventListener("panzoomchange", this.#onPanzoomChange.bind(this));
368
+ this.#wrapper.addEventListener("panzoomend", this.#onPanzoomEnd.bind(this));
369
+ this.#wrapper.parentElement.addEventListener("keyup", this.#onKeyUp.bind(this));
370
+ this.#wrapper.parentElement.addEventListener("focus", this.#enableMouseWheelZoom.bind(this), true);
371
+ this.#wrapper.parentElement.addEventListener("blur", this.#disableMouseWheelZoom.bind(this), true);
372
+ this.#wrapper.parentElement.addEventListener("contextmenu", this.#onContextMenu);
373
+ this.#wrapper.parentElement.setAttribute("tabindex", 0);
374
+ this.#setFocus();
375
+ }
376
+
377
+ /**
378
+ * Unsubscribes all Panzoom and interaction event listeners previously attached.
379
+ * Removes listeners for panzoom events, keyboard shortcuts, mouse wheel zoom, and context menu.
380
+ * @public
381
+ * @returns {void}
382
+ */
383
+ disableEvents() {
384
+ if (!this.#wrapper) {
385
+ return;
386
+ }
387
+ this.#wrapper.parentElement.removeAttribute("tabindex");
388
+ this.#wrapper.removeEventListener("panzoomstart", this.#onPanzoomStart.bind(this));
389
+ this.#wrapper.removeEventListener("panzoomchange", this.#onPanzoomChange.bind(this));
390
+ this.#wrapper.removeEventListener("panzoomend", this.#onPanzoomEnd.bind(this));
391
+ this.#wrapper.parentElement.removeEventListener("keyup", this.#onKeyUp.bind(this));
392
+ this.#wrapper.parentElement.removeEventListener("focus", this.#enableMouseWheelZoom.bind(this));
393
+ this.#wrapper.parentElement.removeEventListener("blur", this.#disableMouseWheelZoom.bind(this));
394
+ this.#wrapper.parentElement.removeEventListener("contextmenu", this.#onContextMenu);
395
+ }
396
+
397
+ /**
398
+ * Pans the SVG view to the specified coordinates.
399
+ * Sets focus on the parent element after panning.
400
+ * @public
401
+ * @param {number} x - The x coordinate to pan to.
402
+ * @param {number} y - The y coordinate to pan to.
403
+ * @returns {void}
404
+ */
405
+ pan(x, y) {
406
+ if (!this.#panzoom) {
407
+ return;
408
+ }
409
+ if (typeof x !== "number" || typeof y !== "number") {
410
+ return;
411
+ }
412
+ this.#panzoom.pan(x, y, { animate: false });
413
+ this.#setFocus();
414
+ }
415
+
416
+ /**
417
+ * Resets pan and zoom so the entire SVG drawing fits within the available space.
418
+ * Sets focus on the parent element after resetting.
419
+ * @public
420
+ * @returns {void}
421
+ */
422
+ reset() {
423
+ if (!this.#panzoom) {
424
+ return;
425
+ }
426
+ this.#panzoom.reset({ animate: false });
427
+ this.#setFocus();
428
+ }
429
+
430
+ /**
431
+ * Zooms in the SVG view by one step.
432
+ * If a focal point is provided, the zoom is centered on that point; otherwise, it is centered on the drawing.
433
+ * Sets focus on the parent element after zooming.
434
+ * @param {{x:number, y:number}=} focal - Optional focal point for the zoom operation.
435
+ * @public
436
+ * @returns {void}
437
+ */
438
+ zoomIn(focal) {
439
+ if (!this.#panzoom) {
440
+ return;
441
+ }
442
+ const options = {
443
+ exponential: false,
444
+ animate: false,
445
+ };
446
+
447
+ if (typeof focal === "object" && focal && "x" in focal && "y" in focal) {
448
+ options.focal = focal;
449
+ }
450
+ this.#panzoom.zoomIn(options);
451
+ this.#setFocus();
452
+ }
453
+
454
+ /**
455
+ * Zooms out the SVG view by one step.
456
+ * If a focal point is provided, the zoom is centered on that point; otherwise, it is centered on the drawing.
457
+ * Sets focus on the parent element after zooming.
458
+ * @param {{x:number, y:number}=} focal - Optional focal point for the zoom operation.
459
+ * @public
460
+ * @returns {void}
461
+ */
462
+ zoomOut(focal) {
463
+ if (!this.#panzoom) {
464
+ return;
465
+ }
466
+ const options = {
467
+ exponential: false,
468
+ animate: false,
469
+ };
470
+ if (typeof focal === "object" && focal && "x" in focal && "y" in focal) {
471
+ options.focal = focal;
472
+ }
473
+ this.#panzoom.zoomOut(options);
474
+ this.#setFocus();
475
+ }
476
+
477
+ /**
478
+ * Returns the current Panzoom instance.
479
+ * @public
480
+ * @returns {object|null} The Panzoom object or null if not initialized.
481
+ */
482
+ get panzoom() {
483
+ return this.#panzoom;
484
+ }
485
+
486
+ /**
487
+ * Returns the current pan/zoom state.
488
+ * @public
489
+ * @returns {object} The state object: { moved, scaled, maximized, minimized }.
490
+ */
491
+ get state() {
492
+ return this.#state;
493
+ }
494
+ }