photoswipe
Version:
JavaScript gallery
1,910 lines (1,592 loc) • 189 kB
JavaScript
/*!
* PhotoSwipe 5.4.4 - https://photoswipe.com
* (c) 2024 Dmytro Semenov
*/
/** @typedef {import('../photoswipe.js').Point} Point */
/**
* @template {keyof HTMLElementTagNameMap} T
* @param {string} className
* @param {T} tagName
* @param {Node} [appendToEl]
* @returns {HTMLElementTagNameMap[T]}
*/
function createElement(className, tagName, appendToEl) {
const el = document.createElement(tagName);
if (className) {
el.className = className;
}
if (appendToEl) {
appendToEl.appendChild(el);
}
return el;
}
/**
* @param {Point} p1
* @param {Point} p2
* @returns {Point}
*/
function equalizePoints(p1, p2) {
p1.x = p2.x;
p1.y = p2.y;
if (p2.id !== undefined) {
p1.id = p2.id;
}
return p1;
}
/**
* @param {Point} p
*/
function roundPoint(p) {
p.x = Math.round(p.x);
p.y = Math.round(p.y);
}
/**
* Returns distance between two points.
*
* @param {Point} p1
* @param {Point} p2
* @returns {number}
*/
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 equal
*
* @param {Point} p1
* @param {Point} p2
* @returns {boolean}
*/
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
* @returns {number}
*/
function clamp(val, min, max) {
return Math.min(Math.max(val, min), max);
}
/**
* Get transform string
*
* @param {number} x
* @param {number} [y]
* @param {number} [scale]
* @returns {string}
*/
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 {HTMLElement} el
* @param {number} x
* @param {number} [y]
* @param {number} [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 {HTMLElement} el
* @param {string} [prop] CSS property to animate
* @param {number} [duration] in ms
* @param {string} [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
*
* @param {HTMLElement} el
* @param {string | number} w
* @param {string | number} h
*/
function setWidthHeight(el, w, h) {
el.style.width = typeof w === 'number' ? `${w}px` : w;
el.style.height = typeof h === 'number' ? `${h}px` : h;
}
/**
* @param {HTMLElement} el
*/
function removeTransitionStyle(el) {
setTransitionStyle(el);
}
/**
* @param {HTMLImageElement} img
* @returns {Promise<HTMLImageElement | void>}
*/
function decodeImage(img) {
if ('decode' in img) {
return img.decode().catch(() => {});
}
if (img.complete) {
return Promise.resolve(img);
}
return new Promise((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = reject;
});
}
/** @typedef {LOAD_STATE[keyof LOAD_STATE]} LoadState */
/** @type {{ IDLE: 'idle'; LOADING: 'loading'; LOADED: 'loaded'; ERROR: 'error' }} */
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 {MouseEvent | KeyboardEvent} e
* @returns {boolean}
*/
function specialKeyUsed(e) {
return 'button' in e && e.button === 1 || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey;
}
/**
* Parse `gallery` or `children` options.
*
* @param {import('../photoswipe.js').ElementProvider} [option]
* @param {string} [legacySelector]
* @param {HTMLElement | Document} [parent]
* @returns HTMLElement[]
*/
function getElementsFromOption(option, legacySelector, parent = document) {
/** @type {HTMLElement[]} */
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;
}
/**
* Check if browser is Safari
*
* @returns {boolean}
*/
function isSafari() {
return !!(navigator.vendor && navigator.vendor.match(/apple/i));
}
// Detect passive event listener support
let supportsPassive = false;
/* eslint-disable */
try {
/* @ts-ignore */
window.addEventListener('test', null, Object.defineProperty({}, 'passive', {
get: () => {
supportsPassive = true;
}
}));
} catch (e) {}
/* eslint-enable */
/**
* @typedef {Object} PoolItem
* @prop {HTMLElement | Window | Document | undefined | null} target
* @prop {string} type
* @prop {EventListenerOrEventListenerObject} listener
* @prop {boolean} [passive]
*/
class DOMEvents {
constructor() {
/**
* @type {PoolItem[]}
* @private
*/
this._pool = [];
}
/**
* Adds event listeners
*
* @param {PoolItem['target']} target
* @param {PoolItem['type']} type Can be multiple, separated by space.
* @param {PoolItem['listener']} listener
* @param {PoolItem['passive']} [passive]
*/
add(target, type, listener, passive) {
this._toggleListener(target, type, listener, passive);
}
/**
* Removes event listeners
*
* @param {PoolItem['target']} target
* @param {PoolItem['type']} type
* @param {PoolItem['listener']} listener
* @param {PoolItem['passive']} [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
*
* @private
* @param {PoolItem['target']} target
* @param {PoolItem['type']} type
* @param {PoolItem['listener']} listener
* @param {PoolItem['passive']} [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 ? 'removeEventListener' : 'addEventListener';
const types = type.split(' ');
types.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);
}
});
}
}
/** @typedef {import('../photoswipe.js').PhotoSwipeOptions} PhotoSwipeOptions */
/** @typedef {import('../core/base.js').default} PhotoSwipeBase */
/** @typedef {import('../photoswipe.js').Point} Point */
/** @typedef {import('../slide/slide.js').SlideData} SlideData */
/**
* @param {PhotoSwipeOptions} options
* @param {PhotoSwipeBase} pswp
* @returns {Point}
*/
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, itemData, index) => {
* return {
* top: 0,
* bottom: 0,
* left: 0,
* right: 0
* };
* }
*
* // Legacy variant
* paddingLeft: 0,
* paddingRight: 0,
* paddingTop: 0,
* paddingBottom: 0,
*
* @param {'left' | 'top' | 'bottom' | 'right'} prop
* @param {PhotoSwipeOptions} options PhotoSwipe options
* @param {Point} viewportSize PhotoSwipe viewport size, for example: { x:800, y:600 }
* @param {SlideData} itemData Data about the slide
* @param {number} index Slide index
* @returns {number}
*/
function parsePaddingOption(prop, options, viewportSize, itemData, index) {
let paddingValue = 0;
if (options.paddingFn) {
paddingValue = options.paddingFn(viewportSize, itemData, index)[prop];
} else if (options.padding) {
paddingValue = options.padding[prop];
} else {
const legacyPropName = 'padding' + prop[0].toUpperCase() + prop.slice(1); // @ts-expect-error
if (options[legacyPropName]) {
// @ts-expect-error
paddingValue = options[legacyPropName];
}
}
return Number(paddingValue) || 0;
}
/**
* @param {PhotoSwipeOptions} options
* @param {Point} viewportSize
* @param {SlideData} itemData
* @param {number} index
* @returns {Point}
*/
function getPanAreaSize(options, viewportSize, itemData, index) {
return {
x: viewportSize.x - parsePaddingOption('left', options, viewportSize, itemData, index) - parsePaddingOption('right', options, viewportSize, itemData, index),
y: viewportSize.y - parsePaddingOption('top', options, viewportSize, itemData, index) - parsePaddingOption('bottom', options, viewportSize, itemData, index)
};
}
/** @typedef {import('./slide.js').default} Slide */
/** @typedef {Record<Axis, number>} Point */
/** @typedef {'x' | 'y'} Axis */
/**
* Calculates minimum, maximum and initial (center) bounds of a slide
*/
class PanBounds {
/**
* @param {Slide} slide
*/
constructor(slide) {
this.slide = slide;
this.currZoomLevel = 1;
this.center =
/** @type {Point} */
{
x: 0,
y: 0
};
this.max =
/** @type {Point} */
{
x: 0,
y: 0
};
this.min =
/** @type {Point} */
{
x: 0,
y: 0
};
}
/**
* _getItemBounds
*
* @param {number} currZoomLevel
*/
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
*
* @param {Axis} axis
*/
_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, this.slide.data, this.slide.index);
const panAreaSize = this.slide.panAreaSize[axis]; // Default position of element.
// By default, 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 {Axis} axis x or y
* @param {number} panOffset
* @returns {number}
*/
correctPan(axis, panOffset) {
// checkPanBounds
return clamp(panOffset, this.max[axis], this.min[axis]);
}
}
const MAX_IMAGE_WIDTH = 4000;
/** @typedef {import('../photoswipe.js').default} PhotoSwipe */
/** @typedef {import('../photoswipe.js').PhotoSwipeOptions} PhotoSwipeOptions */
/** @typedef {import('../photoswipe.js').Point} Point */
/** @typedef {import('../slide/slide.js').SlideData} SlideData */
/** @typedef {'fit' | 'fill' | number | ((zoomLevelObject: ZoomLevel) => number)} ZoomLevelOption */
/**
* Calculates zoom levels for specific slide.
* Depends on viewport size and image size.
*/
class ZoomLevel {
/**
* @param {PhotoSwipeOptions} options PhotoSwipe options
* @param {SlideData} itemData Slide data
* @param {number} index Slide index
* @param {PhotoSwipe} [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;
/** @type { Point | null } */
this.panAreaSize = null;
/** @type { Point | null } */
this.elementSize = null;
this.fit = 1;
this.fill = 1;
this.vFill = 1;
this.initial = 1;
this.secondary = 1;
this.max = 1;
this.min = 1;
}
/**
* Calculate initial, secondary and maximum zoom level for the specified slide.
*
* It should be called when either image or viewport size changes.
*
* @param {number} maxWidth
* @param {number} maxHeight
* @param {Point} panAreaSize
*/
update(maxWidth, maxHeight, panAreaSize) {
/** @type {Point} */
const elementSize = {
x: maxWidth,
y: maxHeight
};
this.elementSize = elementSize;
this.panAreaSize = panAreaSize;
const hRatio = panAreaSize.x / elementSize.x;
const vRatio = panAreaSize.y / 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.
*
* @private
* @param {'initial' | 'secondary' | 'max'} optionPrefix Zoom level option prefix (initial, secondary, max)
* @returns { number | undefined }
*/
_parseZoomLevelOption(optionPrefix) {
const optionName =
/** @type {'initialZoomLevel' | 'secondaryZoomLevel' | 'maxZoomLevel'} */
optionPrefix + 'ZoomLevel';
const optionValue = this.options[optionName];
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.
*
* @private
* @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 (this.elementSize && currZoomLevel * this.elementSize.x > MAX_IMAGE_WIDTH) {
currZoomLevel = MAX_IMAGE_WIDTH / this.elementSize.x;
}
return currZoomLevel;
}
/**
* Get initial image zoom level.
*
* @private
* @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.
*
* @private
* @return {number}
*/
_getMax() {
// max zoom level is x4 from "fit state",
// used for zoom gesture and ctrl/trackpad zoom
return this._parseZoomLevelOption('max') || Math.max(1, this.fit * 4);
}
}
/** @typedef {import('../photoswipe.js').default} PhotoSwipe */
/**
* Renders and allows to control a single slide
*/
class Slide {
/**
* @param {SlideData} data
* @param {number} index
* @param {PhotoSwipe} pswp
*/
constructor(data, index, pswp) {
this.data = data;
this.index = index;
this.pswp = pswp;
this.isActive = index === pswp.currIndex;
this.currentResolution = 0;
/** @type {Point} */
this.panAreaSize = {
x: 0,
y: 0
};
/** @type {Point} */
this.pan = {
x: 0,
y: 0
};
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.content = this.pswp.contentLoader.getContentBySlide(this);
this.container = createElement('pswp__zoom-wrap', 'div');
/** @type {HTMLElement | null} */
this.holderElement = null;
this.currZoomLevel = 1;
/** @type {number} */
this.width = this.content.width;
/** @type {number} */
this.height = this.content.height;
this.heavyAppended = false;
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
*
* @param {HTMLElement} holderElement
*/
append(holderElement) {
this.holderElement = holderElement;
this.container.style.transformOrigin = '0 0'; // Slide appended to DOM
if (!this.data) {
return;
}
this.calculateSize();
this.load();
this.updateContentSize();
this.appendHeavy();
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();
}
}
load() {
this.content.load(false);
this.pswp.dispatch('slideLoad', {
slide: 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; // todo
// 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;
this.content.append();
this.pswp.dispatch('appendHeavyContent', {
slide: this
});
}
/**
* 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();
if (this.currZoomLevel !== this.zoomLevels.initial) {
// allow filtering
this.calculateSize();
} // 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.hasSlide = false;
this.content.remove();
this.container.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;
}
this.content.setDisplayedSize(width, height);
}
/**
* @param {number} width
* @param {number} height
*/
sizeChanged(width, height) {
if (width !== this.prevDisplayedWidth || height !== this.prevDisplayedHeight) {
this.prevDisplayedWidth = width;
this.prevDisplayedHeight = height;
return true;
}
return false;
}
/** @returns {HTMLImageElement | HTMLDivElement | null | undefined} */
getPlaceholderElement() {
var _this$content$placeho;
return (_this$content$placeho = this.content.placeholder) === null || _this$content$placeho === void 0 ? void 0 : _this$content$placeho.element;
}
/**
* Zoom current slide image to...
*
* @param {number} destZoomLevel Destination zoom level.
* @param {Point} [centerPoint]
* Transform origin center point, or false if viewport center should be used.
* @param {number | false} [transitionDuration] Transition duration, may be set to 0.
* @param {boolean} [ignoreBounds] Minimum and maximum zoom levels will be ignored.
*/
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
});
}
}
/**
* @param {Point} [centerPoint]
*/
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 {'x' | 'y'} axis
* @param {Point} [point]
* point based on which zoom is performed, usually refers to the current mouse position,
* if false - viewport center will be used.
* @param {number} [prevZoomLevel] Zoom level before new zoom was applied.
* @returns {number}
*/
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();
}
if (!prevZoomLevel) {
prevZoomLevel = this.zoomLevels.initial;
}
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
* @returns {boolean}
*/
isPannable() {
return Boolean(this.width) && this.currZoomLevel > this.zoomLevels.fit;
}
/**
* If the slide can be zoomed
* @returns {boolean}
*/
isZoomable() {
return Boolean(this.width) && this.content.isZoomable();
}
/**
* 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
* @private
*/
_applyZoomTransform(x, y, zoom) {
zoom /= this.currentResolution || this.zoomLevels.initial;
setTransform(this.container, x, y, zoom);
}
calculateSize() {
const {
pswp
} = this;
equalizePoints(this.panAreaSize, getPanAreaSize(pswp.options, pswp.viewportSize, this.data, this.index));
this.zoomLevels.update(this.width, this.height, this.panAreaSize);
pswp.dispatch('calcSlideSize', {
slide: this
});
}
/** @returns {string} */
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 the 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');
}
}
/** @typedef {import('../photoswipe.js').Point} Point */
/** @typedef {import('./gestures.js').default} Gestures */
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;
/**
* @param {number} initialVelocity
* @param {number} decelerationRate
* @returns {number}
*/
function project(initialVelocity, decelerationRate) {
return initialVelocity * decelerationRate / (1 - decelerationRate);
}
/**
* Handles single pointer dragging
*/
class DragHandler {
/**
* @param {Gestures} gestures
*/
constructor(gestures) {
this.gestures = gestures;
this.pswp = gestures.pswp;
/** @type {Point} */
this.startPan = {
x: 0,
y: 0
};
}
start() {
if (this.pswp.currSlide) {
equalizePoints(this.startPan, this.pswp.currSlide.pan);
}
this.pswp.animations.stopAll();
}
change() {
const {
p1,
prevP1,
dragAxis
} = this.gestures;
const {
currSlide
} = this.pswp;
if (dragAxis === 'y' && this.pswp.options.closeOnVerticalDrag && currSlide && currSlide.currZoomLevel <= currSlide.zoomLevels.fit && !this.gestures.isMultitouch) {
// Handle vertical drag to close
const panY = currSlide.pan.y + (p1.y - prevP1.y);
if (!this.pswp.dispatch('verticalDrag', {
panY
}).defaultPrevented) {
this._setPanWithFriction('y', panY, VERTICAL_DRAG_FRICTION);
const bgOpacity = 1 - Math.abs(this._getVerticalDragRatio(currSlide.pan.y));
this.pswp.applyBgOpacity(bgOpacity);
currSlide.applyCurrentZoomPan();
}
} else {
const mainScrollChanged = this._panOrMoveMainScroll('x');
if (!mainScrollChanged) {
this._panOrMoveMainScroll('y');
if (currSlide) {
roundPoint(currSlide.pan);
currSlide.applyCurrentZoomPan();
}
}
}
}
end() {
const {
velocity
} = this.gestures;
const {
mainScroll,
currSlide
} = this.pswp;
let indexDiff = 0;
this.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 visible
// 1 - slide is fully visible
const currentSlideVisibilityRatio = mainScrollShiftDiff / this.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 (currSlide && currSlide.currZoomLevel > currSlide.zoomLevels.max || 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');
}
}
/**
* @private
* @param {'x' | 'y'} axis
*/
_finishPanGestureForAxis(axis) {
const {
velocity
} = this.gestures;
const {
currSlide
} = this.pswp;
if (!currSlide) {
return;
}
const {
pan,
bounds
} = currSlide;
const panPos = pan[axis];
const restoreBgOpacity = this.pswp.bgOpacity < 1 && axis === 'y'; // 0.995 means - scroll view loses 0.5% of its velocity per millisecond
// Increasing 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) {
this.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 = this.pswp.bgOpacity;
const totalPanDist = correctedPanPosition - panPos;
this.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 && this.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.
this.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.
*
* @private
* @param {'x' | 'y'} axis
* @returns {boolean}
*/
_panOrMoveMainScroll(axis) {
const {
p1,
dragAxis,
prevP1,
isMultitouch
} = this.gestures;
const {
currSlide,
mainScroll
} = this.pswp;
const delta = p1[axis] - prevP1[axis];
const newMainScrollX = mainScroll.x + delta;
if (!delta || !currSlide) {
return false;
} // 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 (this.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);
}
}
return false;
} // 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.
*
* @private
* @param {number} panY The current pan Y position.
* @returns {number}
*/
_getVerticalDragRatio(panY) {
var _this$pswp$currSlide$, _this$pswp$currSlide;
return (panY - ((_this$pswp$currSlide$ = (_this$pswp$currSlide = this.pswp.currSlide) === null || _this$pswp$currSlide === void 0 ? void 0 : _this$pswp$currSlide.bounds.center.y) !== null && _this$pswp$currSlide$ !== void 0 ? _this$pswp$currSlide$ : 0)) / (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.
*
* @private
* @param {'x' | 'y'} axis
* @param {number} potentialPan
* @param {number} [customFriction] (0.1 - 1)
*/
_setPanWithFriction(axis, potentialPan, customFriction) {
const {
currSlide
} = this.pswp;
if (!currSlide) {
return;
}
const {
pan,
bounds
} = 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;
}
}
}
/** @typedef {import('../photoswipe.js').Point} Point */
/** @typedef {import('./gestures.js').default} Gestures */
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
* @returns {Point}
*/
function getZoomPointsCenter(p, p1, p2) {
p.x = (p1.x + p2.x) / 2;
p.y = (p1.y + p2.y) / 2;
return p;
}
class ZoomHandler {
/**
* @param {Gestures} gestures
*/
constructor(gestures) {
this.gestures = gestures;
/**
* @private
* @type {Point}
*/
this._startPan = {
x: 0,
y: 0
};
/**
* @private
* @type {Point}
*/
this._startZoomPoint = {
x: 0,
y: 0
};
/**
* @private
* @type {Point}
*/
this._zoomPoint = {
x: 0,
y: 0
};
/** @private */
this._wasOverFitZoomLevel = false;
/** @private */
this._startZoomLevel = 1;
}
start() {
const {
currSlide
} = this.gestures.pswp;
if (currSlide) {
this._startZoomLevel = currSlide.currZoomLevel;
equalizePoints(this._startPan, currSlide.pan);
}
this.gestures.pswp.animations.stopAllPan();
this._wasOverFitZoomLevel = false;
}
change() {
const {
p1,
startP1,
p2,
startP2,
pswp
} = this.gestures;
const {
currSlide
} = pswp;
if (!currSlide) {
return;
}
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; // 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();
}
end() {
const {
pswp
} = this.gestures;
const {
currSlide
} = pswp;
if ((!currSlide || currSlide.currZoomLevel < currSlide.zoomLevels.initial) && !this._wasOverFitZoomLevel && pswp.options.pinchToClose) {
pswp.close();
} else {
this.correctZoomPan();
}
}
/**
* @private
* @param {'x' | 'y'} axis
* @param {number} currZoomLevel
* @returns {number}
*/
_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.gestures;
const {
currSlide
} = pswp;
if (!(currSlide !== null && currSlide !== void 0 && currSlide.isZoomable())) {
return;
}
if (this._zoomPoint.x === 0) {
ignoreGesture = true;
}
const prevZoomLevel = currSlide.currZoomLevel;
/** @type {number} */
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({
x: 0,
y: 0
}, currSlide.pan);
let destinationPan = equalizePoints({
x: 0,
y: 0
}, 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);
const panNeedsChange = !pointsEqual(destinationPan, initialPan);
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();
}
});
}
}
/**
* @template {string} T
* @template {string} P
* @typedef {import('../types.js').AddPostfix<T, P>} AddPostfix<T, P>
*/
/** @typedef {import('./gestures.js').default} Gestures */
/** @typedef {import('../photoswipe.js').Point} Point */
/** @typedef