UNPKG

31.8 kBJavaScriptView Raw
1import { extend } from "../shared/utils/extend.js";
2import { round } from "../shared/utils/round.js";
3import { isScrollable } from "../shared/utils/isScrollable.js";
4
5import { ResizeObserver } from "../shared/utils/ResizeObserver.js";
6import { PointerTracker, getMidpoint, getDistance } from "../shared/utils/PointerTracker.js";
7
8import { getTextNodeFromPoint } from "../shared/utils/getTextNodeFromPoint.js";
9
10import { getFullWidth, getFullHeight, calculateAspectRatioFit } from "../shared/utils/getDimensions.js";
11
12import { Base } from "../shared/Base/Base.js";
13
14import { Plugins } from "./plugins/index.js";
15
16const defaults = {
17 // Enable touch guestures
18 touch: true,
19
20 // Enable zooming
21 zoom: true,
22
23 // Enable pinch gesture to zoom in/out using two fingers
24 pinchToZoom: true,
25
26 // Disable dragging if scale level is equal to value of `baseScale` option
27 panOnlyZoomed: false,
28
29 // Lock axis while dragging,
30 // possible values: false | "x" | "y" | "xy"
31 lockAxis: false,
32
33 // * All friction values are inside [0, 1) interval,
34 // * where 0 would change instantly, but 0.99 would update extremely slowly
35
36 // Friction while panning/dragging
37 friction: 0.64,
38
39 // Friction while decelerating after drag end
40 decelFriction: 0.88,
41
42 // Friction while scaling
43 zoomFriction: 0.74,
44
45 // Bounciness after hitting the edge
46 bounceForce: 0.2,
47
48 // Initial scale level
49 baseScale: 1,
50
51 // Minimum scale level
52 minScale: 1,
53
54 // Maximum scale level
55 maxScale: 2,
56
57 // Default scale step while zooming
58 step: 0.5,
59
60 // Allow to select text,
61 // if enabled, dragging will be disabled when text selection is detected
62 textSelection: false,
63
64 // Add `click` event listener,
65 // possible values: true | false | function | "toggleZoom"
66 click: "toggleZoom",
67
68 // Add `wheel` event listener,
69 // possible values: true | false | function | "zoom"
70 wheel: "zoom",
71
72 // Value for zoom on mouse wheel
73 wheelFactor: 42,
74
75 // Number of wheel events after which it should stop preventing default behaviour of mouse wheel
76 wheelLimit: 5,
77
78 // Class name added to `$viewport` element to indicate if content is draggable
79 draggableClass: "is-draggable",
80
81 // Class name added to `$viewport` element to indicate that user is currently dragging
82 draggingClass: "is-dragging",
83
84 // Content will be scaled by this number,
85 // this can also be a function which should return a number, for example:
86 // ratio: function() { return 1 / (window.devicePixelRatio || 1) }
87 ratio: 1,
88};
89
90export class Panzoom extends Base {
91 /**
92 * Panzoom constructor
93 * @constructs Panzoom
94 * @param {HTMLElement} $viewport Panzoom container
95 * @param {Object} [options] Options for Panzoom
96 */
97 constructor($container, options = {}) {
98 super(extend(true, {}, defaults, options));
99
100 this.state = "init";
101
102 this.$container = $container;
103
104 // Bind event handlers for referencability
105 for (const methodName of ["onLoad", "onWheel", "onClick"]) {
106 this[methodName] = this[methodName].bind(this);
107 }
108
109 this.initLayout();
110
111 this.resetValues();
112
113 this.attachPlugins(Panzoom.Plugins);
114
115 this.trigger("init");
116
117 this.updateMetrics();
118
119 this.attachEvents();
120
121 this.trigger("ready");
122
123 if (this.option("centerOnStart") === false) {
124 this.state = "ready";
125 } else {
126 this.panTo({
127 friction: 0,
128 });
129 }
130
131 $container.__Panzoom = this;
132 }
133
134 /**
135 * Create references to container, viewport and content elements
136 */
137 initLayout() {
138 const $container = this.$container;
139
140 // Make sure content element exists
141 if (!($container instanceof HTMLElement)) {
142 throw new Error("Panzoom: Container not found");
143 }
144
145 const $content = this.option("content") || $container.querySelector(".panzoom__content");
146
147 // Make sure content element exists
148 if (!$content) {
149 throw new Error("Panzoom: Content not found");
150 }
151
152 this.$content = $content;
153
154 let $viewport = this.option("viewport") || $container.querySelector(".panzoom__viewport");
155
156 if (!$viewport && this.option("wrapInner") !== false) {
157 $viewport = document.createElement("div");
158 $viewport.classList.add("panzoom__viewport");
159
160 $viewport.append(...$container.childNodes);
161
162 $container.appendChild($viewport);
163 }
164
165 this.$viewport = $viewport || $content.parentNode;
166 }
167
168 /**
169 * Restore instance variables to default values
170 */
171 resetValues() {
172 this.updateRate = this.option("updateRate", /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ? 250 : 24);
173
174 this.container = {
175 width: 0,
176 height: 0,
177 };
178
179 this.viewport = {
180 width: 0,
181 height: 0,
182 };
183
184 this.content = {
185 // Full content dimensions (naturalWidth/naturalHeight for images)
186 origWidth: 0,
187 origHeight: 0,
188
189 // Current dimensions of the content
190 width: 0,
191 height: 0,
192
193 // Current position; these values reflect CSS `transform` value
194 x: this.option("x", 0),
195 y: this.option("y", 0),
196
197 // Current scale; does not reflect CSS `transform` value
198 scale: this.option("baseScale"),
199 };
200
201 // End values of current pan / zoom animation
202 this.transform = {
203 x: 0,
204 y: 0,
205 scale: 1,
206 };
207
208 this.resetDragPosition();
209 }
210
211 /**
212 * Handle `load` event
213 * @param {Event} event
214 */
215 onLoad(event) {
216 this.updateMetrics();
217
218 this.panTo({ scale: this.option("baseScale"), friction: 0 });
219
220 this.trigger("load", event);
221 }
222
223 /**
224 * Handle `click` event
225 * @param {Event} event
226 */
227 onClick(event) {
228 if (event.defaultPrevented) {
229 return;
230 }
231
232 // Skip if text is selected
233 if (this.option("textSelection") && window.getSelection().toString().length) {
234 event.stopPropagation();
235 return;
236 }
237
238 const rect = this.$content.getClientRects()[0];
239
240 // Check if container has changed position (for example, when current instance is inside another one)
241 if (this.state !== "ready") {
242 if (
243 this.dragPosition.midPoint ||
244 Math.abs(rect.top - this.dragStart.rect.top) > 1 ||
245 Math.abs(rect.left - this.dragStart.rect.left) > 1
246 ) {
247 event.preventDefault();
248 event.stopPropagation();
249
250 return;
251 }
252 }
253
254 if (this.trigger("click", event) === false) {
255 return;
256 }
257
258 if (this.option("zoom") && this.option("click") === "toggleZoom") {
259 event.preventDefault();
260 event.stopPropagation();
261
262 this.zoomWithClick(event);
263 }
264 }
265
266 /**
267 * Handle `wheel` event
268 * @param {Event} event
269 */
270 onWheel(event) {
271 if (this.trigger("wheel", event) === false) {
272 return;
273 }
274
275 if (this.option("zoom") && this.option("wheel")) {
276 this.zoomWithWheel(event);
277 }
278 }
279
280 /**
281 * Change zoom level depending on scroll direction
282 * @param {Event} event `wheel` event
283 */
284 zoomWithWheel(event) {
285 if (this.changedDelta === undefined) {
286 this.changedDelta = 0;
287 }
288
289 const delta = Math.max(-1, Math.min(1, -event.deltaY || -event.deltaX || event.wheelDelta || -event.detail));
290 const scale = this.content.scale;
291
292 let newScale = (scale * (100 + delta * this.option("wheelFactor"))) / 100;
293
294 if (
295 (delta < 0 && Math.abs(scale - this.option("minScale")) < 0.01) ||
296 (delta > 0 && Math.abs(scale - this.option("maxScale")) < 0.01)
297 ) {
298 this.changedDelta += Math.abs(delta);
299 newScale = scale;
300 } else {
301 this.changedDelta = 0;
302 newScale = Math.max(Math.min(newScale, this.option("maxScale")), this.option("minScale"));
303 }
304
305 if (this.changedDelta > this.option("wheelLimit")) {
306 return;
307 }
308
309 event.preventDefault();
310
311 if (newScale === scale) {
312 return;
313 }
314
315 const rect = this.$content.getBoundingClientRect();
316
317 const x = event.clientX - rect.left;
318 const y = event.clientY - rect.top;
319
320 this.zoomTo(newScale, { x, y });
321 }
322
323 /**
324 * Change zoom level depending on click coordinates
325 * @param {Event} event `click` event
326 */
327 zoomWithClick(event) {
328 const rect = this.$content.getClientRects()[0];
329
330 const x = event.clientX - rect.left;
331 const y = event.clientY - rect.top;
332
333 this.toggleZoom({ x, y });
334 }
335
336 /**
337 * Attach load, wheel and click event listeners, initialize `resizeObserver` and `PointerTracker`
338 */
339 attachEvents() {
340 this.$content.addEventListener("load", this.onLoad);
341
342 this.$container.addEventListener("wheel", this.onWheel, { passive: false });
343 this.$container.addEventListener("click", this.onClick, { passive: false });
344
345 this.initObserver();
346
347 const pointerTracker = new PointerTracker(this.$container, {
348 start: (pointer, event) => {
349 if (!this.option("touch")) {
350 return false;
351 }
352
353 if (this.velocity.scale < 0) {
354 return false;
355 }
356
357 const target = event.composedPath()[0];
358
359 if (!pointerTracker.currentPointers.length) {
360 const ignoreClickedElement =
361 ["BUTTON", "TEXTAREA", "OPTION", "INPUT", "SELECT", "VIDEO"].indexOf(target.nodeName) !== -1;
362
363 if (ignoreClickedElement) {
364 return false;
365 }
366
367 // Allow text selection
368 if (this.option("textSelection") && getTextNodeFromPoint(target, pointer.clientX, pointer.clientY)) {
369 return false;
370 }
371 }
372
373 if (isScrollable(target)) {
374 return false;
375 }
376
377 if (this.trigger("touchStart", event) === false) {
378 return false;
379 }
380
381 if (event.type === "mousedown") {
382 event.preventDefault();
383 }
384
385 this.state = "pointerdown";
386
387 this.resetDragPosition();
388
389 this.dragPosition.midPoint = null;
390 this.dragPosition.time = Date.now();
391
392 return true;
393 },
394 move: (previousPointers, currentPointers, event) => {
395 if (this.state !== "pointerdown") {
396 return;
397 }
398
399 if (this.trigger("touchMove", event) === false) {
400 event.preventDefault();
401 return;
402 }
403
404 // Disable touch action if current zoom level is below base level
405 if (
406 currentPointers.length < 2 &&
407 this.option("panOnlyZoomed") === true &&
408 this.content.width <= this.viewport.width &&
409 this.content.height <= this.viewport.height &&
410 this.transform.scale <= this.option("baseScale")
411 ) {
412 return;
413 }
414
415 if (currentPointers.length > 1 && (!this.option("zoom") || this.option("pinchToZoom") === false)) {
416 return;
417 }
418
419 const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
420 const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
421
422 const panX = newMidpoint.clientX - prevMidpoint.clientX;
423 const panY = newMidpoint.clientY - prevMidpoint.clientY;
424
425 const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
426 const newDistance = getDistance(currentPointers[0], currentPointers[1]);
427
428 const scaleDiff = prevDistance && newDistance ? newDistance / prevDistance : 1;
429
430 this.dragOffset.x += panX;
431 this.dragOffset.y += panY;
432
433 this.dragOffset.scale *= scaleDiff;
434
435 this.dragOffset.time = Date.now() - this.dragPosition.time;
436
437 const axisToLock = this.dragStart.scale === 1 && this.option("lockAxis");
438
439 if (axisToLock && !this.lockAxis) {
440 if (Math.abs(this.dragOffset.x) < 6 && Math.abs(this.dragOffset.y) < 6) {
441 event.preventDefault();
442 return;
443 }
444
445 const angle = Math.abs((Math.atan2(this.dragOffset.y, this.dragOffset.x) * 180) / Math.PI);
446
447 this.lockAxis = angle > 45 && angle < 135 ? "y" : "x";
448 }
449
450 if (axisToLock !== "xy" && this.lockAxis === "y") {
451 return;
452 }
453
454 event.preventDefault();
455 event.stopPropagation();
456
457 event.stopImmediatePropagation();
458
459 if (this.lockAxis) {
460 this.dragOffset[this.lockAxis === "x" ? "y" : "x"] = 0;
461 }
462
463 this.$container.classList.add(this.option("draggingClass"));
464
465 if (!(this.transform.scale === this.option("baseScale") && this.lockAxis === "y")) {
466 this.dragPosition.x = this.dragStart.x + this.dragOffset.x;
467 }
468
469 if (!(this.transform.scale === this.option("baseScale") && this.lockAxis === "x")) {
470 this.dragPosition.y = this.dragStart.y + this.dragOffset.y;
471 }
472
473 this.dragPosition.scale = this.dragStart.scale * this.dragOffset.scale;
474
475 if (currentPointers.length > 1) {
476 const startPoint = getMidpoint(pointerTracker.startPointers[0], pointerTracker.startPointers[1]);
477
478 const xPos = startPoint.clientX - this.dragStart.rect.x;
479 const yPos = startPoint.clientY - this.dragStart.rect.y;
480
481 const { deltaX, deltaY } = this.getZoomDelta(this.content.scale * this.dragOffset.scale, xPos, yPos);
482
483 this.dragPosition.x -= deltaX;
484 this.dragPosition.y -= deltaY;
485
486 this.dragPosition.midPoint = newMidpoint;
487 } else {
488 this.setDragResistance();
489 }
490
491 // Update final position
492 this.transform = {
493 x: this.dragPosition.x,
494 y: this.dragPosition.y,
495 scale: this.dragPosition.scale,
496 };
497
498 this.startAnimation();
499 },
500 end: (pointer, event) => {
501 if (this.state !== "pointerdown") {
502 return;
503 }
504
505 this._dragOffset = { ...this.dragOffset };
506
507 if (pointerTracker.currentPointers.length) {
508 this.resetDragPosition();
509
510 return;
511 }
512
513 this.state = "decel";
514 this.friction = this.option("decelFriction");
515
516 this.recalculateTransform();
517
518 this.$container.classList.remove(this.option("draggingClass"));
519
520 if (this.trigger("touchEnd", event) === false) {
521 return;
522 }
523
524 if (this.state !== "decel") {
525 return;
526 }
527
528 // * Check if scaled content past limits
529
530 // Below minimum
531 const minScale = this.option("minScale");
532
533 if (this.transform.scale < minScale) {
534 this.zoomTo(minScale, { friction: 0.64 });
535
536 return;
537 }
538
539 // Exceed maximum
540 const maxScale = this.option("maxScale");
541
542 if (this.transform.scale - maxScale > 0.01) {
543 const last = this.dragPosition.midPoint || pointer;
544 const rect = this.$content.getClientRects()[0];
545
546 this.zoomTo(maxScale, {
547 friction: 0.64,
548 x: last.clientX - rect.left,
549 y: last.clientY - rect.top,
550 });
551
552 return;
553 }
554 },
555 });
556
557 this.pointerTracker = pointerTracker;
558 }
559
560 initObserver() {
561 if (this.resizeObserver) {
562 return;
563 }
564
565 this.resizeObserver = new ResizeObserver(() => {
566 if (this.updateTimer) {
567 return;
568 }
569
570 this.updateTimer = setTimeout(() => {
571 const rect = this.$container.getBoundingClientRect();
572
573 if (!(rect.width && rect.height)) {
574 this.updateTimer = null;
575 return;
576 }
577
578 // Check to see if there are any changes
579 if (Math.abs(rect.width - this.container.width) > 1 || Math.abs(rect.height - this.container.height) > 1) {
580 if (this.isAnimating()) {
581 this.endAnimation(true);
582 }
583
584 this.updateMetrics();
585
586 this.panTo({
587 x: this.content.x,
588 y: this.content.y,
589 scale: this.option("baseScale"),
590 friction: 0,
591 });
592 }
593
594 this.updateTimer = null;
595 }, this.updateRate);
596 });
597
598 this.resizeObserver.observe(this.$container);
599 }
600
601 /**
602 * Restore drag related variables to default values
603 */
604 resetDragPosition() {
605 this.lockAxis = null;
606 this.friction = this.option("friction");
607
608 this.velocity = {
609 x: 0,
610 y: 0,
611 scale: 0,
612 };
613
614 const { x, y, scale } = this.content;
615
616 this.dragStart = {
617 rect: this.$content.getBoundingClientRect(),
618 x,
619 y,
620 scale,
621 };
622
623 this.dragPosition = {
624 ...this.dragPosition,
625 x,
626 y,
627 scale,
628 };
629
630 this.dragOffset = {
631 x: 0,
632 y: 0,
633 scale: 1,
634 time: 0,
635 };
636 }
637
638 /**
639 * Trigger update events before/after resizing content and viewport
640 * @param {Boolean} silently Should trigger `afterUpdate` event at the end
641 */
642 updateMetrics(silently) {
643 if (silently !== true) {
644 this.trigger("beforeUpdate");
645 }
646
647 const $container = this.$container;
648 const $content = this.$content;
649 const $viewport = this.$viewport;
650
651 const contentIsImage = $content instanceof HTMLImageElement;
652 const contentIsZoomable = this.option("zoom");
653 const shouldResizeParent = this.option("resizeParent", contentIsZoomable);
654
655 let width = this.option("width");
656 let height = this.option("height");
657
658 let origWidth = width || getFullWidth($content);
659 let origHeight = height || getFullHeight($content);
660
661 Object.assign($content.style, {
662 width: width ? `${width}px` : "",
663 height: height ? `${height}px` : "",
664 maxWidth: "",
665 maxHeight: "",
666 });
667
668 if (shouldResizeParent) {
669 Object.assign($viewport.style, { width: "", height: "" });
670 }
671
672 const ratio = this.option("ratio");
673
674 origWidth = round(origWidth * ratio);
675 origHeight = round(origHeight * ratio);
676
677 width = origWidth;
678 height = origHeight;
679
680 const contentRect = $content.getBoundingClientRect();
681 const viewportRect = $viewport.getBoundingClientRect();
682
683 const containerRect = $viewport == $container ? viewportRect : $container.getBoundingClientRect();
684
685 let viewportWidth = Math.max($viewport.offsetWidth, round(viewportRect.width));
686 let viewportHeight = Math.max($viewport.offsetHeight, round(viewportRect.height));
687
688 let viewportStyles = window.getComputedStyle($viewport);
689 viewportWidth -= parseFloat(viewportStyles.paddingLeft) + parseFloat(viewportStyles.paddingRight);
690 viewportHeight -= parseFloat(viewportStyles.paddingTop) + parseFloat(viewportStyles.paddingBottom);
691
692 this.viewport.width = viewportWidth;
693 this.viewport.height = viewportHeight;
694
695 if (contentIsZoomable) {
696 if (Math.abs(origWidth - contentRect.width) > 0.1 || Math.abs(origHeight - contentRect.height) > 0.1) {
697 const rez = calculateAspectRatioFit(
698 origWidth,
699 origHeight,
700 Math.min(origWidth, contentRect.width),
701 Math.min(origHeight, contentRect.height)
702 );
703
704 width = round(rez.width);
705 height = round(rez.height);
706 }
707
708 Object.assign($content.style, {
709 width: `${width}px`,
710 height: `${height}px`,
711 transform: "",
712 });
713 }
714
715 if (shouldResizeParent) {
716 Object.assign($viewport.style, { width: `${width}px`, height: `${height}px` });
717
718 this.viewport = { ...this.viewport, width, height };
719 }
720
721 if (contentIsImage && contentIsZoomable && typeof this.options.maxScale !== "function") {
722 const maxScale = this.option("maxScale");
723
724 this.options.maxScale = function () {
725 return this.content.origWidth > 0 && this.content.fitWidth > 0
726 ? this.content.origWidth / this.content.fitWidth
727 : maxScale;
728 };
729 }
730
731 this.content = {
732 ...this.content,
733 origWidth,
734 origHeight,
735 fitWidth: width,
736 fitHeight: height,
737 width,
738 height,
739 scale: 1,
740 isZoomable: contentIsZoomable,
741 };
742
743 this.container = { width: containerRect.width, height: containerRect.height };
744
745 if (silently !== true) {
746 this.trigger("afterUpdate");
747 }
748 }
749
750 /**
751 * Increase zoom level
752 * @param {Number} [step] Zoom ratio; `0.5` would increase scale from 1 to 1.5
753 */
754 zoomIn(step) {
755 this.zoomTo(this.content.scale + (step || this.option("step")));
756 }
757
758 /**
759 * Decrease zoom level
760 * @param {Number} [step] Zoom ratio; `0.5` would decrease scale from 1.5 to 1
761 */
762 zoomOut(step) {
763 this.zoomTo(this.content.scale - (step || this.option("step")));
764 }
765
766 /**
767 * Toggles zoom level between max and base levels
768 * @param {Object} [options] Additional options
769 */
770 toggleZoom(props = {}) {
771 const maxScale = this.option("maxScale");
772 const baseScale = this.option("baseScale");
773
774 const scale = this.content.scale > baseScale + (maxScale - baseScale) * 0.5 ? baseScale : maxScale;
775
776 this.zoomTo(scale, props);
777 }
778
779 /**
780 * Animate to given zoom level
781 * @param {Number} scale New zoom level
782 * @param {Object} [options] Additional options
783 */
784 zoomTo(scale = this.option("baseScale"), { x = null, y = null } = {}) {
785 scale = Math.max(Math.min(scale, this.option("maxScale")), this.option("minScale"));
786
787 // Adjust zoom position
788 const currentScale = round(this.content.scale / (this.content.width / this.content.fitWidth), 10000000);
789
790 if (x === null) {
791 x = this.content.width * currentScale * 0.5;
792 }
793
794 if (y === null) {
795 y = this.content.height * currentScale * 0.5;
796 }
797
798 const { deltaX, deltaY } = this.getZoomDelta(scale, x, y);
799
800 x = this.content.x - deltaX;
801 y = this.content.y - deltaY;
802
803 this.panTo({ x, y, scale, friction: this.option("zoomFriction") });
804 }
805
806 /**
807 * Calculate difference for top/left values if content would scale at given coordinates
808 * @param {Number} scale
809 * @param {Number} x
810 * @param {Number} y
811 * @returns {Object}
812 */
813 getZoomDelta(scale, x = 0, y = 0) {
814 const currentWidth = this.content.fitWidth * this.content.scale;
815 const currentHeight = this.content.fitHeight * this.content.scale;
816
817 const percentXInCurrentBox = x > 0 && currentWidth ? x / currentWidth : 0;
818 const percentYInCurrentBox = y > 0 && currentHeight ? y / currentHeight : 0;
819
820 const nextWidth = this.content.fitWidth * scale;
821 const nextHeight = this.content.fitHeight * scale;
822
823 const deltaX = (nextWidth - currentWidth) * percentXInCurrentBox;
824 const deltaY = (nextHeight - currentHeight) * percentYInCurrentBox;
825
826 return { deltaX, deltaY };
827 }
828
829 /**
830 * Animate to given positon and/or zoom level
831 * @param {Object} [options] Additional options
832 */
833 panTo({
834 x = this.content.x,
835 y = this.content.y,
836 scale,
837 friction = this.option("friction"),
838 ignoreBounds = false,
839 } = {}) {
840 scale = scale || this.content.scale || 1;
841
842 if (!ignoreBounds) {
843 const { boundX, boundY } = this.getBounds(scale);
844
845 if (boundX) {
846 x = Math.max(Math.min(x, boundX.to), boundX.from);
847 }
848
849 if (boundY) {
850 y = Math.max(Math.min(y, boundY.to), boundY.from);
851 }
852 }
853
854 this.friction = friction;
855
856 this.transform = {
857 ...this.transform,
858 x,
859 y,
860 scale,
861 };
862
863 if (friction) {
864 this.state = "panning";
865
866 this.velocity = {
867 x: (1 / this.friction - 1) * (x - this.content.x),
868 y: (1 / this.friction - 1) * (y - this.content.y),
869 scale: (1 / this.friction - 1) * (scale - this.content.scale),
870 };
871
872 this.startAnimation();
873 } else {
874 this.endAnimation();
875 }
876 }
877
878 /**
879 * Start animation loop
880 */
881 startAnimation() {
882 if (!this.rAF) {
883 this.trigger("startAnimation");
884 } else {
885 cancelAnimationFrame(this.rAF);
886 }
887
888 this.rAF = requestAnimationFrame(() => this.animate());
889 }
890
891 /**
892 * Process animation frame
893 */
894 animate() {
895 this.setEdgeForce();
896 this.setDragForce();
897
898 this.velocity.x *= this.friction;
899 this.velocity.y *= this.friction;
900
901 this.velocity.scale *= this.friction;
902
903 this.content.x += this.velocity.x;
904 this.content.y += this.velocity.y;
905
906 this.content.scale += this.velocity.scale;
907
908 if (this.isAnimating()) {
909 this.setTransform();
910 } else if (this.state !== "pointerdown") {
911 this.endAnimation();
912
913 return;
914 }
915
916 this.rAF = requestAnimationFrame(() => this.animate());
917 }
918
919 /**
920 * Calculate boundaries
921 */
922 getBounds(scale) {
923 let boundX = this.boundX;
924 let boundY = this.boundY;
925
926 if (boundX !== undefined && boundY !== undefined) {
927 return { boundX, boundY };
928 }
929
930 boundX = { from: 0, to: 0 };
931 boundY = { from: 0, to: 0 };
932
933 scale = scale || this.transform.scale;
934
935 const width = this.content.fitWidth * scale;
936 const height = this.content.fitHeight * scale;
937
938 const viewportWidth = this.viewport.width;
939 const viewportHeight = this.viewport.height;
940
941 if (width < viewportWidth) {
942 const deltaX = round((viewportWidth - width) * 0.5);
943
944 boundX.from = deltaX;
945 boundX.to = deltaX;
946 } else {
947 boundX.from = round(viewportWidth - width);
948 }
949
950 if (height < viewportHeight) {
951 const deltaY = (viewportHeight - height) * 0.5;
952
953 boundY.from = deltaY;
954 boundY.to = deltaY;
955 } else {
956 boundY.from = round(viewportHeight - height);
957 }
958
959 return { boundX, boundY };
960 }
961
962 /**
963 * Change animation velocity if boundary is reached
964 */
965 setEdgeForce() {
966 if (this.state !== "decel") {
967 return;
968 }
969
970 const bounceForce = this.option("bounceForce");
971
972 const { boundX, boundY } = this.getBounds(Math.max(this.transform.scale, this.content.scale));
973
974 let pastLeft, pastRight, pastTop, pastBottom;
975
976 if (boundX) {
977 pastLeft = this.content.x < boundX.from;
978 pastRight = this.content.x > boundX.to;
979 }
980
981 if (boundY) {
982 pastTop = this.content.y < boundY.from;
983 pastBottom = this.content.y > boundY.to;
984 }
985
986 if (pastLeft || pastRight) {
987 const bound = pastLeft ? boundX.from : boundX.to;
988 const distance = bound - this.content.x;
989
990 let force = distance * bounceForce;
991
992 const restX = this.content.x + (this.velocity.x + force) / this.friction;
993
994 if (restX >= boundX.from && restX <= boundX.to) {
995 force += this.velocity.x;
996 }
997
998 this.velocity.x = force;
999
1000 this.recalculateTransform();
1001 }
1002
1003 if (pastTop || pastBottom) {
1004 const bound = pastTop ? boundY.from : boundY.to;
1005 const distance = bound - this.content.y;
1006
1007 let force = distance * bounceForce;
1008
1009 const restY = this.content.y + (force + this.velocity.y) / this.friction;
1010
1011 if (restY >= boundY.from && restY <= boundY.to) {
1012 force += this.velocity.y;
1013 }
1014
1015 this.velocity.y = force;
1016
1017 this.recalculateTransform();
1018 }
1019 }
1020
1021 /**
1022 * Change dragging position if boundary is reached
1023 */
1024 setDragResistance() {
1025 if (this.state !== "pointerdown") {
1026 return;
1027 }
1028
1029 const { boundX, boundY } = this.getBounds(this.dragPosition.scale);
1030
1031 let pastLeft, pastRight, pastTop, pastBottom;
1032
1033 if (boundX) {
1034 pastLeft = this.dragPosition.x < boundX.from;
1035 pastRight = this.dragPosition.x > boundX.to;
1036 }
1037
1038 if (boundY) {
1039 pastTop = this.dragPosition.y < boundY.from;
1040 pastBottom = this.dragPosition.y > boundY.to;
1041 }
1042
1043 if ((pastLeft || pastRight) && !(pastLeft && pastRight)) {
1044 const bound = pastLeft ? boundX.from : boundX.to;
1045 const distance = bound - this.dragPosition.x;
1046
1047 this.dragPosition.x = bound - distance * 0.3;
1048 }
1049
1050 if ((pastTop || pastBottom) && !(pastTop && pastBottom)) {
1051 const bound = pastTop ? boundY.from : boundY.to;
1052 const distance = bound - this.dragPosition.y;
1053
1054 this.dragPosition.y = bound - distance * 0.3;
1055 }
1056 }
1057
1058 /**
1059 * Set velocity to move content to drag position
1060 */
1061 setDragForce() {
1062 if (this.state === "pointerdown") {
1063 this.velocity.x = this.dragPosition.x - this.content.x;
1064 this.velocity.y = this.dragPosition.y - this.content.y;
1065 this.velocity.scale = this.dragPosition.scale - this.content.scale;
1066 }
1067 }
1068
1069 /**
1070 * Update end values based on current velocity and friction;
1071 */
1072 recalculateTransform() {
1073 this.transform.x = this.content.x + this.velocity.x / (1 / this.friction - 1);
1074 this.transform.y = this.content.y + this.velocity.y / (1 / this.friction - 1);
1075 this.transform.scale = this.content.scale + this.velocity.scale / (1 / this.friction - 1);
1076 }
1077
1078 /**
1079 * Check if content is currently animating
1080 * @returns {Boolean}
1081 */
1082 isAnimating() {
1083 return !!(
1084 this.friction &&
1085 (Math.abs(this.velocity.x) > 0.05 || Math.abs(this.velocity.y) > 0.05 || Math.abs(this.velocity.scale) > 0.05)
1086 );
1087 }
1088
1089 /**
1090 * Set content `style.transform` value based on current animation frame
1091 */
1092 setTransform(final) {
1093 let x, y, scale;
1094
1095 if (final) {
1096 x = round(this.transform.x);
1097 y = round(this.transform.y);
1098
1099 scale = this.transform.scale;
1100
1101 this.content = { ...this.content, x, y, scale };
1102 } else {
1103 x = round(this.content.x);
1104 y = round(this.content.y);
1105
1106 scale = this.content.scale / (this.content.width / this.content.fitWidth);
1107
1108 this.content = { ...this.content, x, y };
1109 }
1110
1111 this.trigger("beforeTransform");
1112
1113 x = round(this.content.x);
1114 y = round(this.content.y);
1115
1116 if (final && this.option("zoom")) {
1117 let width;
1118 let height;
1119
1120 width = round(this.content.fitWidth * scale);
1121 height = round(this.content.fitHeight * scale);
1122
1123 this.content.width = width;
1124 this.content.height = height;
1125
1126 this.transform = { ...this.transform, width, height, scale };
1127
1128 Object.assign(this.$content.style, {
1129 width: `${width}px`,
1130 height: `${height}px`,
1131 maxWidth: "none",
1132 maxHeight: "none",
1133 transform: `translate3d(${x}px, ${y}px, 0) scale(1)`,
1134 });
1135 } else {
1136 this.$content.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
1137 }
1138
1139 this.trigger("afterTransform");
1140 }
1141
1142 /**
1143 * Stop animation loop
1144 */
1145 endAnimation(silently) {
1146 cancelAnimationFrame(this.rAF);
1147 this.rAF = null;
1148
1149 this.velocity = {
1150 x: 0,
1151 y: 0,
1152 scale: 0,
1153 };
1154
1155 this.setTransform(true);
1156
1157 this.state = "ready";
1158
1159 this.handleCursor();
1160
1161 if (silently !== true) {
1162 this.trigger("endAnimation");
1163 }
1164 }
1165
1166 /**
1167 * Update the class name depending on whether the content is scaled
1168 */
1169 handleCursor() {
1170 const draggableClass = this.option("draggableClass");
1171
1172 if (!draggableClass || !this.option("touch")) {
1173 return;
1174 }
1175
1176 if (
1177 this.option("panOnlyZoomed") == true &&
1178 this.content.width <= this.viewport.width &&
1179 this.content.height <= this.viewport.height &&
1180 this.transform.scale <= this.option("baseScale")
1181 ) {
1182 this.$container.classList.remove(draggableClass);
1183 } else {
1184 this.$container.classList.add(draggableClass);
1185 }
1186 }
1187
1188 /**
1189 * Remove observation and detach event listeners
1190 */
1191 detachEvents() {
1192 this.$content.removeEventListener("load", this.onLoad);
1193
1194 this.$container.removeEventListener("wheel", this.onWheel, { passive: false });
1195 this.$container.removeEventListener("click", this.onClick, { passive: false });
1196
1197 if (this.pointerTracker) {
1198 this.pointerTracker.stop();
1199 this.pointerTracker = null;
1200 }
1201
1202 if (this.resizeObserver) {
1203 this.resizeObserver.disconnect();
1204 this.resizeObserver = null;
1205 }
1206 }
1207
1208 /**
1209 * Clean up
1210 */
1211 destroy() {
1212 if (this.state === "destroy") {
1213 return;
1214 }
1215
1216 this.state = "destroy";
1217
1218 clearTimeout(this.updateTimer);
1219 this.updateTimer = null;
1220
1221 cancelAnimationFrame(this.rAF);
1222 this.rAF = null;
1223
1224 this.detachEvents();
1225
1226 this.detachPlugins();
1227
1228 this.resetDragPosition();
1229 }
1230}
1231
1232// Expose version
1233Panzoom.version = "__VERSION__";
1234
1235// Static properties are a recent addition that dont work in all browsers yet
1236Panzoom.Plugins = Plugins;