JavaScript UI component library, includes the latest Fancybox
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

1236 lines
31 KiB

import { extend } from "../shared/utils/extend.js";
import { round } from "../shared/utils/round.js";
import { isScrollable } from "../shared/utils/isScrollable.js";
import { ResizeObserver } from "../shared/utils/ResizeObserver.js";
import { PointerTracker, getMidpoint, getDistance } from "../shared/utils/PointerTracker.js";
import { getTextNodeFromPoint } from "../shared/utils/getTextNodeFromPoint.js";
import { getFullWidth, getFullHeight, calculateAspectRatioFit } from "../shared/utils/getDimensions.js";
import { Base } from "../shared/Base/Base.js";
import { Plugins } from "./plugins/index.js";
const defaults = {
// Enable touch guestures
touch: true,
// Enable zooming
zoom: true,
// Enable pinch gesture to zoom in/out using two fingers
pinchToZoom: true,
// Disable dragging if scale level is equal to value of `baseScale` option
panOnlyZoomed: false,
// Lock axis while dragging,
// possible values: false | "x" | "y" | "xy"
lockAxis: false,
// * All friction values are inside [0, 1) interval,
// * where 0 would change instantly, but 0.99 would update extremely slowly
// Friction while panning/dragging
friction: 0.64,
// Friction while decelerating after drag end
decelFriction: 0.88,
// Friction while scaling
zoomFriction: 0.74,
// Bounciness after hitting the edge
bounceForce: 0.2,
// Initial scale level
baseScale: 1,
// Minimum scale level
minScale: 1,
// Maximum scale level
maxScale: 2,
// Default scale step while zooming
step: 0.5,
// Allow to select text,
// if enabled, dragging will be disabled when text selection is detected
textSelection: false,
// Add `click` event listener,
// possible values: true | false | function | "toggleZoom"
click: "toggleZoom",
// Add `wheel` event listener,
// possible values: true | false | function | "zoom"
wheel: "zoom",
// Value for zoom on mouse wheel
wheelFactor: 42,
// Number of wheel events after which it should stop preventing default behaviour of mouse wheel
wheelLimit: 5,
// Class name added to `$viewport` element to indicate if content is draggable
draggableClass: "is-draggable",
// Class name added to `$viewport` element to indicate that user is currently dragging
draggingClass: "is-dragging",
// Content will be scaled by this number,
// this can also be a function which should return a number, for example:
// ratio: function() { return 1 / (window.devicePixelRatio || 1) }
ratio: 1,
};
export class Panzoom extends Base {
/**
* Panzoom constructor
* @constructs Panzoom
* @param {HTMLElement} $viewport Panzoom container
* @param {Object} [options] Options for Panzoom
*/
constructor($container, options = {}) {
super(extend(true, {}, defaults, options));
this.state = "init";
this.$container = $container;
// Bind event handlers for referencability
for (const methodName of ["onLoad", "onWheel", "onClick"]) {
this[methodName] = this[methodName].bind(this);
}
this.initLayout();
this.resetValues();
this.attachPlugins(Panzoom.Plugins);
this.trigger("init");
this.updateMetrics();
this.attachEvents();
this.trigger("ready");
if (this.option("centerOnStart") === false) {
this.state = "ready";
} else {
this.panTo({
friction: 0,
});
}
$container.__Panzoom = this;
}
/**
* Create references to container, viewport and content elements
*/
initLayout() {
const $container = this.$container;
// Make sure content element exists
if (!($container instanceof HTMLElement)) {
throw new Error("Panzoom: Container not found");
}
const $content = this.option("content") || $container.querySelector(".panzoom__content");
// Make sure content element exists
if (!$content) {
throw new Error("Panzoom: Content not found");
}
this.$content = $content;
let $viewport = this.option("viewport") || $container.querySelector(".panzoom__viewport");
if (!$viewport && this.option("wrapInner") !== false) {
$viewport = document.createElement("div");
$viewport.classList.add("panzoom__viewport");
$viewport.append(...$container.childNodes);
$container.appendChild($viewport);
}
this.$viewport = $viewport || $content.parentNode;
}
/**
* Restore instance variables to default values
*/
resetValues() {
this.updateRate = this.option("updateRate", /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ? 250 : 24);
this.container = {
width: 0,
height: 0,
};
this.viewport = {
width: 0,
height: 0,
};
this.content = {
// Full content dimensions (naturalWidth/naturalHeight for images)
origWidth: 0,
origHeight: 0,
// Current dimensions of the content
width: 0,
height: 0,
// Current position; these values reflect CSS `transform` value
x: this.option("x", 0),
y: this.option("y", 0),
// Current scale; does not reflect CSS `transform` value
scale: this.option("baseScale"),
};
// End values of current pan / zoom animation
this.transform = {
x: 0,
y: 0,
scale: 1,
};
this.resetDragPosition();
}
/**
* Handle `load` event
* @param {Event} event
*/
onLoad(event) {
this.updateMetrics();
this.panTo({ scale: this.option("baseScale"), friction: 0 });
this.trigger("load", event);
}
/**
* Handle `click` event
* @param {Event} event
*/
onClick(event) {
if (event.defaultPrevented) {
return;
}
// Skip if text is selected
if (this.option("textSelection") && window.getSelection().toString().length) {
event.stopPropagation();
return;
}
const rect = this.$content.getClientRects()[0];
// Check if container has changed position (for example, when current instance is inside another one)
if (this.state !== "ready") {
if (
this.dragPosition.midPoint ||
Math.abs(rect.top - this.dragStart.rect.top) > 1 ||
Math.abs(rect.left - this.dragStart.rect.left) > 1
) {
event.preventDefault();
event.stopPropagation();
return;
}
}
if (this.trigger("click", event) === false) {
return;
}
if (this.option("zoom") && this.option("click") === "toggleZoom") {
event.preventDefault();
event.stopPropagation();
this.zoomWithClick(event);
}
}
/**
* Handle `wheel` event
* @param {Event} event
*/
onWheel(event) {
if (this.trigger("wheel", event) === false) {
return;
}
if (this.option("zoom") && this.option("wheel")) {
this.zoomWithWheel(event);
}
}
/**
* Change zoom level depending on scroll direction
* @param {Event} event `wheel` event
*/
zoomWithWheel(event) {
if (this.changedDelta === undefined) {
this.changedDelta = 0;
}
const delta = Math.max(-1, Math.min(1, -event.deltaY || -event.deltaX || event.wheelDelta || -event.detail));
const scale = this.content.scale;
let newScale = (scale * (100 + delta * this.option("wheelFactor"))) / 100;
if (
(delta < 0 && Math.abs(scale - this.option("minScale")) < 0.01) ||
(delta > 0 && Math.abs(scale - this.option("maxScale")) < 0.01)
) {
this.changedDelta += Math.abs(delta);
newScale = scale;
} else {
this.changedDelta = 0;
newScale = Math.max(Math.min(newScale, this.option("maxScale")), this.option("minScale"));
}
if (this.changedDelta > this.option("wheelLimit")) {
return;
}
event.preventDefault();
if (newScale === scale) {
return;
}
const rect = this.$content.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
this.zoomTo(newScale, { x, y });
}
/**
* Change zoom level depending on click coordinates
* @param {Event} event `click` event
*/
zoomWithClick(event) {
const rect = this.$content.getClientRects()[0];
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
this.toggleZoom({ x, y });
}
/**
* Attach load, wheel and click event listeners, initialize `resizeObserver` and `PointerTracker`
*/
attachEvents() {
this.$content.addEventListener("load", this.onLoad);
this.$container.addEventListener("wheel", this.onWheel, { passive: false });
this.$container.addEventListener("click", this.onClick, { passive: false });
this.initObserver();
const pointerTracker = new PointerTracker(this.$container, {
start: (pointer, event) => {
if (!this.option("touch")) {
return false;
}
if (this.velocity.scale < 0) {
return false;
}
const target = event.composedPath()[0];
if (!pointerTracker.currentPointers.length) {
const ignoreClickedElement =
["BUTTON", "TEXTAREA", "OPTION", "INPUT", "SELECT", "VIDEO"].indexOf(target.nodeName) !== -1;
if (ignoreClickedElement) {
return false;
}
// Allow text selection
if (this.option("textSelection") && getTextNodeFromPoint(target, pointer.clientX, pointer.clientY)) {
return false;
}
}
if (isScrollable(target)) {
return false;
}
if (this.trigger("touchStart", event) === false) {
return false;
}
if (event.type === "mousedown") {
event.preventDefault();
}
this.state = "pointerdown";
this.resetDragPosition();
this.dragPosition.midPoint = null;
this.dragPosition.time = Date.now();
return true;
},
move: (previousPointers, currentPointers, event) => {
if (this.state !== "pointerdown") {
return;
}
if (this.trigger("touchMove", event) === false) {
event.preventDefault();
return;
}
// Disable touch action if current zoom level is below base level
if (
currentPointers.length < 2 &&
this.option("panOnlyZoomed") === true &&
this.content.width <= this.viewport.width &&
this.content.height <= this.viewport.height &&
this.transform.scale <= this.option("baseScale")
) {
return;
}
if (currentPointers.length > 1 && (!this.option("zoom") || this.option("pinchToZoom") === false)) {
return;
}
const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
const panX = newMidpoint.clientX - prevMidpoint.clientX;
const panY = newMidpoint.clientY - prevMidpoint.clientY;
const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
const newDistance = getDistance(currentPointers[0], currentPointers[1]);
const scaleDiff = prevDistance && newDistance ? newDistance / prevDistance : 1;
this.dragOffset.x += panX;
this.dragOffset.y += panY;
this.dragOffset.scale *= scaleDiff;
this.dragOffset.time = Date.now() - this.dragPosition.time;
const axisToLock = this.dragStart.scale === 1 && this.option("lockAxis");
if (axisToLock && !this.lockAxis) {
if (Math.abs(this.dragOffset.x) < 6 && Math.abs(this.dragOffset.y) < 6) {
event.preventDefault();
return;
}
const angle = Math.abs((Math.atan2(this.dragOffset.y, this.dragOffset.x) * 180) / Math.PI);
this.lockAxis = angle > 45 && angle < 135 ? "y" : "x";
}
if (axisToLock !== "xy" && this.lockAxis === "y") {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (this.lockAxis) {
this.dragOffset[this.lockAxis === "x" ? "y" : "x"] = 0;
}
this.$container.classList.add(this.option("draggingClass"));
if (!(this.transform.scale === this.option("baseScale") && this.lockAxis === "y")) {
this.dragPosition.x = this.dragStart.x + this.dragOffset.x;
}
if (!(this.transform.scale === this.option("baseScale") && this.lockAxis === "x")) {
this.dragPosition.y = this.dragStart.y + this.dragOffset.y;
}
this.dragPosition.scale = this.dragStart.scale * this.dragOffset.scale;
if (currentPointers.length > 1) {
const startPoint = getMidpoint(pointerTracker.startPointers[0], pointerTracker.startPointers[1]);
const xPos = startPoint.clientX - this.dragStart.rect.x;
const yPos = startPoint.clientY - this.dragStart.rect.y;
const { deltaX, deltaY } = this.getZoomDelta(this.content.scale * this.dragOffset.scale, xPos, yPos);
this.dragPosition.x -= deltaX;
this.dragPosition.y -= deltaY;
this.dragPosition.midPoint = newMidpoint;
} else {
this.setDragResistance();
}
// Update final position
this.transform = {
x: this.dragPosition.x,
y: this.dragPosition.y,
scale: this.dragPosition.scale,
};
this.startAnimation();
},
end: (pointer, event) => {
if (this.state !== "pointerdown") {
return;
}
this._dragOffset = { ...this.dragOffset };
if (pointerTracker.currentPointers.length) {
this.resetDragPosition();
return;
}
this.state = "decel";
this.friction = this.option("decelFriction");
this.recalculateTransform();
this.$container.classList.remove(this.option("draggingClass"));
if (this.trigger("touchEnd", event) === false) {
return;
}
if (this.state !== "decel") {
return;
}
// * Check if scaled content past limits
// Below minimum
const minScale = this.option("minScale");
if (this.transform.scale < minScale) {
this.zoomTo(minScale, { friction: 0.64 });
return;
}
// Exceed maximum
const maxScale = this.option("maxScale");
if (this.transform.scale - maxScale > 0.01) {
const last = this.dragPosition.midPoint || pointer;
const rect = this.$content.getClientRects()[0];
this.zoomTo(maxScale, {
friction: 0.64,
x: last.clientX - rect.left,
y: last.clientY - rect.top,
});
return;
}
},
});
this.pointerTracker = pointerTracker;
}
initObserver() {
if (this.resizeObserver) {
return;
}
this.resizeObserver = new ResizeObserver(() => {
if (this.updateTimer) {
return;
}
this.updateTimer = setTimeout(() => {
const rect = this.$container.getBoundingClientRect();
if (!(rect.width && rect.height)) {
this.updateTimer = null;
return;
}
// Check to see if there are any changes
if (Math.abs(rect.width - this.container.width) > 1 || Math.abs(rect.height - this.container.height) > 1) {
if (this.isAnimating()) {
this.endAnimation(true);
}
this.updateMetrics();
this.panTo({
x: this.content.x,
y: this.content.y,
scale: this.option("baseScale"),
friction: 0,
});
}
this.updateTimer = null;
}, this.updateRate);
});
this.resizeObserver.observe(this.$container);
}
/**
* Restore drag related variables to default values
*/
resetDragPosition() {
this.lockAxis = null;
this.friction = this.option("friction");
this.velocity = {
x: 0,
y: 0,
scale: 0,
};
const { x, y, scale } = this.content;
this.dragStart = {
rect: this.$content.getBoundingClientRect(),
x,
y,
scale,
};
this.dragPosition = {
...this.dragPosition,
x,
y,
scale,
};
this.dragOffset = {
x: 0,
y: 0,
scale: 1,
time: 0,
};
}
/**
* Trigger update events before/after resizing content and viewport
* @param {Boolean} silently Should trigger `afterUpdate` event at the end
*/
updateMetrics(silently) {
if (silently !== true) {
this.trigger("beforeUpdate");
}
const $container = this.$container;
const $content = this.$content;
const $viewport = this.$viewport;
const contentIsImage = $content instanceof HTMLImageElement;
const contentIsZoomable = this.option("zoom");
const shouldResizeParent = this.option("resizeParent", contentIsZoomable);
let width = this.option("width");
let height = this.option("height");
let origWidth = width || getFullWidth($content);
let origHeight = height || getFullHeight($content);
Object.assign($content.style, {
width: width ? `${width}px` : "",
height: height ? `${height}px` : "",
maxWidth: "",
maxHeight: "",
});
if (shouldResizeParent) {
Object.assign($viewport.style, { width: "", height: "" });
}
const ratio = this.option("ratio");
origWidth = round(origWidth * ratio);
origHeight = round(origHeight * ratio);
width = origWidth;
height = origHeight;
const contentRect = $content.getBoundingClientRect();
const viewportRect = $viewport.getBoundingClientRect();
const containerRect = $viewport == $container ? viewportRect : $container.getBoundingClientRect();
let viewportWidth = Math.max($viewport.offsetWidth, round(viewportRect.width));
let viewportHeight = Math.max($viewport.offsetHeight, round(viewportRect.height));
let viewportStyles = window.getComputedStyle($viewport);
viewportWidth -= parseFloat(viewportStyles.paddingLeft) + parseFloat(viewportStyles.paddingRight);
viewportHeight -= parseFloat(viewportStyles.paddingTop) + parseFloat(viewportStyles.paddingBottom);
this.viewport.width = viewportWidth;
this.viewport.height = viewportHeight;
if (contentIsZoomable) {
if (Math.abs(origWidth - contentRect.width) > 0.1 || Math.abs(origHeight - contentRect.height) > 0.1) {
const rez = calculateAspectRatioFit(
origWidth,
origHeight,
Math.min(origWidth, contentRect.width),
Math.min(origHeight, contentRect.height)
);
width = round(rez.width);
height = round(rez.height);
}
Object.assign($content.style, {
width: `${width}px`,
height: `${height}px`,
transform: "",
});
}
if (shouldResizeParent) {
Object.assign($viewport.style, { width: `${width}px`, height: `${height}px` });
this.viewport = { ...this.viewport, width, height };
}
if (contentIsImage && contentIsZoomable && typeof this.options.maxScale !== "function") {
const maxScale = this.option("maxScale");
this.options.maxScale = function () {
return this.content.origWidth > 0 && this.content.fitWidth > 0
? this.content.origWidth / this.content.fitWidth
: maxScale;
};
}
this.content = {
...this.content,
origWidth,
origHeight,
fitWidth: width,
fitHeight: height,
width,
height,
scale: 1,
isZoomable: contentIsZoomable,
};
this.container = { width: containerRect.width, height: containerRect.height };
if (silently !== true) {
this.trigger("afterUpdate");
}
}
/**
* Increase zoom level
* @param {Number} [step] Zoom ratio; `0.5` would increase scale from 1 to 1.5
*/
zoomIn(step) {
this.zoomTo(this.content.scale + (step || this.option("step")));
}
/**
* Decrease zoom level
* @param {Number} [step] Zoom ratio; `0.5` would decrease scale from 1.5 to 1
*/
zoomOut(step) {
this.zoomTo(this.content.scale - (step || this.option("step")));
}
/**
* Toggles zoom level between max and base levels
* @param {Object} [options] Additional options
*/
toggleZoom(props = {}) {
const maxScale = this.option("maxScale");
const baseScale = this.option("baseScale");
const scale = this.content.scale > baseScale + (maxScale - baseScale) * 0.5 ? baseScale : maxScale;
this.zoomTo(scale, props);
}
/**
* Animate to given zoom level
* @param {Number} scale New zoom level
* @param {Object} [options] Additional options
*/
zoomTo(scale = this.option("baseScale"), { x = null, y = null } = {}) {
scale = Math.max(Math.min(scale, this.option("maxScale")), this.option("minScale"));
// Adjust zoom position
const currentScale = round(this.content.scale / (this.content.width / this.content.fitWidth), 10000000);
if (x === null) {
x = this.content.width * currentScale * 0.5;
}
if (y === null) {
y = this.content.height * currentScale * 0.5;
}
const { deltaX, deltaY } = this.getZoomDelta(scale, x, y);
x = this.content.x - deltaX;
y = this.content.y - deltaY;
this.panTo({ x, y, scale, friction: this.option("zoomFriction") });
}
/**
* Calculate difference for top/left values if content would scale at given coordinates
* @param {Number} scale
* @param {Number} x
* @param {Number} y
* @returns {Object}
*/
getZoomDelta(scale, x = 0, y = 0) {
const currentWidth = this.content.fitWidth * this.content.scale;
const currentHeight = this.content.fitHeight * this.content.scale;
const percentXInCurrentBox = x > 0 && currentWidth ? x / currentWidth : 0;
const percentYInCurrentBox = y > 0 && currentHeight ? y / currentHeight : 0;
const nextWidth = this.content.fitWidth * scale;
const nextHeight = this.content.fitHeight * scale;
const deltaX = (nextWidth - currentWidth) * percentXInCurrentBox;
const deltaY = (nextHeight - currentHeight) * percentYInCurrentBox;
return { deltaX, deltaY };
}
/**
* Animate to given positon and/or zoom level
* @param {Object} [options] Additional options
*/
panTo({
x = this.content.x,
y = this.content.y,
scale,
friction = this.option("friction"),
ignoreBounds = false,
} = {}) {
scale = scale || this.content.scale || 1;
if (!ignoreBounds) {
const { boundX, boundY } = this.getBounds(scale);
if (boundX) {
x = Math.max(Math.min(x, boundX.to), boundX.from);
}
if (boundY) {
y = Math.max(Math.min(y, boundY.to), boundY.from);
}
}
this.friction = friction;
this.transform = {
...this.transform,
x,
y,
scale,
};
if (friction) {
this.state = "panning";
this.velocity = {
x: (1 / this.friction - 1) * (x - this.content.x),
y: (1 / this.friction - 1) * (y - this.content.y),
scale: (1 / this.friction - 1) * (scale - this.content.scale),
};
this.startAnimation();
} else {
this.endAnimation();
}
}
/**
* Start animation loop
*/
startAnimation() {
if (!this.rAF) {
this.trigger("startAnimation");
} else {
cancelAnimationFrame(this.rAF);
}
this.rAF = requestAnimationFrame(() => this.animate());
}
/**
* Process animation frame
*/
animate() {
this.setEdgeForce();
this.setDragForce();
this.velocity.x *= this.friction;
this.velocity.y *= this.friction;
this.velocity.scale *= this.friction;
this.content.x += this.velocity.x;
this.content.y += this.velocity.y;
this.content.scale += this.velocity.scale;
if (this.isAnimating()) {
this.setTransform();
} else if (this.state !== "pointerdown") {
this.endAnimation();
return;
}
this.rAF = requestAnimationFrame(() => this.animate());
}
/**
* Calculate boundaries
*/
getBounds(scale) {
let boundX = this.boundX;
let boundY = this.boundY;
if (boundX !== undefined && boundY !== undefined) {
return { boundX, boundY };
}
boundX = { from: 0, to: 0 };
boundY = { from: 0, to: 0 };
scale = scale || this.transform.scale;
const width = this.content.fitWidth * scale;
const height = this.content.fitHeight * scale;
const viewportWidth = this.viewport.width;
const viewportHeight = this.viewport.height;
if (width < viewportWidth) {
const deltaX = round((viewportWidth - width) * 0.5);
boundX.from = deltaX;
boundX.to = deltaX;
} else {
boundX.from = round(viewportWidth - width);
}
if (height < viewportHeight) {
const deltaY = (viewportHeight - height) * 0.5;
boundY.from = deltaY;
boundY.to = deltaY;
} else {
boundY.from = round(viewportHeight - height);
}
return { boundX, boundY };
}
/**
* Change animation velocity if boundary is reached
*/
setEdgeForce() {
if (this.state !== "decel") {
return;
}
const bounceForce = this.option("bounceForce");
const { boundX, boundY } = this.getBounds(Math.max(this.transform.scale, this.content.scale));
let pastLeft, pastRight, pastTop, pastBottom;
if (boundX) {
pastLeft = this.content.x < boundX.from;
pastRight = this.content.x > boundX.to;
}
if (boundY) {
pastTop = this.content.y < boundY.from;
pastBottom = this.content.y > boundY.to;
}
if (pastLeft || pastRight) {
const bound = pastLeft ? boundX.from : boundX.to;
const distance = bound - this.content.x;
let force = distance * bounceForce;
const restX = this.content.x + (this.velocity.x + force) / this.friction;
if (restX >= boundX.from && restX <= boundX.to) {
force += this.velocity.x;
}
this.velocity.x = force;
this.recalculateTransform();
}
if (pastTop || pastBottom) {
const bound = pastTop ? boundY.from : boundY.to;
const distance = bound - this.content.y;
let force = distance * bounceForce;
const restY = this.content.y + (force + this.velocity.y) / this.friction;
if (restY >= boundY.from && restY <= boundY.to) {
force += this.velocity.y;
}
this.velocity.y = force;
this.recalculateTransform();
}
}
/**
* Change dragging position if boundary is reached
*/
setDragResistance() {
if (this.state !== "pointerdown") {
return;
}
const { boundX, boundY } = this.getBounds(this.dragPosition.scale);
let pastLeft, pastRight, pastTop, pastBottom;
if (boundX) {
pastLeft = this.dragPosition.x < boundX.from;
pastRight = this.dragPosition.x > boundX.to;
}
if (boundY) {
pastTop = this.dragPosition.y < boundY.from;
pastBottom = this.dragPosition.y > boundY.to;
}
if ((pastLeft || pastRight) && !(pastLeft && pastRight)) {
const bound = pastLeft ? boundX.from : boundX.to;
const distance = bound - this.dragPosition.x;
this.dragPosition.x = bound - distance * 0.3;
}
if ((pastTop || pastBottom) && !(pastTop && pastBottom)) {
const bound = pastTop ? boundY.from : boundY.to;
const distance = bound - this.dragPosition.y;
this.dragPosition.y = bound - distance * 0.3;
}
}
/**
* Set velocity to move content to drag position
*/
setDragForce() {
if (this.state === "pointerdown") {
this.velocity.x = this.dragPosition.x - this.content.x;
this.velocity.y = this.dragPosition.y - this.content.y;
this.velocity.scale = this.dragPosition.scale - this.content.scale;
}
}
/**
* Update end values based on current velocity and friction;
*/
recalculateTransform() {
this.transform.x = this.content.x + this.velocity.x / (1 / this.friction - 1);
this.transform.y = this.content.y + this.velocity.y / (1 / this.friction - 1);
this.transform.scale = this.content.scale + this.velocity.scale / (1 / this.friction - 1);
}
/**
* Check if content is currently animating
* @returns {Boolean}
*/
isAnimating() {
return !!(
this.friction &&
(Math.abs(this.velocity.x) > 0.05 || Math.abs(this.velocity.y) > 0.05 || Math.abs(this.velocity.scale) > 0.05)
);
}
/**
* Set content `style.transform` value based on current animation frame
*/
setTransform(final) {
let x, y, scale;
if (final) {
x = round(this.transform.x);
y = round(this.transform.y);
scale = this.transform.scale;
this.content = { ...this.content, x, y, scale };
} else {
x = round(this.content.x);
y = round(this.content.y);
scale = this.content.scale / (this.content.width / this.content.fitWidth);
this.content = { ...this.content, x, y };
}
this.trigger("beforeTransform");
x = round(this.content.x);
y = round(this.content.y);
if (final && this.option("zoom")) {
let width;
let height;
width = round(this.content.fitWidth * scale);
height = round(this.content.fitHeight * scale);
this.content.width = width;
this.content.height = height;
this.transform = { ...this.transform, width, height, scale };
Object.assign(this.$content.style, {
width: `${width}px`,
height: `${height}px`,
maxWidth: "none",
maxHeight: "none",
transform: `translate3d(${x}px, ${y}px, 0) scale(1)`,
});
} else {
this.$content.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
}
this.trigger("afterTransform");
}
/**
* Stop animation loop
*/
endAnimation(silently) {
cancelAnimationFrame(this.rAF);
this.rAF = null;
this.velocity = {
x: 0,
y: 0,
scale: 0,
};
this.setTransform(true);
this.state = "ready";
this.handleCursor();
if (silently !== true) {
this.trigger("endAnimation");
}
}
/**
* Update the class name depending on whether the content is scaled
*/
handleCursor() {
const draggableClass = this.option("draggableClass");
if (!draggableClass || !this.option("touch")) {
return;
}
if (
this.option("panOnlyZoomed") == true &&
this.content.width <= this.viewport.width &&
this.content.height <= this.viewport.height &&
this.transform.scale <= this.option("baseScale")
) {
this.$container.classList.remove(draggableClass);
} else {
this.$container.classList.add(draggableClass);
}
}
/**
* Remove observation and detach event listeners
*/
detachEvents() {
this.$content.removeEventListener("load", this.onLoad);
this.$container.removeEventListener("wheel", this.onWheel, { passive: false });
this.$container.removeEventListener("click", this.onClick, { passive: false });
if (this.pointerTracker) {
this.pointerTracker.stop();
this.pointerTracker = null;
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
/**
* Clean up
*/
destroy() {
if (this.state === "destroy") {
return;
}
this.state = "destroy";
clearTimeout(this.updateTimer);
this.updateTimer = null;
cancelAnimationFrame(this.rAF);
this.rAF = null;
this.detachEvents();
this.detachPlugins();
this.resetDragPosition();
}
}
// Expose version
Panzoom.version = "__VERSION__";
// Static properties are a recent addition that dont work in all browsers yet
Panzoom.Plugins = Plugins;