jekyll-svg-viewer 0.1.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.
@@ -0,0 +1,1604 @@
1
+ /**
2
+ * SVG Viewer Plugin
3
+ * Provides zoom, pan, and centering functionality for embedded SVGs
4
+ */
5
+
6
+ class SVGViewer {
7
+ constructor(options) {
8
+ this.viewerId = options.viewerId;
9
+ this.svgUrl = options.svgUrl;
10
+
11
+ // Configuration (with sensible defaults)
12
+ this.ZOOM_STEP = options.zoomStep || 0.1;
13
+ this.MIN_ZOOM =
14
+ typeof options.minZoom === "number" ? options.minZoom : null;
15
+ this.MAX_ZOOM = options.maxZoom || 8;
16
+ this.initialZoom = options.initialZoom || 1;
17
+
18
+ const parsedCenterX = Number(options.centerX);
19
+ const parsedCenterY = Number(options.centerY);
20
+ this.manualCenter =
21
+ Number.isFinite(parsedCenterX) && Number.isFinite(parsedCenterY)
22
+ ? { x: parsedCenterX, y: parsedCenterY }
23
+ : null;
24
+ this.showCoordinates = Boolean(options.showCoordinates);
25
+ this.panMode = SVGViewer.normalizePanMode(options.panMode);
26
+ this.zoomMode = SVGViewer.normalizeZoomMode(options.zoomMode);
27
+ if (this.zoomMode === "scroll" && this.panMode === "scroll") {
28
+ this.panMode = "drag";
29
+ }
30
+
31
+ // State
32
+ this.currentZoom = this.initialZoom;
33
+ this.svgElement = null;
34
+ this.isLoading = false;
35
+ this.baseDimensions = null;
36
+ this.baseOrigin = { x: 0, y: 0 };
37
+ this.baseCssDimensions = null;
38
+ this.unitsPerCss = { x: 1, y: 1 };
39
+ this.dragState = {
40
+ isActive: false,
41
+ pointerId: null,
42
+ startX: 0,
43
+ startY: 0,
44
+ scrollLeft: 0,
45
+ scrollTop: 0,
46
+ lastClientX: 0,
47
+ lastClientY: 0,
48
+ inputType: null,
49
+ prevScrollBehavior: "",
50
+ };
51
+ this.boundPointerDown = null;
52
+ this.boundPointerMove = null;
53
+ this.boundPointerUp = null;
54
+ this.boundClickHandler = null;
55
+ this.boundWheelHandler = null;
56
+ this.dragListenersAttached = false;
57
+ this.wheelDeltaBuffer = 0;
58
+ this.wheelAnimationFrame = null;
59
+ this.wheelFocusPoint = null;
60
+ this.zoomAnimationFrame = null;
61
+ this.pointerEventsSupported =
62
+ typeof window !== "undefined" && window.PointerEvent;
63
+ this.boundMouseDown = null;
64
+ this.boundMouseMove = null;
65
+ this.boundMouseUp = null;
66
+ this.boundTouchStart = null;
67
+ this.boundTouchMove = null;
68
+ this.boundTouchEnd = null;
69
+
70
+ // DOM Elements
71
+ this.wrapper = document.getElementById(this.viewerId);
72
+ this.container = this.wrapper.querySelector(
73
+ '[data-viewer="' + this.viewerId + '"].svg-container'
74
+ );
75
+ this.viewport = this.wrapper.querySelector(
76
+ '[data-viewer="' + this.viewerId + '"].svg-viewport'
77
+ );
78
+ this.zoomPercentageEl = this.wrapper.querySelector(
79
+ '[data-viewer="' + this.viewerId + '"].zoom-percentage'
80
+ );
81
+ this.coordOutputEl = this.wrapper.querySelector(
82
+ '[data-viewer="' + this.viewerId + '"].coord-output'
83
+ );
84
+ this.zoomInButtons = this.wrapper
85
+ ? Array.from(
86
+ this.wrapper.querySelectorAll(
87
+ '[data-viewer="' + this.viewerId + '"].zoom-in-btn'
88
+ )
89
+ )
90
+ : [];
91
+ this.zoomOutButtons = this.wrapper
92
+ ? Array.from(
93
+ this.wrapper.querySelectorAll(
94
+ '[data-viewer="' + this.viewerId + '"].zoom-out-btn'
95
+ )
96
+ )
97
+ : [];
98
+ this.zoomSliderEls = this.wrapper
99
+ ? Array.from(
100
+ this.wrapper.querySelectorAll(
101
+ '[data-viewer="' + this.viewerId + '"].zoom-slider'
102
+ )
103
+ )
104
+ : [];
105
+ this.cleanupHandlers = [];
106
+ this.boundKeydownHandler = null;
107
+
108
+ this.init();
109
+ }
110
+
111
+ static normalizePanMode(value) {
112
+ const raw =
113
+ typeof value === "string" ? value.trim().toLowerCase() : String("");
114
+ return raw === "drag" ? "drag" : "scroll";
115
+ }
116
+
117
+ static normalizeZoomMode(value) {
118
+ const raw =
119
+ typeof value === "string" ? value.trim().toLowerCase() : String("");
120
+ const normalized = raw.replace(/[\s-]+/g, "_");
121
+ if (normalized === "click") {
122
+ return "click";
123
+ }
124
+ if (normalized === "scroll") {
125
+ return "scroll";
126
+ }
127
+ return "super_scroll";
128
+ }
129
+
130
+ init() {
131
+ this.setupEventListeners();
132
+ this.updateZoomDisplay();
133
+ this.updateViewport();
134
+ this.loadSVG();
135
+ }
136
+
137
+ registerEvent(target, type, handler, options) {
138
+ if (!target || typeof target.addEventListener !== "function") {
139
+ return;
140
+ }
141
+ const listenerOptions = typeof options === "undefined" ? false : options;
142
+ target.addEventListener(type, handler, listenerOptions);
143
+ if (!Array.isArray(this.cleanupHandlers)) {
144
+ this.cleanupHandlers = [];
145
+ }
146
+ this.cleanupHandlers.push(() => {
147
+ if (!target || typeof target.removeEventListener !== "function") {
148
+ return;
149
+ }
150
+ try {
151
+ target.removeEventListener(type, handler, listenerOptions);
152
+ } catch (err) {
153
+ // Ignore listener removal errors
154
+ }
155
+ });
156
+ }
157
+
158
+ setupEventListeners() {
159
+ if (this.zoomInButtons && this.zoomInButtons.length) {
160
+ this.zoomInButtons.forEach((btn) => {
161
+ const handler = () => this.zoomIn();
162
+ this.registerEvent(btn, "click", handler);
163
+ });
164
+ }
165
+
166
+ if (this.zoomOutButtons && this.zoomOutButtons.length) {
167
+ this.zoomOutButtons.forEach((btn) => {
168
+ const handler = () => this.zoomOut();
169
+ this.registerEvent(btn, "click", handler);
170
+ });
171
+ }
172
+
173
+ this.wrapper
174
+ .querySelectorAll('[data-viewer="' + this.viewerId + '"].reset-zoom-btn')
175
+ .forEach((btn) => {
176
+ const handler = () => this.resetZoom();
177
+ this.registerEvent(btn, "click", handler);
178
+ });
179
+
180
+ this.wrapper
181
+ .querySelectorAll('[data-viewer="' + this.viewerId + '"].center-view-btn')
182
+ .forEach((btn) => {
183
+ const handler = () => this.centerView();
184
+ this.registerEvent(btn, "click", handler);
185
+ });
186
+
187
+ if (this.showCoordinates) {
188
+ this.wrapper
189
+ .querySelectorAll(
190
+ '[data-viewer="' + this.viewerId + '"].coord-copy-btn'
191
+ )
192
+ .forEach((btn) => {
193
+ const handler = () => this.copyCenterCoordinates();
194
+ this.registerEvent(btn, "click", handler);
195
+ });
196
+ }
197
+
198
+ this.boundKeydownHandler =
199
+ this.boundKeydownHandler || ((e) => this.handleKeyboard(e));
200
+ this.registerEvent(document, "keydown", this.boundKeydownHandler);
201
+
202
+ if (this.container) {
203
+ this.boundWheelHandler =
204
+ this.boundWheelHandler || ((event) => this.handleMouseWheel(event));
205
+ const wheelOptions = { passive: false };
206
+ this.registerEvent(this.container, "wheel", this.boundWheelHandler, wheelOptions);
207
+ if (this.zoomMode === "click") {
208
+ this.boundClickHandler =
209
+ this.boundClickHandler || ((event) => this.handleContainerClick(event));
210
+ this.registerEvent(this.container, "click", this.boundClickHandler);
211
+ }
212
+ }
213
+
214
+ if (this.panMode === "drag") {
215
+ this.enableDragPan();
216
+ }
217
+
218
+ if (this.zoomSliderEls && this.zoomSliderEls.length) {
219
+ this.zoomSliderEls.forEach((slider) => {
220
+ const handler = (event) => {
221
+ const percent = parseFloat(event.target.value);
222
+ if (!Number.isFinite(percent)) {
223
+ return;
224
+ }
225
+ this.setZoom(percent / 100);
226
+ };
227
+ this.registerEvent(slider, "input", handler);
228
+ });
229
+ }
230
+ }
231
+
232
+ async loadSVG() {
233
+ if (this.isLoading) return;
234
+ this.isLoading = true;
235
+
236
+ try {
237
+ console.debug("[SVGViewer]", this.viewerId, "fetching", this.svgUrl);
238
+ const response = await fetch(this.svgUrl);
239
+ if (!response.ok)
240
+ throw new Error(`Failed to load SVG: ${response.status}`);
241
+
242
+ const svgText = await response.text();
243
+ this.viewport.innerHTML = svgText;
244
+ this.svgElement = this.viewport.querySelector("svg");
245
+
246
+ if (this.svgElement) {
247
+ console.debug("[SVGViewer]", this.viewerId, "SVG loaded");
248
+ this.prepareSvgElement();
249
+ this.captureBaseDimensions();
250
+ this.currentZoom = this.initialZoom;
251
+ this.updateViewport({ immediate: true });
252
+ this.centerView();
253
+ }
254
+ } catch (error) {
255
+ console.error("SVG Viewer Error:", error);
256
+ this.viewport.innerHTML =
257
+ '<div style="padding: 20px; color: red;">Error loading SVG. Check the file path and ensure CORS is configured if needed.</div>';
258
+ }
259
+
260
+ this.isLoading = false;
261
+ }
262
+
263
+ setZoom(newZoom, options = {}) {
264
+ if (options.animate && !options.__animationStep) {
265
+ this.startZoomAnimation(newZoom, options);
266
+ return;
267
+ }
268
+
269
+ if (this.zoomAnimationFrame && !options.__animationStep) {
270
+ window.cancelAnimationFrame(this.zoomAnimationFrame);
271
+ this.zoomAnimationFrame = null;
272
+ }
273
+
274
+ if (!this.container || !this.viewport) {
275
+ const minZoomFallback =
276
+ Number.isFinite(this.MIN_ZOOM) && this.MIN_ZOOM > 0 ? this.MIN_ZOOM : 0;
277
+ const maxZoomFallback =
278
+ Number.isFinite(this.MAX_ZOOM) && this.MAX_ZOOM > 0
279
+ ? this.MAX_ZOOM
280
+ : newZoom;
281
+ this.currentZoom = Math.max(
282
+ minZoomFallback,
283
+ Math.min(maxZoomFallback, newZoom)
284
+ );
285
+ this.updateZoomDisplay();
286
+ return;
287
+ }
288
+
289
+ const prevZoom = this.currentZoom || 1;
290
+ const baseWidth = this.baseDimensions ? this.baseDimensions.width : 1;
291
+ const baseHeight = this.baseDimensions ? this.baseDimensions.height : 1;
292
+ const prevTransform = this.container?.style.transform;
293
+ if (this.container) {
294
+ this.container.style.transform = "translate3d(0,0,0)";
295
+ }
296
+
297
+ const resolvedMinZoom = this.resolveMinZoom();
298
+ const effectiveMinZoom =
299
+ Number.isFinite(resolvedMinZoom) && resolvedMinZoom > 0
300
+ ? resolvedMinZoom
301
+ : 0;
302
+ const effectiveMaxZoom =
303
+ Number.isFinite(this.MAX_ZOOM) && this.MAX_ZOOM > 0
304
+ ? this.MAX_ZOOM
305
+ : Math.max(newZoom, 0);
306
+
307
+ const focusData =
308
+ options.__focusData && typeof options.__focusData === "object"
309
+ ? options.__focusData
310
+ : this._computeFocusData(prevZoom, options);
311
+ const focusBaseX = focusData.focusBaseX;
312
+ const focusBaseY = focusData.focusBaseY;
313
+ const focusOffsetX = focusData.focusOffsetX;
314
+ const focusOffsetY = focusData.focusOffsetY;
315
+
316
+ this.currentZoom = Math.max(
317
+ effectiveMinZoom,
318
+ Math.min(effectiveMaxZoom, newZoom)
319
+ );
320
+
321
+ this.updateViewport(options);
322
+ this.updateZoomDisplay();
323
+
324
+ const newScrollWidth = this.container.scrollWidth;
325
+ const newScrollHeight = this.container.scrollHeight;
326
+
327
+ if (!options.center && newScrollWidth && newScrollHeight) {
328
+ const focusCssXAfter =
329
+ ((focusBaseX - this.baseOrigin.x) / (this.unitsPerCss.x || 1)) *
330
+ this.currentZoom;
331
+ const focusCssYAfter =
332
+ ((focusBaseY - this.baseOrigin.y) / (this.unitsPerCss.y || 1)) *
333
+ this.currentZoom;
334
+
335
+ let targetLeft;
336
+ if (typeof focusOffsetX === "number") {
337
+ targetLeft = focusCssXAfter - focusOffsetX;
338
+ } else {
339
+ targetLeft = focusCssXAfter - this.container.clientWidth / 2;
340
+ }
341
+
342
+ let targetTop;
343
+ if (typeof focusOffsetY === "number") {
344
+ targetTop = focusCssYAfter - focusOffsetY;
345
+ } else {
346
+ targetTop = focusCssYAfter - this.container.clientHeight / 2;
347
+ }
348
+
349
+ this._debugLastZoom = {
350
+ focusBaseX,
351
+ focusBaseY,
352
+ focusCssXAfter,
353
+ focusCssYAfter,
354
+ targetLeft,
355
+ targetTop,
356
+ scrollWidth: newScrollWidth,
357
+ scrollHeight: newScrollHeight,
358
+ };
359
+
360
+ const maxLeft = Math.max(0, newScrollWidth - this.container.clientWidth);
361
+ const maxTop = Math.max(0, newScrollHeight - this.container.clientHeight);
362
+
363
+ const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
364
+ const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
365
+
366
+ const previousBehavior = this.container.style.scrollBehavior;
367
+ this.container.style.scrollBehavior = "auto";
368
+ this.container.scrollLeft = clampedLeft;
369
+ this.container.scrollTop = clampedTop;
370
+ this.container.style.scrollBehavior = previousBehavior;
371
+ }
372
+
373
+ if (options.center) {
374
+ const centerPoint =
375
+ typeof options.focusX === "number" && typeof options.focusY === "number"
376
+ ? { x: options.focusX, y: options.focusY }
377
+ : this.getCenterPoint();
378
+ this.centerView({ focusX: centerPoint.x, focusY: centerPoint.y });
379
+ }
380
+
381
+ if (this.container) {
382
+ this.container.style.transform = prevTransform || "";
383
+ }
384
+ }
385
+
386
+ _computeFocusData(prevZoom, options = {}) {
387
+ const data = {
388
+ focusBaseX: 0,
389
+ focusBaseY: 0,
390
+ focusOffsetX: null,
391
+ focusOffsetY: null,
392
+ };
393
+
394
+ if (!this.container) {
395
+ return data;
396
+ }
397
+
398
+ if (
399
+ typeof options.focusX === "number" &&
400
+ typeof options.focusY === "number"
401
+ ) {
402
+ data.focusBaseX = options.focusX;
403
+ data.focusBaseY = options.focusY;
404
+ } else {
405
+ const visibleCenterX =
406
+ this.container.scrollLeft + this.container.clientWidth / 2;
407
+ const visibleCenterY =
408
+ this.container.scrollTop + this.container.clientHeight / 2;
409
+
410
+ const cssBaseX = visibleCenterX / prevZoom;
411
+ const cssBaseY = visibleCenterY / prevZoom;
412
+
413
+ data.focusBaseX =
414
+ this.baseOrigin.x + cssBaseX * (this.unitsPerCss.x || 1);
415
+ data.focusBaseY =
416
+ this.baseOrigin.y + cssBaseY * (this.unitsPerCss.y || 1);
417
+ }
418
+
419
+ if (typeof options.focusOffsetX === "number") {
420
+ data.focusOffsetX = options.focusOffsetX;
421
+ }
422
+ if (typeof options.focusOffsetY === "number") {
423
+ data.focusOffsetY = options.focusOffsetY;
424
+ }
425
+
426
+ return data;
427
+ }
428
+
429
+ updateViewport({ immediate = false } = {}) {
430
+ if (!this.baseCssDimensions || !this.viewport || !this.svgElement) return;
431
+
432
+ const width = this.baseCssDimensions.width * this.currentZoom;
433
+ const height = this.baseCssDimensions.height * this.currentZoom;
434
+
435
+ this.viewport.style.width = `${width}px`;
436
+ this.viewport.style.height = `${height}px`;
437
+ this.svgElement.style.width = `${width}px`;
438
+ this.svgElement.style.height = `${height}px`;
439
+ this.viewport.style.transform = "none";
440
+ this.svgElement.style.transform = "none";
441
+
442
+ if (immediate) {
443
+ this.viewport.getBoundingClientRect(); // force layout
444
+ }
445
+ }
446
+
447
+ updateZoomDisplay() {
448
+ if (this.zoomPercentageEl) {
449
+ this.zoomPercentageEl.textContent = Math.round(this.currentZoom * 100);
450
+ }
451
+
452
+ if (this.zoomSliderEls && this.zoomSliderEls.length) {
453
+ const sliderValue = Math.round(this.currentZoom * 100);
454
+ this.zoomSliderEls.forEach((slider) => {
455
+ slider.value = String(sliderValue);
456
+ slider.setAttribute("aria-valuenow", String(sliderValue));
457
+ });
458
+ }
459
+
460
+ this.updateZoomButtonState();
461
+ }
462
+
463
+ zoomIn() {
464
+ const targetZoom = this.computeZoomTarget("in");
465
+ this.setZoom(targetZoom, { animate: true });
466
+ }
467
+
468
+ zoomOut() {
469
+ const targetZoom = this.computeZoomTarget("out");
470
+ this.setZoom(targetZoom, { animate: true });
471
+ }
472
+
473
+ resetZoom() {
474
+ this.setZoom(this.initialZoom || 1, { center: true, animate: true });
475
+ }
476
+
477
+ startZoomAnimation(targetZoom, options = {}) {
478
+ if (!this.container || !this.viewport) {
479
+ this.setZoom(targetZoom, { ...options, animate: false });
480
+ return;
481
+ }
482
+
483
+ if (this.zoomAnimationFrame) {
484
+ window.cancelAnimationFrame(this.zoomAnimationFrame);
485
+ this.zoomAnimationFrame = null;
486
+ }
487
+
488
+ const startZoom = this.currentZoom || 1;
489
+ const focusData = this._computeFocusData(startZoom, options);
490
+ const duration =
491
+ typeof options.zoomAnimationDuration === "number"
492
+ ? Math.max(0, options.zoomAnimationDuration)
493
+ : 160;
494
+ const easing =
495
+ typeof options.zoomAnimationEasing === "function"
496
+ ? options.zoomAnimationEasing
497
+ : this._easeOutCubic;
498
+
499
+ const frameOptions = {
500
+ ...options,
501
+ animate: false,
502
+ __animationStep: true,
503
+ __focusData: focusData,
504
+ };
505
+ delete frameOptions.center;
506
+
507
+ const startTimeRef = { value: null };
508
+
509
+ const animateFrame = (timestamp) => {
510
+ if (startTimeRef.value === null) {
511
+ startTimeRef.value = timestamp;
512
+ }
513
+ const elapsed = timestamp - startTimeRef.value;
514
+ const progress =
515
+ duration === 0 ? 1 : Math.min(elapsed / duration, 1);
516
+ const easedProgress = easing(progress);
517
+ const intermediateZoom =
518
+ startZoom + (targetZoom - startZoom) * easedProgress;
519
+
520
+ this.setZoom(intermediateZoom, frameOptions);
521
+
522
+ if (progress < 1) {
523
+ this.zoomAnimationFrame =
524
+ window.requestAnimationFrame(animateFrame);
525
+ } else {
526
+ this.zoomAnimationFrame = null;
527
+ this.setZoom(targetZoom, {
528
+ ...options,
529
+ animate: false,
530
+ __animationStep: true,
531
+ __focusData: focusData,
532
+ });
533
+ }
534
+ };
535
+
536
+ this.zoomAnimationFrame = window.requestAnimationFrame(animateFrame);
537
+ }
538
+
539
+ _easeOutCubic(t) {
540
+ return 1 - Math.pow(1 - t, 3);
541
+ }
542
+
543
+ centerView(input = {}) {
544
+ if (!this.container || !this.baseDimensions || !this.baseCssDimensions)
545
+ return;
546
+
547
+ let focusX;
548
+ let focusY;
549
+
550
+ if (typeof input.focusX === "number" && typeof input.focusY === "number") {
551
+ focusX = input.focusX;
552
+ focusY = input.focusY;
553
+ } else if (typeof input.x === "number" && typeof input.y === "number") {
554
+ focusX = input.x;
555
+ focusY = input.y;
556
+ } else {
557
+ const center = this.getCenterPoint();
558
+ focusX = center.x;
559
+ focusY = center.y;
560
+ }
561
+
562
+ const cssCenterX =
563
+ ((focusX - this.baseOrigin.x) / (this.unitsPerCss.x || 1)) *
564
+ this.currentZoom;
565
+ const cssCenterY =
566
+ ((focusY - this.baseOrigin.y) / (this.unitsPerCss.y || 1)) *
567
+ this.currentZoom;
568
+
569
+ const targetLeft = cssCenterX - this.container.clientWidth / 2;
570
+ const targetTop = cssCenterY - this.container.clientHeight / 2;
571
+
572
+ const maxLeft = Math.max(
573
+ 0,
574
+ this.container.scrollWidth - this.container.clientWidth
575
+ );
576
+ const maxTop = Math.max(
577
+ 0,
578
+ this.container.scrollHeight - this.container.clientHeight
579
+ );
580
+
581
+ const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
582
+ const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
583
+
584
+ const previousBehavior = this.container.style.scrollBehavior;
585
+ this.container.style.scrollBehavior = "auto";
586
+ this.container.scrollLeft = clampedLeft;
587
+ this.container.scrollTop = clampedTop;
588
+ this.container.style.scrollBehavior = previousBehavior;
589
+ }
590
+
591
+ prepareSvgElement() {
592
+ if (!this.svgElement) return;
593
+ this.svgElement.style.maxWidth = "none";
594
+ this.svgElement.style.maxHeight = "none";
595
+ this.svgElement.style.display = "block";
596
+ }
597
+
598
+ captureBaseDimensions() {
599
+ if (!this.svgElement || !this.viewport) return;
600
+
601
+ const rect = this.svgElement.getBoundingClientRect();
602
+ let cssWidth = rect.width || this.svgElement.clientWidth || 1;
603
+ let cssHeight = rect.height || this.svgElement.clientHeight || 1;
604
+
605
+ const viewBox =
606
+ this.svgElement.viewBox && this.svgElement.viewBox.baseVal
607
+ ? this.svgElement.viewBox.baseVal
608
+ : null;
609
+
610
+ if (viewBox) {
611
+ this.baseDimensions = {
612
+ width: viewBox.width || 1,
613
+ height: viewBox.height || 1,
614
+ };
615
+ this.baseOrigin = { x: viewBox.x || 0, y: viewBox.y || 0 };
616
+ } else {
617
+ this.baseDimensions = { width: cssWidth, height: cssHeight };
618
+ this.baseOrigin = { x: 0, y: 0 };
619
+ }
620
+
621
+ if (cssWidth <= 1 || cssHeight <= 1) {
622
+ if (viewBox && viewBox.width && viewBox.height) {
623
+ cssWidth = viewBox.width;
624
+ cssHeight = viewBox.height;
625
+ } else {
626
+ const fallbackWidth = this.container ? this.container.clientWidth : 0;
627
+ const fallbackHeight = this.container ? this.container.clientHeight : 0;
628
+ cssWidth = fallbackWidth || this.baseDimensions.width || 1;
629
+ cssHeight = fallbackHeight || this.baseDimensions.height || 1;
630
+ }
631
+ }
632
+
633
+ this.baseCssDimensions = { width: cssWidth, height: cssHeight };
634
+ this.unitsPerCss = {
635
+ x: this.baseDimensions.width / cssWidth,
636
+ y: this.baseDimensions.height / cssHeight,
637
+ };
638
+
639
+ this.viewport.style.width = `${cssWidth}px`;
640
+ this.viewport.style.height = `${cssHeight}px`;
641
+ this.svgElement.style.width = `${cssWidth}px`;
642
+ this.svgElement.style.height = `${cssHeight}px`;
643
+ }
644
+
645
+ handleKeyboard(e) {
646
+ // Only handle shortcuts if focused on this viewer or its container
647
+ const isViewerFocused =
648
+ this.wrapper.contains(document.activeElement) ||
649
+ e.target === document ||
650
+ e.target === document.body;
651
+
652
+ if (!isViewerFocused) return;
653
+
654
+ // Ctrl/Cmd + Plus
655
+ if ((e.ctrlKey || e.metaKey) && (e.key === "+" || e.key === "=")) {
656
+ e.preventDefault();
657
+ this.zoomIn();
658
+ }
659
+ // Ctrl/Cmd + Minus
660
+ if ((e.ctrlKey || e.metaKey) && e.key === "-") {
661
+ e.preventDefault();
662
+ this.zoomOut();
663
+ }
664
+ // Ctrl/Cmd + 0 to reset
665
+ if ((e.ctrlKey || e.metaKey) && e.key === "0") {
666
+ e.preventDefault();
667
+ this.resetZoom();
668
+ }
669
+ }
670
+
671
+ handleMouseWheel(event) {
672
+ if (!this.container) {
673
+ return;
674
+ }
675
+
676
+ if (this.dragState && this.dragState.isActive && this.panMode === "drag") {
677
+ return;
678
+ }
679
+
680
+ const initialScrollLeft = this.container.scrollLeft;
681
+ const hadHorizontalDelta =
682
+ typeof event.deltaX === "number" && event.deltaX !== 0;
683
+ const absDeltaX = Math.abs(event.deltaX || 0);
684
+ const absDeltaY = Math.abs(event.deltaY || 0);
685
+ const verticalDominant =
686
+ absDeltaX === 0 ? absDeltaY > 0 : absDeltaY >= absDeltaX * 1.5;
687
+ const meetsThreshold = absDeltaY >= 4;
688
+ const shouldZoom = verticalDominant && meetsThreshold;
689
+ const panRequiresDrag = this.panMode === "drag";
690
+
691
+ if (this.zoomMode === "scroll") {
692
+ if (!shouldZoom) {
693
+ if (panRequiresDrag) {
694
+ event.preventDefault();
695
+ if (hadHorizontalDelta) {
696
+ this.container.scrollLeft = initialScrollLeft;
697
+ }
698
+ }
699
+ return;
700
+ }
701
+ event.preventDefault();
702
+ this.performWheelZoom(event);
703
+ if (hadHorizontalDelta) {
704
+ this.container.scrollLeft = initialScrollLeft;
705
+ }
706
+ return;
707
+ }
708
+
709
+ const hasModifier = event.ctrlKey || event.metaKey;
710
+
711
+ if (this.zoomMode === "super_scroll") {
712
+ if (!hasModifier) {
713
+ if (panRequiresDrag) {
714
+ event.preventDefault();
715
+ if (hadHorizontalDelta) {
716
+ this.container.scrollLeft = initialScrollLeft;
717
+ }
718
+ }
719
+ return;
720
+ }
721
+ if (!shouldZoom) {
722
+ if (panRequiresDrag) {
723
+ event.preventDefault();
724
+ if (hadHorizontalDelta) {
725
+ this.container.scrollLeft = initialScrollLeft;
726
+ }
727
+ }
728
+ return;
729
+ }
730
+ event.preventDefault();
731
+ this.performWheelZoom(event);
732
+ if (hadHorizontalDelta) {
733
+ this.container.scrollLeft = initialScrollLeft;
734
+ }
735
+ return;
736
+ }
737
+
738
+ if (this.zoomMode === "click") {
739
+ if (!hasModifier) {
740
+ if (panRequiresDrag) {
741
+ event.preventDefault();
742
+ if (hadHorizontalDelta) {
743
+ this.container.scrollLeft = initialScrollLeft;
744
+ }
745
+ }
746
+ return;
747
+ }
748
+ if (!shouldZoom) {
749
+ if (panRequiresDrag) {
750
+ event.preventDefault();
751
+ if (hadHorizontalDelta) {
752
+ this.container.scrollLeft = initialScrollLeft;
753
+ }
754
+ }
755
+ return;
756
+ }
757
+ event.preventDefault();
758
+ this.performWheelZoom(event);
759
+ if (hadHorizontalDelta) {
760
+ this.container.scrollLeft = initialScrollLeft;
761
+ }
762
+ }
763
+ }
764
+
765
+ getFocusPointFromEvent(event) {
766
+ if (!this.container) {
767
+ return null;
768
+ }
769
+
770
+ let clientX = null;
771
+ let clientY = null;
772
+
773
+ if (typeof event.clientX === "number" && typeof event.clientY === "number") {
774
+ clientX = event.clientX;
775
+ clientY = event.clientY;
776
+ } else if (event.touches && event.touches.length) {
777
+ clientX = event.touches[0].clientX;
778
+ clientY = event.touches[0].clientY;
779
+ }
780
+
781
+ if (clientX === null || clientY === null) {
782
+ return null;
783
+ }
784
+
785
+ const rect = this.container.getBoundingClientRect();
786
+ const cursorX = clientX - rect.left + this.container.scrollLeft;
787
+ const cursorY = clientY - rect.top + this.container.scrollTop;
788
+
789
+ const zoom = this.currentZoom || 1;
790
+ const unitsX = this.unitsPerCss.x || 1;
791
+ const unitsY = this.unitsPerCss.y || 1;
792
+
793
+ const baseX = this.baseOrigin.x + (cursorX / zoom) * unitsX;
794
+ const baseY = this.baseOrigin.y + (cursorY / zoom) * unitsY;
795
+ const pointerOffsetX =
796
+ typeof clientX === "number" && Number.isFinite(clientX)
797
+ ? clientX - rect.left
798
+ : null;
799
+ const pointerOffsetY =
800
+ typeof clientY === "number" && Number.isFinite(clientY)
801
+ ? clientY - rect.top
802
+ : null;
803
+
804
+ return {
805
+ baseX,
806
+ baseY,
807
+ pointerOffsetX,
808
+ pointerOffsetY,
809
+ };
810
+ }
811
+
812
+ performWheelZoom(event) {
813
+ const normalizedDelta = this.normalizeWheelDelta(event);
814
+ if (!normalizedDelta) {
815
+ return;
816
+ }
817
+
818
+ const focusPoint = this.getFocusPointFromEvent(event);
819
+ this.enqueueWheelDelta(normalizedDelta, focusPoint);
820
+ }
821
+
822
+ normalizeWheelDelta(event) {
823
+ if (!event) {
824
+ return 0;
825
+ }
826
+
827
+ let delta = Number(event.deltaY);
828
+ if (!Number.isFinite(delta)) {
829
+ return 0;
830
+ }
831
+
832
+ switch (event.deltaMode) {
833
+ case 1: // lines
834
+ delta *= 16;
835
+ break;
836
+ case 2: // pages
837
+ delta *= this.getWheelPageDistance();
838
+ break;
839
+ default:
840
+ break;
841
+ }
842
+
843
+ return delta;
844
+ }
845
+
846
+ enqueueWheelDelta(delta, focusPoint) {
847
+ if (!Number.isFinite(delta) || delta === 0) {
848
+ return;
849
+ }
850
+
851
+ this.wheelDeltaBuffer += delta;
852
+
853
+ if (focusPoint) {
854
+ this.wheelFocusPoint = focusPoint;
855
+ }
856
+
857
+ if (this.wheelAnimationFrame) {
858
+ return;
859
+ }
860
+
861
+ this.wheelAnimationFrame = window.requestAnimationFrame(() =>
862
+ this.flushWheelDelta()
863
+ );
864
+ }
865
+
866
+ flushWheelDelta() {
867
+ this.wheelAnimationFrame = null;
868
+ const delta = this.wheelDeltaBuffer;
869
+ this.wheelDeltaBuffer = 0;
870
+
871
+ if (!Number.isFinite(delta) || delta === 0) {
872
+ this.wheelFocusPoint = null;
873
+ return;
874
+ }
875
+
876
+ const zoomRange = (this.MAX_ZOOM || 0) - (this.MIN_ZOOM || 0);
877
+ if (!Number.isFinite(zoomRange) || zoomRange <= 0) {
878
+ const direction = delta > 0 ? -1 : 1;
879
+ this.setZoom(this.currentZoom + direction * this.ZOOM_STEP);
880
+ this.wheelFocusPoint = null;
881
+ return;
882
+ }
883
+
884
+ const pageDistance = this.getWheelPageDistance();
885
+ if (!Number.isFinite(pageDistance) || pageDistance <= 0) {
886
+ this.wheelFocusPoint = null;
887
+ return;
888
+ }
889
+
890
+ const zoomDelta = (-delta / pageDistance) * zoomRange;
891
+ if (!Number.isFinite(zoomDelta) || zoomDelta === 0) {
892
+ this.wheelFocusPoint = null;
893
+ return;
894
+ }
895
+
896
+ const targetZoom = this.currentZoom + zoomDelta;
897
+ const focusPoint = this.wheelFocusPoint;
898
+ this.wheelFocusPoint = null;
899
+
900
+ const zoomOptions = {
901
+ animate: true,
902
+ };
903
+
904
+ if (focusPoint) {
905
+ zoomOptions.focusX = focusPoint.baseX;
906
+ zoomOptions.focusY = focusPoint.baseY;
907
+ if (typeof focusPoint.pointerOffsetX === "number") {
908
+ zoomOptions.focusOffsetX = focusPoint.pointerOffsetX;
909
+ }
910
+ if (typeof focusPoint.pointerOffsetY === "number") {
911
+ zoomOptions.focusOffsetY = focusPoint.pointerOffsetY;
912
+ }
913
+ }
914
+
915
+ this.setZoom(targetZoom, zoomOptions);
916
+ }
917
+
918
+ getWheelPageDistance() {
919
+ if (this.container && this.container.clientHeight) {
920
+ return Math.max(200, this.container.clientHeight);
921
+ }
922
+ if (typeof window !== "undefined" && window.innerHeight) {
923
+ return Math.max(200, window.innerHeight);
924
+ }
925
+ return 600;
926
+ }
927
+
928
+ enableDragPan() {
929
+ if (!this.container || this.dragListenersAttached) {
930
+ return;
931
+ }
932
+
933
+ this.boundPointerDown =
934
+ this.boundPointerDown || ((event) => this.handlePointerDown(event));
935
+ this.boundPointerMove =
936
+ this.boundPointerMove || ((event) => this.handlePointerMove(event));
937
+ this.boundPointerUp =
938
+ this.boundPointerUp || ((event) => this.handlePointerUp(event));
939
+ this.boundMouseDown =
940
+ this.boundMouseDown || ((event) => this.handleMouseDown(event));
941
+ this.boundMouseMove =
942
+ this.boundMouseMove || ((event) => this.handleMouseMove(event));
943
+ this.boundMouseUp =
944
+ this.boundMouseUp || ((event) => this.handleMouseUp(event));
945
+ this.boundTouchStart =
946
+ this.boundTouchStart || ((event) => this.handleTouchStart(event));
947
+ this.boundTouchMove =
948
+ this.boundTouchMove || ((event) => this.handleTouchMove(event));
949
+ this.boundTouchEnd =
950
+ this.boundTouchEnd || ((event) => this.handleTouchEnd(event));
951
+
952
+ this.registerEvent(this.container, "pointerdown", this.boundPointerDown);
953
+ this.registerEvent(window, "pointermove", this.boundPointerMove);
954
+ this.registerEvent(window, "pointerup", this.boundPointerUp);
955
+ this.registerEvent(window, "pointercancel", this.boundPointerUp);
956
+
957
+ this.registerEvent(this.container, "mousedown", this.boundMouseDown);
958
+ this.registerEvent(window, "mousemove", this.boundMouseMove);
959
+ this.registerEvent(window, "mouseup", this.boundMouseUp);
960
+
961
+ const touchStartOptions = { passive: false };
962
+ const touchMoveOptions = { passive: false };
963
+ this.registerEvent(this.container, "touchstart", this.boundTouchStart, touchStartOptions);
964
+ this.registerEvent(window, "touchmove", this.boundTouchMove, touchMoveOptions);
965
+ this.registerEvent(window, "touchend", this.boundTouchEnd);
966
+ this.registerEvent(window, "touchcancel", this.boundTouchEnd);
967
+
968
+ if (this.container.style) {
969
+ this.container.style.touchAction = this.pointerEventsSupported
970
+ ? "pan-x pan-y"
971
+ : "none";
972
+ }
973
+ this.dragListenersAttached = true;
974
+ }
975
+
976
+ handlePointerDown(event) {
977
+ if (this.panMode !== "drag" || !this.container) {
978
+ return;
979
+ }
980
+ if (!event.isPrimary) {
981
+ return;
982
+ }
983
+ if (event.pointerType === "mouse" && event.button !== 0) {
984
+ return;
985
+ }
986
+ if (
987
+ this.zoomMode === "click" &&
988
+ (event.metaKey || event.ctrlKey || event.altKey)
989
+ ) {
990
+ return;
991
+ }
992
+
993
+ event.preventDefault();
994
+ this.beginDrag({
995
+ clientX: event.clientX,
996
+ clientY: event.clientY,
997
+ pointerId:
998
+ typeof event.pointerId === "number" ? event.pointerId : event.pointerId,
999
+ inputType: event.pointerType || "pointer",
1000
+ sourceEvent: event,
1001
+ });
1002
+ }
1003
+
1004
+ handlePointerMove(event) {
1005
+ if (
1006
+ !this.dragState.isActive ||
1007
+ !this.container ||
1008
+ (this.dragState.pointerId !== null &&
1009
+ typeof event.pointerId === "number" &&
1010
+ event.pointerId !== this.dragState.pointerId)
1011
+ ) {
1012
+ return;
1013
+ }
1014
+
1015
+ event.preventDefault();
1016
+ this.updateDrag({
1017
+ clientX: event.clientX,
1018
+ clientY: event.clientY,
1019
+ });
1020
+ }
1021
+
1022
+ handlePointerUp(event) {
1023
+ if (
1024
+ !this.dragState.isActive ||
1025
+ (this.dragState.pointerId !== null &&
1026
+ typeof event.pointerId === "number" &&
1027
+ event.pointerId !== this.dragState.pointerId)
1028
+ ) {
1029
+ return;
1030
+ }
1031
+
1032
+ this.endDrag({
1033
+ pointerId:
1034
+ typeof event.pointerId === "number" ? event.pointerId : null,
1035
+ sourceEvent: event,
1036
+ });
1037
+ }
1038
+
1039
+ handleMouseDown(event) {
1040
+ if (this.panMode !== "drag" || !this.container) {
1041
+ return;
1042
+ }
1043
+ if (this.pointerEventsSupported) {
1044
+ return;
1045
+ }
1046
+ if (event.button !== 0) {
1047
+ return;
1048
+ }
1049
+
1050
+ event.preventDefault();
1051
+ this.beginDrag({
1052
+ clientX: event.clientX,
1053
+ clientY: event.clientY,
1054
+ pointerId: "mouse",
1055
+ inputType: "mouse",
1056
+ });
1057
+ }
1058
+
1059
+ handleMouseMove(event) {
1060
+ if (
1061
+ !this.dragState.isActive ||
1062
+ this.dragState.inputType !== "mouse" ||
1063
+ !this.container
1064
+ ) {
1065
+ return;
1066
+ }
1067
+ if (this.pointerEventsSupported) {
1068
+ return;
1069
+ }
1070
+
1071
+ event.preventDefault();
1072
+ this.updateDrag({
1073
+ clientX: event.clientX,
1074
+ clientY: event.clientY,
1075
+ });
1076
+ }
1077
+
1078
+ handleMouseUp() {
1079
+ if (!this.dragState.isActive || this.dragState.inputType !== "mouse") {
1080
+ return;
1081
+ }
1082
+ if (this.pointerEventsSupported) {
1083
+ return;
1084
+ }
1085
+
1086
+ this.endDrag({ pointerId: "mouse" });
1087
+ }
1088
+
1089
+ handleTouchStart(event) {
1090
+ if (this.panMode !== "drag" || !this.container) {
1091
+ return;
1092
+ }
1093
+ if (this.pointerEventsSupported) {
1094
+ return;
1095
+ }
1096
+ if (!event.touches || !event.touches.length) {
1097
+ return;
1098
+ }
1099
+
1100
+ const touch = event.touches[0];
1101
+ event.preventDefault();
1102
+ this.beginDrag({
1103
+ clientX: touch.clientX,
1104
+ clientY: touch.clientY,
1105
+ pointerId: touch.identifier,
1106
+ inputType: "touch",
1107
+ });
1108
+ }
1109
+
1110
+ handleTouchMove(event) {
1111
+ if (
1112
+ !this.dragState.isActive ||
1113
+ this.dragState.inputType !== "touch" ||
1114
+ !this.container
1115
+ ) {
1116
+ return;
1117
+ }
1118
+ if (this.pointerEventsSupported) {
1119
+ return;
1120
+ }
1121
+
1122
+ const touch = this.getTrackedTouch(
1123
+ event.touches,
1124
+ this.dragState.pointerId
1125
+ );
1126
+ if (!touch) {
1127
+ return;
1128
+ }
1129
+
1130
+ event.preventDefault();
1131
+ this.updateDrag({
1132
+ clientX: touch.clientX,
1133
+ clientY: touch.clientY,
1134
+ });
1135
+ }
1136
+
1137
+ handleTouchEnd(event) {
1138
+ if (
1139
+ !this.dragState.isActive ||
1140
+ this.dragState.inputType !== "touch"
1141
+ ) {
1142
+ return;
1143
+ }
1144
+ if (this.pointerEventsSupported) {
1145
+ return;
1146
+ }
1147
+
1148
+ const remainingTouch = this.getTrackedTouch(
1149
+ event.touches,
1150
+ this.dragState.pointerId
1151
+ );
1152
+ if (!remainingTouch) {
1153
+ this.endDrag({ pointerId: this.dragState.pointerId });
1154
+ }
1155
+ }
1156
+
1157
+ beginDrag({
1158
+ clientX,
1159
+ clientY,
1160
+ pointerId = null,
1161
+ inputType = null,
1162
+ sourceEvent = null,
1163
+ }) {
1164
+ if (!this.container) {
1165
+ return;
1166
+ }
1167
+
1168
+ if (this.dragState.isActive) {
1169
+ this.endDrag();
1170
+ }
1171
+
1172
+ this.dragState.isActive = true;
1173
+ this.dragState.pointerId = pointerId;
1174
+ this.dragState.inputType = inputType || null;
1175
+ this.dragState.startX = clientX;
1176
+ this.dragState.startY = clientY;
1177
+ this.dragState.scrollLeft = this.container.scrollLeft;
1178
+ this.dragState.scrollTop = this.container.scrollTop;
1179
+ this.dragState.lastClientX = clientX;
1180
+ this.dragState.lastClientY = clientY;
1181
+ this.dragState.prevScrollBehavior =
1182
+ typeof this.container.style.scrollBehavior === "string"
1183
+ ? this.container.style.scrollBehavior
1184
+ : "";
1185
+
1186
+ if (this.container && this.container.style) {
1187
+ this.container.style.scrollBehavior = "auto";
1188
+ }
1189
+
1190
+ if (this.container.classList) {
1191
+ this.container.classList.add("is-dragging");
1192
+ }
1193
+
1194
+ if (
1195
+ sourceEvent &&
1196
+ typeof sourceEvent.pointerId === "number" &&
1197
+ typeof this.container.setPointerCapture === "function"
1198
+ ) {
1199
+ try {
1200
+ this.container.setPointerCapture(sourceEvent.pointerId);
1201
+ } catch (err) {
1202
+ // Ignore pointer capture errors
1203
+ }
1204
+ }
1205
+ }
1206
+
1207
+ updateDrag({ clientX, clientY }) {
1208
+ if (!this.dragState.isActive || !this.container) {
1209
+ return;
1210
+ }
1211
+
1212
+ const deltaX = clientX - this.dragState.lastClientX;
1213
+ const deltaY = clientY - this.dragState.lastClientY;
1214
+
1215
+ if (deltaX) {
1216
+ this.container.scrollLeft -= deltaX;
1217
+ }
1218
+ if (deltaY) {
1219
+ this.container.scrollTop -= deltaY;
1220
+ }
1221
+
1222
+ this.dragState.lastClientX = clientX;
1223
+ this.dragState.lastClientY = clientY;
1224
+ this.dragState.scrollLeft = this.container.scrollLeft;
1225
+ this.dragState.scrollTop = this.container.scrollTop;
1226
+ }
1227
+
1228
+ endDrag({ pointerId = null, sourceEvent = null } = {}) {
1229
+ if (!this.dragState.isActive) {
1230
+ return;
1231
+ }
1232
+
1233
+ if (
1234
+ this.dragState.pointerId !== null &&
1235
+ pointerId !== null &&
1236
+ pointerId !== this.dragState.pointerId
1237
+ ) {
1238
+ return;
1239
+ }
1240
+
1241
+ const capturedPointer = this.dragState.pointerId;
1242
+
1243
+ this.dragState.isActive = false;
1244
+ this.dragState.pointerId = null;
1245
+ this.dragState.inputType = null;
1246
+ this.dragState.lastClientX = 0;
1247
+ this.dragState.lastClientY = 0;
1248
+ const previousScrollBehavior = this.dragState.prevScrollBehavior;
1249
+ this.dragState.prevScrollBehavior = "";
1250
+
1251
+ if (this.container && this.container.classList) {
1252
+ this.container.classList.remove("is-dragging");
1253
+ }
1254
+
1255
+ if (
1256
+ sourceEvent &&
1257
+ typeof sourceEvent.pointerId === "number" &&
1258
+ typeof this.container.releasePointerCapture === "function"
1259
+ ) {
1260
+ try {
1261
+ this.container.releasePointerCapture(sourceEvent.pointerId);
1262
+ } catch (err) {
1263
+ // Ignore release errors
1264
+ }
1265
+ } else if (
1266
+ typeof capturedPointer === "number" &&
1267
+ typeof this.container.releasePointerCapture === "function"
1268
+ ) {
1269
+ try {
1270
+ this.container.releasePointerCapture(capturedPointer);
1271
+ } catch (err) {
1272
+ // Ignore release errors
1273
+ }
1274
+ }
1275
+
1276
+ if (this.container && this.container.style) {
1277
+ this.container.style.scrollBehavior = previousScrollBehavior || "";
1278
+ }
1279
+ }
1280
+
1281
+ getTrackedTouch(touchList, identifier) {
1282
+ if (!touchList || identifier === null || typeof identifier === "undefined") {
1283
+ return null;
1284
+ }
1285
+ for (let i = 0; i < touchList.length; i += 1) {
1286
+ const touch = touchList[i];
1287
+ if (touch.identifier === identifier) {
1288
+ return touch;
1289
+ }
1290
+ }
1291
+ return null;
1292
+ }
1293
+
1294
+ handleContainerClick(event) {
1295
+ if (this.zoomMode !== "click" || !this.container) {
1296
+ return;
1297
+ }
1298
+
1299
+ if (typeof event.button === "number" && event.button !== 0) {
1300
+ return;
1301
+ }
1302
+
1303
+ const isZoomOut = event.altKey;
1304
+ const hasZoomInModifier = event.metaKey || event.ctrlKey;
1305
+
1306
+ if (!isZoomOut && !hasZoomInModifier) {
1307
+ return;
1308
+ }
1309
+
1310
+ const focusPoint = this.getFocusPointFromEvent(event);
1311
+
1312
+ const zoomOptions = { animate: true };
1313
+ if (focusPoint) {
1314
+ zoomOptions.focusX = focusPoint.baseX;
1315
+ zoomOptions.focusY = focusPoint.baseY;
1316
+ if (typeof focusPoint.pointerOffsetX === "number") {
1317
+ zoomOptions.focusOffsetX = focusPoint.pointerOffsetX;
1318
+ }
1319
+ if (typeof focusPoint.pointerOffsetY === "number") {
1320
+ zoomOptions.focusOffsetY = focusPoint.pointerOffsetY;
1321
+ }
1322
+ }
1323
+
1324
+ if (isZoomOut) {
1325
+ event.preventDefault();
1326
+ event.stopPropagation();
1327
+ const targetZoom = this.currentZoom - this.ZOOM_STEP;
1328
+ this.setZoom(targetZoom, zoomOptions);
1329
+ return;
1330
+ }
1331
+
1332
+ if (hasZoomInModifier) {
1333
+ event.preventDefault();
1334
+ event.stopPropagation();
1335
+ const targetZoom = this.currentZoom + this.ZOOM_STEP;
1336
+ this.setZoom(targetZoom, zoomOptions);
1337
+ }
1338
+ }
1339
+
1340
+ getCenterPoint() {
1341
+ if (
1342
+ this.manualCenter &&
1343
+ Number.isFinite(this.manualCenter.x) &&
1344
+ Number.isFinite(this.manualCenter.y)
1345
+ ) {
1346
+ return this.manualCenter;
1347
+ }
1348
+
1349
+ if (this.baseDimensions) {
1350
+ return {
1351
+ x: this.baseOrigin.x + this.baseDimensions.width / 2,
1352
+ y: this.baseOrigin.y + this.baseDimensions.height / 2,
1353
+ };
1354
+ }
1355
+
1356
+ return { x: 0, y: 0 };
1357
+ }
1358
+
1359
+ getVisibleCenterPoint() {
1360
+ if (!this.container || !this.baseDimensions || !this.unitsPerCss) {
1361
+ return this.getCenterPoint();
1362
+ }
1363
+
1364
+ const visibleCenterX =
1365
+ this.container.scrollLeft + this.container.clientWidth / 2;
1366
+ const visibleCenterY =
1367
+ this.container.scrollTop + this.container.clientHeight / 2;
1368
+
1369
+ const cssBaseX = visibleCenterX / (this.currentZoom || 1);
1370
+ const cssBaseY = visibleCenterY / (this.currentZoom || 1);
1371
+
1372
+ return {
1373
+ x: this.baseOrigin.x + cssBaseX * (this.unitsPerCss.x || 1),
1374
+ y: this.baseOrigin.y + cssBaseY * (this.unitsPerCss.y || 1),
1375
+ };
1376
+ }
1377
+
1378
+ async copyCenterCoordinates() {
1379
+ const point = this.getVisibleCenterPoint();
1380
+ const message = `${point.x.toFixed(2)}, ${point.y.toFixed(2)}`;
1381
+
1382
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1383
+ try {
1384
+ await navigator.clipboard.writeText(message);
1385
+ this.updateCoordOutput(`Copied: ${message}`);
1386
+ return;
1387
+ } catch (err) {
1388
+ console.warn("Clipboard copy failed", err);
1389
+ }
1390
+ }
1391
+
1392
+ this.updateCoordOutput(message);
1393
+ this.fallbackPrompt(message);
1394
+ }
1395
+
1396
+ updateCoordOutput(text) {
1397
+ if (!this.coordOutputEl) return;
1398
+ this.coordOutputEl.textContent = text;
1399
+ clearTimeout(this._coordTimeout);
1400
+ this._coordTimeout = setTimeout(() => {
1401
+ this.coordOutputEl.textContent = "";
1402
+ }, 4000);
1403
+ }
1404
+
1405
+ fallbackPrompt(message) {
1406
+ if (typeof window !== "undefined" && window.prompt) {
1407
+ window.prompt("Copy coordinates", message);
1408
+ }
1409
+ }
1410
+
1411
+ resolveMinZoom() {
1412
+ if (Number.isFinite(this.MIN_ZOOM) && this.MIN_ZOOM > 0) {
1413
+ return this.MIN_ZOOM;
1414
+ }
1415
+ if (
1416
+ !this.container ||
1417
+ !this.baseCssDimensions ||
1418
+ !Number.isFinite(this.baseCssDimensions.width) ||
1419
+ !Number.isFinite(this.baseCssDimensions.height) ||
1420
+ this.baseCssDimensions.width <= 0 ||
1421
+ this.baseCssDimensions.height <= 0
1422
+ ) {
1423
+ return null;
1424
+ }
1425
+ const containerWidth = this.container.clientWidth || 0;
1426
+ const containerHeight = this.container.clientHeight || 0;
1427
+ if (containerWidth <= 0 || containerHeight <= 0) {
1428
+ return null;
1429
+ }
1430
+ const containerDiag = Math.sqrt(
1431
+ containerWidth ** 2 + containerHeight ** 2
1432
+ );
1433
+ const svgDiag = Math.sqrt(
1434
+ this.baseCssDimensions.width ** 2 + this.baseCssDimensions.height ** 2
1435
+ );
1436
+ if (!Number.isFinite(containerDiag) || !Number.isFinite(svgDiag)) {
1437
+ return null;
1438
+ }
1439
+ if (svgDiag <= 0) {
1440
+ return null;
1441
+ }
1442
+ const computedMin = Math.min(1, containerDiag / svgDiag);
1443
+ if (Number.isFinite(computedMin) && computedMin > 0) {
1444
+ this.MIN_ZOOM = computedMin;
1445
+ return this.MIN_ZOOM;
1446
+ }
1447
+ return null;
1448
+ }
1449
+
1450
+ getEffectiveMinZoom() {
1451
+ const resolved = this.resolveMinZoom();
1452
+ if (Number.isFinite(resolved) && resolved > 0) {
1453
+ return resolved;
1454
+ }
1455
+ if (Number.isFinite(this.MIN_ZOOM) && this.MIN_ZOOM > 0) {
1456
+ return this.MIN_ZOOM;
1457
+ }
1458
+ return null;
1459
+ }
1460
+
1461
+ getEffectiveMaxZoom() {
1462
+ if (Number.isFinite(this.MAX_ZOOM) && this.MAX_ZOOM > 0) {
1463
+ return this.MAX_ZOOM;
1464
+ }
1465
+ return this.currentZoom || 1;
1466
+ }
1467
+
1468
+ getZoomStep() {
1469
+ return Number.isFinite(this.ZOOM_STEP) && this.ZOOM_STEP > 0
1470
+ ? this.ZOOM_STEP
1471
+ : 0.1;
1472
+ }
1473
+
1474
+ getZoomTolerance() {
1475
+ const step = this.getZoomStep();
1476
+ return Math.max(1e-4, step / 1000);
1477
+ }
1478
+
1479
+ setManualCenter(x, y, options = {}) {
1480
+ const shouldRecenter = Boolean(options.recenter);
1481
+ if (Number.isFinite(x) && Number.isFinite(y)) {
1482
+ this.manualCenter = { x, y };
1483
+ if (shouldRecenter && this.container && this.baseDimensions) {
1484
+ this.centerView({ focusX: x, focusY: y });
1485
+ }
1486
+ return;
1487
+ }
1488
+
1489
+ this.manualCenter = null;
1490
+ if (shouldRecenter && this.container && this.baseDimensions) {
1491
+ this.centerView();
1492
+ }
1493
+ }
1494
+
1495
+ destroy() {
1496
+ this.endDrag();
1497
+ if (Array.isArray(this.cleanupHandlers)) {
1498
+ while (this.cleanupHandlers.length) {
1499
+ const cleanup = this.cleanupHandlers.pop();
1500
+ try {
1501
+ cleanup();
1502
+ } catch (err) {
1503
+ // Ignore cleanup errors
1504
+ }
1505
+ }
1506
+ this.cleanupHandlers = [];
1507
+ }
1508
+ if (this.container) {
1509
+ if (this.container.classList) {
1510
+ this.container.classList.remove("is-dragging");
1511
+ }
1512
+ if (this.container.style) {
1513
+ this.container.style.touchAction = "";
1514
+ }
1515
+ }
1516
+ if (
1517
+ typeof window !== "undefined" &&
1518
+ this.wheelAnimationFrame &&
1519
+ typeof window.cancelAnimationFrame === "function"
1520
+ ) {
1521
+ window.cancelAnimationFrame(this.wheelAnimationFrame);
1522
+ this.wheelAnimationFrame = null;
1523
+ }
1524
+ if (
1525
+ typeof window !== "undefined" &&
1526
+ this.zoomAnimationFrame &&
1527
+ typeof window.cancelAnimationFrame === "function"
1528
+ ) {
1529
+ window.cancelAnimationFrame(this.zoomAnimationFrame);
1530
+ this.zoomAnimationFrame = null;
1531
+ }
1532
+ this.wheelDeltaBuffer = 0;
1533
+ this.wheelFocusPoint = null;
1534
+ this.dragListenersAttached = false;
1535
+ }
1536
+
1537
+ computeZoomTarget(direction) {
1538
+ const step = this.getZoomStep();
1539
+ const tolerance = this.getZoomTolerance();
1540
+ const maxZoom = this.getEffectiveMaxZoom();
1541
+ const minZoom =
1542
+ this.getEffectiveMinZoom() ?? Math.max(0, this.currentZoom - step);
1543
+
1544
+ if (direction === "in") {
1545
+ const remaining = maxZoom - this.currentZoom;
1546
+ if (remaining <= step + tolerance) {
1547
+ return maxZoom;
1548
+ }
1549
+ return Math.min(maxZoom, this.currentZoom + step);
1550
+ }
1551
+
1552
+ const available = this.currentZoom - minZoom;
1553
+ if (available <= step + tolerance) {
1554
+ return minZoom;
1555
+ }
1556
+ return Math.max(minZoom, this.currentZoom - step);
1557
+ }
1558
+
1559
+ updateZoomButtonState() {
1560
+ if (!Array.isArray(this.zoomInButtons) || !Array.isArray(this.zoomOutButtons)) {
1561
+ return;
1562
+ }
1563
+ const tolerance = this.getZoomTolerance();
1564
+ const maxZoom = this.getEffectiveMaxZoom();
1565
+ const minZoom = this.getEffectiveMinZoom();
1566
+
1567
+ const canZoomIn =
1568
+ maxZoom - this.currentZoom > tolerance && maxZoom > 0;
1569
+ const canZoomOut =
1570
+ minZoom === null
1571
+ ? true
1572
+ : this.currentZoom - minZoom > tolerance;
1573
+
1574
+ this.toggleButtonState(this.zoomInButtons, canZoomIn);
1575
+ this.toggleButtonState(this.zoomOutButtons, canZoomOut);
1576
+ }
1577
+
1578
+ toggleButtonState(buttons, enabled) {
1579
+ if (!Array.isArray(buttons)) {
1580
+ return;
1581
+ }
1582
+ buttons.forEach((button) => {
1583
+ if (!button) {
1584
+ return;
1585
+ }
1586
+ if (enabled) {
1587
+ button.disabled = false;
1588
+ button.classList.remove("is-disabled");
1589
+ button.removeAttribute("aria-disabled");
1590
+ } else {
1591
+ button.disabled = true;
1592
+ button.classList.add("is-disabled");
1593
+ button.setAttribute("aria-disabled", "true");
1594
+ }
1595
+ });
1596
+ }
1597
+ }
1598
+ // Export for global use
1599
+ window.SVGViewer = SVGViewer;
1600
+
1601
+ // Support CommonJS/ESM consumers (e.g., unit tests).
1602
+ if (typeof module !== "undefined" && module.exports) {
1603
+ module.exports = SVGViewer;
1604
+ }