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
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;
|
|
|