/*!
* PhotoSwipe 5.1.8 - https://photoswipe.com
* (c) 2022 Dmitry Semenov
*/
/**
* Creates element and optionally appends it to another.
*
* @param {String} className
* @param {String|NULL} tagName
* @param {Element|NULL} appendToEl
*/
function createElement(className, tagName, appendToEl) {
const el = document.createElement(tagName || 'div');
if (className) {
el.className = className;
}
if (appendToEl) {
appendToEl.appendChild(el);
}
return el;
}
function equalizePoints(p1, p2) {
p1.x = p2.x;
p1.y = p2.y;
if (p2.id !== undefined) {
p1.id = p2.id;
}
return p1;
}
function roundPoint(p) {
p.x = Math.round(p.x);
p.y = Math.round(p.y);
}
/**
* Returns distance between two points.
*
* @param {Object} p1 Point
* @param {Object} p2 Point
*/
function getDistanceBetween(p1, p2) {
const x = Math.abs(p1.x - p2.x);
const y = Math.abs(p1.y - p2.y);
return Math.sqrt((x * x) + (y * y));
}
/**
* Whether X and Y positions of points are qual
*
* @param {Object} p1
* @param {Object} p2
*/
function pointsEqual(p1, p2) {
return p1.x === p2.x && p1.y === p2.y;
}
/**
* The float result between the min and max values.
*
* @param {Number} val
* @param {Number} min
* @param {Number} max
*/
function clamp(val, min, max) {
return Math.min(Math.max(val, min), max);
}
/**
* Get transform string
*
* @param {Number} x
* @param {Number|null} y
* @param {Number|null} scale
*/
function toTransformString(x, y, scale) {
let propValue = 'translate3d('
+ x + 'px,' + (y || 0) + 'px'
+ ',0)';
if (scale !== undefined) {
propValue += ' scale3d('
+ scale + ',' + scale
+ ',1)';
}
return propValue;
}
/**
* Apply transform:translate(x, y) scale(scale) to element
*
* @param {DOMElement} el
* @param {Number} x
* @param {Number|null} y
* @param {Number|null} scale
*/
function setTransform(el, x, y, scale) {
el.style.transform = toTransformString(x, y, scale);
}
const defaultCSSEasing = 'cubic-bezier(.4,0,.22,1)';
/**
* Apply CSS transition to element
*
* @param {Element} el
* @param {String} prop CSS property to animate
* @param {Number} duration in ms
* @param {String|NULL} ease CSS easing function
*/
function setTransitionStyle(el, prop, duration, ease) {
// inOut: 'cubic-bezier(.4, 0, .22, 1)', // for "toggle state" transitions
// out: 'cubic-bezier(0, 0, .22, 1)', // for "show" transitions
// in: 'cubic-bezier(.4, 0, 1, 1)'// for "hide" transitions
el.style.transition = prop
? (prop + ' ' + duration + 'ms ' + (ease || defaultCSSEasing))
: 'none';
}
/**
* Apply width and height CSS properties to element
*/
function setWidthHeight(el, w, h) {
el.style.width = (typeof w === 'number') ? (w + 'px') : w;
el.style.height = (typeof h === 'number') ? (h + 'px') : h;
}
function removeTransitionStyle(el) {
setTransitionStyle(el);
}
function decodeImage(img) {
if ('decode' in img) {
return img.decode();
}
if (img.complete) {
return Promise.resolve(img);
}
return new Promise((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = reject;
});
}
const LOAD_STATE = {
IDLE: 'idle',
LOADING: 'loading',
LOADED: 'loaded',
ERROR: 'error',
};
/**
* Check if click or keydown event was dispatched
* with a special key or via mouse wheel.
*
* @param {Event} e
*/
function specialKeyUsed(e) {
if (e.which === 2 || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return true;
}
}
/**
* Parse `gallery` or `children` options.
*
* @param {Element|NodeList|String} option
* @param {String|null} legacySelector
* @param {Element|null} parent
* @returns Element[]
*/
function getElementsFromOption(option, legacySelector, parent = document) {
let elements = [];
if (option instanceof Element) {
elements = [option];
} else if (option instanceof NodeList || Array.isArray(option)) {
elements = Array.from(option);
} else {
const selector = typeof option === 'string' ? option : legacySelector;
if (selector) {
elements = Array.from(parent.querySelectorAll(selector));
}
}
return elements;
}
// Detect passive event listener support
let supportsPassive = false;
/* eslint-disable */
try {
window.addEventListener('test', null, Object.defineProperty({}, 'passive', {
get: () => {
supportsPassive = true;
}
}));
} catch (e) {}
/* eslint-enable */
class DOMEvents {
constructor() {
this._pool = [];
}
/**
* Adds event listeners
*
* @param {DOMElement} target
* @param {String} type Can be multiple, separated by space.
* @param {Function} listener
* @param {Boolean} passive
*/
add(target, type, listener, passive) {
this._toggleListener(target, type, listener, passive);
}
/**
* Removes event listeners
*
* @param {DOMElement} target
* @param {String} type
* @param {Function} listener
* @param {Boolean} passive
*/
remove(target, type, listener, passive) {
this._toggleListener(target, type, listener, passive, true);
}
/**
* Removes all bound events
*/
removeAll() {
this._pool.forEach((poolItem) => {
this._toggleListener(
poolItem.target,
poolItem.type,
poolItem.listener,
poolItem.passive,
true,
true
);
});
this._pool = [];
}
/**
* Adds or removes event
*
* @param {DOMElement} target
* @param {String} type
* @param {Function} listener
* @param {Boolean} passive
* @param {Boolean} unbind Whether the event should be added or removed
* @param {Boolean} skipPool Whether events pool should be skipped
*/
_toggleListener(target, type, listener, passive, unbind, skipPool) {
if (!target) {
return;
}
const methodName = (unbind ? 'remove' : 'add') + 'EventListener';
type = type.split(' ');
type.forEach((eType) => {
if (eType) {
// Events pool is used to easily unbind all events when PhotoSwipe is closed,
// so developer doesn't need to do this manually
if (!skipPool) {
if (unbind) {
// Remove from the events pool
this._pool = this._pool.filter((poolItem) => {
return poolItem.type !== eType
|| poolItem.listener !== listener
|| poolItem.target !== target;
});
} else {
// Add to the events pool
this._pool.push({
target,
type: eType,
listener,
passive
});
}
}
// most PhotoSwipe events call preventDefault,
// and we do not need browser to scroll the page
const eventOptions = supportsPassive ? { passive: (passive || false) } : false;
target[methodName](
eType,
listener,
eventOptions
);
}
});
}
}
function getViewportSize(options, pswp) {
if (options.getViewportSizeFn) {
const newViewportSize = options.getViewportSizeFn(options, pswp);
if (newViewportSize) {
return newViewportSize;
}
}
return {
x: document.documentElement.clientWidth,
// TODO: height on mobile is very incosistent due to toolbar
// find a way to improve this
//
// document.documentElement.clientHeight - doesn't seem to work well
y: window.innerHeight
};
}
/**
* Parses padding option.
* Supported formats:
*
* // Object
* padding: {
* top: 0,
* bottom: 0,
* left: 0,
* right: 0
* }
*
* // A function that returns the object
* paddingFn: (viewportSize) => {
* return {
* top: 0,
* bottom: 0,
* left: 0,
* right: 0
* };
* }
*
* // Legacy variant
* paddingLeft: 0,
* paddingRight: 0,
* paddingTop: 0,
* paddingBottom: 0,
*
* @param {String} prop 'left', 'top', 'bottom', 'right'
* @param {Object} options PhotoSwipe options
* @param {Object} viewportSize PhotoSwipe viewport size, for example: { x:800, y:600 }
* @returns {Number}
*/
function parsePaddingOption(prop, options, viewportSize) {
let paddingValue;
if (options.paddingFn) {
paddingValue = options.paddingFn(viewportSize)[prop];
} else if (options.padding) {
paddingValue = options.padding[prop];
} else {
const legacyPropName = 'padding' + prop[0].toUpperCase() + prop.slice(1);
if (options[legacyPropName]) {
paddingValue = options[legacyPropName];
}
}
return paddingValue || 0;
}
function getPanAreaSize(options, viewportSize/*, pswp*/) {
return {
x: viewportSize.x
- parsePaddingOption('left', options, viewportSize)
- parsePaddingOption('right', options, viewportSize),
y: viewportSize.y
- parsePaddingOption('top', options, viewportSize)
- parsePaddingOption('bottom', options, viewportSize)
};
}
/**
* Calculates minimum, maximum and initial (center) bounds of a slide
*/
class PanBounds {
constructor(slide) {
this.slide = slide;
this.currZoomLevel = 1;
this.center = {};
this.max = {};
this.min = {};
this.reset();
}
// _getItemBounds
update(currZoomLevel) {
this.currZoomLevel = currZoomLevel;
if (!this.slide.width) {
this.reset();
} else {
this._updateAxis('x');
this._updateAxis('y');
this.slide.pswp.dispatch('calcBounds', { slide: this.slide });
}
}
// _calculateItemBoundsForAxis
_updateAxis(axis) {
const { pswp } = this.slide;
const elSize = this.slide[axis === 'x' ? 'width' : 'height'] * this.currZoomLevel;
const paddingProp = axis === 'x' ? 'left' : 'top';
const padding = parsePaddingOption(paddingProp, pswp.options, pswp.viewportSize);
const panAreaSize = this.slide.panAreaSize[axis];
// Default position of element.
// By defaul it is center of viewport:
this.center[axis] = Math.round((panAreaSize - elSize) / 2) + padding;
// maximum pan position
this.max[axis] = (elSize > panAreaSize)
? Math.round(panAreaSize - elSize) + padding
: this.center[axis];
// minimum pan position
this.min[axis] = (elSize > panAreaSize)
? padding
: this.center[axis];
}
// _getZeroBounds
reset() {
this.center.x = 0;
this.center.y = 0;
this.max.x = 0;
this.max.y = 0;
this.min.x = 0;
this.min.y = 0;
}
/**
* Correct pan position if it's beyond the bounds
*
* @param {String} axis x or y
* @param {Object} panOffset
*/
correctPan(axis, panOffset) { // checkPanBounds
return clamp(panOffset, this.max[axis], this.min[axis]);
}
}
/**
* Calculates zoom levels for specific slide.
* Depends on viewport size and image size.
*/
const MAX_IMAGE_WIDTH = 4000;
class ZoomLevel {
/**
* @param {Object} options PhotoSwipe options
* @param {Object} itemData Slide data
* @param {Integer} index Slide index
* @param {PhotoSwipe|undefined} pswp PhotoSwipe instance, can be undefined if not initialized yet
*/
constructor(options, itemData, index, pswp) {
this.pswp = pswp;
this.options = options;
this.itemData = itemData;
this.index = index;
}
/**
* Calculate initial, secondary and maximum zoom level for the specified slide.
*
* It should be called when either image or viewport size changes.
*
* @param {Slide} slide
*/
update(maxWidth, maxHeight, panAreaSize) {
this.elementSize = {
x: maxWidth,
y: maxHeight
};
this.panAreaSize = panAreaSize;
const hRatio = this.panAreaSize.x / this.elementSize.x;
const vRatio = this.panAreaSize.y / this.elementSize.y;
this.fit = Math.min(1, hRatio < vRatio ? hRatio : vRatio);
this.fill = Math.min(1, hRatio > vRatio ? hRatio : vRatio);
// zoom.vFill defines zoom level of the image
// when it has 100% of viewport vertical space (height)
this.vFill = Math.min(1, vRatio);
this.initial = this._getInitial();
this.secondary = this._getSecondary();
this.max = Math.max(
this.initial,
this.secondary,
this._getMax()
);
this.min = Math.min(
this.fit,
this.initial,
this.secondary
);
if (this.pswp) {
this.pswp.dispatch('zoomLevelsUpdate', { zoomLevels: this, slideData: this.itemData });
}
}
/**
* Parses user-defined zoom option.
*
* @param {Mixed} optionPrefix Zoom level option prefix (initial, secondary, max)
*/
_parseZoomLevelOption(optionPrefix) {
// zoom.initial
// zoom.secondary
// zoom.max
const optionValue = this.options[optionPrefix + 'ZoomLevel'];
if (!optionValue) {
return;
}
if (typeof optionValue === 'function') {
return optionValue(this);
}
if (optionValue === 'fill') {
return this.fill;
}
if (optionValue === 'fit') {
return this.fit;
}
return Number(optionValue);
}
/**
* Get zoom level to which image will be zoomed after double-tap gesture,
* or when user clicks on zoom icon,
* or mouse-click on image itself.
* If you return 1 image will be zoomed to its original size.
*
* @return {Number}
*/
_getSecondary() {
let currZoomLevel = this._parseZoomLevelOption('secondary');
if (currZoomLevel) {
return currZoomLevel;
}
// 3x of "fit" state, but not larger than original
currZoomLevel = Math.min(1, this.fit * 3);
if (currZoomLevel * this.elementSize.x > MAX_IMAGE_WIDTH) {
currZoomLevel = MAX_IMAGE_WIDTH / this.elementSize.x;
}
return currZoomLevel;
}
/**
* Get initial image zoom level.
*
* @return {Number}
*/
_getInitial() {
return this._parseZoomLevelOption('initial') || this.fit;
}
/**
* Maximum zoom level when user zooms
* via zoom/pinch gesture,
* via cmd/ctrl-wheel or via trackpad.
*
* @return {Number}
*/
_getMax() {
const currZoomLevel = this._parseZoomLevelOption('max');
if (currZoomLevel) {
return currZoomLevel;
}
// max zoom level is x4 from "fit state",
// used for zoom gesture and ctrl/trackpad zoom
return Math.max(1, this.fit * 4);
}
}
class Placeholder {
/**
* @param {String|false} imageSrc
* @param {Element} container
*/
constructor(imageSrc, container) {
// Create placeholder
// (stretched thumbnail or simple div behind the main image)
this.element = createElement(
'pswp__img pswp__img--placeholder',
imageSrc ? 'img' : '',
container
);
if (imageSrc) {
this.element.decoding = 'async';
this.element.alt = '';
this.element.src = imageSrc;
this.element.setAttribute('role', 'presentation');
}
this.element.setAttribute('aria-hiden', 'true');
}
setDisplayedSize(width, height) {
if (!this.element) {
return;
}
if (this.element.tagName === 'IMG') {
// Use transform scale() to modify img placeholder size
// (instead of changing width/height directly).
// This helps with performance, specifically in iOS15 Safari.
setWidthHeight(this.element, 250, 'auto');
this.element.style.transformOrigin = '0 0';
this.element.style.transform = toTransformString(0, 0, width / 250);
} else {
setWidthHeight(this.element, width, height);
}
}
destroy() {
if (this.element.parentNode) {
this.element.remove();
}
this.element = null;
}
}
/**
* Renders and allows to control a single slide
*/
class Slide {
constructor(data, index, pswp) {
this.data = data;
this.index = index;
this.pswp = pswp;
this.isActive = (index === pswp.currIndex);
this.currentResolution = 0;
this.panAreaSize = {};
this.isFirstSlide = (this.isActive && !pswp.opener.isOpen);
this.zoomLevels = new ZoomLevel(pswp.options, data, index, pswp);
this.pswp.dispatch('gettingData', {
slide: this,
data: this.data,
index
});
this.pan = {
x: 0,
y: 0
};
this.content = this.pswp.contentLoader.getContentBySlide(this);
this.currZoomLevel = 1;
this.width = this.content.width;
this.height = this.content.height;
this.bounds = new PanBounds(this);
this.prevDisplayedWidth = -1;
this.prevDisplayedHeight = -1;
this.pswp.dispatch('slideInit', { slide: this });
}
/**
* If this slide is active/current/visible
*
* @param {Boolean} isActive
*/
setIsActive(isActive) {
if (isActive && !this.isActive) {
// slide just became active
this.activate();
} else if (!isActive && this.isActive) {
// slide just became non-active
this.deactivate();
}
}
/**
* Appends slide content to DOM
*/
append(holderElement) {
this.holderElement = holderElement;
// Slide appended to DOM
if (!this.data) {
this.holderElement.innerHTML = '';
return;
}
this.calculateSize();
this.container = createElement('pswp__zoom-wrap');
this.container.transformOrigin = '0 0';
this.load();
this.appendHeavy();
this.updateContentSize();
this.holderElement.innerHTML = '';
this.holderElement.appendChild(this.container);
this.zoomAndPanToInitial();
this.pswp.dispatch('firstZoomPan', { slide: this });
this.applyCurrentZoomPan();
this.pswp.dispatch('afterSetContent', { slide: this });
if (this.isActive) {
this.activate();
}
}
removePlaceholder() {
if (this.placeholder && this.content && !this.content.keepPlaceholder()) {
// With delay, as image might be loaded, but not decoded
setTimeout(() => {
if (this.placeholder) {
this.placeholder.destroy();
this.placeholder = null;
}
}, 500);
}
}
load() {
if (this.usePlaceholder() && !this.placeholder) {
const placeholderSrc = this.pswp.applyFilters(
'placeholderSrc',
(this.data.msrc && this.isFirstSlide) ? this.data.msrc : false,
this
);
this.placeholder = new Placeholder(
placeholderSrc,
this.container
);
}
this.content.load();
this.pswp.dispatch('slideLoad', { slide: this });
}
isLoading() {
return this.pswp.applyFilters(
'isSlideLoading',
this.content.isLoading && this.content.isLoading(),
this
);
}
/**
* Append "heavy" DOM elements
*
* This may depend on a type of slide,
* but generally these are large images.
*/
appendHeavy() {
const { pswp } = this;
const appendHeavyNearby = true;
// Avoid appending heavy elements during animations
if (this.heavyAppended
|| !pswp.opener.isOpen
|| pswp.mainScroll.isShifted()
|| (!this.isActive && !appendHeavyNearby)) {
return;
}
if (this.pswp.dispatch('appendHeavy', { slide: this }).defaultPrevented) {
return;
}
this.heavyAppended = true;
if (this.content.state === LOAD_STATE.ERROR) {
this.displayError();
} else {
this.content.appendTo(this.container);
if (this.placeholder && this.content.state === LOAD_STATE.LOADED) {
this.removePlaceholder();
}
}
this.pswp.dispatch('appendHeavyContent', { slide: this });
}
/**
* Append HTML content to slide container
* (usually item.html or error message)
*
* @param {DOMElement} containerEl
* @param {String} html
*/
setSlideHTML(html) {
const { container } = this;
if (html.tagName) {
container.appendChild(html);
} else {
container.innerHTML = html;
}
}
displayError() {
const errorElement = this.content.getErrorElement();
errorElement.style.position = 'absolute';
errorElement.style.left = 0;
errorElement.style.top = 0;
this.activeErrorElement = errorElement;
this.setSlideHTML(errorElement);
this.updateContentSize(true);
}
/**
* Triggered when this slide is active (selected).
*
* If it's part of opening/closing transition -
* activate() will trigger after the transition is ended.
*/
activate() {
this.isActive = true;
this.appendHeavy();
this.content.activate();
this.pswp.dispatch('slideActivate', { slide: this });
}
/**
* Triggered when this slide becomes inactive.
*
* Slide can become inactive only after it was active.
*/
deactivate() {
this.isActive = false;
this.content.deactivate();
// reset zoom level
this.currentResolution = 0;
this.zoomAndPanToInitial();
this.applyCurrentZoomPan();
this.updateContentSize();
this.pswp.dispatch('slideDeactivate', { slide: this });
}
/**
* The slide should destroy itself, it will never be used again.
* (unbind all events and destroy internal components)
*/
destroy() {
this.content.remove();
this.pswp.dispatch('slideDestroy', { slide: this });
}
resize() {
if (this.currZoomLevel === this.zoomLevels.initial || !this.isActive) {
// Keep initial zoom level if it was before the resize,
// as well as when this slide is not active
// Reset position and scale to original state
this.calculateSize();
this.currentResolution = 0;
this.zoomAndPanToInitial();
this.applyCurrentZoomPan();
this.updateContentSize();
} else {
// readjust pan position if it's beyond the bounds
this.calculateSize();
this.bounds.update(this.currZoomLevel);
this.panTo(this.pan.x, this.pan.y);
}
}
/**
* Apply size to current slide content,
* based on the current resolution and scale.
*
* @param {Boolean} force if size should be updated even if dimensions weren't changed
*/
updateContentSize(force) {
// Use initial zoom level
// if resolution is not defined (user didn't zoom yet)
const scaleMultiplier = this.currentResolution || this.zoomLevels.initial;
if (!scaleMultiplier) {
return;
}
const width = Math.round(this.width * scaleMultiplier) || this.pswp.viewportSize.x;
const height = Math.round(this.height * scaleMultiplier) || this.pswp.viewportSize.y;
if (!this.sizeChanged(width, height) && !force) {
return;
}
if (this.placeholder) {
this.placeholder.setDisplayedSize(width, height);
}
if (this.activeErrorElement) {
setWidthHeight(this.activeErrorElement, width, height);
}
this.content.setDisplayedSize(width, height);
}
sizeChanged(width, height) {
if (width !== this.prevDisplayedWidth
|| height !== this.prevDisplayedHeight) {
this.prevDisplayedWidth = width;
this.prevDisplayedHeight = height;
return true;
}
return false;
}
getPlaceholderElement() {
if (this.placeholder) {
return this.placeholder.element;
}
}
/**
* Zoom current slide image to...
*
* @param {Number} destZoomLevel Destination zoom level.
* @param {Object|false} centerPoint Transform origin center point,
* or false if viewport center should be used.
* @param {Number} transitionDuration Transition duration, may be set to 0.
* @param {Boolean|null} ignoreBounds Minimum and maximum zoom levels will be ignored.
* @return {Boolean|null} Returns true if animated.
*/
zoomTo(destZoomLevel, centerPoint, transitionDuration, ignoreBounds) {
const { pswp } = this;
if (!this.isZoomable()
|| pswp.mainScroll.isShifted()) {
return;
}
pswp.dispatch('beforeZoomTo', {
destZoomLevel, centerPoint, transitionDuration
});
// stop all pan and zoom transitions
pswp.animations.stopAllPan();
// if (!centerPoint) {
// centerPoint = pswp.getViewportCenterPoint();
// }
const prevZoomLevel = this.currZoomLevel;
if (!ignoreBounds) {
destZoomLevel = clamp(destZoomLevel, this.zoomLevels.min, this.zoomLevels.max);
}
// if (transitionDuration === undefined) {
// transitionDuration = this.pswp.options.zoomAnimationDuration;
// }
this.setZoomLevel(destZoomLevel);
this.pan.x = this.calculateZoomToPanOffset('x', centerPoint, prevZoomLevel);
this.pan.y = this.calculateZoomToPanOffset('y', centerPoint, prevZoomLevel);
roundPoint(this.pan);
const finishTransition = () => {
this._setResolution(destZoomLevel);
this.applyCurrentZoomPan();
};
if (!transitionDuration) {
finishTransition();
} else {
pswp.animations.startTransition({
isPan: true,
name: 'zoomTo',
target: this.container,
transform: this.getCurrentTransform(),
onComplete: finishTransition,
duration: transitionDuration,
easing: pswp.options.easing
});
}
}
toggleZoom(centerPoint) {
this.zoomTo(
this.currZoomLevel === this.zoomLevels.initial
? this.zoomLevels.secondary : this.zoomLevels.initial,
centerPoint,
this.pswp.options.zoomAnimationDuration
);
}
/**
* Updates zoom level property and recalculates new pan bounds,
* unlike zoomTo it does not apply transform (use applyCurrentZoomPan)
*
* @param {Number} currZoomLevel
*/
setZoomLevel(currZoomLevel) {
this.currZoomLevel = currZoomLevel;
this.bounds.update(this.currZoomLevel);
}
/**
* Get pan position after zoom at a given `point`.
*
* Always call setZoomLevel(newZoomLevel) beforehand to recalculate
* pan bounds according to the new zoom level.
*
* @param {String} axis
* @param {Object|null} centerPoint point based on which zoom is performed,
* usually refers to the current mouse position,
* if false - viewport center will be used.
* @param {Number|null} prevZoomLevel Zoom level before new zoom was applied.
*/
calculateZoomToPanOffset(axis, point, prevZoomLevel) {
const totalPanDistance = this.bounds.max[axis] - this.bounds.min[axis];
if (totalPanDistance === 0) {
return this.bounds.center[axis];
}
if (!point) {
point = this.pswp.getViewportCenterPoint();
}
const zoomFactor = this.currZoomLevel / prevZoomLevel;
return this.bounds.correctPan(
axis,
(this.pan[axis] - point[axis]) * zoomFactor + point[axis]
);
}
/**
* Apply pan and keep it within bounds.
*
* @param {Number} panX
* @param {Number} panY
*/
panTo(panX, panY) {
this.pan.x = this.bounds.correctPan('x', panX);
this.pan.y = this.bounds.correctPan('y', panY);
this.applyCurrentZoomPan();
}
/**
* If the slide in the current state can be panned by the user
*/
isPannable() {
return this.width && (this.currZoomLevel > this.zoomLevels.fit);
}
/**
* If the slide can be zoomed
*/
isZoomable() {
return this.width && this.content.isZoomable();
}
usePlaceholder() {
return this.content.usePlaceholder();
}
/**
* Apply transform and scale based on
* the current pan position (this.pan) and zoom level (this.currZoomLevel)
*/
applyCurrentZoomPan() {
this._applyZoomTransform(this.pan.x, this.pan.y, this.currZoomLevel);
if (this === this.pswp.currSlide) {
this.pswp.dispatch('zoomPanUpdate', { slide: this });
}
}
zoomAndPanToInitial() {
this.currZoomLevel = this.zoomLevels.initial;
// pan according to the zoom level
this.bounds.update(this.currZoomLevel);
equalizePoints(this.pan, this.bounds.center);
this.pswp.dispatch('initialZoomPan', { slide: this });
}
/**
* Set translate and scale based on current resolution
*
* @param {Number} x
* @param {Number} y
* @param {Number} zoom
*/
_applyZoomTransform(x, y, zoom) {
zoom /= this.currentResolution || this.zoomLevels.initial;
setTransform(this.container, x, y, zoom);
}
calculateSize() {
// this.zoomLevels.fit = 1;
// this.zoomLevels.vFill = 1;
// this.zoomLevels.initial = 1;
const { pswp } = this;
equalizePoints(
this.panAreaSize,
getPanAreaSize(pswp.options, pswp.viewportSize)
);
this.zoomLevels.update(this.width, this.height, this.panAreaSize);
pswp.dispatch('calcSlideSize', {
slide: this
});
}
getCurrentTransform() {
const scale = this.currZoomLevel / (this.currentResolution || this.zoomLevels.initial);
return toTransformString(this.pan.x, this.pan.y, scale);
}
/**
* Set resolution and re-render the image.
*
* For example, if the real image size is 2000x1500,
* and resolution is 0.5 - it will be rendered as 1000x750.
*
* Image with zoom level 2 and resolution 0.5 is
* the same as image with zoom level 1 and resolution 1.
*
* Used to optimize animations and make
* sure that browser renders image in highest quality.
* Also used by responsive images to load the correct one.
*
* @param {Number} newResolution
*/
_setResolution(newResolution) {
if (newResolution === this.currentResolution) {
return;
}
this.currentResolution = newResolution;
this.updateContentSize();
this.pswp.dispatch('resolutionChanged');
}
}
/**
* Handles single pointer dragging
*/
const PAN_END_FRICTION = 0.35;
const VERTICAL_DRAG_FRICTION = 0.6;
// 1 corresponds to the third of viewport height
const MIN_RATIO_TO_CLOSE = 0.4;
// Minimum speed required to navigate
// to next or previous slide
const MIN_NEXT_SLIDE_SPEED = 0.5;
function project(initialVelocity, decelerationRate) {
return initialVelocity * decelerationRate / (1 - decelerationRate);
}
class DragHandler {
constructor(gestures) {
this.gestures = gestures;
this.pswp = gestures.pswp;
this.startPan = {};
}
start() {
equalizePoints(this.startPan, this.pswp.currSlide.pan);
this.pswp.animations.stopAll();
}
change() {
const { p1, prevP1, dragAxis, pswp } = this.gestures;
const { currSlide } = pswp;
if (dragAxis === 'y'
&& pswp.options.closeOnVerticalDrag
&& currSlide.currZoomLevel <= currSlide.zoomLevels.fit
&& !this.gestures.isMultitouch) {
// Handle vertical drag to close
const panY = currSlide.pan.y + (p1.y - prevP1.y);
if (!pswp.dispatch('verticalDrag', { panY }).defaultPrevented) {
this._setPanWithFriction('y', panY, VERTICAL_DRAG_FRICTION);
const bgOpacity = 1 - Math.abs(this._getVerticalDragRatio(currSlide.pan.y));
pswp.applyBgOpacity(bgOpacity);
currSlide.applyCurrentZoomPan();
}
} else {
const mainScrollChanged = this._panOrMoveMainScroll('x');
if (!mainScrollChanged) {
this._panOrMoveMainScroll('y');
roundPoint(currSlide.pan);
currSlide.applyCurrentZoomPan();
}
}
}
end() {
const { pswp, velocity } = this.gestures;
const { mainScroll } = pswp;
let indexDiff = 0;
pswp.animations.stopAll();
// Handle main scroll if it's shifted
if (mainScroll.isShifted()) {
// Position of the main scroll relative to the viewport
const mainScrollShiftDiff = mainScroll.x - mainScroll.getCurrSlideX();
// Ratio between 0 and 1:
// 0 - slide is not visible at all,
// 0.5 - half of the slide is vicible
// 1 - slide is fully visible
const currentSlideVisibilityRatio = (mainScrollShiftDiff / pswp.viewportSize.x);
// Go next slide.
//
// - if velocity and its direction is matched
// and we see at least tiny part of the next slide
//
// - or if we see less than 50% of the current slide
// and velocity is close to 0
//
if ((velocity.x < -MIN_NEXT_SLIDE_SPEED && currentSlideVisibilityRatio < 0)
|| (velocity.x < 0.1 && currentSlideVisibilityRatio < -0.5)) {
// Go to next slide
indexDiff = 1;
velocity.x = Math.min(velocity.x, 0);
} else if ((velocity.x > MIN_NEXT_SLIDE_SPEED && currentSlideVisibilityRatio > 0)
|| (velocity.x > -0.1 && currentSlideVisibilityRatio > 0.5)) {
// Go to prev slide
indexDiff = -1;
velocity.x = Math.max(velocity.x, 0);
}
mainScroll.moveIndexBy(indexDiff, true, velocity.x);
}
// Restore zoom level
if ((pswp.currSlide.currZoomLevel > pswp.currSlide.zoomLevels.max
&& this.pswp.options.limitMaxZoom)
|| this.gestures.isMultitouch) {
this.gestures.zoomLevels.correctZoomPan(true);
} else {
// we run two animations instead of one,
// as each axis has own pan boundaries and thus different spring function
// (correctZoomPan does not have this functionality,
// it animates all properties with single timing function)
this._finishPanGestureForAxis('x');
this._finishPanGestureForAxis('y');
}
}
_finishPanGestureForAxis(axis) {
const { pswp } = this;
const { currSlide } = pswp;
const { velocity } = this.gestures;
const { pan, bounds } = currSlide;
const panPos = pan[axis];
const restoreBgOpacity = (pswp.bgOpacity < 1 && axis === 'y');
// 0.995 means - scroll view loses 0.5% of its velocity per millisecond
// Inceasing this number will reduce travel distance
const decelerationRate = 0.995; // 0.99
// Pan position if there is no bounds
const projectedPosition = panPos + project(velocity[axis], decelerationRate);
if (restoreBgOpacity) {
const vDragRatio = this._getVerticalDragRatio(panPos);
const projectedVDragRatio = this._getVerticalDragRatio(projectedPosition);
// If we are above and moving upwards,
// or if we are below and moving downwards
if ((vDragRatio < 0 && projectedVDragRatio < -MIN_RATIO_TO_CLOSE)
|| (vDragRatio > 0 && projectedVDragRatio > MIN_RATIO_TO_CLOSE)) {
pswp.close();
return;
}
}
// Pan position with corrected bounds
const correctedPanPosition = bounds.correctPan(axis, projectedPosition);
// Exit if pan position should not be changed
// or if speed it too low
if (panPos === correctedPanPosition) {
return;
}
// Overshoot if the final position is out of pan bounds
const dampingRatio = (correctedPanPosition === projectedPosition) ? 1 : 0.82;
const initialBgOpacity = pswp.bgOpacity;
const totalPanDist = correctedPanPosition - panPos;
pswp.animations.startSpring({
name: 'panGesture' + axis,
isPan: true,
start: panPos,
end: correctedPanPosition,
velocity: velocity[axis],
dampingRatio,
onUpdate: (pos) => {
// Animate opacity of background relative to Y pan position of an image
if (restoreBgOpacity && pswp.bgOpacity < 1) {
// 0 - start of animation, 1 - end of animation
const animationProgressRatio = 1 - (correctedPanPosition - pos) / totalPanDist;
// We clamp opacity to keep it between 0 and 1.
// As progress ratio can be larger than 1 due to overshoot,
// and we do not want to bounce opacity.
pswp.applyBgOpacity(clamp(
initialBgOpacity + (1 - initialBgOpacity) * animationProgressRatio,
0,
1
));
}
pan[axis] = Math.floor(pos);
currSlide.applyCurrentZoomPan();
},
});
}
/**
* Update position of the main scroll,
* or/and update pan position of the current slide.
*
* Should return true if it changes (or can change) main scroll.
*
* @param {String} axis
*/
_panOrMoveMainScroll(axis) {
const { p1, pswp, dragAxis, prevP1, isMultitouch } = this.gestures;
const { currSlide, mainScroll } = pswp;
const delta = (p1[axis] - prevP1[axis]);
const newMainScrollX = mainScroll.x + delta;
if (!delta) {
return;
}
// Always move main scroll if image can not be panned
if (axis === 'x' && !currSlide.isPannable() && !isMultitouch) {
mainScroll.moveTo(newMainScrollX, true);
return true; // changed main scroll
}
const { bounds } = currSlide;
const newPan = currSlide.pan[axis] + delta;
if (pswp.options.allowPanToNext
&& dragAxis === 'x'
&& axis === 'x'
&& !isMultitouch) {
const currSlideMainScrollX = mainScroll.getCurrSlideX();
// Position of the main scroll relative to the viewport
const mainScrollShiftDiff = mainScroll.x - currSlideMainScrollX;
const isLeftToRight = delta > 0;
const isRightToLeft = !isLeftToRight;
if (newPan > bounds.min[axis] && isLeftToRight) {
// Panning from left to right, beyond the left edge
// Wether the image was at minimum pan position (or less)
// when this drag gesture started.
// Minimum pan position refers to the left edge of the image.
const wasAtMinPanPosition = (bounds.min[axis] <= this.startPan[axis]);
if (wasAtMinPanPosition) {
mainScroll.moveTo(newMainScrollX, true);
return true;
} else {
this._setPanWithFriction(axis, newPan);
//currSlide.pan[axis] = newPan;
}
} else if (newPan < bounds.max[axis] && isRightToLeft) {
// Paning from right to left, beyond the right edge
// Maximum pan position refers to the right edge of the image.
const wasAtMaxPanPosition = (this.startPan[axis] <= bounds.max[axis]);
if (wasAtMaxPanPosition) {
mainScroll.moveTo(newMainScrollX, true);
return true;
} else {
this._setPanWithFriction(axis, newPan);
//currSlide.pan[axis] = newPan;
}
} else {
// If main scroll is shifted
if (mainScrollShiftDiff !== 0) {
// If main scroll is shifted right
if (mainScrollShiftDiff > 0 /*&& isRightToLeft*/) {
mainScroll.moveTo(Math.max(newMainScrollX, currSlideMainScrollX), true);
return true;
} else if (mainScrollShiftDiff < 0 /*&& isLeftToRight*/) {
// Main scroll is shifted left (Position is less than 0 comparing to the viewport 0)
mainScroll.moveTo(Math.min(newMainScrollX, currSlideMainScrollX), true);
return true;
}
} else {
// We are within pan bounds, so just pan
this._setPanWithFriction(axis, newPan);
}
}
} else {
if (axis === 'y') {
// Do not pan vertically if main scroll is shifted o
if (!mainScroll.isShifted() && bounds.min.y !== bounds.max.y) {
this._setPanWithFriction(axis, newPan);
}
} else {
this._setPanWithFriction(axis, newPan);
}
}
}
//
// If we move above - the ratio is negative
// If we move below the ratio is positive
/**
* Relation between pan Y position and third of viewport height.
*
* When we are at initial position (center bounds) - the ratio is 0,
* if position is shifted upwards - the ratio is negative,
* if position is shifted downwards - the ratio is positive.
*
* @param {Number} panY The current pan Y position.
*/
_getVerticalDragRatio(panY) {
return (panY - this.pswp.currSlide.bounds.center.y)
/ (this.pswp.viewportSize.y / 3);
}
/**
* Set pan position of the current slide.
* Apply friction if the position is beyond the pan bounds,
* or if custom friction is defined.
*
* @param {String} axis
* @param {Number} potentialPan
* @param {Number|null} customFriction (0.1 - 1)
*/
_setPanWithFriction(axis, potentialPan, customFriction) {
const { pan, bounds } = this.pswp.currSlide;
const correctedPan = bounds.correctPan(axis, potentialPan);
// If we are out of pan bounds
if (correctedPan !== potentialPan || customFriction) {
const delta = Math.round(potentialPan - pan[axis]);
pan[axis] += delta * (customFriction || PAN_END_FRICTION);
} else {
pan[axis] = potentialPan;
}
}
}
const UPPER_ZOOM_FRICTION = 0.05;
const LOWER_ZOOM_FRICTION = 0.15;
/**
* Get center point between two points
*
* @param {Point} p
* @param {Point} p1
* @param {Point} p2
*/
function getZoomPointsCenter(p, p1, p2) {
p.x = (p1.x + p2.x) / 2;
p.y = (p1.y + p2.y) / 2;
return p;
}
class ZoomHandler {
constructor(gestures) {
this.gestures = gestures;
this.pswp = this.gestures.pswp;
this._startPan = {};
this._startZoomPoint = {};
this._zoomPoint = {};
}
start() {
this._startZoomLevel = this.pswp.currSlide.currZoomLevel;
equalizePoints(this._startPan, this.pswp.currSlide.pan);
this.pswp.animations.stopAllPan();
this._wasOverFitZoomLevel = false;
}
change() {
const { p1, startP1, p2, startP2, pswp } = this.gestures;
const { currSlide } = pswp;
const minZoomLevel = currSlide.zoomLevels.min;
const maxZoomLevel = currSlide.zoomLevels.max;
if (!currSlide.isZoomable() || pswp.mainScroll.isShifted()) {
return;
}
getZoomPointsCenter(this._startZoomPoint, startP1, startP2);
getZoomPointsCenter(this._zoomPoint, p1, p2);
let currZoomLevel = (1 / getDistanceBetween(startP1, startP2))
* getDistanceBetween(p1, p2)
* this._startZoomLevel;
// if (!this.zoomStarted) {
// this.zoomStarted = true;
// pswp.dispatch('zoomGestureStarted');
// }
// slightly over the zoom.fit
if (currZoomLevel > currSlide.zoomLevels.initial + (currSlide.zoomLevels.initial / 15)) {
this._wasOverFitZoomLevel = true;
}
if (currZoomLevel < minZoomLevel) {
if (pswp.options.pinchToClose
&& !this._wasOverFitZoomLevel
&& this._startZoomLevel <= currSlide.zoomLevels.initial) {
// fade out background if zooming out
const bgOpacity = 1 - ((minZoomLevel - currZoomLevel) / (minZoomLevel / 1.2));
if (!pswp.dispatch('pinchClose', { bgOpacity }).defaultPrevented) {
pswp.applyBgOpacity(bgOpacity);
}
} else {
// Apply the friction if zoom level is below the min
currZoomLevel = minZoomLevel - (minZoomLevel - currZoomLevel) * LOWER_ZOOM_FRICTION;
}
} else if (currZoomLevel > maxZoomLevel) {
// Apply the friction if zoom level is above the max
currZoomLevel = maxZoomLevel + (currZoomLevel - maxZoomLevel) * UPPER_ZOOM_FRICTION;
}
currSlide.pan.x = this._calculatePanForZoomLevel('x', currZoomLevel);
currSlide.pan.y = this._calculatePanForZoomLevel('y', currZoomLevel);
currSlide.setZoomLevel(currZoomLevel);
currSlide.applyCurrentZoomPan();
//_isZoomingIn = currZoomLevel > _currZoomLevel;
//_currZoomLevel = currZoomLevel;
//_applyCurrentZoomPan();
//_updatePrevPoints();
}
end() {
const { pswp } = this;
const { currSlide } = pswp;
if (currSlide.currZoomLevel < currSlide.zoomLevels.initial
&& !this._wasOverFitZoomLevel
&& pswp.options.pinchToClose) {
pswp.close();
} else {
this.correctZoomPan();
}
}
_calculatePanForZoomLevel(axis, currZoomLevel) {
const zoomFactor = currZoomLevel / this._startZoomLevel;
return this._zoomPoint[axis]
- ((this._startZoomPoint[axis] - this._startPan[axis]) * zoomFactor);
}
/**
* Correct currZoomLevel and pan if they are
* beyond minimum or maximum values.
* With animation.
*
* @param {Boolean} ignoreGesture Wether gesture coordinates should be ignored
* when calculating destination pan position.
*/
correctZoomPan(ignoreGesture) {
const { pswp } = this;
const { currSlide } = pswp;
if (!currSlide.isZoomable()) {
return;
}
if (this._zoomPoint.x === undefined) {
ignoreGesture = true;
}
const prevZoomLevel = currSlide.currZoomLevel;
let destinationZoomLevel;
let currZoomLevelNeedsChange = true;
if (prevZoomLevel < currSlide.zoomLevels.initial) {
destinationZoomLevel = currSlide.zoomLevels.initial;
// zoom to min
} else if (prevZoomLevel > currSlide.zoomLevels.max) {
destinationZoomLevel = currSlide.zoomLevels.max;
// zoom to max
} else {
currZoomLevelNeedsChange = false;
destinationZoomLevel = prevZoomLevel;
}
const initialBgOpacity = pswp.bgOpacity;
const restoreBgOpacity = pswp.bgOpacity < 1;
const initialPan = equalizePoints({}, currSlide.pan);
let destinationPan = equalizePoints({}, initialPan);
if (ignoreGesture) {
this._zoomPoint.x = 0;
this._zoomPoint.y = 0;
this._startZoomPoint.x = 0;
this._startZoomPoint.y = 0;
this._startZoomLevel = prevZoomLevel;
equalizePoints(this._startPan, initialPan);
}
if (currZoomLevelNeedsChange) {
destinationPan = {
x: this._calculatePanForZoomLevel('x', destinationZoomLevel),
y: this._calculatePanForZoomLevel('y', destinationZoomLevel)
};
}
// set zoom level, so pan bounds are updated according to it
currSlide.setZoomLevel(destinationZoomLevel);
destinationPan = {
x: currSlide.bounds.correctPan('x', destinationPan.x),
y: currSlide.bounds.correctPan('y', destinationPan.y)
};
// return zoom level and its bounds to initial
currSlide.setZoomLevel(prevZoomLevel);
let panNeedsChange = true;
if (pointsEqual(destinationPan, initialPan)) {
panNeedsChange = false;
}
if (!panNeedsChange && !currZoomLevelNeedsChange && !restoreBgOpacity) {
// update resolution after gesture
currSlide._setResolution(destinationZoomLevel);
currSlide.applyCurrentZoomPan();
// nothing to animate
return;
}
pswp.animations.stopAllPan();
pswp.animations.startSpring({
isPan: true,
start: 0,
end: 1000,
velocity: 0,
dampingRatio: 1,
naturalFrequency: 40,
onUpdate: (now) => {
now /= 1000; // 0 - start, 1 - end
if (panNeedsChange || currZoomLevelNeedsChange) {
if (panNeedsChange) {
currSlide.pan.x = initialPan.x + (destinationPan.x - initialPan.x) * now;
currSlide.pan.y = initialPan.y + (destinationPan.y - initialPan.y) * now;
}
if (currZoomLevelNeedsChange) {
const newZoomLevel = prevZoomLevel
+ (destinationZoomLevel - prevZoomLevel) * now;
currSlide.setZoomLevel(newZoomLevel);
}
currSlide.applyCurrentZoomPan();
}
// Restore background opacity
if (restoreBgOpacity && pswp.bgOpacity < 1) {
// We clamp opacity to keep it between 0 and 1.
// As progress ratio can be larger than 1 due to overshoot,
// and we do not want to bounce opacity.
pswp.applyBgOpacity(clamp(
initialBgOpacity + (1 - initialBgOpacity) * now, 0, 1
));
}
},
onComplete: () => {
// update resolution after transition ends
currSlide._setResolution(destinationZoomLevel);
currSlide.applyCurrentZoomPan();
}
});
}
}
/**
* Tap, double-tap handler.
*/
/**
* Whether the tap was performed on the main slide
* (rather than controls or caption).
*
* @param {Event} event
*/
function didTapOnMainContent(event) {
return !!(event.target.closest('.pswp__container'));
}
class TapHandler {
constructor(gestures) {
this.gestures = gestures;
}
click(point, originalEvent) {
const targetClassList = originalEvent.target.classList;
const isImageClick = targetClassList.contains('pswp__img');
const isBackgroundClick = targetClassList.contains('pswp__item')
|| targetClassList.contains('pswp__zoom-wrap');
if (isImageClick) {
this._doClickOrTapAction('imageClick', point, originalEvent);
} else if (isBackgroundClick) {
this._doClickOrTapAction('bgClick', point, originalEvent);
}
}
tap(point, originalEvent) {
if (didTapOnMainContent(originalEvent)) {
this._doClickOrTapAction('tap', point, originalEvent);
}
}
doubleTap(point, originalEvent) {
if (didTapOnMainContent(originalEvent)) {
this._doClickOrTapAction('doubleTap', point, originalEvent);
}
}
_doClickOrTapAction(actionName, point, originalEvent) {
const { pswp } = this.gestures;
const { currSlide } = pswp;
const optionValue = pswp.options[actionName + 'Action'];
if (pswp.dispatch(actionName + 'Action', { point, originalEvent }).defaultPrevented) {
return;
}
if (typeof optionValue === 'function') {
optionValue.call(pswp, point, originalEvent);
return;
}
switch (optionValue) {
case 'close':
case 'next':
pswp[optionValue]();
break;
case 'zoom':
currSlide.toggleZoom(point);
break;
case 'zoom-or-close':
// by default click zooms current image,
// if it can not be zoomed - gallery will be closed
if (currSlide.isZoomable()
&& currSlide.zoomLevels.secondary !== currSlide.zoomLevels.initial) {
currSlide.toggleZoom(point);
} else if (pswp.options.clickToCloseNonZoomable) {
pswp.close();
}
break;
case 'toggle-controls':
this.gestures.pswp.template.classList.toggle('pswp--ui-visible');
// if (_controlsVisible) {
// _ui.hideControls();
// } else {
// _ui.showControls();
// }
break;
}
}
}
/**
* Gestures class bind touch, pointer or mouse events
* and emits drag to drag-handler and zoom events zoom-handler.
*
* Drag and zoom events are emited in requestAnimationFrame,
* and only when one of pointers was actually changed.
*/
// How far should user should drag
// until we can determine that the gesture is swipe and its direction
const AXIS_SWIPE_HYSTERISIS = 10;
//const PAN_END_FRICTION = 0.35;
const DOUBLE_TAP_DELAY = 300; // ms
const MIN_TAP_DISTANCE = 25; // px
class Gestures {
constructor(pswp) {
this.pswp = pswp;
// point objects are defined once and reused
// PhotoSwipe keeps track only of two pointers, others are ignored
this.p1 = {}; // the first pressed pointer
this.p2 = {}; // the second pressed pointer
this.prevP1 = {};
this.prevP2 = {};
this.startP1 = {};
this.startP2 = {};
this.velocity = {};
this._lastStartP1 = {};
this._intervalP1 = {};
this._numActivePoints = 0;
this._ongoingPointers = [];
this._touchEventEnabled = 'ontouchstart' in window;
this._pointerEventEnabled = !!(window.PointerEvent);
this.supportsTouch = this._touchEventEnabled
|| (this._pointerEventEnabled && navigator.maxTouchPoints > 1);
if (!this.supportsTouch) {
// disable pan to next slide for non-touch devices
pswp.options.allowPanToNext = false;
}
this.drag = new DragHandler(this);
this.zoomLevels = new ZoomHandler(this);
this.tapHandler = new TapHandler(this);
pswp.on('bindEvents', () => {
pswp.events.add(pswp.scrollWrap, 'click', e => this._onClick(e));
if (this._pointerEventEnabled) {
this._bindEvents('pointer', 'down', 'up', 'cancel');
} else if (this._touchEventEnabled) {
this._bindEvents('touch', 'start', 'end', 'cancel');
// In previous versions we also bound mouse event here,
// in case device supports both touch and mouse events,
// but newer versions of browsers now support PointerEvent.
// on iOS10 if you bind touchmove/end after touchstart,
// and you don't preventDefault touchstart (which PhotoSwipe does),
// preventDefault will have no effect on touchmove and touchend.
// Unless you bind it previously.
pswp.scrollWrap.ontouchmove = () => {}; // eslint-disable-line
pswp.scrollWrap.ontouchend = () => {}; // eslint-disable-line
} else {
this._bindEvents('mouse', 'down', 'up');
}
});
}
_bindEvents(pref, down, up, cancel) {
const { pswp } = this;
const { events } = pswp;
const cancelEvent = cancel ? pref + cancel : '';
events.add(pswp.scrollWrap, pref + down, this.onPointerDown.bind(this));
events.add(window, pref + 'move', this.onPointerMove.bind(this));
events.add(window, pref + up, this.onPointerUp.bind(this));
if (cancelEvent) {
events.add(pswp.scrollWrap, cancelEvent, this.onPointerUp.bind(this));
}
}
onPointerDown(e) {
// We do not call preventDefault for touch events
// to allow browser to show native dialog on longpress
// (the one that allows to save image or open it in new tab).
//
// Desktop Safari allows to drag images when preventDefault isn't called on mousedown,
// even though preventDefault IS called on mousemove. That's why we preventDefault mousedown.
let isMousePointer;
if (e.type === 'mousedown' || e.pointerType === 'mouse') {
isMousePointer = true;
}
// Allow dragging only via left mouse button.
// http://www.quirksmode.org/js/events_properties.html
// https://developer.mozilla.org/en-US/docs/Web/API/event.button
if (isMousePointer && e.button > 0) {
return;
}
const { pswp } = this;
// if PhotoSwipe is opening or closing
if (!pswp.opener.isOpen) {
e.preventDefault();
return;
}
if (pswp.dispatch('pointerDown', { originalEvent: e }).defaultPrevented) {
return;
}
if (isMousePointer) {
pswp.mouseDetected();
// preventDefault mouse event to prevent
// browser image drag feature
this._preventPointerEventBehaviour(e);
}
pswp.animations.stopAll();
this._updatePoints(e, 'down');
this.pointerDown = true;
if (this._numActivePoints === 1) {
this.dragAxis = null;
// we need to store initial point to determine the main axis,
// drag is activated only after the axis is determined
equalizePoints(this.startP1, this.p1);
}
if (this._numActivePoints > 1) {
// Tap or double tap should not trigger if more than one pointer
this._clearTapTimer();
this.isMultitouch = true;
} else {
this.isMultitouch = false;
}
}
onPointerMove(e) {
e.preventDefault(); // always preventDefault move event
if (!this._numActivePoints) {
return;
}
this._updatePoints(e, 'move');
if (this.pswp.dispatch('pointerMove', { originalEvent: e }).defaultPrevented) {
return;
}
if (this._numActivePoints === 1 && !this.isDragging) {
if (!this.dragAxis) {
this._calculateDragDirection();
}
// Drag axis was detected, emit drag.start
if (this.dragAxis && !this.isDragging) {
if (this.isZooming) {
this.isZooming = false;
this.zoomLevels.end();
}
this.isDragging = true;
this._clearTapTimer(); // Tap can not trigger after drag
// Adjust starting point
this._updateStartPoints();
this._intervalTime = Date.now();
//this._startTime = this._intervalTime;
this._velocityCalculated = false;
equalizePoints(this._intervalP1, this.p1);
this.velocity.x = 0;
this.velocity.y = 0;
this.drag.start();
this._rafStopLoop();
this._rafRenderLoop();
}
} else if (this._numActivePoints > 1 && !this.isZooming) {
this._finishDrag();
this.isZooming = true;
// Adjust starting points
this._updateStartPoints();
this.zoomLevels.start();
this._rafStopLoop();
this._rafRenderLoop();
}
}
_finishDrag() {
if (this.isDragging) {
this.isDragging = false;
// Try to calculate velocity,
// if it wasn't calculated yet in drag.change
if (!this._velocityCalculated) {
this._updateVelocity(true);
}
this.drag.end();
this.dragAxis = null;
}
}
onPointerUp(e) {
if (!this._numActivePoints) {
return;
}
this._updatePoints(e, 'up');
if (this.pswp.dispatch('pointerUp', { originalEvent: e }).defaultPrevented) {
return;
}
if (this._numActivePoints === 0) {
this.pointerDown = false;
this._rafStopLoop();
if (this.isDragging) {
this._finishDrag();
} else if (!this.isZooming && !this.isMultitouch) {
//this.zoomLevels.correctZoomPan();
this._finishTap(e);
}
}
if (this._numActivePoints < 2 && this.isZooming) {
this.isZooming = false;
this.zoomLevels.end();
if (this._numActivePoints === 1) {
// Since we have 1 point left, we need to reinitiate drag
this.dragAxis = null;
this._updateStartPoints();
}
}
}
_rafRenderLoop() {
if (this.isDragging || this.isZooming) {
this._updateVelocity();
if (this.isDragging) {
// make sure that pointer moved since the last update
if (!pointsEqual(this.p1, this.prevP1)) {
this.drag.change();
}
} else /* if (this.isZooming) */ {
if (!pointsEqual(this.p1, this.prevP1)
|| !pointsEqual(this.p2, this.prevP2)) {
this.zoomLevels.change();
}
}
this._updatePrevPoints();
this.raf = requestAnimationFrame(this._rafRenderLoop.bind(this));
}
}
/**
* Update velocity at 50ms interval
*/
_updateVelocity(force) {
const time = Date.now();
const duration = time - this._intervalTime;
if (duration < 50 && !force) {
return;
}
this.velocity.x = this._getVelocity('x', duration);
this.velocity.y = this._getVelocity('y', duration);
this._intervalTime = time;
equalizePoints(this._intervalP1, this.p1);
this._velocityCalculated = true;
}
_finishTap(e) {
const { mainScroll } = this.pswp;
// Do not trigger tap events if main scroll is shifted
if (mainScroll.isShifted()) {
// restore main scroll position
// (usually happens if stopped in the middle of animation)
mainScroll.moveIndexBy(0, true);
return;
}
// Do not trigger tap for touchcancel or pointercancel
if (e.type.indexOf('cancel') > 0) {
return;
}
// Trigger click instead of tap for mouse events
if (e.type === 'mouseup' || e.pointerType === 'mouse') {
this.tapHandler.click(this.startP1, e);
return;
}
// Disable delay if there is no doubleTapAction
const tapDelay = this.pswp.options.doubleTapAction ? DOUBLE_TAP_DELAY : 0;
// If tapTimer is defined - we tapped recently,
// check if the current tap is close to the previous one,
// if yes - trigger double tap
if (this._tapTimer) {
this._clearTapTimer();
// Check if two taps were more or less on the same place
if (getDistanceBetween(this._lastStartP1, this.startP1) < MIN_TAP_DISTANCE) {
this.tapHandler.doubleTap(this.startP1, e);
}
} else {
equalizePoints(this._lastStartP1, this.startP1);
this._tapTimer = setTimeout(() => {
this.tapHandler.tap(this.startP1, e);
this._clearTapTimer();
}, tapDelay);
}
}
_clearTapTimer() {
if (this._tapTimer) {
clearTimeout(this._tapTimer);
this._tapTimer = null;
}
}
/**
* Get velocity for axis
*
* @param {Number} axis
* @param {Number} duration
*/
_getVelocity(axis, duration) {
// displacement is like distance, but can be negative.
const displacement = this.p1[axis] - this._intervalP1[axis];
if (Math.abs(displacement) > 1 && duration > 5) {
return displacement / duration;
}
return 0;
}
_rafStopLoop() {
if (this.raf) {
cancelAnimationFrame(this.raf);
this.raf = null;
}
}
// eslint-disable-next-line class-methods-use-this
_preventPointerEventBehaviour(e) {
// TODO find a way to disable e.preventDefault on some elements
// via event or some class or something
e.preventDefault();
return true;
}
/**
* Parses and normalizes points from the touch, mouse or pointer event.
* Updates p1 and p2.
*
* @param {Event} e
* @param {String} pointerType Normalized pointer type ('up', 'down' or 'move')
*/
_updatePoints(e, pointerType) {
if (this._pointerEventEnabled) {
// Try to find the current pointer in ongoing pointers by its ID
const pointerIndex = this._ongoingPointers.findIndex((ongoingPoiner) => {
return ongoingPoiner.id === e.pointerId;
});
if (pointerType === 'up' && pointerIndex > -1) {
// release the pointer - remove it from ongoing
this._ongoingPointers.splice(pointerIndex, 1);
} else if (pointerType === 'down' && pointerIndex === -1) {
// add new pointer
this._ongoingPointers.push(this._convertEventPosToPoint(e, {}));
} else if (pointerIndex > -1) {
// update existing pointer
this._convertEventPosToPoint(e, this._ongoingPointers[pointerIndex]);
}
this._numActivePoints = this._ongoingPointers.length;
// update points that PhotoSwipe uses
// to calculate position and scale
if (this._numActivePoints > 0) {
equalizePoints(this.p1, this._ongoingPointers[0]);
}
if (this._numActivePoints > 1) {
equalizePoints(this.p2, this._ongoingPointers[1]);
}
} else {
this._numActivePoints = 0;
if (e.type.indexOf('touch') > -1) {
// Touch Event
// https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent
if (e.touches && e.touches.length > 0) {
this._convertEventPosToPoint(e.touches[0], this.p1);
this._numActivePoints++;
if (e.touches.length > 1) {
this._convertEventPosToPoint(e.touches[1], this.p2);
this._numActivePoints++;
}
}
} else {
// Mouse Event
this._convertEventPosToPoint(e, this.p1);
if (pointerType === 'up') {
// clear all points on mouseup
this._numActivePoints = 0;
} else {
this._numActivePoints++;
}
}
}
}
// update points that were used during previous rAF tick
_updatePrevPoints() {
equalizePoints(this.prevP1, this.p1);
equalizePoints(this.prevP2, this.p2);
}
// update points at the start of gesture
_updateStartPoints() {
equalizePoints(this.startP1, this.p1);
equalizePoints(this.startP2, this.p2);
this._updatePrevPoints();
}
_calculateDragDirection() {
if (this.pswp.mainScroll.isShifted()) {
// if main scroll position is shifted – direction is always horizontal
this.dragAxis = 'x';
} else {
// calculate delta of the last touchmove tick
const diff = Math.abs(this.p1.x - this.startP1.x) - Math.abs(this.p1.y - this.startP1.y);
if (diff !== 0) {
// check if pointer was shifted horizontally or vertically
const axisToCheck = diff > 0 ? 'x' : 'y';
if (Math.abs(this.p1[axisToCheck] - this.startP1[axisToCheck]) >= AXIS_SWIPE_HYSTERISIS) {
this.dragAxis = axisToCheck;
}
}
}
}
/**
* Converts touch, pointer or mouse event
* to PhotoSwipe point.
*
* @param {Event} e
* @param {Point} p
*/
_convertEventPosToPoint(e, p) {
p.x = e.pageX - this.pswp.offset.x;
p.y = e.pageY - this.pswp.offset.y;
// e.pointerId can be zero
if (e.pointerId !== undefined) {
p.id = e.pointerId;
} else if (e.identifier !== undefined) {
p.id = e.identifier;
}
return p;
}
_onClick(e) {
// Do not allow click event to pass through after drag
if (this.pswp.mainScroll.isShifted()) {
e.preventDefault();
e.stopPropagation();
}
}
}
/**
* Handles movement of the main scrolling container
* (for example, it repositions when user swipes left or right).
*
* Also stores its state.
*/
const MAIN_SCROLL_END_FRICTION = 0.35;
// const MIN_SWIPE_TRANSITION_DURATION = 250;
// const MAX_SWIPE_TRABSITION_DURATION = 500;
// const DEFAULT_SWIPE_TRANSITION_DURATION = 333;
class MainScroll {
/**
* @param {PhotoSwipe} pswp
*/
constructor(pswp) {
this.pswp = pswp;
this.x = 0;
this.resetPosition();
}
/**
* Position the scroller and slide containers
* according to viewport size.
*
* @param {Boolean} resizeSlides Whether slides content should resized
*/
resize(resizeSlides) {
const { pswp } = this;
const newSlideWidth = Math.round(
pswp.viewportSize.x + pswp.viewportSize.x * pswp.options.spacing
);
// Mobile browsers might trigger a resize event during a gesture.
// (due to toolbar appearing or hiding).
// Avoid re-adjusting main scroll position if width wasn't changed
const slideWidthChanged = (newSlideWidth !== this.slideWidth);
if (slideWidthChanged) {
this.slideWidth = newSlideWidth;
this.moveTo(this.getCurrSlideX());
}
this.itemHolders.forEach((itemHolder, index) => {
if (slideWidthChanged) {
setTransform(itemHolder.el, (index + this._containerShiftIndex)
* this.slideWidth);
}
if (resizeSlides && itemHolder.slide) {
itemHolder.slide.resize();
}
});
}
/**
* Reset X position of the main scroller to zero
*/
resetPosition() {
// Position on the main scroller (offset)
// it is independent from slide index
this._currPositionIndex = 0;
this._prevPositionIndex = 0;
// This will force recalculation of size on next resize()
this.slideWidth = 0;
// _containerShiftIndex*viewportSize will give you amount of transform of the current slide
this._containerShiftIndex = -1;
}
/**
* Create and append array of three items
* that hold data about slides in DOM
*/
appendHolders() {
this.itemHolders = [];
// append our three slide holders -
// previous, current, and next
for (let i = 0; i < 3; i++) {
const el = createElement('pswp__item', false, this.pswp.container);
// hide nearby item holders until initial zoom animation finishes (to avoid extra Paints)
el.style.display = (i === 1) ? 'block' : 'none';
this.itemHolders.push({
el,
//index: -1
});
}
}
/**
* Whether the main scroll can be horizontally swiped to the next or previous slide.
*/
canBeSwiped() {
return this.pswp.getNumItems() > 1;
}
/**
* Move main scroll by X amount of slides.
* For example:
* `-1` will move to the previous slide,
* `0` will reset the scroll position of the current slide,
* `3` will move three slides forward
*
* If loop option is enabled - index will be automatically looped too,
* (for example `-1` will move to the last slide of the gallery).
*
* @param {Integer} diff
* @returns {Boolean} whether index was changed or not
*/
moveIndexBy(diff, animate, velocityX) {
const { pswp } = this;
let newIndex = pswp.potentialIndex + diff;
if (pswp.options.loop) {
newIndex = pswp.getLoopedIndex(newIndex);
} else {
if (newIndex < 0) {
newIndex = 0;
} else if (newIndex >= pswp.getNumItems()) {
newIndex = pswp.getNumItems() - 1;
}
diff = newIndex - pswp.potentialIndex;
}
pswp.potentialIndex = newIndex;
this._currPositionIndex -= diff;
pswp.animations.stopMainScroll();
const destinationX = this.getCurrSlideX();
if (!animate) {
this.moveTo(destinationX);
this.updateCurrItem();
} else {
pswp.animations.startSpring({
isMainScroll: true,
start: this.x,
end: destinationX,
velocity: velocityX || 0,
naturalFrequency: 30,
dampingRatio: 1, //0.7,
onUpdate: (x) => {
this.moveTo(x);
},
onComplete: () => {
this.updateCurrItem();
pswp.appendHeavy();
}
});
// Force-append new slides during transition
// if difference between slides is more than 1
if (Math.abs(pswp.potentialIndex - pswp.currIndex) > 1) {
this.updateCurrItem();
}
}
if (diff) {
return true;
}
}
/**
* X position of the main scroll for the current slide
* (ignores position during dragging)
*/
getCurrSlideX() {
return this.slideWidth * this._currPositionIndex;
}
/**
* Whether scroll position is shifted.
* For example, it will return true if the scroll is being dragged or animated.
*/
isShifted() {
return this.x !== this.getCurrSlideX();
}
/**
* Update slides X positions and set their content
*/
updateCurrItem() {
const { pswp } = this;
const positionDifference = this._prevPositionIndex - this._currPositionIndex;
if (!positionDifference) {
return;
}
this._prevPositionIndex = this._currPositionIndex;
pswp.currIndex = pswp.potentialIndex;
let diffAbs = Math.abs(positionDifference);
let tempHolder;
if (diffAbs >= 3) {
this._containerShiftIndex += positionDifference + (positionDifference > 0 ? -3 : 3);
diffAbs = 3;
}
for (let i = 0; i < diffAbs; i++) {
if (positionDifference > 0) {
tempHolder = this.itemHolders.shift();
this.itemHolders[2] = tempHolder; // move first to last
this._containerShiftIndex++;
setTransform(tempHolder.el, (this._containerShiftIndex + 2) * this.slideWidth);
pswp.setContent(tempHolder, (pswp.currIndex - diffAbs) + i + 2);
} else {
tempHolder = this.itemHolders.pop();
this.itemHolders.unshift(tempHolder); // move last to first
this._containerShiftIndex--;
setTransform(tempHolder.el, this._containerShiftIndex * this.slideWidth);
pswp.setContent(tempHolder, (pswp.currIndex + diffAbs) - i - 2);
}
}
// Reset transfrom every 50ish navigations in one direction.
//
// Otherwise transform will keep growing indefinitely,
// which might cause issues as browsers have a maximum transform limit.
// I wasn't able to reach it, but just to be safe.
// This should not cause noticable lag.
if (Math.abs(this._containerShiftIndex) > 50 && !this.isShifted()) {
this.resetPosition();
this.resize();
}
// Pan transition might be running (and consntantly updating pan position)
pswp.animations.stopAllPan();
this.itemHolders.forEach((itemHolder, i) => {
if (itemHolder.slide) {
// Slide in the 2nd holder is always active
itemHolder.slide.setIsActive(i === 1);
}
});
pswp.currSlide = this.itemHolders[1].slide;
pswp.contentLoader.updateLazy(positionDifference);
pswp.currSlide.applyCurrentZoomPan();
pswp.dispatch('change');
}
/**
* Move the X position of the main scroll container
*
* @param {Number} x
* @param {Boolean} dragging
*/
moveTo(x, dragging) {
let newSlideIndexOffset;
let delta;
if (!this.pswp.options.loop && dragging) {
// Apply friction
newSlideIndexOffset = ((this.slideWidth * this._currPositionIndex) - x) / this.slideWidth;
newSlideIndexOffset += this.pswp.currIndex;
delta = Math.round(x - this.x);
if ((newSlideIndexOffset < 0 && delta > 0)
|| (newSlideIndexOffset >= this.pswp.getNumItems() - 1 && delta < 0)) {
x = this.x + (delta * MAIN_SCROLL_END_FRICTION);
}
}
this.x = x;
setTransform(this.pswp.container, x);
this.pswp.dispatch('moveMainScroll', { x, dragging });
}
}
/**
*
* keyboard.js
*
* - Manages keyboard shortcuts.
* - Heps trap focus within photoswipe.
*
*/
class Keyboard {
constructor(pswp) {
this.pswp = pswp;
pswp.on('bindEvents', () => {
// Dialog was likely opened by keyboard if initial point is not defined
if (!pswp.options.initialPointerPos) {
// focus causes layout,
// which causes lag during the animation,
// that's why we delay it until the opener transition ends
this._focusRoot();
}
pswp.events.add(document, 'focusin', this._onFocusIn.bind(this));
pswp.events.add(document, 'keydown', this._onKeyDown.bind(this));
});
const lastActiveElement = document.activeElement;
pswp.on('destroy', () => {
if (pswp.options.returnFocus
&& lastActiveElement
&& this._wasFocused) {
lastActiveElement.focus();
}
});
}
_focusRoot() {
if (!this._wasFocused) {
this.pswp.template.focus();
this._wasFocused = true;
}
}
_onKeyDown(e) {
const { pswp } = this;
if (pswp.dispatch('keydown', { originalEvent: e }).defaultPrevented) {
return;
}
if (specialKeyUsed(e)) {
// don't do anything if special key pressed
// to prevent from overriding default browser actions
// for example, in Chrome on Mac cmd+arrow-left returns to previous page
return;
}
let keydownAction;
let axis;
let isForward;
switch (e.keyCode) {
case 27: // esc
if (pswp.options.escKey) {
keydownAction = 'close';
}
break;
case 90: // z key
keydownAction = 'toggleZoom';
break;
case 37: // left
axis = 'x';
break;
case 38: // top
axis = 'y';
break;
case 39: // right
axis = 'x';
isForward = true;
break;
case 40: // bottom
isForward = true;
axis = 'y';
break;
case 9: // tab
this._focusRoot();
break;
}
// if left/right/top/bottom key
if (axis) {
// prevent page scroll
e.preventDefault();
const { currSlide } = pswp;
if (pswp.options.arrowKeys
&& axis === 'x'
&& pswp.getNumItems() > 1) {
keydownAction = isForward ? 'next' : 'prev';
} else if (currSlide && currSlide.currZoomLevel > currSlide.zoomLevels.fit) {
// up/down arrow keys pan the image vertically
// left/right arrow keys pan horizontally.
// Unless there is only one image,
// or arrowKeys option is disabled
currSlide.pan[axis] += isForward ? -80 : 80;
currSlide.panTo(currSlide.pan.x, currSlide.pan.y);
}
}
if (keydownAction) {
e.preventDefault();
pswp[keydownAction]();
}
}
/**
* Trap focus inside photoswipe
*
* @param {Event} e
*/
_onFocusIn(e) {
const { template } = this.pswp;
if (document !== e.target
&& template !== e.target
&& !template.contains(e.target)) {
// focus root element
template.focus();
}
}
}
/**
* Runs CSS transition.
*/
const DEFAULT_EASING = 'cubic-bezier(.4,0,.22,1)';
class CSSAnimation {
// onComplete can be unpredictable, be careful about current state
constructor(props) {
this.props = props;
const {
target,
onComplete,
transform,
// opacity
} = props;
let {
duration,
easing,
} = props;
// support only transform and opacity
const prop = transform ? 'transform' : 'opacity';
const propValue = props[prop];
this._target = target;
this._onComplete = onComplete;
duration = duration || 333;
easing = easing || DEFAULT_EASING;
this._onTransitionEnd = this._onTransitionEnd.bind(this);
// Using timeout hack to make sure that animation
// starts even if the animated property was changed recently,
// otherwise transitionend might not fire or transiton won't start.
// https://drafts.csswg.org/css-transitions/#starting
//
// ¯\_(ツ)_/¯
this._firstFrameTimeout = setTimeout(() => {
setTransitionStyle(target, prop, duration, easing);
this._firstFrameTimeout = setTimeout(() => {
target.addEventListener('transitionend', this._onTransitionEnd, false);
target.addEventListener('transitioncancel', this._onTransitionEnd, false);
target.style[prop] = propValue;
}, 30); // Do not reduce this number
}, 0);
}
_onTransitionEnd(e) {
if (e.target === this._target) {
this._finalizeAnimation();
}
}
_finalizeAnimation() {
if (!this._finished) {
this._finished = true;
this.onFinish();
if (this._onComplete) {
this._onComplete();
}
}
}
// Destroy is called automatically onFinish
destroy() {
if (this._firstFrameTimeout) {
clearTimeout(this._firstFrameTimeout);
}
removeTransitionStyle(this._target);
this._target.removeEventListener('transitionend', this._onTransitionEnd, false);
this._target.removeEventListener('transitioncancel', this._onTransitionEnd, false);
if (!this._finished) {
this._finalizeAnimation();
}
}
}
/**
* Spring easing helper
*/
const DEFAULT_NATURAL_FREQUENCY = 12;
const DEFAULT_DAMPING_RATIO = 0.75;
class SpringEaser {
/**
* @param {Number} initialVelocity Initial velocity, px per ms.
*
* @param {Number} dampingRatio Determines how bouncy animation will be.
* From 0 to 1, 0 - always overshoot, 1 - do not overshoot.
* "overshoot" refers to part of animation that
* goes beyond the final value.
*
* @param {Number} naturalFrequency Determines how fast animation will slow down.
* The higher value - the stiffer the transition will be,
* and the faster it will slow down.
* Recommended value from 10 to 50
*/
constructor(initialVelocity, dampingRatio, naturalFrequency) {
this.velocity = initialVelocity * 1000; // convert to "pixels per second"
// https://en.wikipedia.org/wiki/Damping_ratio
this._dampingRatio = dampingRatio || DEFAULT_DAMPING_RATIO;
// https://en.wikipedia.org/wiki/Natural_frequency
this._naturalFrequency = naturalFrequency || DEFAULT_NATURAL_FREQUENCY;
if (this._dampingRatio < 1) {
this._dampedFrequency = this._naturalFrequency
* Math.sqrt(1 - this._dampingRatio * this._dampingRatio);
}
}
/**
* @param {Number} deltaPosition Difference between current and end position of the animation
* @param {Number} deltaTime Frame duration in milliseconds
*
* @returns {Number} Displacement, relative to the end position.
*/
easeFrame(deltaPosition, deltaTime) {
// Inspired by Apple Webkit and Android spring function implementation
// https://en.wikipedia.org/wiki/Oscillation
// https://en.wikipedia.org/wiki/Damping_ratio
// we ignore mass (assume that it's 1kg)
let displacement = 0;
let coeff;
deltaTime /= 1000;
const naturalDumpingPow = Math.E ** (-this._dampingRatio * this._naturalFrequency * deltaTime);
if (this._dampingRatio === 1) {
coeff = this.velocity + this._naturalFrequency * deltaPosition;
displacement = (deltaPosition + coeff * deltaTime) * naturalDumpingPow;
this.velocity = displacement
* (-this._naturalFrequency) + coeff
* naturalDumpingPow;
} else if (this._dampingRatio < 1) {
coeff = (1 / this._dampedFrequency)
* (this._dampingRatio * this._naturalFrequency * deltaPosition + this.velocity);
const dumpedFCos = Math.cos(this._dampedFrequency * deltaTime);
const dumpedFSin = Math.sin(this._dampedFrequency * deltaTime);
displacement = naturalDumpingPow
* (deltaPosition * dumpedFCos + coeff * dumpedFSin);
this.velocity = displacement
* (-this._naturalFrequency)
* this._dampingRatio
+ naturalDumpingPow
* (-this._dampedFrequency * deltaPosition * dumpedFSin
+ this._dampedFrequency * coeff * dumpedFCos);
}
// Overdamped (>1) damping ratio is not supported
return displacement;
}
}
class SpringAnimation {
constructor(props) {
this.props = props;
const {
start,
end,
velocity,
onUpdate,
onComplete,
onFinish,
dampingRatio,
naturalFrequency
} = props;
const easer = new SpringEaser(velocity, dampingRatio, naturalFrequency);
let prevTime = Date.now();
let deltaPosition = start - end;
this._onFinish = onFinish;
const animationLoop = () => {
if (this._raf) {
deltaPosition = easer.easeFrame(deltaPosition, Date.now() - prevTime);
// Stop the animation if velocity is low and position is close to end
if (Math.abs(deltaPosition) < 1 && Math.abs(easer.velocity) < 50) {
// Finalize the animation
onUpdate(end);
if (onComplete) {
onComplete();
}
this.onFinish();
} else {
prevTime = Date.now();
onUpdate(deltaPosition + end);
this._raf = requestAnimationFrame(animationLoop);
}
}
};
this._raf = requestAnimationFrame(animationLoop);
}
// Destroy is called automatically onFinish
destroy() {
if (this._raf >= 0) {
cancelAnimationFrame(this._raf);
}
this._raf = null;
}
}
/**
* Manages animations
*/
class Animations {
constructor() {
this.activeAnimations = [];
}
startSpring(props) {
this._start(props, true);
}
startTransition(props) {
this._start(props);
}
_start(props, isSpring) {
// if (!props.name) {
// props.name = this._uid++;
// }
// const { name } = props;
// if (!name || this.activeAnimations[name]) {
// // Animation already running or no name provided
// return;
// }
let animation;
if (isSpring) {
animation = new SpringAnimation(props);
} else {
animation = new CSSAnimation(props);
}
this.activeAnimations.push(animation);
animation.onFinish = () => this.stop(animation);
return animation;
}
stop(animation) {
animation.destroy();
const index = this.activeAnimations.indexOf(animation);
if (index > -1) {
this.activeAnimations.splice(index, 1);
}
}
stopAll() { // _stopAllAnimations
this.activeAnimations.forEach((animation) => {
animation.destroy();
});
this.activeAnimations = [];
}
/**
* Stop all pan or zoom transitions
*/
stopAllPan() {
this.activeAnimations = this.activeAnimations.filter((animation) => {
if (animation.props.isPan) {
animation.destroy();
return false;
}
return true;
});
}
stopMainScroll() {
this.activeAnimations = this.activeAnimations.filter((animation) => {
if (animation.props.isMainScroll) {
animation.destroy();
return false;
}
return true;
});
}
/**
* Returns true if main scroll transition is running
*/
// isMainScrollRunning() {
// return this.activeAnimations.some((animation) => {
// return animation.props.isMainScroll;
// });
// }
/**
* Returns true if any pan or zoom transition is running
*/
isPanRunning() {
return this.activeAnimations.some((animation) => {
return animation.props.isPan;
});
}
}
/**
* Handles scroll wheel.
* Can pan and zoom current slide image.
*/
class ScrollWheel {
constructor(pswp) {
this.pswp = pswp;
pswp.events.add(pswp.template, 'wheel', this._onWheel.bind(this));
}
_onWheel(e) {
e.preventDefault();
const { currSlide } = this.pswp;
let { deltaX, deltaY } = e;
if (!currSlide) {
return;
}
if (this.pswp.dispatch('wheel', { originalEvent: e }).defaultPrevented) {
return;
}
if (e.ctrlKey || this.pswp.options.wheelToZoom) {
// zoom
if (currSlide.isZoomable()) {
let zoomFactor = -deltaY;
if (e.deltaMode === 1 /* DOM_DELTA_LINE */) {
zoomFactor *= 0.05;
} else {
zoomFactor *= e.deltaMode ? 1 : 0.002;
}
zoomFactor = 2 ** zoomFactor;
if (this.pswp.options.getWheelZoomFactorFn) {
zoomFactor = this.pswp.options.getWheelZoomFactorFn(e, this.pswp);
}
const destZoomLevel = currSlide.currZoomLevel * zoomFactor;
currSlide.zoomTo(destZoomLevel, {
x: e.clientX,
y: e.clientY
});
}
} else {
// pan
if (currSlide.isPannable()) {
if (e.deltaMode === 1 /* DOM_DELTA_LINE */) {
// 18 - average line height
deltaX *= 18;
deltaY *= 18;
}
currSlide.panTo(
currSlide.pan.x - deltaX,
currSlide.pan.y - deltaY
);
}
}
}
}
function addElementHTML(htmlData) {
if (typeof htmlData === 'string') {
// Allow developers to provide full svg,
// For example:
//
// Can also be any HTML string.
return htmlData;
}
if (!htmlData || !htmlData.isCustomSVG) {
return '';
}
const svgData = htmlData;
let out = '';
return out;
}
class UIElement {
constructor(pswp, data) {
const name = data.name || data.class;
let elementHTML = data.html;
if (pswp.options[name] === false) {
// exit if element is disabled from options
return;
}
// Allow to override SVG icons from options
if (typeof pswp.options[name + 'SVG'] === 'string') {
// arrowPrevSVG
// arrowNextSVG
// closeSVG
// zoomSVG
elementHTML = pswp.options[name + 'SVG'];
}
pswp.dispatch('uiElementCreate', { data });
let className = 'pswp__';
if (data.isButton) {
className += 'button pswp__button--';
}
className += (data.class || data.name);
let element;
if (data.isButton) {
// create button element
element = createElement(className, 'button');
element.type = 'button';
if (typeof pswp.options[name + 'Title'] === 'string') {
element.title = pswp.options[name + 'Title'];
} else if (data.title) {
element.title = data.title;
}
} else {
element = createElement(className);
}
element.innerHTML = addElementHTML(elementHTML);
if (data.onInit) {
data.onInit(element, pswp);
}
if (data.onClick) {
element.onclick = (e) => {
if (typeof data.onClick === 'string') {
pswp[data.onClick]();
} else {
data.onClick(e, element, pswp);
}
};
}
// Top bar is default position
const appendTo = data.appendTo || 'bar';
let container;
if (appendTo === 'bar') {
if (!pswp.topBar) {
pswp.topBar = createElement('pswp__top-bar pswp__hide-on-close', false, pswp.scrollWrap);
}
container = pswp.topBar;
} else {
// element outside of top bar gets a secondary class
// that makes element fade out on close
element.classList.add('pswp__hide-on-close');
if (appendTo === 'wrapper') {
container = pswp.scrollWrap;
} else {
// root element
container = pswp.template;
}
}
container.appendChild(element);
}
}
/*
Backward and forward arrow buttons
*/
function initArrowButton(element, pswp, isNextButton) {
element.classList.add('pswp__button--arrow');
pswp.on('change', () => {
if (!pswp.options.loop) {
if (isNextButton) {
element.disabled = !(pswp.currIndex < pswp.getNumItems() - 1);
} else {
element.disabled = !(pswp.currIndex > 0);
}
}
});
}
const arrowPrev = {
name: 'arrowPrev',
class: 'arrow--prev',
title: 'Previous',
order: 10,
isButton: true,
appendTo: 'wrapper',
html: {
isCustomSVG: true,
size: 60,
inner: '