lib/intranet/resources/www/photoswipe/photoswipe.esm.js in intranet-pictures-2.0.0 vs lib/intranet/resources/www/photoswipe/photoswipe.esm.js in intranet-pictures-2.1.0

- old
+ new

@@ -1,331 +1,387 @@ /*! - * PhotoSwipe 5.2.4 - https://photoswipe.com - * (c) 2022 Dmytro Semenov + * PhotoSwipe 5.4.2 - https://photoswipe.com + * (c) 2023 Dmytro Semenov */ +/** @typedef {import('../photoswipe.js').Point} Point */ + /** - * Creates element and optionally appends it to another. - * - * @param {String} className - * @param {String|NULL} tagName - * @param {Element|NULL} appendToEl - */ + * @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 || 'div'); + 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 {Object} p1 Point - * @param {Object} p2 Point + * @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)); + return Math.sqrt(x * x + y * y); } - /** - * Whether X and Y positions of points are qual + * Whether X and Y positions of points are equal * - * @param {Object} p1 - * @param {Object} p2 + * @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 + * @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|null} y - * @param {Number|null} scale + * @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)'; + let propValue = `translate3d(${x}px,${y || 0}px,0)`; if (scale !== undefined) { - propValue += ' scale3d(' - + scale + ',' + scale - + ',1)'; + 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 + * @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 {Element} el - * @param {String} prop CSS property to animate - * @param {Number} duration in ms - * @param {String|NULL} ease CSS easing function + * @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'; + 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; + 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(); + 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', + ERROR: 'error' }; - - /** * Check if click or keydown event was dispatched * with a special key or via mouse wheel. * - * @param {Event} e + * @param {MouseEvent | KeyboardEvent} e + * @returns {boolean} */ + function specialKeyUsed(e) { - if (e.which === 2 || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { - return true; - } + return 'button' in e && e.button === 1 || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey; } - /** * Parse `gallery` or `children` options. * - * @param {Element|NodeList|String} option - * @param {String|null} legacySelector - * @param {Element|null} parent - * @returns Element[] + * @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 {DOMElement} target - * @param {String} type Can be multiple, separated by space. - * @param {Function} listener - * @param {Boolean} passive + * @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 {DOMElement} target - * @param {String} type - * @param {Function} listener - * @param {Boolean} passive + * @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.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 + * @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 ? 'remove' : 'add') + 'EventListener'; - type = type.split(' '); - type.forEach((eType) => { + 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; + 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, + } // 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 - ); + + 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 @@ -350,202 +406,252 @@ * 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 } - * @param {Object} itemData Data about the slide - * @param {Integer} index Slide index - * @returns {Number} + * @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; + 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); + const legacyPropName = 'padding' + prop[0].toUpperCase() + prop.slice(1); // @ts-expect-error + if (options[legacyPropName]) { + // @ts-expect-error paddingValue = options[legacyPropName]; } } - return paddingValue || 0; + 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) + 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 = {}; - this.max = {}; - this.min = {}; - - this.reset(); + 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 + */ - // _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 }); + + this.slide.pswp.dispatch('calcBounds', { + slide: this.slide + }); } } + /** + * _calculateItemBoundsForAxis + * + * @param {Axis} axis + */ - // _calculateItemBoundsForAxis + _updateAxis(axis) { - const { pswp } = this.slide; + 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 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: - const panAreaSize = this.slide.panAreaSize[axis]; + this.center[axis] = Math.round((panAreaSize - elSize) / 2) + padding; // maximum pan position - // Default position of element. - // By defaul it is center of viewport: - this.center[axis] = Math.round((panAreaSize - elSize) / 2) + padding; + this.max[axis] = elSize > panAreaSize ? Math.round(panAreaSize - elSize) + padding : this.center[axis]; // minimum pan position - // maximum pan position - this.max[axis] = (elSize > panAreaSize) - ? Math.round(panAreaSize - elSize) + padding - : this.center[axis]; + this.min[axis] = elSize > panAreaSize ? padding : this.center[axis]; + } // _getZeroBounds - // 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 + * @param {Axis} axis x or y + * @param {number} panOffset + * @returns {number} */ - correctPan(axis, panOffset) { // checkPanBounds + + + 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. */ -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 + * @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 {Slide} slide + * @param {number} maxWidth + * @param {number} maxHeight + * @param {Point} panAreaSize */ + + update(maxWidth, maxHeight, panAreaSize) { - this.elementSize = { + /** @type {Point} */ + const elementSize = { x: maxWidth, y: maxHeight }; - + this.elementSize = elementSize; this.panAreaSize = panAreaSize; - - const hRatio = this.panAreaSize.x / this.elementSize.x; - const vRatio = this.panAreaSize.y / this.elementSize.y; - + 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 + 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.vFill = Math.min(1, vRatio); this.initial = this._getInitial(); this.secondary = this._getSecondary(); - this.max = Math.max( - this.initial, - this.secondary, - this._getMax() - ); + this.max = Math.max(this.initial, this.secondary, this._getMax()); + this.min = Math.min(this.fit, this.initial, this.secondary); - this.min = Math.min( - this.fit, - this.initial, - this.secondary - ); - if (this.pswp) { - this.pswp.dispatch('zoomLevelsUpdate', { zoomLevels: this, slideData: this.itemData }); + this.pswp.dispatch('zoomLevelsUpdate', { + zoomLevels: this, + slideData: this.itemData + }); } } - /** * Parses user-defined zoom option. * - * @param {Mixed} optionPrefix Zoom level option prefix (initial, secondary, max) + * @private + * @param {'initial' | 'secondary' | 'max'} optionPrefix Zoom level option prefix (initial, secondary, max) + * @returns { number | undefined } */ + + _parseZoomLevelOption(optionPrefix) { - // zoom.initial - // zoom.secondary - // zoom.max - const optionValue = this.options[optionPrefix + 'ZoomLevel']; + const optionName = + /** @type {'initialZoomLevel' | 'secondaryZoomLevel' | 'maxZoomLevel'} */ + optionPrefix + 'ZoomLevel'; + const optionValue = this.options[optionName]; if (!optionValue) { return; } @@ -561,239 +667,269 @@ 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} + * @private + * @return {number} */ + + _getSecondary() { let currZoomLevel = this._parseZoomLevelOption('secondary'); if (currZoomLevel) { return currZoomLevel; - } + } // 3x of "fit" state, but not larger than original - // 3x of "fit" state, but not larger than original + currZoomLevel = Math.min(1, this.fit * 3); - if (currZoomLevel * this.elementSize.x > MAX_IMAGE_WIDTH) { + if (this.elementSize && currZoomLevel * this.elementSize.x > MAX_IMAGE_WIDTH) { currZoomLevel = MAX_IMAGE_WIDTH / this.elementSize.x; } return currZoomLevel; } - /** * Get initial image zoom level. * - * @return {Number} + * @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. * - * @return {Number} + * @private + * @return {number} */ - _getMax() { - const currZoomLevel = this._parseZoomLevelOption('max'); - if (currZoomLevel) { - return currZoomLevel; - } + _getMax() { // max zoom level is x4 from "fit state", // used for zoom gesture and ctrl/trackpad zoom - return Math.max(1, this.fit * 4); + 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.isActive = index === pswp.currIndex; this.currentResolution = 0; - this.panAreaSize = {}; + /** @type {Point} */ - this.isFirstSlide = (this.isActive && !pswp.opener.isOpen); + 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.pan = { - x: 0, - y: 0 - }; - this.content = this.pswp.contentLoader.getContentBySlide(this); - this.container = createElement('pswp__zoom-wrap'); + this.container = createElement('pswp__zoom-wrap', 'div'); + /** @type {HTMLElement | null} */ + this.holderElement = null; this.currZoomLevel = 1; + /** @type {number} */ + this.width = this.content.width; - this.height = this.content.height; + /** @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 }); + this.pswp.dispatch('slideInit', { + slide: this + }); } - /** * If this slide is active/current/visible * - * @param {Boolean} isActive + * @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 - // Slide appended to DOM if (!this.data) { - this.holderElement.innerHTML = ''; return; } this.calculateSize(); - - this.container.transformOrigin = '0 0'; - this.load(); - this.appendHeavy(); this.updateContentSize(); - - this.holderElement.innerHTML = ''; + this.appendHeavy(); this.holderElement.appendChild(this.container); - this.zoomAndPanToInitial(); - - this.pswp.dispatch('firstZoomPan', { slide: this }); - + this.pswp.dispatch('firstZoomPan', { + slide: this + }); this.applyCurrentZoomPan(); + this.pswp.dispatch('afterSetContent', { + slide: this + }); - this.pswp.dispatch('afterSetContent', { slide: this }); - if (this.isActive) { this.activate(); } } load() { - this.content.load(); - this.pswp.dispatch('slideLoad', { slide: this }); + 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 { + pswp + } = this; const appendHeavyNearby = true; // todo - // Avoid appending heavy elements during animations - if (this.heavyAppended - || !pswp.opener.isOpen - || pswp.mainScroll.isShifted() - || (!this.isActive && !appendHeavyNearby)) { + + if (this.heavyAppended || !pswp.opener.isOpen || pswp.mainScroll.isShifted() || !this.isActive && !appendHeavyNearby) { return; } - if (this.pswp.dispatch('appendHeavy', { slide: this }).defaultPrevented) { + if (this.pswp.dispatch('appendHeavy', { + slide: this + }).defaultPrevented) { return; } this.heavyAppended = true; - this.content.append(); - - this.pswp.dispatch('appendHeavyContent', { slide: this }); + 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 }); + 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 + 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 }); + 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.pswp.dispatch('slideDestroy', { slide: this }); + 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(); @@ -803,18 +939,18 @@ 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 + * @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; @@ -826,75 +962,83 @@ 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) { + if (width !== this.prevDisplayedWidth || height !== this.prevDisplayedHeight) { this.prevDisplayedWidth = width; this.prevDisplayedHeight = height; return true; } return false; } + /** @returns {HTMLImageElement | HTMLDivElement | null | undefined} */ + getPlaceholderElement() { - if (this.content.placeholder) { - return this.content.placeholder.element; - } - } + 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 {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. + * @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()) { + const { + pswp + } = this; + + if (!this.isZoomable() || pswp.mainScroll.isShifted()) { return; } pswp.dispatch('beforeZoomTo', { - destZoomLevel, centerPoint, transitionDuration - }); + destZoomLevel, + centerPoint, + transitionDuration + }); // stop all pan and zoom transitions - // stop all pan and zoom transitions - pswp.animations.stopAllPan(); - - // if (!centerPoint) { + 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) { + } // 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(); @@ -908,424 +1052,483 @@ 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 - ); + 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 + * @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. + * @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] - ); + 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 + * @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 this.width && (this.currZoomLevel > this.zoomLevels.fit); + return Boolean(this.width) && this.currZoomLevel > this.zoomLevels.fit; } - /** * If the slide can be zoomed + * @returns {boolean} */ + + isZoomable() { - return this.width && this.content.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 }); + this.pswp.dispatch('zoomPanUpdate', { + slide: this + }); } } zoomAndPanToInitial() { - this.currZoomLevel = this.zoomLevels.initial; + this.currZoomLevel = this.zoomLevels.initial; // pan according to the zoom level - // pan according to the zoom level this.bounds.update(this.currZoomLevel); equalizePoints(this.pan, this.bounds.center); - this.pswp.dispatch('initialZoomPan', { slide: this }); + this.pswp.dispatch('initialZoomPan', { + slide: this + }); } - /** * Set translate and scale based on current resolution * - * @param {Number} x - * @param {Number} y - * @param {Number} zoom + * @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) - ); - + 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 highest quality. + * sure that browser renders image in the highest quality. * Also used by responsive images to load the correct one. * - * @param {Number} newResolution + * @param {number} newResolution */ + + _setResolution(newResolution) { if (newResolution === this.currentResolution) { return; } this.currentResolution = newResolution; this.updateContentSize(); - this.pswp.dispatch('resolutionChanged'); } + } -/** - * Handles single pointer dragging - */ +/** @typedef {import('../photoswipe.js').Point} Point */ +/** @typedef {import('./gestures.js').default} Gestures */ + const PAN_END_FRICTION = 0.35; -const VERTICAL_DRAG_FRICTION = 0.6; +const VERTICAL_DRAG_FRICTION = 0.6; // 1 corresponds to the third of viewport height -// 1 corresponds to the third of viewport height -const MIN_RATIO_TO_CLOSE = 0.4; - -// Minimum speed required to navigate +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; - this.startPan = {}; + /** @type {Point} */ + + this.startPan = { + x: 0, + y: 0 + }; } start() { - equalizePoints(this.startPan, this.pswp.currSlide.pan); + if (this.pswp.currSlide) { + equalizePoints(this.startPan, this.pswp.currSlide.pan); + } + this.pswp.animations.stopAll(); } change() { - const { p1, prevP1, dragAxis, pswp } = this.gestures; - const { currSlide } = pswp; + const { + p1, + prevP1, + dragAxis + } = this.gestures; + const { + currSlide + } = this.pswp; - if (dragAxis === 'y' - && pswp.options.closeOnVerticalDrag - && currSlide.currZoomLevel <= currSlide.zoomLevels.fit - && !this.gestures.isMultitouch) { + 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 (!pswp.dispatch('verticalDrag', { panY }).defaultPrevented) { + + if (!this.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); + this.pswp.applyBgOpacity(bgOpacity); currSlide.applyCurrentZoomPan(); } } else { const mainScrollChanged = this._panOrMoveMainScroll('x'); + if (!mainScrollChanged) { this._panOrMoveMainScroll('y'); - roundPoint(currSlide.pan); - currSlide.applyCurrentZoomPan(); + if (currSlide) { + roundPoint(currSlide.pan); + currSlide.applyCurrentZoomPan(); + } } } } end() { - const { pswp, velocity } = this.gestures; - const { mainScroll } = pswp; + const { + velocity + } = this.gestures; + const { + mainScroll, + currSlide + } = this.pswp; let indexDiff = 0; + this.pswp.animations.stopAll(); // Handle main scroll if it's shifted - 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: + 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 + // 0.5 - half of the slide is visible // 1 - slide is fully visible - const currentSlideVisibilityRatio = (mainScrollShiftDiff / pswp.viewportSize.x); - // Go next slide. + const currentSlideVisibilityRatio = mainScrollShiftDiff / this.pswp.viewportSize.x; // Go next slide. // - // - if velocity and its direction is matched + // - 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)) { + + 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)) { + } 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 - // Restore zoom level - if (pswp.currSlide.currZoomLevel > pswp.currSlide.zoomLevels.max - || this.gestures.isMultitouch) { + + 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 { pswp } = this; - const { currSlide } = pswp; - const { velocity } = this.gestures; - const { pan, bounds } = currSlide; + const { + velocity + } = this.gestures; + const { + currSlide + } = this.pswp; + + if (!currSlide) { + return; + } + + const { + pan, + bounds + } = currSlide; const panPos = pan[axis]; - const restoreBgOpacity = (pswp.bgOpacity < 1 && axis === 'y'); + 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 - // 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, + 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(); + + + if (vDragRatio < 0 && projectedVDragRatio < -MIN_RATIO_TO_CLOSE || vDragRatio > 0 && projectedVDragRatio > MIN_RATIO_TO_CLOSE) { + this.pswp.close(); return; } - } + } // Pan position with corrected bounds - // Pan position with corrected bounds - const correctedPanPosition = bounds.correctPan(axis, projectedPosition); - // Exit if pan position should not be changed + 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 - // Overshoot if the final position is out of pan bounds - const dampingRatio = (correctedPanPosition === projectedPosition) ? 1 : 0.82; - const initialBgOpacity = pswp.bgOpacity; + const dampingRatio = correctedPanPosition === projectedPosition ? 1 : 0.82; + const initialBgOpacity = this.pswp.bgOpacity; const totalPanDist = correctedPanPosition - panPos; - - pswp.animations.startSpring({ + this.pswp.animations.startSpring({ name: 'panGesture' + axis, isPan: true, start: panPos, end: correctedPanPosition, velocity: velocity[axis], dampingRatio, - onUpdate: (pos) => { + onUpdate: pos => { // Animate opacity of background relative to Y pan position of an image - if (restoreBgOpacity && pswp.bgOpacity < 1) { + 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. + 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 - )); + + 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. * - * @param {String} axis + * @private + * @param {'x' | 'y'} axis + * @returns {boolean} */ + + _panOrMoveMainScroll(axis) { - const { p1, pswp, dragAxis, prevP1, isMultitouch } = this.gestures; - const { currSlide, mainScroll } = pswp; - const delta = (p1[axis] - prevP1[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) { - return; - } + if (!delta || !currSlide) { + return false; + } // Always move main scroll if image can not be panned - // 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 { + bounds + } = currSlide; const newPan = currSlide.pan[axis] + delta; - if (pswp.options.allowPanToNext - && dragAxis === 'x' - && axis === 'x' - && !isMultitouch) { - const currSlideMainScrollX = mainScroll.getCurrSlideX(); + if (this.pswp.options.allowPanToNext && dragAxis === 'x' && axis === 'x' && !isMultitouch) { + const currSlideMainScrollX = mainScroll.getCurrSlideX(); // Position of the main scroll relative to the viewport - // 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]); + 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; + 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]); + 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; + 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*/) { + if (mainScrollShiftDiff > 0 + /*&& isRightToLeft*/ + ) { mainScroll.moveTo(Math.max(newMainScrollX, currSlideMainScrollX), true); return true; - } else if (mainScrollShiftDiff < 0 /*&& isLeftToRight*/) { + } 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 { @@ -1341,114 +1544,187 @@ } } else { this._setPanWithFriction(axis, newPan); } } - } - // - // If we move above - the ratio is negative + + 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. * - * @param {Number} panY The current pan Y position. + * @private + * @param {number} panY The current pan Y position. + * @returns {number} */ + + _getVerticalDragRatio(panY) { - return (panY - this.pswp.currSlide.bounds.center.y) - / (this.pswp.viewportSize.y / 3); - } + 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. * - * @param {String} axis - * @param {Number} potentialPan - * @param {Number|null} customFriction (0.1 - 1) + * @private + * @param {'x' | 'y'} axis + * @param {number} potentialPan + * @param {number} [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 + 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; } } + } -const UPPER_ZOOM_FRICTION = 0.05; -const LOWER_ZOOM_FRICTION = 0.15; +/** @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; - this.pswp = this.gestures.pswp; - this._startPan = {}; + /** + * @private + * @type {Point} + */ - this._startZoomPoint = {}; - this._zoomPoint = {}; + 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() { - this._startZoomLevel = this.pswp.currSlide.currZoomLevel; - equalizePoints(this._startPan, this.pswp.currSlide.pan); - this.pswp.animations.stopAllPan(); + 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; + 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; + let currZoomLevel = 1 / getDistanceBetween(startP1, startP2) * getDistanceBetween(p1, p2) * this._startZoomLevel; // slightly over the zoom.fit - // slightly over the zoom.fit - if (currZoomLevel > currSlide.zoomLevels.initial + (currSlide.zoomLevels.initial / 15)) { + + 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) { + 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) { + 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; @@ -1458,75 +1734,92 @@ 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; - const { currSlide } = pswp; - if (currSlide.currZoomLevel < currSlide.zoomLevels.initial - && !this._wasOverFitZoomLevel - && pswp.options.pinchToClose) { + 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); + 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. + * @param {boolean} [ignoreGesture] + * Wether gesture coordinates should be ignored when calculating destination pan position. */ + + correctZoomPan(ignoreGesture) { - const { pswp } = this; - const { currSlide } = pswp; + const { + pswp + } = this.gestures; + const { + currSlide + } = pswp; - if (!currSlide.isZoomable()) { + if (!(currSlide !== null && currSlide !== void 0 && currSlide.isZoomable())) { return; } - if (this._zoomPoint.x === undefined) { + 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 + destinationZoomLevel = currSlide.zoomLevels.initial; // zoom to min } else if (prevZoomLevel > currSlide.zoomLevels.max) { - destinationZoomLevel = currSlide.zoomLevels.max; - // zoom to 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); - 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; @@ -1537,134 +1830,178 @@ if (currZoomLevelNeedsChange) { destinationPan = { x: this._calculatePanForZoomLevel('x', destinationZoomLevel), y: this._calculatePanForZoomLevel('y', destinationZoomLevel) }; - } + } // set zoom level, so pan bounds are updated according to it - // set zoom level, so pan bounds are updated according to it - currSlide.setZoomLevel(destinationZoomLevel); + 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 - // return zoom level and its bounds to initial currSlide.setZoomLevel(prevZoomLevel); + const panNeedsChange = !pointsEqual(destinationPan, initialPan); - 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 + 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) => { + 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; + const newZoomLevel = prevZoomLevel + (destinationZoomLevel - prevZoomLevel) * now; currSlide.setZoomLevel(newZoomLevel); } currSlide.applyCurrentZoomPan(); - } + } // Restore background opacity - // 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 - )); + pswp.applyBgOpacity(clamp(initialBgOpacity + (1 - initialBgOpacity) * now, 0, 1)); } }, onComplete: () => { // update resolution after transition ends currSlide._setResolution(destinationZoomLevel); + currSlide.applyCurrentZoomPan(); } }); } + } /** - * Tap, double-tap handler. + * @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 {'imageClick' | 'bgClick' | 'tap' | 'doubleTap'} Actions */ + /** * Whether the tap was performed on the main slide * (rather than controls or caption). * - * @param {Event} event + * @param {PointerEvent} event + * @returns {boolean} */ function didTapOnMainContent(event) { - return !!(event.target.closest('.pswp__container')); + return !! + /** @type {HTMLElement} */ + event.target.closest('.pswp__container'); } +/** + * Tap, double-tap handler. + */ + class TapHandler { + /** + * @param {Gestures} gestures + */ constructor(gestures) { this.gestures = gestures; } + /** + * @param {Point} point + * @param {PointerEvent} originalEvent + */ click(point, originalEvent) { - const targetClassList = originalEvent.target.classList; + const targetClassList = + /** @type {HTMLElement} */ + originalEvent.target.classList; const isImageClick = targetClassList.contains('pswp__img'); - const isBackgroundClick = targetClassList.contains('pswp__item') - || targetClassList.contains('pswp__zoom-wrap'); + 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); } } + /** + * @param {Point} point + * @param {PointerEvent} originalEvent + */ + tap(point, originalEvent) { if (didTapOnMainContent(originalEvent)) { this._doClickOrTapAction('tap', point, originalEvent); } } + /** + * @param {Point} point + * @param {PointerEvent} originalEvent + */ + doubleTap(point, originalEvent) { if (didTapOnMainContent(originalEvent)) { this._doClickOrTapAction('doubleTap', point, originalEvent); } } + /** + * @private + * @param {Actions} actionName + * @param {Point} point + * @param {PointerEvent} originalEvent + */ + _doClickOrTapAction(actionName, point, originalEvent) { - const { pswp } = this.gestures; - const { currSlide } = pswp; - const optionValue = pswp.options[actionName + 'Action']; + var _this$gestures$pswp$e; - if (pswp.dispatch(actionName + 'Action', { point, originalEvent }).defaultPrevented) { + const { + pswp + } = this.gestures; + const { + currSlide + } = pswp; + const actionFullName = + /** @type {AddPostfix<Actions, 'Action'>} */ + actionName + 'Action'; + const optionValue = pswp.options[actionFullName]; + + if (pswp.dispatch(actionFullName, { + point, + originalEvent + }).defaultPrevented) { return; } if (typeof optionValue === 'function') { optionValue.call(pswp, point, originalEvent); @@ -1674,270 +2011,399 @@ switch (optionValue) { case 'close': case 'next': pswp[optionValue](); break; + case 'zoom': - currSlide.toggleZoom(point); + currSlide === null || currSlide === void 0 || 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) { + if (currSlide !== null && currSlide !== void 0 && 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.element.classList.toggle('pswp--ui-visible'); - // if (_controlsVisible) { + (_this$gestures$pswp$e = this.gestures.pswp.element) === null || _this$gestures$pswp$e === void 0 || _this$gestures$pswp$e.classList.toggle('pswp--ui-visible'); // if (_controlsVisible) { // _ui.hideControls(); // } else { // _ui.showControls(); // } + break; } } + } +/** @typedef {import('../photoswipe.js').default} PhotoSwipe */ + +/** @typedef {import('../photoswipe.js').Point} Point */ +// 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 + /** * 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 { + /** + * @param {PhotoSwipe} pswp + */ constructor(pswp) { this.pswp = pswp; + /** @type {'x' | 'y' | null} */ - - // point objects are defined once and reused + this.dragAxis = null; // 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 = {}; + /** @type {Point} */ + + this.p1 = { + x: 0, + y: 0 + }; // the first pressed pointer + + /** @type {Point} */ + + this.p2 = { + x: 0, + y: 0 + }; // the second pressed pointer + + /** @type {Point} */ + + this.prevP1 = { + x: 0, + y: 0 + }; + /** @type {Point} */ + + this.prevP2 = { + x: 0, + y: 0 + }; + /** @type {Point} */ + + this.startP1 = { + x: 0, + y: 0 + }; + /** @type {Point} */ + + this.startP2 = { + x: 0, + y: 0 + }; + /** @type {Point} */ + + this.velocity = { + x: 0, + y: 0 + }; + /** @type {Point} + * @private + */ + + this._lastStartP1 = { + x: 0, + y: 0 + }; + /** @type {Point} + * @private + */ + + this._intervalP1 = { + x: 0, + y: 0 + }; + /** @private */ + this._numActivePoints = 0; + /** @type {Point[]} + * @private + */ + this._ongoingPointers = []; + /** @private */ this._touchEventEnabled = 'ontouchstart' in window; - this._pointerEventEnabled = !!(window.PointerEvent); - this.supportsTouch = this._touchEventEnabled - || (this._pointerEventEnabled && navigator.maxTouchPoints > 1); + /** @private */ + this._pointerEventEnabled = !!window.PointerEvent; + this.supportsTouch = this._touchEventEnabled || this._pointerEventEnabled && navigator.maxTouchPoints > 1; + /** @private */ + + this._numActivePoints = 0; + /** @private */ + + this._intervalTime = 0; + /** @private */ + + this._velocityCalculated = false; + this.isMultitouch = false; + this.isDragging = false; + this.isZooming = false; + /** @type {number | null} */ + + this.raf = null; + /** @type {NodeJS.Timeout | null} + * @private + */ + + this._tapTimer = null; + 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)); + pswp.events.add(pswp.scrollWrap, 'click', + /** @type EventListener */ + this._onClick.bind(this)); 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, + 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 + + + if (pswp.scrollWrap) { + pswp.scrollWrap.ontouchmove = () => {}; + + pswp.scrollWrap.ontouchend = () => {}; + } } else { this._bindEvents('mouse', 'down', 'up'); } }); } + /** + * @private + * @param {'mouse' | 'touch' | 'pointer'} pref + * @param {'down' | 'start'} down + * @param {'up' | 'end'} up + * @param {'cancel'} [cancel] + */ - _bindEvents(pref, down, up, cancel) { - const { pswp } = this; - const { events } = pswp; + _bindEvents(pref, down, up, cancel) { + const { + pswp + } = this; + const { + events + } = pswp; const cancelEvent = cancel ? pref + cancel : ''; + events.add(pswp.scrollWrap, pref + down, + /** @type EventListener */ + this.onPointerDown.bind(this)); + events.add(window, pref + 'move', + /** @type EventListener */ + this.onPointerMove.bind(this)); + events.add(window, pref + up, + /** @type EventListener */ + this.onPointerUp.bind(this)); - 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)); + events.add(pswp.scrollWrap, cancelEvent, + /** @type EventListener */ + this.onPointerUp.bind(this)); } } + /** + * @param {PointerEvent} e + */ 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. + const isMousePointer = e.type === 'mousedown' || e.pointerType === 'mouse'; // 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; + const { + pswp + } = this; // if PhotoSwipe is opening or closing - // if PhotoSwipe is opening or closing if (!pswp.opener.isOpen) { e.preventDefault(); return; } - if (pswp.dispatch('pointerDown', { originalEvent: e }).defaultPrevented) { + if (pswp.dispatch('pointerDown', { + originalEvent: e + }).defaultPrevented) { return; } if (isMousePointer) { - pswp.mouseDetected(); - - // preventDefault mouse event to prevent + pswp.mouseDetected(); // preventDefault mouse event to prevent // browser image drag feature - this._preventPointerEventBehaviour(e); + + this._preventPointerEventBehaviour(e, 'down'); } 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, + 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; } } + /** + * @param {PointerEvent} e + */ + onPointerMove(e) { - e.preventDefault(); // always preventDefault move event + this._preventPointerEventBehaviour(e, 'move'); if (!this._numActivePoints) { return; } this._updatePoints(e, 'move'); - if (this.pswp.dispatch('pointerMove', { originalEvent: e }).defaultPrevented) { + 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 - // 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 + this._clearTapTimer(); // Tap can not trigger after drag // Adjust starting point + + this._updateStartPoints(); - this._intervalTime = Date.now(); - //this._startTime = this._intervalTime; + + 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; + this.isZooming = true; // Adjust starting points - // Adjust starting points this._updateStartPoints(); this.zoomLevels.start(); this._rafStopLoop(); + this._rafRenderLoop(); } } + /** + * @private + */ + _finishDrag() { if (this.isDragging) { - this.isDragging = false; - - // Try to calculate velocity, + 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; } } + /** + * @param {PointerEvent} e + */ onPointerUp(e) { if (!this._numActivePoints) { return; } this._updatePoints(e, 'up'); - if (this.pswp.dispatch('pointerUp', { originalEvent: e }).defaultPrevented) { + 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) { @@ -1951,14 +2417,18 @@ this.zoomLevels.end(); if (this._numActivePoints === 1) { // Since we have 1 point left, we need to reinitiate drag this.dragAxis = null; + this._updateStartPoints(); } } } + /** + * @private + */ _rafRenderLoop() { if (this.isDragging || this.isZooming) { this._updateVelocity(); @@ -1966,199 +2436,253 @@ 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(); + } 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 + * + * @private + * @param {boolean} [force] */ + + _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; } + /** + * @private + * @param {PointerEvent} e + */ + _finishTap(e) { - const { mainScroll } = this.pswp; + const { + mainScroll + } = this.pswp; // Do not trigger tap events if main scroll is shifted - // 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 - // Do not trigger tap for touchcancel or pointercancel + if (e.type.indexOf('cancel') > 0) { return; - } + } // Trigger click instead of tap for mouse events - // 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 - // Disable delay if there is no doubleTapAction - const tapDelay = this.pswp.options.doubleTapAction ? DOUBLE_TAP_DELAY : 0; - // If tapTimer is defined - we tapped recently, + 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 + 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); } } + /** + * @private + */ + _clearTapTimer() { if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; } } - /** * Get velocity for axis * - * @param {Number} axis - * @param {Number} duration + * @private + * @param {'x' | 'y'} axis + * @param {number} duration + * @returns {number} */ + + _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; } + /** + * @private + */ + _rafStopLoop() { if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; } } + /** + * @private + * @param {PointerEvent} e + * @param {'up' | 'down' | 'move'} pointerType Normalized pointer type + */ - // 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; - } + _preventPointerEventBehaviour(e, pointerType) { + const preventPointerEvent = this.pswp.applyFilters('preventPointerEvent', true, e, pointerType); + + if (preventPointerEvent) { + e.preventDefault(); + } + } /** * 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') + * @private + * @param {PointerEvent | TouchEvent} e + * @param {'up' | 'down' | 'move'} pointerType Normalized pointer type */ + + _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; + const pointerEvent = + /** @type {PointerEvent} */ + e; // Try to find the current pointer in ongoing pointers by its ID + + const pointerIndex = this._ongoingPointers.findIndex(ongoingPointer => { + return ongoingPointer.id === pointerEvent.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, {})); + this._ongoingPointers.push(this._convertEventPosToPoint(pointerEvent, { + x: 0, + y: 0 + })); } else if (pointerIndex > -1) { // update existing pointer - this._convertEventPosToPoint(e, this._ongoingPointers[pointerIndex]); + this._convertEventPosToPoint(pointerEvent, this._ongoingPointers[pointerIndex]); } - this._numActivePoints = this._ongoingPointers.length; - - // update points that PhotoSwipe uses + 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 { + const touchEvent = + /** @type {TouchEvent} */ + e; this._numActivePoints = 0; - if (e.type.indexOf('touch') > -1) { + + if (touchEvent.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); + if (touchEvent.touches && touchEvent.touches.length > 0) { + this._convertEventPosToPoint(touchEvent.touches[0], this.p1); + this._numActivePoints++; - if (e.touches.length > 1) { - this._convertEventPosToPoint(e.touches[1], this.p2); + + if (touchEvent.touches.length > 1) { + this._convertEventPosToPoint(touchEvent.touches[1], this.p2); + this._numActivePoints++; } } } else { // Mouse Event - this._convertEventPosToPoint(e, this.p1); + this._convertEventPosToPoint( + /** @type {PointerEvent} */ + 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 + * @private + */ - // 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 + * @private + */ - // update points at the start of gesture + _updateStartPoints() { equalizePoints(this.startP1, this.p1); equalizePoints(this.startP2, this.p2); + this._updatePrevPoints(); } + /** @private */ + _calculateDragDirection() { if (this.pswp.mainScroll.isShifted()) { // if main scroll position is shifted – direction is always horizontal this.dragAxis = 'x'; } else { @@ -2173,165 +2697,195 @@ this.dragAxis = axisToCheck; } } } } - /** * Converts touch, pointer or mouse event * to PhotoSwipe point. * - * @param {Event} e + * @private + * @param {Touch | PointerEvent} e * @param {Point} p + * @returns {Point} */ + + _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) { + if ('pointerId' in e) { p.id = e.pointerId; } else if (e.identifier !== undefined) { p.id = e.identifier; } return p; } + /** + * @private + * @param {PointerEvent} e + */ + _onClick(e) { // Do not allow click event to pass through after drag if (this.pswp.mainScroll.isShifted()) { e.preventDefault(); e.stopPropagation(); } } + } +/** @typedef {import('./photoswipe.js').default} PhotoSwipe */ + +/** @typedef {import('./slide/slide.js').default} Slide */ + +/** @typedef {{ el: HTMLDivElement; slide?: Slide }} ItemHolder */ + +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; + /** * 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.slideWidth = 0; + /** @private */ - this.resetPosition(); - } + this._currPositionIndex = 0; + /** @private */ + this._prevPositionIndex = 0; + /** @private */ + + this._containerShiftIndex = -1; + /** @type {ItemHolder[]} */ + + this.itemHolders = []; + } /** * Position the scroller and slide containers * according to viewport size. * - * @param {Boolean} resizeSlides Whether slides content should resized + * @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. + 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); + 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); + 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._prevPositionIndex = 0; // This will force recalculation of size on next resize() - // This will force recalculation of size on next resize() - this.slideWidth = 0; + this.slideWidth = 0; // _containerShiftIndex*viewportSize will give you amount of transform of the current slide - // _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 - + + 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); + const el = createElement('pswp__item', 'div', this.pswp.container); + el.setAttribute('role', 'group'); + el.setAttribute('aria-roledescription', 'slide'); + el.setAttribute('aria-hidden', 'true'); // hide nearby item holders until initial zoom animation finishes (to avoid extra Paints) - // hide nearby item holders until initial zoom animation finishes (to avoid extra Paints) - el.style.display = (i === 1) ? 'block' : 'none'; - + el.style.display = i === 1 ? 'block' : 'none'; this.itemHolders.push({ - el, - //index: -1 + el //index: -1 + }); } } - /** * Whether the main scroll can be horizontally swiped to the next or previous slide. + * @returns {boolean} */ + + 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 + * @param {number} diff + * @param {boolean} [animate] + * @param {number} [velocityX] + * @returns {boolean} whether index was changed or not */ + + moveIndexBy(diff, animate, velocityX) { - const { pswp } = this; + const { + pswp + } = this; let newIndex = pswp.potentialIndex + diff; const numSlides = pswp.getNumItems(); if (pswp.canLoop()) { newIndex = pswp.getLoopedIndex(newIndex); const distance = (diff + numSlides) % numSlides; + if (distance <= numSlides / 2) { // go forward diff = distance; } else { // go backwards @@ -2341,280 +2895,353 @@ if (newIndex < 0) { newIndex = 0; } else if (newIndex >= numSlides) { newIndex = numSlides - 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) => { + dampingRatio: 1, + //0.7, + onUpdate: x => { this.moveTo(x); }, onComplete: () => { this.updateCurrItem(); pswp.appendHeavy(); } }); - let currDiff = pswp.potentialIndex - pswp.currIndex; + if (pswp.canLoop()) { const currDistance = (currDiff + numSlides) % numSlides; + if (currDistance <= numSlides / 2) { // go forward currDiff = currDistance; } else { // go backwards currDiff = currDistance - numSlides; } - } - - // Force-append new slides during transition + } // Force-append new slides during transition // if difference between slides is more than 1 + + if (Math.abs(currDiff) > 1) { this.updateCurrItem(); } } - if (diff) { - return true; - } + return Boolean(diff); } - /** * X position of the main scroll for the current slide * (ignores position during dragging) + * @returns {number} */ + + 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. + * @returns {boolean} */ + + isShifted() { return this.x !== this.getCurrSlideX(); } - /** * Update slides X positions and set their content */ + + updateCurrItem() { - const { pswp } = this; + var _this$itemHolders$; + + 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); + /** @type {ItemHolder | undefined} */ + 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++; + if (tempHolder) { + this.itemHolders[2] = tempHolder; // move first to last - setTransform(tempHolder.el, (this._containerShiftIndex + 2) * this.slideWidth); - - pswp.setContent(tempHolder, (pswp.currIndex - diffAbs) + i + 2); + 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--; + if (tempHolder) { + this.itemHolders.unshift(tempHolder); // move last to first - setTransform(tempHolder.el, this._containerShiftIndex * this.slideWidth); - - pswp.setContent(tempHolder, (pswp.currIndex + diffAbs) - i - 2); + 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. + } // 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) - // Pan transition might be running (and consntantly updating pan position) - pswp.animations.stopAllPan(); + 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.currSlide = (_this$itemHolders$ = this.itemHolders[1]) === null || _this$itemHolders$ === void 0 ? void 0 : _this$itemHolders$.slide; pswp.contentLoader.updateLazy(positionDifference); - pswp.currSlide.applyCurrentZoomPan(); + if (pswp.currSlide) { + pswp.currSlide.applyCurrentZoomPan(); + } + pswp.dispatch('change'); } - /** * Move the X position of the main scroll container * - * @param {Number} x - * @param {Boolean} dragging + * @param {number} x + * @param {boolean} [dragging] */ - moveTo(x, dragging) { - let newSlideIndexOffset; - let delta; + + moveTo(x, dragging) { if (!this.pswp.canLoop() && dragging) { // Apply friction - newSlideIndexOffset = ((this.slideWidth * this._currPositionIndex) - x) / this.slideWidth; + let newSlideIndexOffset = (this.slideWidth * this._currPositionIndex - x) / this.slideWidth; newSlideIndexOffset += this.pswp.currIndex; - delta = Math.round(x - this.x); + const 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); + 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 }); + if (this.pswp.container) { + setTransform(this.pswp.container, x); + } + + this.pswp.dispatch('moveMainScroll', { + x, + dragging: dragging !== null && dragging !== void 0 ? dragging : false + }); } + } +/** @typedef {import('./photoswipe.js').default} PhotoSwipe */ + /** - * - * keyboard.js - * + * @template T + * @typedef {import('./types.js').Methods<T>} Methods<T> + */ + +const KeyboardKeyCodesMap = { + Escape: 27, + z: 90, + ArrowLeft: 37, + ArrowUp: 38, + ArrowRight: 39, + ArrowDown: 40, + Tab: 9 +}; +/** + * @template {keyof KeyboardKeyCodesMap} T + * @param {T} key + * @param {boolean} isKeySupported + * @returns {T | number | undefined} + */ + +const getKeyboardEventKey = (key, isKeySupported) => { + return isKeySupported ? key : KeyboardKeyCodesMap[key]; +}; +/** * - Manages keyboard shortcuts. - * - Heps trap focus within photoswipe. - * + * - Helps trap focus within photoswipe. */ + class Keyboard { + /** + * @param {PhotoSwipe} pswp + */ constructor(pswp) { this.pswp = pswp; + /** @private */ + this._wasFocused = false; 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(); + if (pswp.options.trapFocus) { + // 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', + /** @type EventListener */ + this._onFocusIn.bind(this)); } - pswp.events.add(document, 'focusin', this._onFocusIn.bind(this)); - pswp.events.add(document, 'keydown', this._onKeyDown.bind(this)); + pswp.events.add(document, 'keydown', + /** @type EventListener */ + this._onKeyDown.bind(this)); }); - - const lastActiveElement = document.activeElement; + const lastActiveElement = + /** @type {HTMLElement} */ + document.activeElement; pswp.on('destroy', () => { - if (pswp.options.returnFocus - && lastActiveElement - && this._wasFocused) { + if (pswp.options.returnFocus && lastActiveElement && this._wasFocused) { lastActiveElement.focus(); } }); } + /** @private */ + _focusRoot() { - if (!this._wasFocused) { + if (!this._wasFocused && this.pswp.element) { this.pswp.element.focus(); this._wasFocused = true; } } + /** + * @private + * @param {KeyboardEvent} e + */ + _onKeyDown(e) { - const { pswp } = this; + const { + pswp + } = this; - if (pswp.dispatch('keydown', { originalEvent: e }).defaultPrevented) { + 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; } + /** @type {Methods<PhotoSwipe> | undefined} */ + let keydownAction; + /** @type {'x' | 'y' | undefined} */ + let axis; - let isForward; + let isForward = false; + const isKeySupported = ('key' in e); - switch (e.keyCode) { - case 27: // esc + switch (isKeySupported ? e.key : e.keyCode) { + case getKeyboardEventKey('Escape', isKeySupported): if (pswp.options.escKey) { keydownAction = 'close'; } + break; - case 90: // z key + + case getKeyboardEventKey('z', isKeySupported): keydownAction = 'toggleZoom'; break; - case 37: // left + + case getKeyboardEventKey('ArrowLeft', isKeySupported): axis = 'x'; break; - case 38: // top + + case getKeyboardEventKey('ArrowUp', isKeySupported): axis = 'y'; break; - case 39: // right + + case getKeyboardEventKey('ArrowRight', isKeySupported): axis = 'x'; isForward = true; break; - case 40: // bottom + + case getKeyboardEventKey('ArrowDown', isKeySupported): isForward = true; axis = 'y'; break; - case 9: // tab + + case getKeyboardEventKey('Tab', isKeySupported): this._focusRoot(); + break; - } + } // if left/right/top/bottom key - // if left/right/top/bottom key + if (axis) { // prevent page scroll e.preventDefault(); + const { + currSlide + } = pswp; - const { currSlide } = pswp; - - if (pswp.options.arrowKeys - && axis === 'x' - && pswp.getNumItems() > 1) { + 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, @@ -2623,322 +3250,411 @@ currSlide.panTo(currSlide.pan.x, currSlide.pan.y); } } if (keydownAction) { - e.preventDefault(); + e.preventDefault(); // @ts-ignore + pswp[keydownAction](); } } - /** * Trap focus inside photoswipe * - * @param {Event} e + * @private + * @param {FocusEvent} e */ + + _onFocusIn(e) { - const { template } = this.pswp; - if (document !== e.target - && template !== e.target - && !template.contains(e.target)) { + const { + template + } = this.pswp; + + if (template && document !== e.target && template !== e.target && !template.contains( + /** @type {Node} */ + e.target)) { // focus root element template.focus(); } } + } +const DEFAULT_EASING = 'cubic-bezier(.4,0,.22,1)'; +/** @typedef {import('./animations.js').SharedAnimationProps} SharedAnimationProps */ + +/** @typedef {Object} DefaultCssAnimationProps + * + * @prop {HTMLElement} target + * @prop {number} [duration] + * @prop {string} [easing] + * @prop {string} [transform] + * @prop {string} [opacity] + * */ + +/** @typedef {SharedAnimationProps & DefaultCssAnimationProps} CssAnimationProps */ + /** * Runs CSS transition. */ -const DEFAULT_EASING = 'cubic-bezier(.4,0,.22,1)'; - class CSSAnimation { - // onComplete can be unpredictable, be careful about current state + /** + * onComplete can be unpredictable, be careful about current state + * + * @param {CssAnimationProps} props + */ constructor(props) { + var _props$prop; + this.props = props; const { target, onComplete, transform, - // opacity + onFinish = () => {}, + duration = 333, + easing = DEFAULT_EASING } = props; + this.onFinish = onFinish; // support only transform and opacity - let { - duration, - easing, - } = props; - - // support only transform and opacity const prop = transform ? 'transform' : 'opacity'; - const propValue = props[prop]; + const propValue = (_props$prop = props[prop]) !== null && _props$prop !== void 0 ? _props$prop : ''; + /** @private */ this._target = target; + /** @private */ + this._onComplete = onComplete; + /** @private */ - duration = duration || 333; - easing = easing || DEFAULT_EASING; + this._finished = false; + /** @private */ - this._onTransitionEnd = this._onTransitionEnd.bind(this); - - // Using timeout hack to make sure that animation + 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. + // otherwise transitionend might not fire or transition won't start. // https://drafts.csswg.org/css-transitions/#starting // // ¯\_(ツ)_/¯ - this._firstFrameTimeout = setTimeout(() => { + + /** @private */ + + this._helperTimeout = setTimeout(() => { setTransitionStyle(target, prop, duration, easing); - this._firstFrameTimeout = setTimeout(() => { + this._helperTimeout = setTimeout(() => { target.addEventListener('transitionend', this._onTransitionEnd, false); - target.addEventListener('transitioncancel', this._onTransitionEnd, false); + target.addEventListener('transitioncancel', this._onTransitionEnd, false); // Safari occasionally does not emit transitionend event + // if element property was modified during the transition, + // which may be caused by resize or third party component, + // using timeout as a safety fallback + + this._helperTimeout = setTimeout(() => { + this._finalizeAnimation(); + }, duration + 500); target.style[prop] = propValue; }, 30); // Do not reduce this number }, 0); } + /** + * @private + * @param {TransitionEvent} e + */ + _onTransitionEnd(e) { if (e.target === this._target) { this._finalizeAnimation(); } } + /** + * @private + */ + _finalizeAnimation() { if (!this._finished) { this._finished = true; this.onFinish(); + if (this._onComplete) { this._onComplete(); } } - } + } // Destroy is called automatically onFinish - // Destroy is called automatically onFinish + destroy() { - if (this._firstFrameTimeout) { - clearTimeout(this._firstFrameTimeout); + if (this._helperTimeout) { + clearTimeout(this._helperTimeout); } + removeTransitionStyle(this._target); + this._target.removeEventListener('transitionend', this._onTransitionEnd, false); + this._target.removeEventListener('transitioncancel', this._onTransitionEnd, false); + if (!this._finished) { this._finalizeAnimation(); } } + } +const DEFAULT_NATURAL_FREQUENCY = 12; +const DEFAULT_DAMPING_RATIO = 0.75; /** * 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} 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} [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 + * @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._dampingRatio = dampingRatio || DEFAULT_DAMPING_RATIO; // https://en.wikipedia.org/wiki/Natural_frequency + this._naturalFrequency = naturalFrequency || DEFAULT_NATURAL_FREQUENCY; + this._dampedFrequency = this._naturalFrequency; if (this._dampingRatio < 1) { - this._dampedFrequency = this._naturalFrequency - * Math.sqrt(1 - this._dampingRatio * this._dampingRatio); + this._dampedFrequency *= 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 + * @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. + * @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; + this.velocity = displacement * -this._naturalFrequency + coeff * naturalDumpingPow; } else if (this._dampingRatio < 1) { - coeff = (1 / this._dampedFrequency) - * (this._dampingRatio * this._naturalFrequency * deltaPosition + this.velocity); - + 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 - 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; } + } +/** @typedef {import('./animations.js').SharedAnimationProps} SharedAnimationProps */ + +/** + * @typedef {Object} DefaultSpringAnimationProps + * + * @prop {number} start + * @prop {number} end + * @prop {number} velocity + * @prop {number} [dampingRatio] + * @prop {number} [naturalFrequency] + * @prop {(end: number) => void} onUpdate + */ + +/** @typedef {SharedAnimationProps & DefaultSpringAnimationProps} SpringAnimationProps */ + class SpringAnimation { + /** + * @param {SpringAnimationProps} props + */ constructor(props) { this.props = props; - + this._raf = 0; const { start, end, velocity, onUpdate, onComplete, - onFinish, + onFinish = () => {}, dampingRatio, naturalFrequency } = props; - + this.onFinish = onFinish; 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); + deltaPosition = easer.easeFrame(deltaPosition, Date.now() - prevTime); // Stop the animation if velocity is low and position is close to end - // 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 is called automatically onFinish + destroy() { if (this._raf >= 0) { cancelAnimationFrame(this._raf); } - this._raf = null; + + this._raf = 0; } + } +/** @typedef {import('./css-animation.js').CssAnimationProps} CssAnimationProps */ + +/** @typedef {import('./spring-animation.js').SpringAnimationProps} SpringAnimationProps */ + +/** @typedef {Object} SharedAnimationProps + * @prop {string} [name] + * @prop {boolean} [isPan] + * @prop {boolean} [isMainScroll] + * @prop {VoidFunction} [onComplete] + * @prop {VoidFunction} [onFinish] + */ + +/** @typedef {SpringAnimation | CSSAnimation} Animation */ + +/** @typedef {SpringAnimationProps | CssAnimationProps} AnimationProps */ + /** * Manages animations */ class Animations { constructor() { + /** @type {Animation[]} */ this.activeAnimations = []; } + /** + * @param {SpringAnimationProps} props + */ + startSpring(props) { this._start(props, true); } + /** + * @param {CssAnimationProps} props + */ + startTransition(props) { this._start(props); } + /** + * @private + * @param {AnimationProps} props + * @param {boolean} [isSpring] + * @returns {Animation} + */ - _start(props, isSpring) { - let animation; - if (isSpring) { - animation = new SpringAnimation(props); - } else { - animation = new CSSAnimation(props); - } + _start(props, isSpring) { + const animation = isSpring ? new SpringAnimation( + /** @type SpringAnimationProps */ + props) : new CSSAnimation( + /** @type CssAnimationProps */ + props); this.activeAnimations.push(animation); + animation.onFinish = () => this.stop(animation); return animation; } + /** + * @param {Animation} 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) => { + stopAll() { + // _stopAllAnimations + this.activeAnimations.forEach(animation => { animation.destroy(); }); this.activeAnimations = []; } - /** * Stop all pan or zoom transitions */ + + stopAllPan() { - this.activeAnimations = this.activeAnimations.filter((animation) => { + this.activeAnimations = this.activeAnimations.filter(animation => { if (animation.props.isPan) { animation.destroy(); return false; } return true; }); } stopMainScroll() { - this.activeAnimations = this.activeAnimations.filter((animation) => { + 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) => { @@ -2947,75 +3663,139 @@ // } /** * Returns true if any pan or zoom transition is running */ + + isPanRunning() { - return this.activeAnimations.some((animation) => { + return this.activeAnimations.some(animation => { return animation.props.isPan; }); } + } +/** @typedef {import('./photoswipe.js').default} PhotoSwipe */ + /** * Handles scroll wheel. * Can pan and zoom current slide image. */ class ScrollWheel { + /** + * @param {PhotoSwipe} pswp + */ constructor(pswp) { this.pswp = pswp; - pswp.events.add(pswp.element, 'wheel', this._onWheel.bind(this)); + pswp.events.add(pswp.element, 'wheel', + /** @type EventListener */ + this._onWheel.bind(this)); } + /** + * @private + * @param {WheelEvent} e + */ + _onWheel(e) { e.preventDefault(); - const { currSlide } = this.pswp; - let { deltaX, deltaY } = e; + const { + currSlide + } = this.pswp; + let { + deltaX, + deltaY + } = e; if (!currSlide) { return; } - if (this.pswp.dispatch('wheel', { originalEvent: e }).defaultPrevented) { + 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 */) { + + if (e.deltaMode === 1 + /* DOM_DELTA_LINE */ + ) { zoomFactor *= 0.05; } else { zoomFactor *= e.deltaMode ? 1 : 0.002; } - zoomFactor = 2 ** zoomFactor; + zoomFactor = 2 ** zoomFactor; 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 */) { + 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 - ); + currSlide.panTo(currSlide.pan.x - deltaX, currSlide.pan.y - deltaY); } } } + } +/** @typedef {import('../photoswipe.js').default} PhotoSwipe */ + +/** + * @template T + * @typedef {import('../types.js').Methods<T>} Methods<T> + */ + +/** + * @typedef {Object} UIElementMarkupProps + * @prop {boolean} [isCustomSVG] + * @prop {string} inner + * @prop {string} [outlineID] + * @prop {number | string} [size] + */ + +/** + * @typedef {Object} UIElementData + * @prop {DefaultUIElements | string} [name] + * @prop {string} [className] + * @prop {UIElementMarkup} [html] + * @prop {boolean} [isButton] + * @prop {keyof HTMLElementTagNameMap} [tagName] + * @prop {string} [title] + * @prop {string} [ariaLabel] + * @prop {(element: HTMLElement, pswp: PhotoSwipe) => void} [onInit] + * @prop {Methods<PhotoSwipe> | ((e: MouseEvent, element: HTMLElement, pswp: PhotoSwipe) => void)} [onClick] + * @prop {'bar' | 'wrapper' | 'root'} [appendTo] + * @prop {number} [order] + */ + +/** @typedef {'arrowPrev' | 'arrowNext' | 'close' | 'zoom' | 'counter'} DefaultUIElements */ + +/** @typedef {string | UIElementMarkupProps} UIElementMarkup */ + +/** + * @param {UIElementMarkup} [htmlData] + * @returns {string} + */ + function addElementHTML(htmlData) { if (typeof htmlData === 'string') { // Allow developers to provide full svg, // For example: // <svg viewBox="0 0 32 32" width="32" height="32" aria-hidden="true" class="pswp__icn"> @@ -3029,144 +3809,182 @@ if (!htmlData || !htmlData.isCustomSVG) { return ''; } const svgData = htmlData; - let out = '<svg aria-hidden="true" class="pswp__icn" viewBox="0 0 %d %d" width="%d" height="%d">'; - out = out.split('%d').join(svgData.size || 32); // replace all %d with size + let out = '<svg aria-hidden="true" class="pswp__icn" viewBox="0 0 %d %d" width="%d" height="%d">'; // replace all %d with size - // Icons may contain outline/shadow, + out = out.split('%d').join( + /** @type {string} */ + svgData.size || 32); // Icons may contain outline/shadow, // to make it we "clone" base icon shape and add border to it. // Icon itself and border are styled via CSS. // // Property shadowID defines ID of element that should be cloned. + if (svgData.outlineID) { out += '<use class="pswp__icn-shadow" xlink:href="#' + svgData.outlineID + '"/>'; } out += svgData.inner; - out += '</svg>'; - return out; } class UIElement { + /** + * @param {PhotoSwipe} pswp + * @param {UIElementData} data + */ constructor(pswp, data) { + var _container; + const name = data.name || data.className; - let elementHTML = data.html; + let elementHTML = data.html; // @ts-expect-error lookup only by `data.name` maybe? if (pswp.options[name] === false) { // exit if element is disabled from options return; - } + } // Allow to override SVG icons from options + // @ts-expect-error lookup only by `data.name` maybe? - // Allow to override SVG icons from options + if (typeof pswp.options[name + 'SVG'] === 'string') { // arrowPrevSVG // arrowNextSVG // closeSVG // zoomSVG + // @ts-expect-error lookup only by `data.name` maybe? elementHTML = pswp.options[name + 'SVG']; } - pswp.dispatch('uiElementCreate', { data }); - + pswp.dispatch('uiElementCreate', { + data + }); let className = ''; + if (data.isButton) { className += 'pswp__button '; - className += (data.className || `pswp__button--${data.name}`); + className += data.className || `pswp__button--${data.name}`; } else { - className += (data.className || `pswp__${data.name}`); + className += data.className || `pswp__${data.name}`; } - let element; - let tagName = data.isButton ? (data.tagName || 'button') : (data.tagName || 'div'); - tagName = tagName.toLowerCase(); - element = createElement(className, tagName); + let tagName = data.isButton ? data.tagName || 'button' : data.tagName || 'div'; + tagName = + /** @type {keyof HTMLElementTagNameMap} */ + tagName.toLowerCase(); + /** @type {HTMLElement} */ + const element = createElement(className, tagName); + if (data.isButton) { - // create button element - element = createElement(className, tagName); if (tagName === 'button') { + /** @type {HTMLButtonElement} */ element.type = 'button'; } - let { title } = data; - const { ariaLabel } = data; + let { + title + } = data; + const { + ariaLabel + } = data; // @ts-expect-error lookup only by `data.name` maybe? if (typeof pswp.options[name + 'Title'] === 'string') { + // @ts-expect-error lookup only by `data.name` maybe? title = pswp.options[name + 'Title']; } if (title) { element.title = title; } - if (ariaLabel || title) { - element.setAttribute('aria-label', ariaLabel || title); + const ariaText = ariaLabel || title; + + if (ariaText) { + element.setAttribute('aria-label', ariaText); } } element.innerHTML = addElementHTML(elementHTML); if (data.onInit) { data.onInit(element, pswp); } if (data.onClick) { - element.onclick = (e) => { + element.onclick = e => { if (typeof data.onClick === 'string') { + // @ts-ignore pswp[data.onClick](); - } else { + } else if (typeof data.onClick === 'function') { data.onClick(e, element, pswp); } }; - } + } // Top bar is default position - // Top bar is default position + const appendTo = data.appendTo || 'bar'; - let container; + /** @type {HTMLElement | undefined} root element by default */ + + let container = pswp.element; + if (appendTo === 'bar') { if (!pswp.topBar) { - pswp.topBar = createElement('pswp__top-bar pswp__hide-on-close', false, pswp.scrollWrap); + pswp.topBar = createElement('pswp__top-bar pswp__hide-on-close', 'div', 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.element; } } - container.appendChild(pswp.applyFilters('uiElement', element, data)); + (_container = container) === null || _container === void 0 || _container.appendChild(pswp.applyFilters('uiElement', element, data)); } + } /* Backward and forward arrow buttons */ +/** @typedef {import('./ui-element.js').UIElementData} UIElementData */ + +/** @typedef {import('../photoswipe.js').default} PhotoSwipe */ + +/** + * + * @param {HTMLElement} element + * @param {PhotoSwipe} pswp + * @param {boolean} [isNextButton] + */ function initArrowButton(element, pswp, isNextButton) { - element.classList.add('pswp__button--arrow'); + element.classList.add('pswp__button--arrow'); // TODO: this should point to a unique id for this instance + + element.setAttribute('aria-controls', 'pswp__items'); pswp.on('change', () => { if (!pswp.options.loop) { if (isNextButton) { + /** @type {HTMLButtonElement} */ element.disabled = !(pswp.currIndex < pswp.getNumItems() - 1); } else { + /** @type {HTMLButtonElement} */ element.disabled = !(pswp.currIndex > 0); } } }); } +/** @type {UIElementData} */ + const arrowPrev = { name: 'arrowPrev', className: 'pswp__button--arrow--prev', title: 'Previous', order: 10, @@ -3179,10 +3997,11 @@ outlineID: 'pswp__icn-arrow' }, onClick: 'prev', onInit: initArrowButton }; +/** @type {UIElementData} */ const arrowNext = { name: 'arrowNext', className: 'pswp__button--arrow--next', title: 'Next', @@ -3199,10 +4018,11 @@ onInit: (el, pswp) => { initArrowButton(el, pswp, true); } }; +/** @type {import('./ui-element.js').UIElementData} UIElementData */ const closeButton = { name: 'close', title: 'Close', order: 20, isButton: true, @@ -3212,421 +4032,764 @@ outlineID: 'pswp__icn-close' }, onClick: 'close' }; +/** @type {import('./ui-element.js').UIElementData} UIElementData */ const zoomButton = { name: 'zoom', title: 'Zoom', order: 10, isButton: true, html: { isCustomSVG: true, - inner: '<path d="M17.426 19.926a6 6 0 1 1 1.5-1.5L23 22.5 21.5 24l-4.074-4.074z" id="pswp__icn-zoom"/>' - + '<path fill="currentColor" class="pswp__zoom-icn-bar-h" d="M11 16v-2h6v2z"/>' - + '<path fill="currentColor" class="pswp__zoom-icn-bar-v" d="M13 12h2v6h-2z"/>', + // eslint-disable-next-line max-len + inner: '<path d="M17.426 19.926a6 6 0 1 1 1.5-1.5L23 22.5 21.5 24l-4.074-4.074z" id="pswp__icn-zoom"/>' + '<path fill="currentColor" class="pswp__zoom-icn-bar-h" d="M11 16v-2h6v2z"/>' + '<path fill="currentColor" class="pswp__zoom-icn-bar-v" d="M13 12h2v6h-2z"/>', outlineID: 'pswp__icn-zoom' }, onClick: 'toggleZoom' }; +/** @type {import('./ui-element.js').UIElementData} UIElementData */ const loadingIndicator = { name: 'preloader', appendTo: 'bar', order: 7, html: { isCustomSVG: true, + // eslint-disable-next-line max-len inner: '<path fill-rule="evenodd" clip-rule="evenodd" d="M21.2 16a5.2 5.2 0 1 1-5.2-5.2V8a8 8 0 1 0 8 8h-2.8Z" id="pswp__icn-loading"/>', outlineID: 'pswp__icn-loading' }, onInit: (indicatorElement, pswp) => { + /** @type {boolean | undefined} */ let isVisible; - let delayTimeout; + /** @type {NodeJS.Timeout | null} */ + let delayTimeout = null; + /** + * @param {string} className + * @param {boolean} add + */ + const toggleIndicatorClass = (className, add) => { - indicatorElement.classList[add ? 'add' : 'remove']('pswp__preloader--' + className); + indicatorElement.classList.toggle('pswp__preloader--' + className, add); }; + /** + * @param {boolean} visible + */ - const setIndicatorVisibility = (visible) => { + + const setIndicatorVisibility = visible => { if (isVisible !== visible) { isVisible = visible; toggleIndicatorClass('active', visible); } }; const updatePreloaderVisibility = () => { - if (!pswp.currSlide.content.isLoading()) { + var _pswp$currSlide; + + if (!((_pswp$currSlide = pswp.currSlide) !== null && _pswp$currSlide !== void 0 && _pswp$currSlide.content.isLoading())) { setIndicatorVisibility(false); + if (delayTimeout) { clearTimeout(delayTimeout); delayTimeout = null; } + return; } if (!delayTimeout) { // display loading indicator with delay delayTimeout = setTimeout(() => { - setIndicatorVisibility(pswp.currSlide.content.isLoading()); + var _pswp$currSlide2; + + setIndicatorVisibility(Boolean((_pswp$currSlide2 = pswp.currSlide) === null || _pswp$currSlide2 === void 0 ? void 0 : _pswp$currSlide2.content.isLoading())); delayTimeout = null; }, pswp.options.preloaderDelay); } }; pswp.on('change', updatePreloaderVisibility); - - pswp.on('loadComplete', (e) => { + pswp.on('loadComplete', e => { if (pswp.currSlide === e.slide) { updatePreloaderVisibility(); } - }); + }); // expose the method - // expose the method - pswp.ui.updatePreloaderVisibility = updatePreloaderVisibility; + if (pswp.ui) { + pswp.ui.updatePreloaderVisibility = updatePreloaderVisibility; + } } }; +/** @type {import('./ui-element.js').UIElementData} UIElementData */ const counterIndicator = { name: 'counter', order: 5, onInit: (counterElement, pswp) => { pswp.on('change', () => { - counterElement.innerText = (pswp.currIndex + 1) - + pswp.options.indexIndicatorSep - + pswp.getNumItems(); + counterElement.innerText = pswp.currIndex + 1 + pswp.options.indexIndicatorSep + pswp.getNumItems(); }); } }; +/** @typedef {import('../photoswipe.js').default} PhotoSwipe */ + +/** @typedef {import('./ui-element.js').UIElementData} UIElementData */ + /** * Set special class on element when image is zoomed. * - * By default it is used to adjust + * By default, it is used to adjust * zoom icon and zoom cursor via CSS. * - * @param {Boolean} isZoomedIn + * @param {HTMLElement} el + * @param {boolean} isZoomedIn */ + function setZoomedIn(el, isZoomedIn) { - el.classList[isZoomedIn ? 'add' : 'remove']('pswp--zoomed-in'); + el.classList.toggle('pswp--zoomed-in', isZoomedIn); } class UI { + /** + * @param {PhotoSwipe} pswp + */ constructor(pswp) { this.pswp = pswp; + this.isRegistered = false; + /** @type {UIElementData[]} */ + + this.uiElementsData = []; + /** @type {(UIElement | UIElementData)[]} */ + + this.items = []; + /** @type {() => void} */ + + this.updatePreloaderVisibility = () => {}; + /** + * @private + * @type {number | undefined} + */ + + + this._lastUpdatedZoomLevel = undefined; } init() { - const { pswp } = this; + const { + pswp + } = this; this.isRegistered = false; - this.uiElementsData = [ - closeButton, - arrowPrev, - arrowNext, - zoomButton, - loadingIndicator, - counterIndicator - ]; + this.uiElementsData = [closeButton, arrowPrev, arrowNext, zoomButton, loadingIndicator, counterIndicator]; + pswp.dispatch('uiRegister'); // sort by order - pswp.dispatch('uiRegister'); - - // sort by order this.uiElementsData.sort((a, b) => { // default order is 0 return (a.order || 0) - (b.order || 0); }); - this.items = []; - this.isRegistered = true; - this.uiElementsData.forEach((uiElementData) => { + this.uiElementsData.forEach(uiElementData => { this.registerElement(uiElementData); }); - pswp.on('change', () => { - pswp.element.classList[pswp.getNumItems() === 1 ? 'add' : 'remove']('pswp--one-slide'); - }); + var _pswp$element; + (_pswp$element = pswp.element) === null || _pswp$element === void 0 || _pswp$element.classList.toggle('pswp--one-slide', pswp.getNumItems() === 1); + }); pswp.on('zoomPanUpdate', () => this._onZoomPanUpdate()); } + /** + * @param {UIElementData} elementData + */ + registerElement(elementData) { if (this.isRegistered) { - this.items.push( - new UIElement(this.pswp, elementData) - ); + this.items.push(new UIElement(this.pswp, elementData)); } else { this.uiElementsData.push(elementData); } } - /** * Fired each time zoom or pan position is changed. * Update classes that control visibility of zoom button and cursor icon. + * + * @private */ + + _onZoomPanUpdate() { - const { template, currSlide, options } = this.pswp; - let { currZoomLevel } = currSlide; + const { + template, + currSlide, + options + } = this.pswp; - if (this.pswp.opener.isClosing) { + if (this.pswp.opener.isClosing || !template || !currSlide) { return; } - // if not open yet - check against initial zoom level + let { + currZoomLevel + } = currSlide; // if not open yet - check against initial zoom level + if (!this.pswp.opener.isOpen) { currZoomLevel = currSlide.zoomLevels.initial; } if (currZoomLevel === this._lastUpdatedZoomLevel) { return; } + this._lastUpdatedZoomLevel = currZoomLevel; + const currZoomLevelDiff = currSlide.zoomLevels.initial - currSlide.zoomLevels.secondary; // Initial and secondary zoom levels are almost equal - const currZoomLevelDiff = currSlide.zoomLevels.initial - currSlide.zoomLevels.secondary; - - // Initial and secondary zoom levels are almost equal if (Math.abs(currZoomLevelDiff) < 0.01 || !currSlide.isZoomable()) { // disable zoom setZoomedIn(template, false); template.classList.remove('pswp--zoom-allowed'); return; } template.classList.add('pswp--zoom-allowed'); - const secondaryIsHigher = (currZoomLevelDiff < 0); + const potentialZoomLevel = currZoomLevel === currSlide.zoomLevels.initial ? currSlide.zoomLevels.secondary : currSlide.zoomLevels.initial; + setZoomedIn(template, potentialZoomLevel <= currZoomLevel); - if (currZoomLevel === currSlide.zoomLevels.secondary) { - setZoomedIn(template, secondaryIsHigher); - } else if (currZoomLevel > currSlide.zoomLevels.secondary) { - setZoomedIn(template, true); - } else { - // if (currZoomLevel < currSlide.zoomLevels.secondary) - setZoomedIn(template, false); - } - - if (options.imageClickAction === 'zoom' - || options.imageClickAction === 'zoom-or-close') { + if (options.imageClickAction === 'zoom' || options.imageClickAction === 'zoom-or-close') { template.classList.add('pswp--click-to-zoom'); } } + } +/** @typedef {import('./slide.js').SlideData} SlideData */ + +/** @typedef {import('../photoswipe.js').default} PhotoSwipe */ + +/** @typedef {{ x: number; y: number; w: number; innerRect?: { w: number; h: number; x: number; y: number } }} Bounds */ + +/** + * @param {HTMLElement} el + * @returns Bounds + */ function getBoundsByElement(el) { const thumbAreaRect = el.getBoundingClientRect(); return { x: thumbAreaRect.left, y: thumbAreaRect.top, w: thumbAreaRect.width }; } +/** + * @param {HTMLElement} el + * @param {number} imageWidth + * @param {number} imageHeight + * @returns Bounds + */ -function getCroppedBoundsByElement(el, imageWidth, imageHeight) { - const thumbAreaRect = el.getBoundingClientRect(); - // fill image into the area +function getCroppedBoundsByElement(el, imageWidth, imageHeight) { + const thumbAreaRect = el.getBoundingClientRect(); // fill image into the area // (do they same as object-fit:cover does to retrieve coordinates) + const hRatio = thumbAreaRect.width / imageWidth; const vRatio = thumbAreaRect.height / imageHeight; const fillZoomLevel = hRatio > vRatio ? hRatio : vRatio; - const offsetX = (thumbAreaRect.width - imageWidth * fillZoomLevel) / 2; const offsetY = (thumbAreaRect.height - imageHeight * fillZoomLevel) / 2; + /** + * Coordinates of the image, + * as if it was not cropped, + * height is calculated automatically + * + * @type {Bounds} + */ - // Coordinates of the image, - // as if it was not cropped, - // height is calculated automatically const bounds = { x: thumbAreaRect.left + offsetX, y: thumbAreaRect.top + offsetY, w: imageWidth * fillZoomLevel - }; - - // Coordinates of inner crop area + }; // Coordinates of inner crop area // relative to the image + bounds.innerRect = { w: thumbAreaRect.width, h: thumbAreaRect.height, x: offsetX, y: offsetY }; - return bounds; } - /** * Get dimensions of thumbnail image * (click on which opens photoswipe or closes photoswipe to) * - * @param {Integer} index - * @param {Object} itemData + * @param {number} index + * @param {SlideData} itemData * @param {PhotoSwipe} instance PhotoSwipe instance - * @returns Object|undefined + * @returns {Bounds | undefined} */ + + function getThumbBounds(index, itemData, instance) { // legacy event, before filters were introduced const event = instance.dispatch('thumbBounds', { index, itemData, instance - }); + }); // @ts-expect-error + if (event.thumbBounds) { + // @ts-expect-error return event.thumbBounds; } - const { element } = itemData; + const { + element + } = itemData; + /** @type {Bounds | undefined} */ + let thumbBounds; + /** @type {HTMLElement | null | undefined} */ + let thumbnail; if (element && instance.options.thumbSelector !== false) { const thumbSelector = instance.options.thumbSelector || 'img'; - thumbnail = element.matches(thumbSelector) - ? element : element.querySelector(thumbSelector); + thumbnail = element.matches(thumbSelector) ? element : + /** @type {HTMLElement | null} */ + element.querySelector(thumbSelector); } thumbnail = instance.applyFilters('thumbEl', thumbnail, itemData, index); if (thumbnail) { if (!itemData.thumbCropped) { thumbBounds = getBoundsByElement(thumbnail); } else { - thumbBounds = getCroppedBoundsByElement( - thumbnail, - itemData.w, - itemData.h - ); + thumbBounds = getCroppedBoundsByElement(thumbnail, itemData.width || itemData.w || 0, itemData.height || itemData.h || 0); } } return instance.applyFilters('thumbBounds', thumbBounds, itemData, index); } +/** @typedef {import('../lightbox/lightbox.js').default} PhotoSwipeLightbox */ + +/** @typedef {import('../photoswipe.js').default} PhotoSwipe */ + +/** @typedef {import('../photoswipe.js').PhotoSwipeOptions} PhotoSwipeOptions */ + +/** @typedef {import('../photoswipe.js').DataSource} DataSource */ + +/** @typedef {import('../ui/ui-element.js').UIElementData} UIElementData */ + +/** @typedef {import('../slide/content.js').default} ContentDefault */ + +/** @typedef {import('../slide/slide.js').default} Slide */ + +/** @typedef {import('../slide/slide.js').SlideData} SlideData */ + +/** @typedef {import('../slide/zoom-level.js').default} ZoomLevel */ + +/** @typedef {import('../slide/get-thumb-bounds.js').Bounds} Bounds */ + /** + * Allow adding an arbitrary props to the Content + * https://photoswipe.com/custom-content/#using-webp-image-format + * @typedef {ContentDefault & Record<string, any>} Content + */ + +/** @typedef {{ x?: number; y?: number }} Point */ + +/** + * @typedef {Object} PhotoSwipeEventsMap https://photoswipe.com/events/ + * + * + * https://photoswipe.com/adding-ui-elements/ + * + * @prop {undefined} uiRegister + * @prop {{ data: UIElementData }} uiElementCreate + * + * + * https://photoswipe.com/events/#initialization-events + * + * @prop {undefined} beforeOpen + * @prop {undefined} firstUpdate + * @prop {undefined} initialLayout + * @prop {undefined} change + * @prop {undefined} afterInit + * @prop {undefined} bindEvents + * + * + * https://photoswipe.com/events/#opening-or-closing-transition-events + * + * @prop {undefined} openingAnimationStart + * @prop {undefined} openingAnimationEnd + * @prop {undefined} closingAnimationStart + * @prop {undefined} closingAnimationEnd + * + * + * https://photoswipe.com/events/#closing-events + * + * @prop {undefined} close + * @prop {undefined} destroy + * + * + * https://photoswipe.com/events/#pointer-and-gesture-events + * + * @prop {{ originalEvent: PointerEvent }} pointerDown + * @prop {{ originalEvent: PointerEvent }} pointerMove + * @prop {{ originalEvent: PointerEvent }} pointerUp + * @prop {{ bgOpacity: number }} pinchClose can be default prevented + * @prop {{ panY: number }} verticalDrag can be default prevented + * + * + * https://photoswipe.com/events/#slide-content-events + * + * @prop {{ content: Content }} contentInit + * @prop {{ content: Content; isLazy: boolean }} contentLoad can be default prevented + * @prop {{ content: Content; isLazy: boolean }} contentLoadImage can be default prevented + * @prop {{ content: Content; slide: Slide; isError?: boolean }} loadComplete + * @prop {{ content: Content; slide: Slide }} loadError + * @prop {{ content: Content; width: number; height: number }} contentResize can be default prevented + * @prop {{ content: Content; width: number; height: number; slide: Slide }} imageSizeChange + * @prop {{ content: Content }} contentLazyLoad can be default prevented + * @prop {{ content: Content }} contentAppend can be default prevented + * @prop {{ content: Content }} contentActivate can be default prevented + * @prop {{ content: Content }} contentDeactivate can be default prevented + * @prop {{ content: Content }} contentRemove can be default prevented + * @prop {{ content: Content }} contentDestroy can be default prevented + * + * + * undocumented + * + * @prop {{ point: Point; originalEvent: PointerEvent }} imageClickAction can be default prevented + * @prop {{ point: Point; originalEvent: PointerEvent }} bgClickAction can be default prevented + * @prop {{ point: Point; originalEvent: PointerEvent }} tapAction can be default prevented + * @prop {{ point: Point; originalEvent: PointerEvent }} doubleTapAction can be default prevented + * + * @prop {{ originalEvent: KeyboardEvent }} keydown can be default prevented + * @prop {{ x: number; dragging: boolean }} moveMainScroll + * @prop {{ slide: Slide }} firstZoomPan + * @prop {{ slide: Slide | undefined, data: SlideData, index: number }} gettingData + * @prop {undefined} beforeResize + * @prop {undefined} resize + * @prop {undefined} viewportSize + * @prop {undefined} updateScrollOffset + * @prop {{ slide: Slide }} slideInit + * @prop {{ slide: Slide }} afterSetContent + * @prop {{ slide: Slide }} slideLoad + * @prop {{ slide: Slide }} appendHeavy can be default prevented + * @prop {{ slide: Slide }} appendHeavyContent + * @prop {{ slide: Slide }} slideActivate + * @prop {{ slide: Slide }} slideDeactivate + * @prop {{ slide: Slide }} slideDestroy + * @prop {{ destZoomLevel: number, centerPoint: Point | undefined, transitionDuration: number | false | undefined }} beforeZoomTo + * @prop {{ slide: Slide }} zoomPanUpdate + * @prop {{ slide: Slide }} initialZoomPan + * @prop {{ slide: Slide }} calcSlideSize + * @prop {undefined} resolutionChanged + * @prop {{ originalEvent: WheelEvent }} wheel can be default prevented + * @prop {{ content: Content }} contentAppendImage can be default prevented + * @prop {{ index: number; itemData: SlideData }} lazyLoadSlide can be default prevented + * @prop {undefined} lazyLoad + * @prop {{ slide: Slide }} calcBounds + * @prop {{ zoomLevels: ZoomLevel, slideData: SlideData }} zoomLevelsUpdate + * + * + * legacy + * + * @prop {undefined} init + * @prop {undefined} initialZoomIn + * @prop {undefined} initialZoomOut + * @prop {undefined} initialZoomInEnd + * @prop {undefined} initialZoomOutEnd + * @prop {{ dataSource: DataSource | undefined, numItems: number }} numItems + * @prop {{ itemData: SlideData; index: number }} itemData + * @prop {{ index: number, itemData: SlideData, instance: PhotoSwipe }} thumbBounds + */ + +/** + * @typedef {Object} PhotoSwipeFiltersMap https://photoswipe.com/filters/ + * + * @prop {(numItems: number, dataSource: DataSource | undefined) => number} numItems + * Modify the total amount of slides. Example on Data sources page. + * https://photoswipe.com/filters/#numitems + * + * @prop {(itemData: SlideData, index: number) => SlideData} itemData + * Modify slide item data. Example on Data sources page. + * https://photoswipe.com/filters/#itemdata + * + * @prop {(itemData: SlideData, element: HTMLElement, linkEl: HTMLAnchorElement) => SlideData} domItemData + * Modify item data when it's parsed from DOM element. Example on Data sources page. + * https://photoswipe.com/filters/#domitemdata + * + * @prop {(clickedIndex: number, e: MouseEvent, instance: PhotoSwipeLightbox) => number} clickedIndex + * Modify clicked gallery item index. + * https://photoswipe.com/filters/#clickedindex + * + * @prop {(placeholderSrc: string | false, content: Content) => string | false} placeholderSrc + * Modify placeholder image source. + * https://photoswipe.com/filters/#placeholdersrc + * + * @prop {(isContentLoading: boolean, content: Content) => boolean} isContentLoading + * Modify if the content is currently loading. + * https://photoswipe.com/filters/#iscontentloading + * + * @prop {(isContentZoomable: boolean, content: Content) => boolean} isContentZoomable + * Modify if the content can be zoomed. + * https://photoswipe.com/filters/#iscontentzoomable + * + * @prop {(useContentPlaceholder: boolean, content: Content) => boolean} useContentPlaceholder + * Modify if the placeholder should be used for the content. + * https://photoswipe.com/filters/#usecontentplaceholder + * + * @prop {(isKeepingPlaceholder: boolean, content: Content) => boolean} isKeepingPlaceholder + * Modify if the placeholder should be kept after the content is loaded. + * https://photoswipe.com/filters/#iskeepingplaceholder + * + * + * @prop {(contentErrorElement: HTMLElement, content: Content) => HTMLElement} contentErrorElement + * Modify an element when the content has error state (for example, if image cannot be loaded). + * https://photoswipe.com/filters/#contenterrorelement + * + * @prop {(element: HTMLElement, data: UIElementData) => HTMLElement} uiElement + * Modify a UI element that's being created. + * https://photoswipe.com/filters/#uielement + * + * @prop {(thumbnail: HTMLElement | null | undefined, itemData: SlideData, index: number) => HTMLElement} thumbEl + * Modify the thumbnail element from which opening zoom animation starts or ends. + * https://photoswipe.com/filters/#thumbel + * + * @prop {(thumbBounds: Bounds | undefined, itemData: SlideData, index: number) => Bounds} thumbBounds + * Modify the thumbnail bounds from which opening zoom animation starts or ends. + * https://photoswipe.com/filters/#thumbbounds + * + * @prop {(srcsetSizesWidth: number, content: Content) => number} srcsetSizesWidth + * + * @prop {(preventPointerEvent: boolean, event: PointerEvent, pointerType: string) => boolean} preventPointerEvent + * + */ + +/** + * @template {keyof PhotoSwipeFiltersMap} T + * @typedef {{ fn: PhotoSwipeFiltersMap[T], priority: number }} Filter + */ + +/** + * @template {keyof PhotoSwipeEventsMap} T + * @typedef {PhotoSwipeEventsMap[T] extends undefined ? PhotoSwipeEvent<T> : PhotoSwipeEvent<T> & PhotoSwipeEventsMap[T]} AugmentedEvent + */ + +/** + * @template {keyof PhotoSwipeEventsMap} T + * @typedef {(event: AugmentedEvent<T>) => void} EventCallback + */ + +/** * Base PhotoSwipe event object + * + * @template {keyof PhotoSwipeEventsMap} T */ class PhotoSwipeEvent { + /** + * @param {T} type + * @param {PhotoSwipeEventsMap[T]} [details] + */ constructor(type, details) { this.type = type; + this.defaultPrevented = false; + if (details) { Object.assign(this, details); } } preventDefault() { this.defaultPrevented = true; } -} +} /** * PhotoSwipe base class that can listen and dispatch for events. * Shared by PhotoSwipe Core and PhotoSwipe Lightbox, extended by base.js */ + + class Eventable { constructor() { + /** + * @type {{ [T in keyof PhotoSwipeEventsMap]?: ((event: AugmentedEvent<T>) => void)[] }} + */ this._listeners = {}; + /** + * @type {{ [T in keyof PhotoSwipeFiltersMap]?: Filter<T>[] }} + */ + this._filters = {}; + /** @type {PhotoSwipe | undefined} */ + + this.pswp = undefined; + /** @type {PhotoSwipeOptions | undefined} */ + + this.options = undefined; } + /** + * @template {keyof PhotoSwipeFiltersMap} T + * @param {T} name + * @param {PhotoSwipeFiltersMap[T]} fn + * @param {number} priority + */ + addFilter(name, fn, priority = 100) { + var _this$_filters$name, _this$_filters$name2, _this$pswp; + if (!this._filters[name]) { this._filters[name] = []; } - this._filters[name].push({ fn, priority }); - this._filters[name].sort((f1, f2) => f1.priority - f2.priority); - - if (this.pswp) { - this.pswp.addFilter(name, fn, priority); - } + (_this$_filters$name = this._filters[name]) === null || _this$_filters$name === void 0 || _this$_filters$name.push({ + fn, + priority + }); + (_this$_filters$name2 = this._filters[name]) === null || _this$_filters$name2 === void 0 || _this$_filters$name2.sort((f1, f2) => f1.priority - f2.priority); + (_this$pswp = this.pswp) === null || _this$pswp === void 0 || _this$pswp.addFilter(name, fn, priority); } + /** + * @template {keyof PhotoSwipeFiltersMap} T + * @param {T} name + * @param {PhotoSwipeFiltersMap[T]} fn + */ + removeFilter(name, fn) { if (this._filters[name]) { - this._filters[name] = this._filters[name].filter(filter => (filter.fn !== fn)); + // @ts-expect-error + this._filters[name] = this._filters[name].filter(filter => filter.fn !== fn); } if (this.pswp) { this.pswp.removeFilter(name, fn); } } + /** + * @template {keyof PhotoSwipeFiltersMap} T + * @param {T} name + * @param {Parameters<PhotoSwipeFiltersMap[T]>} args + * @returns {Parameters<PhotoSwipeFiltersMap[T]>[0]} + */ + applyFilters(name, ...args) { - if (this._filters[name]) { - this._filters[name].forEach((filter) => { - args[0] = filter.fn.apply(this, args); - }); - } + var _this$_filters$name3; + + (_this$_filters$name3 = this._filters[name]) === null || _this$_filters$name3 === void 0 || _this$_filters$name3.forEach(filter => { + // @ts-expect-error + args[0] = filter.fn.apply(this, args); + }); return args[0]; } + /** + * @template {keyof PhotoSwipeEventsMap} T + * @param {T} name + * @param {EventCallback<T>} fn + */ + on(name, fn) { + var _this$_listeners$name, _this$pswp2; + if (!this._listeners[name]) { this._listeners[name] = []; } - this._listeners[name].push(fn); - // When binding events to lightbox, + (_this$_listeners$name = this._listeners[name]) === null || _this$_listeners$name === void 0 || _this$_listeners$name.push(fn); // When binding events to lightbox, // also bind events to PhotoSwipe Core, // if it's open. - if (this.pswp) { - this.pswp.on(name, fn); - } + + (_this$pswp2 = this.pswp) === null || _this$pswp2 === void 0 || _this$pswp2.on(name, fn); } + /** + * @template {keyof PhotoSwipeEventsMap} T + * @param {T} name + * @param {EventCallback<T>} fn + */ + off(name, fn) { + var _this$pswp3; + if (this._listeners[name]) { - this._listeners[name] = this._listeners[name].filter(listener => (fn !== listener)); + // @ts-expect-error + this._listeners[name] = this._listeners[name].filter(listener => fn !== listener); } - if (this.pswp) { - this.pswp.off(name, fn); - } + (_this$pswp3 = this.pswp) === null || _this$pswp3 === void 0 || _this$pswp3.off(name, fn); } + /** + * @template {keyof PhotoSwipeEventsMap} T + * @param {T} name + * @param {PhotoSwipeEventsMap[T]} [details] + * @returns {AugmentedEvent<T>} + */ + dispatch(name, details) { + var _this$_listeners$name2; + if (this.pswp) { return this.pswp.dispatch(name, details); } - const event = new PhotoSwipeEvent(name, details); - - if (!this._listeners) { - return event; - } - - if (this._listeners[name]) { - this._listeners[name].forEach((listener) => { - listener.call(this, event); - }); - } - + const event = + /** @type {AugmentedEvent<T>} */ + new PhotoSwipeEvent(name, details); + (_this$_listeners$name2 = this._listeners[name]) === null || _this$_listeners$name2 === void 0 || _this$_listeners$name2.forEach(listener => { + listener.call(this, event); + }); return event; } + } class Placeholder { /** - * @param {String|false} imageSrc - * @param {Element} container + * @param {string | false} imageSrc + * @param {HTMLElement} 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 - ); + /** @type {HTMLImageElement | HTMLDivElement | null} */ + this.element = createElement('pswp__img pswp__img--placeholder', imageSrc ? 'img' : 'div', container); + if (imageSrc) { - this.element.decoding = 'async'; - this.element.alt = ''; - this.element.src = imageSrc; - this.element.setAttribute('role', 'presentation'); + const imgEl = + /** @type {HTMLImageElement} */ + this.element; + imgEl.decoding = 'async'; + imgEl.alt = ''; + imgEl.src = imageSrc; + imgEl.setAttribute('role', 'presentation'); } - this.element.setAttribute('aria-hiden', 'true'); + this.element.setAttribute('aria-hidden', 'true'); } + /** + * @param {number} width + * @param {number} height + */ + setDisplayedSize(width, height) { if (!this.element) { return; } @@ -3641,345 +4804,443 @@ setWidthHeight(this.element, width, height); } } destroy() { - if (this.element.parentNode) { + var _this$element; + + if ((_this$element = this.element) !== null && _this$element !== void 0 && _this$element.parentNode) { this.element.remove(); } + this.element = null; } + } +/** @typedef {import('./slide.js').default} Slide */ + +/** @typedef {import('./slide.js').SlideData} SlideData */ + +/** @typedef {import('../core/base.js').default} PhotoSwipeBase */ + +/** @typedef {import('../util/util.js').LoadState} LoadState */ + class Content { /** - * @param {Object} itemData Slide data + * @param {SlideData} itemData Slide data * @param {PhotoSwipeBase} instance PhotoSwipe or PhotoSwipeLightbox instance - * @param {Slide|undefined} slide Slide that requested the image, - * can be undefined if image was requested by something else - * (for example by lazy-loader) + * @param {number} index */ constructor(itemData, instance, index) { this.instance = instance; this.data = itemData; this.index = index; + /** @type {HTMLImageElement | HTMLDivElement | undefined} */ + this.element = undefined; + /** @type {Placeholder | undefined} */ + + this.placeholder = undefined; + /** @type {Slide | undefined} */ + + this.slide = undefined; + this.displayedImageWidth = 0; + this.displayedImageHeight = 0; this.width = Number(this.data.w) || Number(this.data.width) || 0; this.height = Number(this.data.h) || Number(this.data.height) || 0; - this.isAttached = false; this.hasSlide = false; + this.isDecoding = false; + /** @type {LoadState} */ + this.state = LOAD_STATE.IDLE; if (this.data.type) { this.type = this.data.type; } else if (this.data.src) { this.type = 'image'; } else { this.type = 'html'; } - this.instance.dispatch('contentInit', { content: this }); + this.instance.dispatch('contentInit', { + content: this + }); } removePlaceholder() { if (this.placeholder && !this.keepPlaceholder()) { // With delay, as image might be loaded, but not rendered setTimeout(() => { if (this.placeholder) { this.placeholder.destroy(); - this.placeholder = null; + this.placeholder = undefined; } - }, 500); + }, 1000); } } - /** * Preload content * - * @param {Boolean} isLazy + * @param {boolean} isLazy + * @param {boolean} [reload] */ + + load(isLazy, reload) { - if (!this.placeholder && this.slide && this.usePlaceholder()) { - // use -based placeholder only for the first slide, - // as rendering (even small stretched thumbnail) is an expensive operation - const placeholderSrc = this.instance.applyFilters( - 'placeholderSrc', - (this.data.msrc && this.slide.isFirstSlide) ? this.data.msrc : false, - this - ); - this.placeholder = new Placeholder( - placeholderSrc, - this.slide.container - ); + if (this.slide && this.usePlaceholder()) { + if (!this.placeholder) { + const placeholderSrc = this.instance.applyFilters('placeholderSrc', // use image-based placeholder only for the first slide, + // as rendering (even small stretched thumbnail) is an expensive operation + this.data.msrc && this.slide.isFirstSlide ? this.data.msrc : false, this); + this.placeholder = new Placeholder(placeholderSrc, this.slide.container); + } else { + const placeholderEl = this.placeholder.element; // Add placeholder to DOM if it was already created + + if (placeholderEl && !placeholderEl.parentElement) { + this.slide.container.prepend(placeholderEl); + } + } } if (this.element && !reload) { return; } - if (this.instance.dispatch('contentLoad', { content: this, isLazy }).defaultPrevented) { + if (this.instance.dispatch('contentLoad', { + content: this, + isLazy + }).defaultPrevented) { return; } if (this.isImageContent()) { - this.loadImage(isLazy); + this.element = createElement('pswp__img', 'img'); // Start loading only after width is defined, as sizes might depend on it. + // Due to Safari feature, we must define sizes before srcset. + + if (this.displayedImageWidth) { + this.loadImage(isLazy); + } } else { - this.element = createElement('pswp__content'); + this.element = createElement('pswp__content', 'div'); this.element.innerHTML = this.data.html || ''; } if (reload && this.slide) { this.slide.updateContentSize(true); } } - /** * Preload image * - * @param {Boolean} isLazy + * @param {boolean} isLazy */ + + loadImage(isLazy) { - this.element = createElement('pswp__img', 'img'); + var _this$data$src, _this$data$alt; - if (this.instance.dispatch('contentLoadImage', { content: this, isLazy }).defaultPrevented) { + if (!this.isImageContent() || !this.element || this.instance.dispatch('contentLoadImage', { + content: this, + isLazy + }).defaultPrevented) { return; } + const imageElement = + /** @type HTMLImageElement */ + this.element; + this.updateSrcsetSizes(); + if (this.data.srcset) { - this.element.srcset = this.data.srcset; + imageElement.srcset = this.data.srcset; } - this.element.src = this.data.src; - - this.element.alt = this.data.alt || ''; - + imageElement.src = (_this$data$src = this.data.src) !== null && _this$data$src !== void 0 ? _this$data$src : ''; + imageElement.alt = (_this$data$alt = this.data.alt) !== null && _this$data$alt !== void 0 ? _this$data$alt : ''; this.state = LOAD_STATE.LOADING; - if (this.element.complete) { + if (imageElement.complete) { this.onLoaded(); } else { - this.element.onload = () => { + imageElement.onload = () => { this.onLoaded(); }; - this.element.onerror = () => { + imageElement.onerror = () => { this.onError(); }; } } - /** * Assign slide to content * * @param {Slide} slide */ + + setSlide(slide) { this.slide = slide; this.hasSlide = true; - this.instance = slide.pswp; - - // todo: do we need to unset slide? + this.instance = slide.pswp; // todo: do we need to unset slide? } - /** * Content load success handler */ + + onLoaded() { this.state = LOAD_STATE.LOADED; - if (this.slide) { - this.instance.dispatch('loadComplete', { slide: this.slide, content: this }); + if (this.slide && this.element) { + this.instance.dispatch('loadComplete', { + slide: this.slide, + content: this + }); // if content is reloaded - // if content is reloaded - if (this.slide.isActive - && this.slide.heavyAppended - && !this.element.parentNode) { - this.slide.container.innerHTML = ''; + if (this.slide.isActive && this.slide.heavyAppended && !this.element.parentNode) { this.append(); this.slide.updateContentSize(true); } + + if (this.state === LOAD_STATE.LOADED || this.state === LOAD_STATE.ERROR) { + this.removePlaceholder(); + } } } - /** * Content load error handler */ + + onError() { this.state = LOAD_STATE.ERROR; if (this.slide) { this.displayError(); - this.instance.dispatch('loadComplete', { slide: this.slide, isError: true, content: this }); - this.instance.dispatch('loadError', { slide: this.slide, content: this }); + this.instance.dispatch('loadComplete', { + slide: this.slide, + isError: true, + content: this + }); + this.instance.dispatch('loadError', { + slide: this.slide, + content: this + }); } } - /** * @returns {Boolean} If the content is currently loading */ + + isLoading() { - return this.instance.applyFilters( - 'isContentLoading', - this.state === LOAD_STATE.LOADING, - this - ); + return this.instance.applyFilters('isContentLoading', this.state === LOAD_STATE.LOADING, this); } + /** + * @returns {Boolean} If the content is in error state + */ + isError() { return this.state === LOAD_STATE.ERROR; } - /** - * @returns {Boolean} If the content is image + * @returns {boolean} If the content is image */ + + isImageContent() { return this.type === 'image'; } - /** * Update content size * * @param {Number} width * @param {Number} height */ + + setDisplayedSize(width, height) { if (!this.element) { return; } if (this.placeholder) { this.placeholder.setDisplayedSize(width, height); } - if (this.instance.dispatch('contentResize', { content: this, width, height }).defaultPrevented) { + if (this.instance.dispatch('contentResize', { + content: this, + width, + height + }).defaultPrevented) { return; } setWidthHeight(this.element, width, height); if (this.isImageContent() && !this.isError()) { - const image = this.element; - // Handle srcset sizes attribute. - // - // Never lower quality, if it was increased previously. - // Chrome does this automatically, Firefox and Safari do not, - // so we store largest used size in dataset. - if (image.srcset - && (!image.dataset.largestUsedSize || width > image.dataset.largestUsedSize)) { - image.sizes = width + 'px'; - image.dataset.largestUsedSize = width; + const isInitialSizeUpdate = !this.displayedImageWidth && width; + this.displayedImageWidth = width; + this.displayedImageHeight = height; + + if (isInitialSizeUpdate) { + this.loadImage(false); + } else { + this.updateSrcsetSizes(); } if (this.slide) { - this.instance.dispatch('imageSizeChange', { slide: this.slide, width, height, content: this }); + this.instance.dispatch('imageSizeChange', { + slide: this.slide, + width, + height, + content: this + }); } } } - /** - * @returns {Boolean} If the content can be zoomed + * @returns {boolean} If the content can be zoomed */ + + isZoomable() { - return this.instance.applyFilters( - 'isContentZoomable', - this.isImageContent() && (this.state !== LOAD_STATE.ERROR), - this - ); + return this.instance.applyFilters('isContentZoomable', this.isImageContent() && this.state !== LOAD_STATE.ERROR, this); } + /** + * Update image srcset sizes attribute based on width and height + */ + + updateSrcsetSizes() { + // Handle srcset sizes attribute. + // + // Never lower quality, if it was increased previously. + // Chrome does this automatically, Firefox and Safari do not, + // so we store largest used size in dataset. + if (!this.isImageContent() || !this.element || !this.data.srcset) { + return; + } + + const image = + /** @type HTMLImageElement */ + this.element; + const sizesWidth = this.instance.applyFilters('srcsetSizesWidth', this.displayedImageWidth, this); + + if (!image.dataset.largestUsedSize || sizesWidth > parseInt(image.dataset.largestUsedSize, 10)) { + image.sizes = sizesWidth + 'px'; + image.dataset.largestUsedSize = String(sizesWidth); + } + } /** - * @returns {Boolean} If content should use a placeholder (from msrc by default) + * @returns {boolean} If content should use a placeholder (from msrc by default) */ + + usePlaceholder() { - return this.instance.applyFilters( - 'useContentPlaceholder', - this.isImageContent(), - this - ); + return this.instance.applyFilters('useContentPlaceholder', this.isImageContent(), this); } - /** * Preload content with lazy-loading param - * - * @param {Boolean} isLazy */ + + lazyLoad() { - if (this.instance.dispatch('contentLazyLoad', { content: this }).defaultPrevented) { + if (this.instance.dispatch('contentLazyLoad', { + content: this + }).defaultPrevented) { return; } this.load(true); } - /** - * @returns {Boolean} If placeholder should be kept after content is loaded + * @returns {boolean} If placeholder should be kept after content is loaded */ + + keepPlaceholder() { - return this.instance.applyFilters( - 'isKeepingPlaceholder', - this.isLoading(), - this - ); + return this.instance.applyFilters('isKeepingPlaceholder', this.isLoading(), this); } - /** * Destroy the content */ + + destroy() { this.hasSlide = false; - this.slide = null; + this.slide = undefined; - if (this.instance.dispatch('contentDestroy', { content: this }).defaultPrevented) { + if (this.instance.dispatch('contentDestroy', { + content: this + }).defaultPrevented) { return; } this.remove(); + if (this.placeholder) { + this.placeholder.destroy(); + this.placeholder = undefined; + } + if (this.isImageContent() && this.element) { this.element.onload = null; this.element.onerror = null; - this.element = null; + this.element = undefined; } } - /** * Display error message */ + + displayError() { if (this.slide) { - let errorMsgEl = createElement('pswp__error-msg'); - errorMsgEl.innerText = this.instance.options.errorMsg; - errorMsgEl = this.instance.applyFilters( - 'contentErrorElement', - errorMsgEl, - this - ); - this.element = createElement('pswp__content pswp__error-msg-container'); + var _this$instance$option, _this$instance$option2; + + let errorMsgEl = createElement('pswp__error-msg', 'div'); + errorMsgEl.innerText = (_this$instance$option = (_this$instance$option2 = this.instance.options) === null || _this$instance$option2 === void 0 ? void 0 : _this$instance$option2.errorMsg) !== null && _this$instance$option !== void 0 ? _this$instance$option : ''; + errorMsgEl = + /** @type {HTMLDivElement} */ + this.instance.applyFilters('contentErrorElement', errorMsgEl, this); + this.element = createElement('pswp__content pswp__error-msg-container', 'div'); this.element.appendChild(errorMsgEl); - this.slide.container.innerHTML = ''; + this.slide.container.innerText = ''; this.slide.container.appendChild(this.element); this.slide.updateContentSize(true); this.removePlaceholder(); } } - /** * Append the content */ + + append() { + if (this.isAttached || !this.element) { + return; + } + this.isAttached = true; if (this.state === LOAD_STATE.ERROR) { this.displayError(); return; } - if (this.instance.dispatch('contentAppend', { content: this }).defaultPrevented) { + if (this.instance.dispatch('contentAppend', { + content: this + }).defaultPrevented) { return; } + const supportsDecode = ('decode' in this.element); + if (this.isImageContent()) { // Use decode() on nearby slides // // Nearby slide images are in DOM and not hidden via display:none. // However, they are placed offscreen (to the left and right side). @@ -3988,169 +5249,413 @@ // using decode() helps. // // You might ask "why dont you just decode() and then append all images", // that's because I want to show image before it's fully loaded, // as browser can render parts of image while it is loading. - if (this.slide - && !this.slide.isActive - && ('decode' in this.element)) { - this.isDecoding = true; - // Make sure that we start decoding on the next frame - requestAnimationFrame(() => { - // element might change - if (this.element && this.element.tagName === 'IMG') { - this.element.decode().then(() => { - this.isDecoding = false; - requestAnimationFrame(() => { - this.appendImage(); - }); - }).catch(() => { - this.isDecoding = false; - }); - } + // We do not do this in Safari due to partial loading bug. + if (supportsDecode && this.slide && (!this.slide.isActive || isSafari())) { + this.isDecoding = true; // purposefully using finally instead of then, + // as if srcset sizes changes dynamically - it may cause decode error + + /** @type {HTMLImageElement} */ + + this.element.decode().catch(() => {}).finally(() => { + this.isDecoding = false; + this.appendImage(); }); } else { - if (this.placeholder - && (this.state === LOAD_STATE.LOADED || this.state === LOAD_STATE.ERROR)) { - this.removePlaceholder(); - } this.appendImage(); } - } else if (this.element && !this.element.parentNode) { + } else if (this.slide && !this.element.parentNode) { this.slide.container.appendChild(this.element); } } - /** * Activate the slide, * active slide is generally the current one, * meaning the user can see it. */ + + activate() { - if (this.instance.dispatch('contentActivate', { content: this }).defaultPrevented) { + if (this.instance.dispatch('contentActivate', { + content: this + }).defaultPrevented || !this.slide) { return; } - if (this.slide) { - if (this.isImageContent() && this.isDecoding) { - // add image to slide when it becomes active, - // even if it's not finished decoding - this.appendImage(); - } else if (this.isError()) { - this.load(false, true); // try to reload - } + if (this.isImageContent() && this.isDecoding && !isSafari()) { + // add image to slide when it becomes active, + // even if it's not finished decoding + this.appendImage(); + } else if (this.isError()) { + this.load(false, true); // try to reload } - } + if (this.slide.holderElement) { + this.slide.holderElement.setAttribute('aria-hidden', 'false'); + } + } /** * Deactivate the content */ - deactivate() { - this.instance.dispatch('contentDeactivate', { content: this }); - } + deactivate() { + this.instance.dispatch('contentDeactivate', { + content: this + }); + + if (this.slide && this.slide.holderElement) { + this.slide.holderElement.setAttribute('aria-hidden', 'true'); + } + } /** * Remove the content from DOM */ + + remove() { this.isAttached = false; - if (this.instance.dispatch('contentRemove', { content: this }).defaultPrevented) { + if (this.instance.dispatch('contentRemove', { + content: this + }).defaultPrevented) { return; } if (this.element && this.element.parentNode) { this.element.remove(); } - } + if (this.placeholder && this.placeholder.element) { + this.placeholder.element.remove(); + } + } /** * Append the image content to slide container */ + + appendImage() { if (!this.isAttached) { return; } - if (this.instance.dispatch('contentAppendImage', { content: this }).defaultPrevented) { + if (this.instance.dispatch('contentAppendImage', { + content: this + }).defaultPrevented) { return; - } + } // ensure that element exists and is not already appended - // ensure that element exists and is not already appended + if (this.slide && this.element && !this.element.parentNode) { this.slide.container.appendChild(this.element); + } - if (this.placeholder - && (this.state === LOAD_STATE.LOADED || this.state === LOAD_STATE.ERROR)) { - this.removePlaceholder(); + if (this.state === LOAD_STATE.LOADED || this.state === LOAD_STATE.ERROR) { + this.removePlaceholder(); + } + } + +} + +/** @typedef {import('./content.js').default} Content */ + +/** @typedef {import('./slide.js').default} Slide */ + +/** @typedef {import('./slide.js').SlideData} SlideData */ + +/** @typedef {import('../core/base.js').default} PhotoSwipeBase */ + +/** @typedef {import('../photoswipe.js').default} PhotoSwipe */ + +const MIN_SLIDES_TO_CACHE = 5; +/** + * Lazy-load an image + * This function is used both by Lightbox and PhotoSwipe core, + * thus it can be called before dialog is opened. + * + * @param {SlideData} itemData Data about the slide + * @param {PhotoSwipeBase} instance PhotoSwipe or PhotoSwipeLightbox instance + * @param {number} index + * @returns {Content} Image that is being decoded or false. + */ + +function lazyLoadData(itemData, instance, index) { + const content = instance.createContentFromData(itemData, index); + /** @type {ZoomLevel | undefined} */ + + let zoomLevel; + const { + options + } = instance; // We need to know dimensions of the image to preload it, + // as it might use srcset, and we need to define sizes + + if (options) { + zoomLevel = new ZoomLevel(options, itemData, -1); + let viewportSize; + + if (instance.pswp) { + viewportSize = instance.pswp.viewportSize; + } else { + viewportSize = getViewportSize(options, instance); + } + + const panAreaSize = getPanAreaSize(options, viewportSize, itemData, index); + zoomLevel.update(content.width, content.height, panAreaSize); + } + + content.lazyLoad(); + + if (zoomLevel) { + content.setDisplayedSize(Math.ceil(content.width * zoomLevel.initial), Math.ceil(content.height * zoomLevel.initial)); + } + + return content; +} +/** + * Lazy-loads specific slide. + * This function is used both by Lightbox and PhotoSwipe core, + * thus it can be called before dialog is opened. + * + * By default, it loads image based on viewport size and initial zoom level. + * + * @param {number} index Slide index + * @param {PhotoSwipeBase} instance PhotoSwipe or PhotoSwipeLightbox eventable instance + * @returns {Content | undefined} + */ + +function lazyLoadSlide(index, instance) { + const itemData = instance.getItemData(index); + + if (instance.dispatch('lazyLoadSlide', { + index, + itemData + }).defaultPrevented) { + return; + } + + return lazyLoadData(itemData, instance, index); +} + +class ContentLoader { + /** + * @param {PhotoSwipe} pswp + */ + constructor(pswp) { + this.pswp = pswp; // Total amount of cached images + + this.limit = Math.max(pswp.options.preload[0] + pswp.options.preload[1] + 1, MIN_SLIDES_TO_CACHE); + /** @type {Content[]} */ + + this._cachedItems = []; + } + /** + * Lazy load nearby slides based on `preload` option. + * + * @param {number} [diff] Difference between slide indexes that was changed recently, or 0. + */ + + + updateLazy(diff) { + const { + pswp + } = this; + + if (pswp.dispatch('lazyLoad').defaultPrevented) { + return; + } + + const { + preload + } = pswp.options; + const isForward = diff === undefined ? true : diff >= 0; + let i; // preload[1] - num items to preload in forward direction + + for (i = 0; i <= preload[1]; i++) { + this.loadSlideByIndex(pswp.currIndex + (isForward ? i : -i)); + } // preload[0] - num items to preload in backward direction + + + for (i = 1; i <= preload[0]; i++) { + this.loadSlideByIndex(pswp.currIndex + (isForward ? -i : i)); + } + } + /** + * @param {number} initialIndex + */ + + + loadSlideByIndex(initialIndex) { + const index = this.pswp.getLoopedIndex(initialIndex); // try to get cached content + + let content = this.getContentByIndex(index); + + if (!content) { + // no cached content, so try to load from scratch: + content = lazyLoadSlide(index, this.pswp); // if content can be loaded, add it to cache: + + if (content) { + this.addToCache(content); } } } + /** + * @param {Slide} slide + * @returns {Content} + */ + + + getContentBySlide(slide) { + let content = this.getContentByIndex(slide.index); + + if (!content) { + // create content if not found in cache + content = this.pswp.createContentFromData(slide.data, slide.index); + this.addToCache(content); + } // assign slide to content + + + content.setSlide(slide); + return content; + } + /** + * @param {Content} content + */ + + + addToCache(content) { + // move to the end of array + this.removeByIndex(content.index); + + this._cachedItems.push(content); + + if (this._cachedItems.length > this.limit) { + // Destroy the first content that's not attached + const indexToRemove = this._cachedItems.findIndex(item => { + return !item.isAttached && !item.hasSlide; + }); + + if (indexToRemove !== -1) { + const removedItem = this._cachedItems.splice(indexToRemove, 1)[0]; + + removedItem.destroy(); + } + } + } + /** + * Removes an image from cache, does not destroy() it, just removes. + * + * @param {number} index + */ + + + removeByIndex(index) { + const indexToRemove = this._cachedItems.findIndex(item => item.index === index); + + if (indexToRemove !== -1) { + this._cachedItems.splice(indexToRemove, 1); + } + } + /** + * @param {number} index + * @returns {Content | undefined} + */ + + + getContentByIndex(index) { + return this._cachedItems.find(content => content.index === index); + } + + destroy() { + this._cachedItems.forEach(content => content.destroy()); + + this._cachedItems = []; + } + } +/** @typedef {import("../photoswipe.js").default} PhotoSwipe */ + +/** @typedef {import("../slide/slide.js").SlideData} SlideData */ + /** * PhotoSwipe base class that can retrieve data about every slide. * Shared by PhotoSwipe Core and PhotoSwipe Lightbox */ - class PhotoSwipeBase extends Eventable { /** * Get total number of slides + * + * @returns {number} */ getNumItems() { - let numItems; - const { dataSource } = this.options; - if (!dataSource) { - numItems = 0; - } else if (dataSource.length) { + var _this$options; + + let numItems = 0; + const dataSource = (_this$options = this.options) === null || _this$options === void 0 ? void 0 : _this$options.dataSource; + + if (dataSource && 'length' in dataSource) { // may be an array or just object with length property numItems = dataSource.length; - } else if (dataSource.gallery) { + } else if (dataSource && 'gallery' in dataSource) { // query DOM elements if (!dataSource.items) { dataSource.items = this._getGalleryDOMElements(dataSource.gallery); } if (dataSource.items) { numItems = dataSource.items.length; } - } + } // legacy event, before filters were introduced - // legacy event, before filters were introduced + const event = this.dispatch('numItems', { dataSource, numItems }); return this.applyFilters('numItems', event.numItems, dataSource); } + /** + * @param {SlideData} slideData + * @param {number} index + * @returns {Content} + */ + createContentFromData(slideData, index) { return new Content(slideData, this, index); } - /** * Get item data by index. * * "item data" should contain normalized information that PhotoSwipe needs to generate a slide. * For example, it may contain properties like * `src`, `srcset`, `w`, `h`, which will be used to generate a slide with image. * - * @param {Integer} index + * @param {number} index + * @returns {SlideData} */ + + getItemData(index) { - const { dataSource } = this.options; - let dataSourceItem; + var _this$options2; + + const dataSource = (_this$options2 = this.options) === null || _this$options2 === void 0 ? void 0 : _this$options2.dataSource; + /** @type {SlideData | HTMLElement} */ + + let dataSourceItem = {}; + if (Array.isArray(dataSource)) { // Datasource is an array of elements dataSourceItem = dataSource[index]; - } else if (dataSource && dataSource.gallery) { + } else if (dataSource && 'gallery' in dataSource) { // dataSource has gallery property, // thus it was created by Lightbox, based on - // gallerySelecor and childSelector options - + // gallery and children options // query DOM elements if (!dataSource.items) { dataSource.items = this._getGalleryDOMElements(dataSource.gallery); } @@ -4159,631 +5664,815 @@ let itemData = dataSourceItem; if (itemData instanceof Element) { itemData = this._domElementToItemData(itemData); - } - - // Dispatching the itemData event, + } // Dispatching the itemData event, // it's a legacy verion before filters were introduced + + const event = this.dispatch('itemData', { itemData: itemData || {}, index }); - return this.applyFilters('itemData', event.itemData, index); } - /** * Get array of gallery DOM elements, * based on childSelector and gallery element. * - * @param {Element} galleryElement + * @param {HTMLElement} galleryElement + * @returns {HTMLElement[]} */ + + _getGalleryDOMElements(galleryElement) { - if (this.options.children || this.options.childSelector) { - return getElementsFromOption( - this.options.children, - this.options.childSelector, - galleryElement - ) || []; + var _this$options3, _this$options4; + + if ((_this$options3 = this.options) !== null && _this$options3 !== void 0 && _this$options3.children || (_this$options4 = this.options) !== null && _this$options4 !== void 0 && _this$options4.childSelector) { + return getElementsFromOption(this.options.children, this.options.childSelector, galleryElement) || []; } return [galleryElement]; } - /** * Converts DOM element to item data object. * - * @param {Element} element DOM element + * @param {HTMLElement} element DOM element + * @returns {SlideData} */ - // eslint-disable-next-line class-methods-use-this + + _domElementToItemData(element) { + /** @type {SlideData} */ const itemData = { element }; + const linkEl = + /** @type {HTMLAnchorElement} */ + element.tagName === 'A' ? element : element.querySelector('a'); - const linkEl = element.tagName === 'A' ? element : element.querySelector('a'); - if (linkEl) { // src comes from data-pswp-src attribute, // if it's empty link href is used itemData.src = linkEl.dataset.pswpSrc || linkEl.href; if (linkEl.dataset.pswpSrcset) { itemData.srcset = linkEl.dataset.pswpSrcset; } - itemData.width = parseInt(linkEl.dataset.pswpWidth, 10); - itemData.height = parseInt(linkEl.dataset.pswpHeight, 10); + itemData.width = linkEl.dataset.pswpWidth ? parseInt(linkEl.dataset.pswpWidth, 10) : 0; + itemData.height = linkEl.dataset.pswpHeight ? parseInt(linkEl.dataset.pswpHeight, 10) : 0; // support legacy w & h properties - // support legacy w & h properties itemData.w = itemData.width; itemData.h = itemData.height; if (linkEl.dataset.pswpType) { itemData.type = linkEl.dataset.pswpType; } const thumbnailEl = element.querySelector('img'); if (thumbnailEl) { + var _thumbnailEl$getAttri; + // msrc is URL to placeholder image that's displayed before large image is loaded // by default it's displayed only for the first slide itemData.msrc = thumbnailEl.currentSrc || thumbnailEl.src; - itemData.alt = thumbnailEl.getAttribute('alt'); + itemData.alt = (_thumbnailEl$getAttri = thumbnailEl.getAttribute('alt')) !== null && _thumbnailEl$getAttri !== void 0 ? _thumbnailEl$getAttri : ''; } if (linkEl.dataset.pswpCropped || linkEl.dataset.cropped) { itemData.thumbCropped = true; } } - this.applyFilters('domItemData', itemData, element, linkEl); + return this.applyFilters('domItemData', itemData, element, linkEl); + } + /** + * Lazy-load by slide data + * + * @param {SlideData} itemData Data about the slide + * @param {number} index + * @returns {Content} Image that is being decoded or false. + */ - return itemData; + + lazyLoadData(itemData, index) { + return lazyLoadData(itemData, this, index); } + } -/** - * Manages opening and closing transitions of the PhotoSwipe. - * - * It can perform zoom, fade or no transition. - */ +/** @typedef {import('./photoswipe.js').default} PhotoSwipe */ +/** @typedef {import('./slide/get-thumb-bounds.js').Bounds} Bounds */ + +/** @typedef {import('./util/animations.js').AnimationProps} AnimationProps */ // some browsers do not paint // elements which opacity is set to 0, // since we need to pre-render elements for the animation - // we set it to the minimum amount + const MIN_OPACITY = 0.003; +/** + * Manages opening and closing transitions of the PhotoSwipe. + * + * It can perform zoom, fade or no transition. + */ class Opener { + /** + * @param {PhotoSwipe} pswp + */ constructor(pswp) { this.pswp = pswp; this.isClosed = true; - this._prepareOpen = this._prepareOpen.bind(this); + this.isOpen = false; + this.isClosing = false; + this.isOpening = false; + /** + * @private + * @type {number | false | undefined} + */ - // Override initial zoom and pan position + this._duration = undefined; + /** @private */ + + this._useAnimation = false; + /** @private */ + + this._croppedZoom = false; + /** @private */ + + this._animateRootOpacity = false; + /** @private */ + + this._animateBgOpacity = false; + /** + * @private + * @type { HTMLDivElement | HTMLImageElement | null | undefined } + */ + + this._placeholder = undefined; + /** + * @private + * @type { HTMLDivElement | undefined } + */ + + this._opacityElement = undefined; + /** + * @private + * @type { HTMLDivElement | undefined } + */ + + this._cropContainer1 = undefined; + /** + * @private + * @type { HTMLElement | null | undefined } + */ + + this._cropContainer2 = undefined; + /** + * @private + * @type {Bounds | undefined} + */ + + this._thumbBounds = undefined; + this._prepareOpen = this._prepareOpen.bind(this); // Override initial zoom and pan position + pswp.on('firstZoomPan', this._prepareOpen); } open() { this._prepareOpen(); + this._start(); } close() { if (this.isClosed || this.isClosing || this.isOpening) { // if we close during opening animation // for now do nothing, // browsers aren't good at changing the direction of the CSS transition - return false; + return; } const slide = this.pswp.currSlide; - this.isOpen = false; this.isOpening = false; this.isClosing = true; this._duration = this.pswp.options.hideAnimationDuration; if (slide && slide.currZoomLevel * slide.width >= this.pswp.options.maxWidthToAnimate) { this._duration = 0; } this._applyStartProps(); + setTimeout(() => { this._start(); }, this._croppedZoom ? 30 : 0); - - return true; } + /** @private */ + _prepareOpen() { this.pswp.off('firstZoomPan', this._prepareOpen); + if (!this.isOpening) { const slide = this.pswp.currSlide; this.isOpening = true; this.isClosing = false; this._duration = this.pswp.options.showAnimationDuration; + if (slide && slide.zoomLevels.initial * slide.width >= this.pswp.options.maxWidthToAnimate) { this._duration = 0; } + this._applyStartProps(); } } + /** @private */ + _applyStartProps() { - const { pswp } = this; + const { + pswp + } = this; const slide = this.pswp.currSlide; - const { options } = pswp; + const { + options + } = pswp; if (options.showHideAnimationType === 'fade') { options.showHideOpacity = true; - this._thumbBounds = false; + this._thumbBounds = undefined; } else if (options.showHideAnimationType === 'none') { options.showHideOpacity = false; this._duration = 0; - this._thumbBounds = false; + this._thumbBounds = undefined; } else if (this.isOpening && pswp._initialThumbBounds) { // Use initial bounds if defined this._thumbBounds = pswp._initialThumbBounds; } else { this._thumbBounds = this.pswp.getThumbBounds(); } - this._placeholder = slide.getPlaceholderElement(); + this._placeholder = slide === null || slide === void 0 ? void 0 : slide.getPlaceholderElement(); + pswp.animations.stopAll(); // Discard animations when duration is less than 50ms - pswp.animations.stopAll(); + this._useAnimation = Boolean(this._duration && this._duration > 50); + this._animateZoom = Boolean(this._thumbBounds) && (slide === null || slide === void 0 ? void 0 : slide.content.usePlaceholder()) && (!this.isClosing || !pswp.mainScroll.isShifted()); - // Discard animations when duration is less than 50ms - this._useAnimation = (this._duration > 50); - this._animateZoom = Boolean(this._thumbBounds) - && (slide.content && slide.content.usePlaceholder()) - && (!this.isClosing || !pswp.mainScroll.isShifted()); if (!this._animateZoom) { this._animateRootOpacity = true; - if (this.isOpening) { + if (this.isOpening && slide) { slide.zoomAndPanToInitial(); slide.applyCurrentZoomPan(); } } else { - this._animateRootOpacity = options.showHideOpacity; + var _options$showHideOpac; + + this._animateRootOpacity = (_options$showHideOpac = options.showHideOpacity) !== null && _options$showHideOpac !== void 0 ? _options$showHideOpac : false; } + this._animateBgOpacity = !this._animateRootOpacity && this.pswp.options.bgOpacity > MIN_OPACITY; this._opacityElement = this._animateRootOpacity ? pswp.element : pswp.bg; if (!this._useAnimation) { this._duration = 0; this._animateZoom = false; this._animateBgOpacity = false; this._animateRootOpacity = true; + if (this.isOpening) { - pswp.element.style.opacity = MIN_OPACITY; + if (pswp.element) { + pswp.element.style.opacity = String(MIN_OPACITY); + } + pswp.applyBgOpacity(1); } + return; } - if (this._animateZoom && this._thumbBounds.innerRect) { + if (this._animateZoom && this._thumbBounds && this._thumbBounds.innerRect) { + var _this$pswp$currSlide; + // Properties are used when animation from cropped thumbnail this._croppedZoom = true; this._cropContainer1 = this.pswp.container; - this._cropContainer2 = this.pswp.currSlide.holderElement; + this._cropContainer2 = (_this$pswp$currSlide = this.pswp.currSlide) === null || _this$pswp$currSlide === void 0 ? void 0 : _this$pswp$currSlide.holderElement; - pswp.container.style.overflow = 'hidden'; - pswp.container.style.width = pswp.viewportSize.x + 'px'; + if (pswp.container) { + pswp.container.style.overflow = 'hidden'; + pswp.container.style.width = pswp.viewportSize.x + 'px'; + } } else { this._croppedZoom = false; } if (this.isOpening) { // Apply styles before opening transition if (this._animateRootOpacity) { - pswp.element.style.opacity = MIN_OPACITY; + if (pswp.element) { + pswp.element.style.opacity = String(MIN_OPACITY); + } + pswp.applyBgOpacity(1); } else { - if (this._animateBgOpacity) { - pswp.bg.style.opacity = MIN_OPACITY; + if (this._animateBgOpacity && pswp.bg) { + pswp.bg.style.opacity = String(MIN_OPACITY); } - pswp.element.style.opacity = 1; + + if (pswp.element) { + pswp.element.style.opacity = '1'; + } } if (this._animateZoom) { this._setClosedStateZoomPan(); + if (this._placeholder) { // tell browser that we plan to animate the placeholder - this._placeholder.willChange = 'transform'; - - // hide placeholder to allow hiding of + this._placeholder.style.willChange = 'transform'; // hide placeholder to allow hiding of // elements that overlap it (such as icons over the thumbnail) - this._placeholder.style.opacity = MIN_OPACITY; + + this._placeholder.style.opacity = String(MIN_OPACITY); } } } else if (this.isClosing) { // hide nearby slides to make sure that // they are not painted during the transition - pswp.mainScroll.itemHolders[0].el.style.display = 'none'; - pswp.mainScroll.itemHolders[2].el.style.display = 'none'; + if (pswp.mainScroll.itemHolders[0]) { + pswp.mainScroll.itemHolders[0].el.style.display = 'none'; + } + if (pswp.mainScroll.itemHolders[2]) { + pswp.mainScroll.itemHolders[2].el.style.display = 'none'; + } + if (this._croppedZoom) { if (pswp.mainScroll.x !== 0) { // shift the main scroller to zero position pswp.mainScroll.resetPosition(); pswp.mainScroll.resize(); } } } } + /** @private */ + _start() { - if (this.isOpening - && this._useAnimation - && this._placeholder - && this._placeholder.tagName === 'IMG') { + if (this.isOpening && this._useAnimation && this._placeholder && this._placeholder.tagName === 'IMG') { // To ensure smooth animation // we wait till the current slide image placeholder is decoded, // but no longer than 250ms, // and no shorter than 50ms // (just using requestanimationframe is not enough in Firefox, // for some reason) - new Promise((resolve) => { + new Promise(resolve => { let decoded = false; let isDelaying = true; - decodeImage(this._placeholder).finally(() => { + decodeImage( + /** @type {HTMLImageElement} */ + this._placeholder).finally(() => { decoded = true; + if (!isDelaying) { - resolve(); + resolve(true); } }); setTimeout(() => { isDelaying = false; + if (decoded) { - resolve(); + resolve(true); } }, 50); setTimeout(resolve, 250); }).finally(() => this._initiate()); } else { this._initiate(); } } + /** @private */ + _initiate() { - this.pswp.element.style.setProperty('--pswp-transition-duration', this._duration + 'ms'); + var _this$pswp$element, _this$pswp$element2; + (_this$pswp$element = this.pswp.element) === null || _this$pswp$element === void 0 || _this$pswp$element.style.setProperty('--pswp-transition-duration', this._duration + 'ms'); + this.pswp.dispatch(this.isOpening ? 'openingAnimationStart' : 'closingAnimationStart'); // legacy event + this.pswp.dispatch( - this.isOpening ? 'openingAnimationStart' : 'closingAnimationStart' - ); + /** @type {'initialZoomIn' | 'initialZoomOut'} */ + 'initialZoom' + (this.isOpening ? 'In' : 'Out')); + (_this$pswp$element2 = this.pswp.element) === null || _this$pswp$element2 === void 0 || _this$pswp$element2.classList.toggle('pswp--ui-visible', this.isOpening); - // legacy event - this.pswp.dispatch('initialZoom' + (this.isOpening ? 'In' : 'Out')); - - this.pswp.element.classList[this.isOpening ? 'add' : 'remove']('pswp--ui-visible'); - if (this.isOpening) { if (this._placeholder) { // unhide the placeholder - this._placeholder.style.opacity = 1; + this._placeholder.style.opacity = '1'; } + this._animateToOpenState(); } else if (this.isClosing) { this._animateToClosedState(); } if (!this._useAnimation) { this._onAnimationComplete(); } } + /** @private */ + _onAnimationComplete() { - const { pswp } = this; + const { + pswp + } = this; this.isOpen = this.isOpening; this.isClosed = this.isClosing; this.isOpening = false; this.isClosing = false; + pswp.dispatch(this.isOpen ? 'openingAnimationEnd' : 'closingAnimationEnd'); // legacy event pswp.dispatch( - this.isOpen ? 'openingAnimationEnd' : 'closingAnimationEnd' - ); + /** @type {'initialZoomInEnd' | 'initialZoomOutEnd'} */ + 'initialZoom' + (this.isOpen ? 'InEnd' : 'OutEnd')); - // legacy event - pswp.dispatch('initialZoom' + (this.isOpen ? 'InEnd' : 'OutEnd')); - if (this.isClosed) { pswp.destroy(); } else if (this.isOpen) { - if (this._animateZoom) { + var _pswp$currSlide; + + if (this._animateZoom && pswp.container) { pswp.container.style.overflow = 'visible'; pswp.container.style.width = '100%'; } - pswp.currSlide.applyCurrentZoomPan(); + + (_pswp$currSlide = pswp.currSlide) === null || _pswp$currSlide === void 0 || _pswp$currSlide.applyCurrentZoomPan(); } } + /** @private */ + _animateToOpenState() { - const { pswp } = this; + const { + pswp + } = this; + if (this._animateZoom) { - if (this._croppedZoom) { + if (this._croppedZoom && this._cropContainer1 && this._cropContainer2) { this._animateTo(this._cropContainer1, 'transform', 'translate3d(0,0,0)'); + this._animateTo(this._cropContainer2, 'transform', 'none'); } - pswp.currSlide.zoomAndPanToInitial(); - this._animateTo( - pswp.currSlide.container, - 'transform', - pswp.currSlide.getCurrentTransform() - ); + if (pswp.currSlide) { + pswp.currSlide.zoomAndPanToInitial(); + + this._animateTo(pswp.currSlide.container, 'transform', pswp.currSlide.getCurrentTransform()); + } } - if (this._animateBgOpacity) { - this._animateTo(pswp.bg, 'opacity', pswp.options.bgOpacity); + if (this._animateBgOpacity && pswp.bg) { + this._animateTo(pswp.bg, 'opacity', String(pswp.options.bgOpacity)); } - if (this._animateRootOpacity) { - this._animateTo(pswp.element, 'opacity', 1); + if (this._animateRootOpacity && pswp.element) { + this._animateTo(pswp.element, 'opacity', '1'); } } + /** @private */ + _animateToClosedState() { - const { pswp } = this; + const { + pswp + } = this; if (this._animateZoom) { this._setClosedStateZoomPan(true); - } + } // do not animate opacity if it's already at 0 - if (this._animateBgOpacity - && pswp.bgOpacity > 0.01) { // do not animate opacity if it's already at 0 - this._animateTo(pswp.bg, 'opacity', 0); + + if (this._animateBgOpacity && pswp.bgOpacity > 0.01 && pswp.bg) { + this._animateTo(pswp.bg, 'opacity', '0'); } - if (this._animateRootOpacity) { - this._animateTo(pswp.element, 'opacity', 0); + if (this._animateRootOpacity && pswp.element) { + this._animateTo(pswp.element, 'opacity', '0'); } } + /** + * @private + * @param {boolean} [animate] + */ + _setClosedStateZoomPan(animate) { - const { pswp } = this; - const { innerRect } = this._thumbBounds; - const { currSlide, viewportSize } = pswp; + if (!this._thumbBounds) return; + const { + pswp + } = this; + const { + innerRect + } = this._thumbBounds; + const { + currSlide, + viewportSize + } = pswp; - if (this._croppedZoom) { + if (this._croppedZoom && innerRect && this._cropContainer1 && this._cropContainer2) { const containerOnePanX = -viewportSize.x + (this._thumbBounds.x - innerRect.x) + innerRect.w; const containerOnePanY = -viewportSize.y + (this._thumbBounds.y - innerRect.y) + innerRect.h; const containerTwoPanX = viewportSize.x - innerRect.w; const containerTwoPanY = viewportSize.y - innerRect.h; - if (animate) { - this._animateTo( - this._cropContainer1, - 'transform', - toTransformString(containerOnePanX, containerOnePanY) - ); + this._animateTo(this._cropContainer1, 'transform', toTransformString(containerOnePanX, containerOnePanY)); - this._animateTo( - this._cropContainer2, - 'transform', - toTransformString(containerTwoPanX, containerTwoPanY) - ); + this._animateTo(this._cropContainer2, 'transform', toTransformString(containerTwoPanX, containerTwoPanY)); } else { setTransform(this._cropContainer1, containerOnePanX, containerOnePanY); setTransform(this._cropContainer2, containerTwoPanX, containerTwoPanY); } } - equalizePoints(currSlide.pan, innerRect || this._thumbBounds); - currSlide.currZoomLevel = this._thumbBounds.w / currSlide.width; + if (currSlide) { + equalizePoints(currSlide.pan, innerRect || this._thumbBounds); + currSlide.currZoomLevel = this._thumbBounds.w / currSlide.width; - if (animate) { - this._animateTo(currSlide.container, 'transform', currSlide.getCurrentTransform()); - } else { - currSlide.applyCurrentZoomPan(); + if (animate) { + this._animateTo(currSlide.container, 'transform', currSlide.getCurrentTransform()); + } else { + currSlide.applyCurrentZoomPan(); + } } } - /** - * @param {Element} target - * @param {String} prop - * @param {String} propValue + * @private + * @param {HTMLElement} target + * @param {'transform' | 'opacity'} prop + * @param {string} propValue */ + + _animateTo(target, prop, propValue) { if (!this._duration) { target.style[prop] = propValue; return; } - const { animations } = this.pswp; + const { + animations + } = this.pswp; + /** @type {AnimationProps} */ + const animProps = { duration: this._duration, easing: this.pswp.options.easing, onComplete: () => { if (!animations.activeAnimations.length) { this._onAnimationComplete(); } }, - target, + target }; animProps[prop] = propValue; animations.startTransition(animProps); } + } -const MIN_SLIDES_TO_CACHE = 5; - /** - * Lazy-load an image - * This function is used both by Lightbox and PhotoSwipe core, - * thus it can be called before dialog is opened. - * - * @param {Object} itemData Data about the slide - * @param {PhotoSwipeBase} instance PhotoSwipe or PhotoSwipeLightbox - * @param {Integer} index - * @returns {Object|Boolean} Image that is being decoded or false. + * @template T + * @typedef {import('./types.js').Type<T>} Type<T> */ -function lazyLoadData(itemData, instance, index) { - // src/slide/content/content.js - const content = instance.createContentFromData(itemData, index); - if (!content || !content.lazyLoad) { - return; - } +/** @typedef {import('./slide/slide.js').SlideData} SlideData */ - const { options } = instance; +/** @typedef {import('./slide/zoom-level.js').ZoomLevelOption} ZoomLevelOption */ - // We need to know dimensions of the image to preload it, - // as it might use srcset and we need to define sizes - const viewportSize = instance.viewportSize || getViewportSize(options); - const panAreaSize = getPanAreaSize(options, viewportSize, itemData, index); +/** @typedef {import('./ui/ui-element.js').UIElementData} UIElementData */ - const zoomLevel = new ZoomLevel(options, itemData, -1); - zoomLevel.update(content.width, content.height, panAreaSize); +/** @typedef {import('./main-scroll.js').ItemHolder} ItemHolder */ - content.lazyLoad(); - content.setDisplayedSize( - Math.ceil(content.width * zoomLevel.initial), - Math.ceil(content.height * zoomLevel.initial) - ); +/** @typedef {import('./core/eventable.js').PhotoSwipeEventsMap} PhotoSwipeEventsMap */ - return content; -} +/** @typedef {import('./core/eventable.js').PhotoSwipeFiltersMap} PhotoSwipeFiltersMap */ +/** @typedef {import('./slide/get-thumb-bounds').Bounds} Bounds */ /** - * Lazy-loads specific slide. - * This function is used both by Lightbox and PhotoSwipe core, - * thus it can be called before dialog is opened. - * - * By default it loads image based on viewport size and initial zoom level. - * - * @param {Integer} index Slide index - * @param {Object} instance PhotoSwipe or PhotoSwipeLightbox eventable instance + * @template {keyof PhotoSwipeEventsMap} T + * @typedef {import('./core/eventable.js').EventCallback<T>} EventCallback<T> */ -function lazyLoadSlide(index, instance) { - const itemData = instance.getItemData(index); - if (instance.dispatch('lazyLoadSlide', { index, itemData }).defaultPrevented) { - return; - } +/** + * @template {keyof PhotoSwipeEventsMap} T + * @typedef {import('./core/eventable.js').AugmentedEvent<T>} AugmentedEvent<T> + */ - return lazyLoadData(itemData, instance, index); -} +/** @typedef {{ x: number; y: number; id?: string | number }} Point */ +/** @typedef {{ top: number; bottom: number; left: number; right: number }} Padding */ -class ContentLoader { - constructor(pswp) { - this.pswp = pswp; - // Total amount of cached images - this.limit = Math.max( - pswp.options.preload[0] + pswp.options.preload[1] + 1, - MIN_SLIDES_TO_CACHE - ); - this._cachedItems = []; - } +/** @typedef {SlideData[]} DataSourceArray */ - /** - * Lazy load nearby slides based on `preload` option. - * - * @param {Integer} diff Difference between slide indexes that was changed recently, or 0. - */ - updateLazy(diff) { - const { pswp } = this; +/** @typedef {{ gallery: HTMLElement; items?: HTMLElement[] }} DataSourceObject */ - if (pswp.dispatch('lazyLoad').defaultPrevented) { - return; - } +/** @typedef {DataSourceArray | DataSourceObject} DataSource */ - const { preload } = pswp.options; - const isForward = diff === undefined ? true : (diff >= 0); - let i; +/** @typedef {(point: Point, originalEvent: PointerEvent) => void} ActionFn */ - // preload[1] - num items to preload in forward direction - for (i = 0; i <= preload[1]; i++) { - this.loadSlideByIndex(pswp.currIndex + (isForward ? i : (-i))); - } +/** @typedef {'close' | 'next' | 'zoom' | 'zoom-or-close' | 'toggle-controls'} ActionType */ - // preload[0] - num items to preload in backward direction - for (i = 1; i <= preload[0]; i++) { - this.loadSlideByIndex(pswp.currIndex + (isForward ? (-i) : i)); - } - } +/** @typedef {Type<PhotoSwipe> | { default: Type<PhotoSwipe> }} PhotoSwipeModule */ - loadSlideByIndex(index) { - index = this.pswp.getLoopedIndex(index); - // try to get cached content - let content = this.getContentByIndex(index); - if (!content) { - // no cached content, so try to load from scratch: - content = lazyLoadSlide(index, this.pswp); - // if content can be loaded, add it to cache: - if (content) { - this.addToCache(content); - } - } - } +/** @typedef {PhotoSwipeModule | Promise<PhotoSwipeModule> | (() => Promise<PhotoSwipeModule>)} PhotoSwipeModuleOption */ - getContentBySlide(slide) { - let content = this.getContentByIndex(slide.index); - if (!content) { - // create content if not found in cache - content = this.pswp.createContentFromData(slide.data, slide.index); - if (content) { - this.addToCache(content); - } - } +/** + * @typedef {string | NodeListOf<HTMLElement> | HTMLElement[] | HTMLElement} ElementProvider + */ - if (content) { - // assign slide to content - content.setSlide(slide); - } - return content; - } +/** @typedef {Partial<PreparedPhotoSwipeOptions>} PhotoSwipeOptions https://photoswipe.com/options/ */ - /** - * @param {Content} content - */ - addToCache(content) { - // move to the end of array - this.removeByIndex(content.index); - this._cachedItems.push(content); +/** + * @typedef {Object} PreparedPhotoSwipeOptions + * + * @prop {DataSource} [dataSource] + * Pass an array of any items via dataSource option. Its length will determine amount of slides + * (which may be modified further from numItems event). + * + * Each item should contain data that you need to generate slide + * (for image slide it would be src (image URL), width (image width), height, srcset, alt). + * + * If these properties are not present in your initial array, you may "pre-parse" each item from itemData filter. + * + * @prop {number} bgOpacity + * Background backdrop opacity, always define it via this option and not via CSS rgba color. + * + * @prop {number} spacing + * Spacing between slides. Defined as ratio relative to the viewport width (0.1 = 10% of viewport). + * + * @prop {boolean} allowPanToNext + * Allow swipe navigation to the next slide when the current slide is zoomed. Does not apply to mouse events. + * + * @prop {boolean} loop + * If set to true you'll be able to swipe from the last to the first image. + * Option is always false when there are less than 3 slides. + * + * @prop {boolean} [wheelToZoom] + * By default PhotoSwipe zooms image with ctrl-wheel, if you enable this option - image will zoom just via wheel. + * + * @prop {boolean} pinchToClose + * Pinch touch gesture to close the gallery. + * + * @prop {boolean} closeOnVerticalDrag + * Vertical drag gesture to close the PhotoSwipe. + * + * @prop {Padding} [padding] + * Slide area padding (in pixels). + * + * @prop {(viewportSize: Point, itemData: SlideData, index: number) => Padding} [paddingFn] + * The option is checked frequently, so make sure it's performant. Overrides padding option if defined. For example: + * + * @prop {number | false} hideAnimationDuration + * Transition duration in milliseconds, can be 0. + * + * @prop {number | false} showAnimationDuration + * Transition duration in milliseconds, can be 0. + * + * @prop {number | false} zoomAnimationDuration + * Transition duration in milliseconds, can be 0. + * + * @prop {string} easing + * String, 'cubic-bezier(.4,0,.22,1)'. CSS easing function for open/close/zoom transitions. + * + * @prop {boolean} escKey + * Esc key to close. + * + * @prop {boolean} arrowKeys + * Left/right arrow keys for navigation. + * + * @prop {boolean} trapFocus + * Trap focus within PhotoSwipe element while it's open. + * + * @prop {boolean} returnFocus + * Restore focus the last active element after PhotoSwipe is closed. + * + * @prop {boolean} clickToCloseNonZoomable + * If image is not zoomable (for example, smaller than viewport) it can be closed by clicking on it. + * + * @prop {ActionType | ActionFn | false} imageClickAction + * Refer to click and tap actions page. + * + * @prop {ActionType | ActionFn | false} bgClickAction + * Refer to click and tap actions page. + * + * @prop {ActionType | ActionFn | false} tapAction + * Refer to click and tap actions page. + * + * @prop {ActionType | ActionFn | false} doubleTapAction + * Refer to click and tap actions page. + * + * @prop {number} preloaderDelay + * Delay before the loading indicator will be displayed, + * if image is loaded during it - the indicator will not be displayed at all. Can be zero. + * + * @prop {string} indexIndicatorSep + * Used for slide count indicator ("1 of 10 "). + * + * @prop {(options: PhotoSwipeOptions, pswp: PhotoSwipeBase) => Point} [getViewportSizeFn] + * A function that should return slide viewport width and height, in format {x: 100, y: 100}. + * + * @prop {string} errorMsg + * Message to display when the image wasn't able to load. If you need to display HTML - use contentErrorElement filter. + * + * @prop {[number, number]} preload + * Lazy loading of nearby slides based on direction of movement. Should be an array with two integers, + * first one - number of items to preload before the current image, second one - after the current image. + * Two nearby images are always loaded. + * + * @prop {string} [mainClass] + * Class that will be added to the root element of PhotoSwipe, may contain multiple separated by space. + * Example on Styling page. + * + * @prop {HTMLElement} [appendToEl] + * Element to which PhotoSwipe dialog will be appended when it opens. + * + * @prop {number} maxWidthToAnimate + * Maximum width of image to animate, if initial rendered image width + * is larger than this value - the opening/closing transition will be automatically disabled. + * + * @prop {string} [closeTitle] + * Translating + * + * @prop {string} [zoomTitle] + * Translating + * + * @prop {string} [arrowPrevTitle] + * Translating + * + * @prop {string} [arrowNextTitle] + * Translating + * + * @prop {'zoom' | 'fade' | 'none'} [showHideAnimationType] + * To adjust opening or closing transition type use lightbox option `showHideAnimationType` (`String`). + * It supports three values - `zoom` (default), `fade` (default if there is no thumbnail) and `none`. + * + * Animations are automatically disabled if user `(prefers-reduced-motion: reduce)`. + * + * @prop {number} index + * Defines start slide index. + * + * @prop {(e: MouseEvent) => number} [getClickedIndexFn] + * + * @prop {boolean} [arrowPrev] + * @prop {boolean} [arrowNext] + * @prop {boolean} [zoom] + * @prop {boolean} [close] + * @prop {boolean} [counter] + * + * @prop {string} [arrowPrevSVG] + * @prop {string} [arrowNextSVG] + * @prop {string} [zoomSVG] + * @prop {string} [closeSVG] + * @prop {string} [counterSVG] + * + * @prop {string} [arrowPrevTitle] + * @prop {string} [arrowNextTitle] + * @prop {string} [zoomTitle] + * @prop {string} [closeTitle] + * @prop {string} [counterTitle] + * + * @prop {ZoomLevelOption} [initialZoomLevel] + * @prop {ZoomLevelOption} [secondaryZoomLevel] + * @prop {ZoomLevelOption} [maxZoomLevel] + * + * @prop {boolean} [mouseMovePan] + * @prop {Point | null} [initialPointerPos] + * @prop {boolean} [showHideOpacity] + * + * @prop {PhotoSwipeModuleOption} [pswpModule] + * @prop {() => Promise<any>} [openPromise] + * @prop {boolean} [preloadFirstSlide] + * @prop {ElementProvider} [gallery] + * @prop {string} [gallerySelector] + * @prop {ElementProvider} [children] + * @prop {string} [childSelector] + * @prop {string | false} [thumbSelector] + */ - if (this._cachedItems.length > this.limit) { - // Destroy the first content that's not attached - const indexToRemove = this._cachedItems.findIndex((item) => { - return !item.isAttached && !item.hasSlide; - }); - if (indexToRemove !== -1) { - const removedItem = this._cachedItems.splice(indexToRemove, 1)[0]; - removedItem.destroy(); - } - } - } +/** @type {PreparedPhotoSwipeOptions} */ - /** - * Removes an image from cache, does not destroy() it, just removes. - * - * @param {Integer} index - */ - removeByIndex(index) { - const indexToRemove = this._cachedItems.findIndex(item => item.index === index); - if (indexToRemove !== -1) { - this._cachedItems.splice(indexToRemove, 1); - } - } - - getContentByIndex(index) { - return this._cachedItems.find(content => content.index === index); - } - - destroy() { - this._cachedItems.forEach(content => content.destroy()); - this._cachedItems = null; - } -} - const defaultOptions = { allowPanToNext: true, spacing: 0.1, loop: true, pinchToClose: true, @@ -4791,142 +6480,209 @@ hideAnimationDuration: 333, showAnimationDuration: 333, zoomAnimationDuration: 333, escKey: true, arrowKeys: true, + trapFocus: true, returnFocus: true, maxWidthToAnimate: 4000, clickToCloseNonZoomable: true, imageClickAction: 'zoom-or-close', bgClickAction: 'close', tapAction: 'toggle-controls', doubleTapAction: 'zoom', indexIndicatorSep: ' / ', preloaderDelay: 2000, bgOpacity: 0.8, - index: 0, errorMsg: 'The image cannot be loaded', preload: [1, 2], easing: 'cubic-bezier(.4,0,.22,1)' }; +/** + * PhotoSwipe Core + */ class PhotoSwipe extends PhotoSwipeBase { + /** + * @param {PhotoSwipeOptions} [options] + */ constructor(options) { super(); + this.options = this._prepareOptions(options || {}); + /** + * offset of viewport relative to document + * + * @type {Point} + */ - this._prepareOptions(options); + this.offset = { + x: 0, + y: 0 + }; + /** + * @type {Point} + * @private + */ - // offset of viewport relative to document - this.offset = {}; + this._prevViewportSize = { + x: 0, + y: 0 + }; + /** + * Size of scrollable PhotoSwipe viewport + * + * @type {Point} + */ - this._prevViewportSize = {}; + this.viewportSize = { + x: 0, + y: 0 + }; + /** + * background (backdrop) opacity + */ - // Size of scrollable PhotoSwipe viewport - this.viewportSize = {}; - - // background (backdrop) opacity this.bgOpacity = 1; + this.currIndex = 0; + this.potentialIndex = 0; + this.isOpen = false; + this.isDestroying = false; + this.hasMouse = false; + /** + * @private + * @type {SlideData} + */ - this.events = new DOMEvents(); + this._initialItemData = {}; + /** @type {Bounds | undefined} */ - /** @type {Animations} */ - this.animations = new Animations(); + this._initialThumbBounds = undefined; + /** @type {HTMLDivElement | undefined} */ + this.topBar = undefined; + /** @type {HTMLDivElement | undefined} */ + + this.element = undefined; + /** @type {HTMLDivElement | undefined} */ + + this.template = undefined; + /** @type {HTMLDivElement | undefined} */ + + this.container = undefined; + /** @type {HTMLElement | undefined} */ + + this.scrollWrap = undefined; + /** @type {Slide | undefined} */ + + this.currSlide = undefined; + this.events = new DOMEvents(); + this.animations = new Animations(); this.mainScroll = new MainScroll(this); this.gestures = new Gestures(this); this.opener = new Opener(this); this.keyboard = new Keyboard(this); this.contentLoader = new ContentLoader(this); } + /** @returns {boolean} */ + init() { if (this.isOpen || this.isDestroying) { - return; + return false; } this.isOpen = true; this.dispatch('init'); // legacy + this.dispatch('beforeOpen'); - this._createMainStructure(); + this._createMainStructure(); // add classes to the root element of PhotoSwipe - // add classes to the root element of PhotoSwipe + let rootClasses = 'pswp--open'; + if (this.gestures.supportsTouch) { rootClasses += ' pswp--touch'; } + if (this.options.mainClass) { rootClasses += ' ' + this.options.mainClass; } - this.element.className += ' ' + rootClasses; + if (this.element) { + this.element.className += ' ' + rootClasses; + } + this.currIndex = this.options.index || 0; this.potentialIndex = this.currIndex; this.dispatch('firstUpdate'); // starting index can be modified here - // initialize scroll wheel handler to block the scroll - this.scrollWheel = new ScrollWheel(this); - // sanitize index - if (Number.isNaN(this.currIndex) - || this.currIndex < 0 - || this.currIndex >= this.getNumItems()) { + this.scrollWheel = new ScrollWheel(this); // sanitize index + + if (Number.isNaN(this.currIndex) || this.currIndex < 0 || this.currIndex >= this.getNumItems()) { this.currIndex = 0; } if (!this.gestures.supportsTouch) { // enable mouse features if no touch support detected this.mouseDetected(); - } + } // causes forced synchronous layout - // causes forced synchronous layout - this.updateSize(); + this.updateSize(); this.offset.y = window.pageYOffset; - this._initialItemData = this.getItemData(this.currIndex); - this.dispatch('gettingData', this.currIndex, this._initialItemData, true); + this.dispatch('gettingData', { + index: this.currIndex, + data: this._initialItemData, + slide: undefined + }); // *Layout* - calculate size and position of elements here - // *Layout* - calculate size and position of elements here this._initialThumbBounds = this.getThumbBounds(); this.dispatch('initialLayout'); - this.on('openingAnimationEnd', () => { - // Add content to the previous and next slide - this.setContent(this.mainScroll.itemHolders[0], this.currIndex - 1); - this.setContent(this.mainScroll.itemHolders[2], this.currIndex + 1); + const { + itemHolders + } = this.mainScroll; // Add content to the previous and next slide - this.mainScroll.itemHolders[0].el.style.display = 'block'; - this.mainScroll.itemHolders[2].el.style.display = 'block'; + if (itemHolders[0]) { + itemHolders[0].el.style.display = 'block'; + this.setContent(itemHolders[0], this.currIndex - 1); + } - this.appendHeavy(); + if (itemHolders[2]) { + itemHolders[2].el.style.display = 'block'; + this.setContent(itemHolders[2], this.currIndex + 1); + } + this.appendHeavy(); this.contentLoader.updateLazy(); - this.events.add(window, 'resize', this._handlePageResize.bind(this)); this.events.add(window, 'scroll', this._updatePageScrollOffset.bind(this)); this.dispatch('bindEvents'); - }); + }); // set content for center slide (first time) - // set content for center slide (first time) - this.setContent(this.mainScroll.itemHolders[1], this.currIndex); - this.dispatch('change'); + if (this.mainScroll.itemHolders[1]) { + this.setContent(this.mainScroll.itemHolders[1], this.currIndex); + } + this.dispatch('change'); this.opener.open(); - this.dispatch('afterInit'); - return true; } - /** * Get looped slide index * (for example, -1 will return the last slide) * - * @param {Integer} index + * @param {number} index + * @returns {number} */ + + getLoopedIndex(index) { const numSlides = this.getNumItems(); if (this.options.loop) { if (index > numSlides - 1) { @@ -4936,344 +6692,383 @@ if (index < 0) { index += numSlides; } } - index = clamp(index, 0, numSlides - 1); - - return index; + return clamp(index, 0, numSlides - 1); } appendHeavy() { - this.mainScroll.itemHolders.forEach((itemHolder) => { - if (itemHolder.slide) { - itemHolder.slide.appendHeavy(); - } + this.mainScroll.itemHolders.forEach(itemHolder => { + var _itemHolder$slide; + + (_itemHolder$slide = itemHolder.slide) === null || _itemHolder$slide === void 0 || _itemHolder$slide.appendHeavy(); }); } - /** * Change the slide - * @param {Integer} New index + * @param {number} index New index */ + + goTo(index) { - this.mainScroll.moveIndexBy( - this.getLoopedIndex(index) - this.potentialIndex - ); + this.mainScroll.moveIndexBy(this.getLoopedIndex(index) - this.potentialIndex); } - /** * Go to the next slide. */ + + next() { this.goTo(this.potentialIndex + 1); } - /** * Go to the previous slide. */ + + prev() { this.goTo(this.potentialIndex - 1); } - /** * @see slide/slide.js zoomTo + * + * @param {Parameters<Slide['zoomTo']>} args */ + + zoomTo(...args) { - this.currSlide.zoomTo(...args); - } + var _this$currSlide; + (_this$currSlide = this.currSlide) === null || _this$currSlide === void 0 || _this$currSlide.zoomTo(...args); + } /** * @see slide/slide.js toggleZoom */ + + toggleZoom() { - this.currSlide.toggleZoom(); - } + var _this$currSlide2; + (_this$currSlide2 = this.currSlide) === null || _this$currSlide2 === void 0 || _this$currSlide2.toggleZoom(); + } /** * Close the gallery. * After closing transition ends - destroy it */ + + close() { if (!this.opener.isOpen || this.isDestroying) { return; } this.isDestroying = true; - this.dispatch('close'); - this.events.removeAll(); this.opener.close(); } - /** * Destroys the gallery: * - instantly closes the gallery * - unbinds events, * - cleans intervals and timeouts * - removes elements from DOM */ + + destroy() { + var _this$element; + if (!this.isDestroying) { this.options.showHideAnimationType = 'none'; this.close(); return; } this.dispatch('destroy'); + this._listeners = {}; - this.listeners = null; + if (this.scrollWrap) { + this.scrollWrap.ontouchmove = null; + this.scrollWrap.ontouchend = null; + } - this.scrollWrap.ontouchmove = null; - this.scrollWrap.ontouchend = null; + (_this$element = this.element) === null || _this$element === void 0 || _this$element.remove(); + this.mainScroll.itemHolders.forEach(itemHolder => { + var _itemHolder$slide2; - this.element.remove(); - - this.mainScroll.itemHolders.forEach((itemHolder) => { - if (itemHolder.slide) { - itemHolder.slide.destroy(); - } + (_itemHolder$slide2 = itemHolder.slide) === null || _itemHolder$slide2 === void 0 || _itemHolder$slide2.destroy(); }); - this.contentLoader.destroy(); this.events.removeAll(); } - /** * Refresh/reload content of a slide by its index * - * @param {Integer} slideIndex + * @param {number} slideIndex */ + + refreshSlideContent(slideIndex) { this.contentLoader.removeByIndex(slideIndex); this.mainScroll.itemHolders.forEach((itemHolder, i) => { - let potentialHolderIndex = this.currSlide.index - 1 + i; + var _this$currSlide$index, _this$currSlide3; + + let potentialHolderIndex = ((_this$currSlide$index = (_this$currSlide3 = this.currSlide) === null || _this$currSlide3 === void 0 ? void 0 : _this$currSlide3.index) !== null && _this$currSlide$index !== void 0 ? _this$currSlide$index : 0) - 1 + i; + if (this.canLoop()) { potentialHolderIndex = this.getLoopedIndex(potentialHolderIndex); } + if (potentialHolderIndex === slideIndex) { // set the new slide content - this.setContent(itemHolder, slideIndex, true); + this.setContent(itemHolder, slideIndex, true); // activate the new slide if it's current - // activate the new slide if it's current if (i === 1) { + var _itemHolder$slide3; + this.currSlide = itemHolder.slide; - itemHolder.slide.setIsActive(true); + (_itemHolder$slide3 = itemHolder.slide) === null || _itemHolder$slide3 === void 0 || _itemHolder$slide3.setIsActive(true); } } }); - this.dispatch('change'); } - - /** * Set slide content * - * @param {Object} holder mainScroll.itemHolders array item - * @param {Integer} index Slide index - * @param {Boolean} force If content should be set even if index wasn't changed + * @param {ItemHolder} holder mainScroll.itemHolders array item + * @param {number} index Slide index + * @param {boolean} [force] If content should be set even if index wasn't changed */ + + setContent(holder, index, force) { if (this.canLoop()) { index = this.getLoopedIndex(index); } if (holder.slide) { if (holder.slide.index === index && !force) { // exit if holder already contains this slide // this could be common when just three slides are used return; - } + } // destroy previous slide - // destroy previous slide + holder.slide.destroy(); - holder.slide = null; - } + holder.slide = undefined; + } // exit if no loop and index is out of bounds - // exit if no loop and index is out of bounds + if (!this.canLoop() && (index < 0 || index >= this.getNumItems())) { return; } const itemData = this.getItemData(index); - holder.slide = new Slide(itemData, index, this); + holder.slide = new Slide(itemData, index, this); // set current slide - // set current slide if (index === this.currIndex) { this.currSlide = holder.slide; } holder.slide.append(holder.el); } + /** @returns {Point} */ + getViewportCenterPoint() { return { x: this.viewportSize.x / 2, y: this.viewportSize.y / 2 }; } - /** * Update size of all elements. * Executed on init and on page resize. * - * @param {Boolean} force Update size even if size of viewport was not changed. + * @param {boolean} [force] Update size even if size of viewport was not changed. */ + + updateSize(force) { // let item; // let itemIndex; - if (this.isDestroying) { // exit if PhotoSwipe is closed or closing // (to avoid errors, as resize event might be delayed) return; - } - - //const newWidth = this.scrollWrap.clientWidth; + } //const newWidth = this.scrollWrap.clientWidth; //const newHeight = this.scrollWrap.clientHeight; + const newViewportSize = getViewportSize(this.options, this); if (!force && pointsEqual(newViewportSize, this._prevViewportSize)) { // Exit if dimensions were not changed return; - } - - //this._prevViewportSize.x = newWidth; + } //this._prevViewportSize.x = newWidth; //this._prevViewportSize.y = newHeight; - equalizePoints(this._prevViewportSize, newViewportSize); - this.dispatch('beforeResize'); + equalizePoints(this._prevViewportSize, newViewportSize); + this.dispatch('beforeResize'); equalizePoints(this.viewportSize, this._prevViewportSize); this._updatePageScrollOffset(); - this.dispatch('viewportSize'); - - // Resize slides only after opener animation is finished + this.dispatch('viewportSize'); // Resize slides only after opener animation is finished // and don't re-calculate size on inital size update + this.mainScroll.resize(this.opener.isOpen); if (!this.hasMouse && window.matchMedia('(any-hover: hover)').matches) { this.mouseDetected(); } this.dispatch('resize'); } + /** + * @param {number} opacity + */ + applyBgOpacity(opacity) { this.bgOpacity = Math.max(opacity, 0); - this.bg.style.opacity = this.bgOpacity * this.options.bgOpacity; - } + if (this.bg) { + this.bg.style.opacity = String(this.bgOpacity * this.options.bgOpacity); + } + } /** * Whether mouse is detected */ + + mouseDetected() { if (!this.hasMouse) { + var _this$element2; + this.hasMouse = true; - this.element.classList.add('pswp--has_mouse'); + (_this$element2 = this.element) === null || _this$element2 === void 0 || _this$element2.classList.add('pswp--has_mouse'); } } - /** * Page resize event handler + * + * @private */ - _handlePageResize() { - this.updateSize(); - // In iOS webview, if element size depends on document size, + + _handlePageResize() { + this.updateSize(); // In iOS webview, if element size depends on document size, // it'll be measured incorrectly in resize event // // https://bugs.webkit.org/show_bug.cgi?id=170595 // https://hackernoon.com/onresize-event-broken-in-mobile-safari-d8469027bf4d + if (/iPhone|iPad|iPod/i.test(window.navigator.userAgent)) { setTimeout(() => { this.updateSize(); }, 500); } } - /** * Page scroll offset is used * to get correct coordinates * relative to PhotoSwipe viewport. + * + * @private */ + + _updatePageScrollOffset() { this.setScrollOffset(0, window.pageYOffset); } + /** + * @param {number} x + * @param {number} y + */ + setScrollOffset(x, y) { this.offset.x = x; this.offset.y = y; this.dispatch('updateScrollOffset'); } - /** * Create main HTML structure of PhotoSwipe, * and add it to DOM + * + * @private */ + + _createMainStructure() { // root DOM element of PhotoSwipe (.pswp) - this.element = createElement('pswp'); - this.element.setAttribute('tabindex', -1); - this.element.setAttribute('role', 'dialog'); + this.element = createElement('pswp', 'div'); + this.element.setAttribute('tabindex', '-1'); + this.element.setAttribute('role', 'dialog'); // template is legacy prop - // template is legacy prop - this.template = this.element; - - // Background is added as a separate element, + this.template = this.element; // Background is added as a separate element, // as animating opacity is faster than animating rgba() - this.bg = createElement('pswp__bg', false, this.element); - this.scrollWrap = createElement('pswp__scroll-wrap', false, this.element); - this.container = createElement('pswp__container', false, this.scrollWrap); - this.mainScroll.appendHolders(); + this.bg = createElement('pswp__bg', 'div', this.element); + this.scrollWrap = createElement('pswp__scroll-wrap', 'section', this.element); + this.container = createElement('pswp__container', 'div', this.scrollWrap); // aria pattern: carousel + this.scrollWrap.setAttribute('aria-roledescription', 'carousel'); + this.container.setAttribute('aria-live', 'off'); + this.container.setAttribute('id', 'pswp__items'); + this.mainScroll.appendHolders(); this.ui = new UI(this); - this.ui.init(); + this.ui.init(); // append to DOM - // append to DOM (this.options.appendToEl || document.body).appendChild(this.element); } - - /** * Get position and dimensions of small thumbnail * {x:,y:,w:} * * Height is optional (calculated based on the large image) + * + * @returns {Bounds | undefined} */ + + getThumbBounds() { - return getThumbBounds( - this.currIndex, - this.currSlide ? this.currSlide.data : this._initialItemData, - this - ); + return getThumbBounds(this.currIndex, this.currSlide ? this.currSlide.data : this._initialItemData, this); } - /** - * If the PhotoSwipe can have continious loop + * If the PhotoSwipe can have continuous loop * @returns Boolean */ + + canLoop() { - return (this.options.loop && this.getNumItems() > 2); + return this.options.loop && this.getNumItems() > 2; } + /** + * @private + * @param {PhotoSwipeOptions} options + * @returns {PreparedPhotoSwipeOptions} + */ + _prepareOptions(options) { if (window.matchMedia('(prefers-reduced-motion), (update: slow)').matches) { options.showHideAnimationType = 'none'; options.zoomAnimationDuration = 0; } + /** @type {PreparedPhotoSwipeOptions} */ - this.options = { - ...defaultOptions, + + return { ...defaultOptions, ...options }; } + } -export default PhotoSwipe; +export { PhotoSwipe as default }; //# sourceMappingURL=photoswipe.esm.js.map