UNPKG

photoswipe

Version:
1,764 lines (1,501 loc) 145 kB
/*! * PhotoSwipe 5.2.1 - https://photoswipe.com * (c) 2022 Dmytro Semenov */ /** * Creates element and optionally appends it to another. * * @param {String} className * @param {String|NULL} tagName * @param {Element|NULL} appendToEl */ function createElement(className, tagName, appendToEl) { const el = document.createElement(tagName || 'div'); if (className) { el.className = className; } if (appendToEl) { appendToEl.appendChild(el); } return el; } function equalizePoints(p1, p2) { p1.x = p2.x; p1.y = p2.y; if (p2.id !== undefined) { p1.id = p2.id; } return p1; } function roundPoint(p) { p.x = Math.round(p.x); p.y = Math.round(p.y); } /** * Returns distance between two points. * * @param {Object} p1 Point * @param {Object} p2 Point */ function getDistanceBetween(p1, p2) { const x = Math.abs(p1.x - p2.x); const y = Math.abs(p1.y - p2.y); return Math.sqrt((x * x) + (y * y)); } /** * Whether X and Y positions of points are qual * * @param {Object} p1 * @param {Object} p2 */ function pointsEqual(p1, p2) { return p1.x === p2.x && p1.y === p2.y; } /** * The float result between the min and max values. * * @param {Number} val * @param {Number} min * @param {Number} max */ function clamp(val, min, max) { return Math.min(Math.max(val, min), max); } /** * Get transform string * * @param {Number} x * @param {Number|null} y * @param {Number|null} scale */ function toTransformString(x, y, scale) { let propValue = 'translate3d(' + x + 'px,' + (y || 0) + 'px' + ',0)'; if (scale !== undefined) { propValue += ' scale3d(' + scale + ',' + scale + ',1)'; } return propValue; } /** * Apply transform:translate(x, y) scale(scale) to element * * @param {DOMElement} el * @param {Number} x * @param {Number|null} y * @param {Number|null} scale */ function setTransform(el, x, y, scale) { el.style.transform = toTransformString(x, y, scale); } const defaultCSSEasing = 'cubic-bezier(.4,0,.22,1)'; /** * Apply CSS transition to element * * @param {Element} el * @param {String} prop CSS property to animate * @param {Number} duration in ms * @param {String|NULL} ease CSS easing function */ function setTransitionStyle(el, prop, duration, ease) { // inOut: 'cubic-bezier(.4, 0, .22, 1)', // for "toggle state" transitions // out: 'cubic-bezier(0, 0, .22, 1)', // for "show" transitions // in: 'cubic-bezier(.4, 0, 1, 1)'// for "hide" transitions el.style.transition = prop ? (prop + ' ' + duration + 'ms ' + (ease || defaultCSSEasing)) : 'none'; } /** * Apply width and height CSS properties to element */ function setWidthHeight(el, w, h) { el.style.width = (typeof w === 'number') ? (w + 'px') : w; el.style.height = (typeof h === 'number') ? (h + 'px') : h; } function removeTransitionStyle(el) { setTransitionStyle(el); } function decodeImage(img) { if ('decode' in img) { return img.decode(); } if (img.complete) { return Promise.resolve(img); } return new Promise((resolve, reject) => { img.onload = () => resolve(img); img.onerror = reject; }); } const LOAD_STATE = { IDLE: 'idle', LOADING: 'loading', LOADED: 'loaded', ERROR: 'error', }; /** * Check if click or keydown event was dispatched * with a special key or via mouse wheel. * * @param {Event} e */ function specialKeyUsed(e) { if (e.which === 2 || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { return true; } } /** * Parse `gallery` or `children` options. * * @param {Element|NodeList|String} option * @param {String|null} legacySelector * @param {Element|null} parent * @returns Element[] */ function getElementsFromOption(option, legacySelector, parent = document) { let elements = []; if (option instanceof Element) { elements = [option]; } else if (option instanceof NodeList || Array.isArray(option)) { elements = Array.from(option); } else { const selector = typeof option === 'string' ? option : legacySelector; if (selector) { elements = Array.from(parent.querySelectorAll(selector)); } } return elements; } // Detect passive event listener support let supportsPassive = false; /* eslint-disable */ try { window.addEventListener('test', null, Object.defineProperty({}, 'passive', { get: () => { supportsPassive = true; } })); } catch (e) {} /* eslint-enable */ class DOMEvents { constructor() { this._pool = []; } /** * Adds event listeners * * @param {DOMElement} target * @param {String} type Can be multiple, separated by space. * @param {Function} listener * @param {Boolean} passive */ add(target, type, listener, passive) { this._toggleListener(target, type, listener, passive); } /** * Removes event listeners * * @param {DOMElement} target * @param {String} type * @param {Function} listener * @param {Boolean} passive */ remove(target, type, listener, passive) { this._toggleListener(target, type, listener, passive, true); } /** * Removes all bound events */ removeAll() { this._pool.forEach((poolItem) => { this._toggleListener( poolItem.target, poolItem.type, poolItem.listener, poolItem.passive, true, true ); }); this._pool = []; } /** * Adds or removes event * * @param {DOMElement} target * @param {String} type * @param {Function} listener * @param {Boolean} passive * @param {Boolean} unbind Whether the event should be added or removed * @param {Boolean} skipPool Whether events pool should be skipped */ _toggleListener(target, type, listener, passive, unbind, skipPool) { if (!target) { return; } const methodName = (unbind ? 'remove' : 'add') + 'EventListener'; type = type.split(' '); type.forEach((eType) => { if (eType) { // Events pool is used to easily unbind all events when PhotoSwipe is closed, // so developer doesn't need to do this manually if (!skipPool) { if (unbind) { // Remove from the events pool this._pool = this._pool.filter((poolItem) => { return poolItem.type !== eType || poolItem.listener !== listener || poolItem.target !== target; }); } else { // Add to the events pool this._pool.push({ target, type: eType, listener, passive }); } } // most PhotoSwipe events call preventDefault, // and we do not need browser to scroll the page const eventOptions = supportsPassive ? { passive: (passive || false) } : false; target[methodName]( eType, listener, eventOptions ); } }); } } function getViewportSize(options, pswp) { if (options.getViewportSizeFn) { const newViewportSize = options.getViewportSizeFn(options, pswp); if (newViewportSize) { return newViewportSize; } } return { x: document.documentElement.clientWidth, // TODO: height on mobile is very incosistent due to toolbar // find a way to improve this // // document.documentElement.clientHeight - doesn't seem to work well y: window.innerHeight }; } /** * Parses padding option. * Supported formats: * * // Object * padding: { * top: 0, * bottom: 0, * left: 0, * right: 0 * } * * // A function that returns the object * paddingFn: (viewportSize, itemData, index) => { * return { * top: 0, * bottom: 0, * left: 0, * right: 0 * }; * } * * // Legacy variant * paddingLeft: 0, * paddingRight: 0, * paddingTop: 0, * paddingBottom: 0, * * @param {String} prop 'left', 'top', 'bottom', 'right' * @param {Object} options PhotoSwipe options * @param {Object} viewportSize PhotoSwipe viewport size, for example: { x:800, y:600 } * @param {Object} itemData Data about the slide * @param {Integer} index Slide index * @returns {Number} */ function parsePaddingOption(prop, options, viewportSize, itemData, index) { let paddingValue; 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); if (options[legacyPropName]) { paddingValue = options[legacyPropName]; } } return paddingValue || 0; } 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) }; } /** * Calculates minimum, maximum and initial (center) bounds of a slide */ class PanBounds { constructor(slide) { this.slide = slide; this.currZoomLevel = 1; this.center = {}; this.max = {}; this.min = {}; this.reset(); } // _getItemBounds update(currZoomLevel) { this.currZoomLevel = currZoomLevel; if (!this.slide.width) { this.reset(); } else { this._updateAxis('x'); this._updateAxis('y'); this.slide.pswp.dispatch('calcBounds', { slide: this.slide }); } } // _calculateItemBoundsForAxis _updateAxis(axis) { const { pswp } = this.slide; const elSize = this.slide[axis === 'x' ? 'width' : 'height'] * this.currZoomLevel; const paddingProp = axis === 'x' ? 'left' : 'top'; const padding = parsePaddingOption( paddingProp, pswp.options, pswp.viewportSize, this.slide.data, this.slide.index ); const panAreaSize = this.slide.panAreaSize[axis]; // Default position of element. // By defaul it is center of viewport: this.center[axis] = Math.round((panAreaSize - elSize) / 2) + padding; // maximum pan position this.max[axis] = (elSize > panAreaSize) ? Math.round(panAreaSize - elSize) + padding : this.center[axis]; // minimum pan position this.min[axis] = (elSize > panAreaSize) ? padding : this.center[axis]; } // _getZeroBounds reset() { this.center.x = 0; this.center.y = 0; this.max.x = 0; this.max.y = 0; this.min.x = 0; this.min.y = 0; } /** * Correct pan position if it's beyond the bounds * * @param {String} axis x or y * @param {Object} panOffset */ correctPan(axis, panOffset) { // checkPanBounds return clamp(panOffset, this.max[axis], this.min[axis]); } } /** * Calculates zoom levels for specific slide. * Depends on viewport size and image size. */ const MAX_IMAGE_WIDTH = 4000; class ZoomLevel { /** * @param {Object} options PhotoSwipe options * @param {Object} itemData Slide data * @param {Integer} index Slide index * @param {PhotoSwipe|undefined} pswp PhotoSwipe instance, can be undefined if not initialized yet */ constructor(options, itemData, index, pswp) { this.pswp = pswp; this.options = options; this.itemData = itemData; this.index = index; } /** * Calculate initial, secondary and maximum zoom level for the specified slide. * * It should be called when either image or viewport size changes. * * @param {Slide} slide */ update(maxWidth, maxHeight, panAreaSize) { this.elementSize = { x: maxWidth, y: maxHeight }; this.panAreaSize = panAreaSize; const hRatio = this.panAreaSize.x / this.elementSize.x; const vRatio = this.panAreaSize.y / this.elementSize.y; this.fit = Math.min(1, hRatio < vRatio ? hRatio : vRatio); this.fill = Math.min(1, hRatio > vRatio ? hRatio : vRatio); // zoom.vFill defines zoom level of the image // when it has 100% of viewport vertical space (height) this.vFill = Math.min(1, vRatio); this.initial = this._getInitial(); this.secondary = this._getSecondary(); this.max = Math.max( this.initial, this.secondary, this._getMax() ); this.min = Math.min( this.fit, this.initial, this.secondary ); if (this.pswp) { this.pswp.dispatch('zoomLevelsUpdate', { zoomLevels: this, slideData: this.itemData }); } } /** * Parses user-defined zoom option. * * @param {Mixed} optionPrefix Zoom level option prefix (initial, secondary, max) */ _parseZoomLevelOption(optionPrefix) { // zoom.initial // zoom.secondary // zoom.max const optionValue = this.options[optionPrefix + 'ZoomLevel']; if (!optionValue) { return; } if (typeof optionValue === 'function') { return optionValue(this); } if (optionValue === 'fill') { return this.fill; } if (optionValue === 'fit') { return this.fit; } return Number(optionValue); } /** * Get zoom level to which image will be zoomed after double-tap gesture, * or when user clicks on zoom icon, * or mouse-click on image itself. * If you return 1 image will be zoomed to its original size. * * @return {Number} */ _getSecondary() { let currZoomLevel = this._parseZoomLevelOption('secondary'); if (currZoomLevel) { return currZoomLevel; } // 3x of "fit" state, but not larger than original currZoomLevel = Math.min(1, this.fit * 3); if (currZoomLevel * this.elementSize.x > MAX_IMAGE_WIDTH) { currZoomLevel = MAX_IMAGE_WIDTH / this.elementSize.x; } return currZoomLevel; } /** * Get initial image zoom level. * * @return {Number} */ _getInitial() { return this._parseZoomLevelOption('initial') || this.fit; } /** * Maximum zoom level when user zooms * via zoom/pinch gesture, * via cmd/ctrl-wheel or via trackpad. * * @return {Number} */ _getMax() { const currZoomLevel = this._parseZoomLevelOption('max'); if (currZoomLevel) { return currZoomLevel; } // max zoom level is x4 from "fit state", // used for zoom gesture and ctrl/trackpad zoom return Math.max(1, this.fit * 4); } } /** * Renders and allows to control a single slide */ class Slide { constructor(data, index, pswp) { this.data = data; this.index = index; this.pswp = pswp; this.isActive = (index === pswp.currIndex); this.currentResolution = 0; this.panAreaSize = {}; this.isFirstSlide = (this.isActive && !pswp.opener.isOpen); this.zoomLevels = new ZoomLevel(pswp.options, data, index, pswp); this.pswp.dispatch('gettingData', { slide: this, data: this.data, index }); this.pan = { x: 0, y: 0 }; this.content = this.pswp.contentLoader.getContentBySlide(this); this.container = createElement('pswp__zoom-wrap'); this.currZoomLevel = 1; this.width = this.content.width; this.height = this.content.height; this.bounds = new PanBounds(this); this.prevDisplayedWidth = -1; this.prevDisplayedHeight = -1; this.pswp.dispatch('slideInit', { slide: this }); } /** * If this slide is active/current/visible * * @param {Boolean} isActive */ setIsActive(isActive) { if (isActive && !this.isActive) { // slide just became active this.activate(); } else if (!isActive && this.isActive) { // slide just became non-active this.deactivate(); } } /** * Appends slide content to DOM */ append(holderElement) { this.holderElement = holderElement; // Slide appended to DOM if (!this.data) { this.holderElement.innerHTML = ''; return; } this.calculateSize(); this.container.transformOrigin = '0 0'; this.load(); this.appendHeavy(); this.updateContentSize(); this.holderElement.innerHTML = ''; this.holderElement.appendChild(this.container); this.zoomAndPanToInitial(); this.pswp.dispatch('firstZoomPan', { slide: this }); this.applyCurrentZoomPan(); this.pswp.dispatch('afterSetContent', { slide: this }); if (this.isActive) { this.activate(); } } load() { this.content.load(); this.pswp.dispatch('slideLoad', { slide: this }); } /** * Append "heavy" DOM elements * * This may depend on a type of slide, * but generally these are large images. */ appendHeavy() { const { pswp } = this; const appendHeavyNearby = true; // todo // Avoid appending heavy elements during animations if (this.heavyAppended || !pswp.opener.isOpen || pswp.mainScroll.isShifted() || (!this.isActive && !appendHeavyNearby)) { return; } if (this.pswp.dispatch('appendHeavy', { slide: this }).defaultPrevented) { return; } this.heavyAppended = true; this.content.append(); this.pswp.dispatch('appendHeavyContent', { slide: this }); } /** * Triggered when this slide is active (selected). * * If it's part of opening/closing transition - * activate() will trigger after the transition is ended. */ activate() { this.isActive = true; this.appendHeavy(); this.content.activate(); this.pswp.dispatch('slideActivate', { slide: this }); } /** * Triggered when this slide becomes inactive. * * Slide can become inactive only after it was active. */ deactivate() { this.isActive = false; this.content.deactivate(); // reset zoom level this.currentResolution = 0; this.zoomAndPanToInitial(); this.applyCurrentZoomPan(); this.updateContentSize(); this.pswp.dispatch('slideDeactivate', { slide: this }); } /** * The slide should destroy itself, it will never be used again. * (unbind all events and destroy internal components) */ destroy() { this.content.hasSlide = false; this.content.remove(); this.pswp.dispatch('slideDestroy', { slide: this }); } resize() { if (this.currZoomLevel === this.zoomLevels.initial || !this.isActive) { // Keep initial zoom level if it was before the resize, // as well as when this slide is not active // Reset position and scale to original state this.calculateSize(); this.currentResolution = 0; this.zoomAndPanToInitial(); this.applyCurrentZoomPan(); this.updateContentSize(); } else { // readjust pan position if it's beyond the bounds this.calculateSize(); this.bounds.update(this.currZoomLevel); this.panTo(this.pan.x, this.pan.y); } } /** * Apply size to current slide content, * based on the current resolution and scale. * * @param {Boolean} force if size should be updated even if dimensions weren't changed */ updateContentSize(force) { // Use initial zoom level // if resolution is not defined (user didn't zoom yet) const scaleMultiplier = this.currentResolution || this.zoomLevels.initial; if (!scaleMultiplier) { return; } const width = Math.round(this.width * scaleMultiplier) || this.pswp.viewportSize.x; const height = Math.round(this.height * scaleMultiplier) || this.pswp.viewportSize.y; if (!this.sizeChanged(width, height) && !force) { return; } this.content.setDisplayedSize(width, height); } sizeChanged(width, height) { if (width !== this.prevDisplayedWidth || height !== this.prevDisplayedHeight) { this.prevDisplayedWidth = width; this.prevDisplayedHeight = height; return true; } return false; } getPlaceholderElement() { if (this.content.placeholder) { return this.content.placeholder.element; } } /** * Zoom current slide image to... * * @param {Number} destZoomLevel Destination zoom level. * @param {Object|false} centerPoint Transform origin center point, * or false if viewport center should be used. * @param {Number} transitionDuration Transition duration, may be set to 0. * @param {Boolean|null} ignoreBounds Minimum and maximum zoom levels will be ignored. * @return {Boolean|null} Returns true if animated. */ zoomTo(destZoomLevel, centerPoint, transitionDuration, ignoreBounds) { const { pswp } = this; if (!this.isZoomable() || pswp.mainScroll.isShifted()) { return; } pswp.dispatch('beforeZoomTo', { destZoomLevel, centerPoint, transitionDuration }); // stop all pan and zoom transitions pswp.animations.stopAllPan(); // if (!centerPoint) { // centerPoint = pswp.getViewportCenterPoint(); // } const prevZoomLevel = this.currZoomLevel; if (!ignoreBounds) { destZoomLevel = clamp(destZoomLevel, this.zoomLevels.min, this.zoomLevels.max); } // if (transitionDuration === undefined) { // transitionDuration = this.pswp.options.zoomAnimationDuration; // } this.setZoomLevel(destZoomLevel); this.pan.x = this.calculateZoomToPanOffset('x', centerPoint, prevZoomLevel); this.pan.y = this.calculateZoomToPanOffset('y', centerPoint, prevZoomLevel); roundPoint(this.pan); const finishTransition = () => { this._setResolution(destZoomLevel); this.applyCurrentZoomPan(); }; if (!transitionDuration) { finishTransition(); } else { pswp.animations.startTransition({ isPan: true, name: 'zoomTo', target: this.container, transform: this.getCurrentTransform(), onComplete: finishTransition, duration: transitionDuration, easing: pswp.options.easing }); } } toggleZoom(centerPoint) { this.zoomTo( this.currZoomLevel === this.zoomLevels.initial ? this.zoomLevels.secondary : this.zoomLevels.initial, centerPoint, this.pswp.options.zoomAnimationDuration ); } /** * Updates zoom level property and recalculates new pan bounds, * unlike zoomTo it does not apply transform (use applyCurrentZoomPan) * * @param {Number} currZoomLevel */ setZoomLevel(currZoomLevel) { this.currZoomLevel = currZoomLevel; this.bounds.update(this.currZoomLevel); } /** * Get pan position after zoom at a given `point`. * * Always call setZoomLevel(newZoomLevel) beforehand to recalculate * pan bounds according to the new zoom level. * * @param {String} axis * @param {Object|null} centerPoint point based on which zoom is performed, * usually refers to the current mouse position, * if false - viewport center will be used. * @param {Number|null} prevZoomLevel Zoom level before new zoom was applied. */ calculateZoomToPanOffset(axis, point, prevZoomLevel) { const totalPanDistance = this.bounds.max[axis] - this.bounds.min[axis]; if (totalPanDistance === 0) { return this.bounds.center[axis]; } if (!point) { point = this.pswp.getViewportCenterPoint(); } const zoomFactor = this.currZoomLevel / prevZoomLevel; return this.bounds.correctPan( axis, (this.pan[axis] - point[axis]) * zoomFactor + point[axis] ); } /** * Apply pan and keep it within bounds. * * @param {Number} panX * @param {Number} panY */ panTo(panX, panY) { this.pan.x = this.bounds.correctPan('x', panX); this.pan.y = this.bounds.correctPan('y', panY); this.applyCurrentZoomPan(); } /** * If the slide in the current state can be panned by the user */ isPannable() { return this.width && (this.currZoomLevel > this.zoomLevels.fit); } /** * If the slide can be zoomed */ isZoomable() { return this.width && this.content.isZoomable(); } /** * Apply transform and scale based on * the current pan position (this.pan) and zoom level (this.currZoomLevel) */ applyCurrentZoomPan() { this._applyZoomTransform(this.pan.x, this.pan.y, this.currZoomLevel); if (this === this.pswp.currSlide) { this.pswp.dispatch('zoomPanUpdate', { slide: this }); } } zoomAndPanToInitial() { this.currZoomLevel = this.zoomLevels.initial; // pan according to the zoom level this.bounds.update(this.currZoomLevel); equalizePoints(this.pan, this.bounds.center); this.pswp.dispatch('initialZoomPan', { slide: this }); } /** * Set translate and scale based on current resolution * * @param {Number} x * @param {Number} y * @param {Number} zoom */ _applyZoomTransform(x, y, zoom) { zoom /= this.currentResolution || this.zoomLevels.initial; setTransform(this.container, x, y, zoom); } calculateSize() { 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 }); } getCurrentTransform() { const scale = this.currZoomLevel / (this.currentResolution || this.zoomLevels.initial); return toTransformString(this.pan.x, this.pan.y, scale); } /** * Set resolution and re-render the image. * * For example, if the real image size is 2000x1500, * and resolution is 0.5 - it will be rendered as 1000x750. * * Image with zoom level 2 and resolution 0.5 is * the same as image with zoom level 1 and resolution 1. * * Used to optimize animations and make * sure that browser renders image in highest quality. * Also used by responsive images to load the correct one. * * @param {Number} newResolution */ _setResolution(newResolution) { if (newResolution === this.currentResolution) { return; } this.currentResolution = newResolution; this.updateContentSize(); this.pswp.dispatch('resolutionChanged'); } } /** * Handles single pointer dragging */ const PAN_END_FRICTION = 0.35; const VERTICAL_DRAG_FRICTION = 0.6; // 1 corresponds to the third of viewport height const MIN_RATIO_TO_CLOSE = 0.4; // Minimum speed required to navigate // to next or previous slide const MIN_NEXT_SLIDE_SPEED = 0.5; function project(initialVelocity, decelerationRate) { return initialVelocity * decelerationRate / (1 - decelerationRate); } class DragHandler { constructor(gestures) { this.gestures = gestures; this.pswp = gestures.pswp; this.startPan = {}; } start() { equalizePoints(this.startPan, this.pswp.currSlide.pan); this.pswp.animations.stopAll(); } change() { const { p1, prevP1, dragAxis, pswp } = this.gestures; const { currSlide } = pswp; if (dragAxis === 'y' && pswp.options.closeOnVerticalDrag && currSlide.currZoomLevel <= currSlide.zoomLevels.fit && !this.gestures.isMultitouch) { // Handle vertical drag to close const panY = currSlide.pan.y + (p1.y - prevP1.y); if (!pswp.dispatch('verticalDrag', { panY }).defaultPrevented) { this._setPanWithFriction('y', panY, VERTICAL_DRAG_FRICTION); const bgOpacity = 1 - Math.abs(this._getVerticalDragRatio(currSlide.pan.y)); pswp.applyBgOpacity(bgOpacity); currSlide.applyCurrentZoomPan(); } } else { const mainScrollChanged = this._panOrMoveMainScroll('x'); if (!mainScrollChanged) { this._panOrMoveMainScroll('y'); roundPoint(currSlide.pan); currSlide.applyCurrentZoomPan(); } } } end() { const { pswp, velocity } = this.gestures; const { mainScroll } = pswp; let indexDiff = 0; pswp.animations.stopAll(); // Handle main scroll if it's shifted if (mainScroll.isShifted()) { // Position of the main scroll relative to the viewport const mainScrollShiftDiff = mainScroll.x - mainScroll.getCurrSlideX(); // Ratio between 0 and 1: // 0 - slide is not visible at all, // 0.5 - half of the slide is vicible // 1 - slide is fully visible const currentSlideVisibilityRatio = (mainScrollShiftDiff / pswp.viewportSize.x); // Go next slide. // // - if velocity and its direction is matched // and we see at least tiny part of the next slide // // - or if we see less than 50% of the current slide // and velocity is close to 0 // if ((velocity.x < -MIN_NEXT_SLIDE_SPEED && currentSlideVisibilityRatio < 0) || (velocity.x < 0.1 && currentSlideVisibilityRatio < -0.5)) { // Go to next slide indexDiff = 1; velocity.x = Math.min(velocity.x, 0); } else if ((velocity.x > MIN_NEXT_SLIDE_SPEED && currentSlideVisibilityRatio > 0) || (velocity.x > -0.1 && currentSlideVisibilityRatio > 0.5)) { // Go to prev slide indexDiff = -1; velocity.x = Math.max(velocity.x, 0); } mainScroll.moveIndexBy(indexDiff, true, velocity.x); } // Restore zoom level if (pswp.currSlide.currZoomLevel > pswp.currSlide.zoomLevels.max || this.gestures.isMultitouch) { this.gestures.zoomLevels.correctZoomPan(true); } else { // we run two animations instead of one, // as each axis has own pan boundaries and thus different spring function // (correctZoomPan does not have this functionality, // it animates all properties with single timing function) this._finishPanGestureForAxis('x'); this._finishPanGestureForAxis('y'); } } _finishPanGestureForAxis(axis) { const { pswp } = this; const { currSlide } = pswp; const { velocity } = this.gestures; const { pan, bounds } = currSlide; const panPos = pan[axis]; const restoreBgOpacity = (pswp.bgOpacity < 1 && axis === 'y'); // 0.995 means - scroll view loses 0.5% of its velocity per millisecond // Inceasing this number will reduce travel distance const decelerationRate = 0.995; // 0.99 // Pan position if there is no bounds const projectedPosition = panPos + project(velocity[axis], decelerationRate); if (restoreBgOpacity) { const vDragRatio = this._getVerticalDragRatio(panPos); const projectedVDragRatio = this._getVerticalDragRatio(projectedPosition); // If we are above and moving upwards, // or if we are below and moving downwards if ((vDragRatio < 0 && projectedVDragRatio < -MIN_RATIO_TO_CLOSE) || (vDragRatio > 0 && projectedVDragRatio > MIN_RATIO_TO_CLOSE)) { pswp.close(); return; } } // Pan position with corrected bounds const correctedPanPosition = bounds.correctPan(axis, projectedPosition); // Exit if pan position should not be changed // or if speed it too low if (panPos === correctedPanPosition) { return; } // Overshoot if the final position is out of pan bounds const dampingRatio = (correctedPanPosition === projectedPosition) ? 1 : 0.82; const initialBgOpacity = pswp.bgOpacity; const totalPanDist = correctedPanPosition - panPos; pswp.animations.startSpring({ name: 'panGesture' + axis, isPan: true, start: panPos, end: correctedPanPosition, velocity: velocity[axis], dampingRatio, onUpdate: (pos) => { // Animate opacity of background relative to Y pan position of an image if (restoreBgOpacity && pswp.bgOpacity < 1) { // 0 - start of animation, 1 - end of animation const animationProgressRatio = 1 - (correctedPanPosition - pos) / totalPanDist; // We clamp opacity to keep it between 0 and 1. // As progress ratio can be larger than 1 due to overshoot, // and we do not want to bounce opacity. pswp.applyBgOpacity(clamp( initialBgOpacity + (1 - initialBgOpacity) * animationProgressRatio, 0, 1 )); } pan[axis] = Math.floor(pos); currSlide.applyCurrentZoomPan(); }, }); } /** * Update position of the main scroll, * or/and update pan position of the current slide. * * Should return true if it changes (or can change) main scroll. * * @param {String} axis */ _panOrMoveMainScroll(axis) { const { p1, pswp, dragAxis, prevP1, isMultitouch } = this.gestures; const { currSlide, mainScroll } = pswp; const delta = (p1[axis] - prevP1[axis]); const newMainScrollX = mainScroll.x + delta; if (!delta) { return; } // Always move main scroll if image can not be panned if (axis === 'x' && !currSlide.isPannable() && !isMultitouch) { mainScroll.moveTo(newMainScrollX, true); return true; // changed main scroll } const { bounds } = currSlide; const newPan = currSlide.pan[axis] + delta; if (pswp.options.allowPanToNext && dragAxis === 'x' && axis === 'x' && !isMultitouch) { const currSlideMainScrollX = mainScroll.getCurrSlideX(); // Position of the main scroll relative to the viewport const mainScrollShiftDiff = mainScroll.x - currSlideMainScrollX; const isLeftToRight = delta > 0; const isRightToLeft = !isLeftToRight; if (newPan > bounds.min[axis] && isLeftToRight) { // Panning from left to right, beyond the left edge // Wether the image was at minimum pan position (or less) // when this drag gesture started. // Minimum pan position refers to the left edge of the image. const wasAtMinPanPosition = (bounds.min[axis] <= this.startPan[axis]); if (wasAtMinPanPosition) { mainScroll.moveTo(newMainScrollX, true); return true; } else { this._setPanWithFriction(axis, newPan); //currSlide.pan[axis] = newPan; } } else if (newPan < bounds.max[axis] && isRightToLeft) { // Paning from right to left, beyond the right edge // Maximum pan position refers to the right edge of the image. const wasAtMaxPanPosition = (this.startPan[axis] <= bounds.max[axis]); if (wasAtMaxPanPosition) { mainScroll.moveTo(newMainScrollX, true); return true; } else { this._setPanWithFriction(axis, newPan); //currSlide.pan[axis] = newPan; } } else { // If main scroll is shifted if (mainScrollShiftDiff !== 0) { // If main scroll is shifted right if (mainScrollShiftDiff > 0 /*&& isRightToLeft*/) { mainScroll.moveTo(Math.max(newMainScrollX, currSlideMainScrollX), true); return true; } else if (mainScrollShiftDiff < 0 /*&& isLeftToRight*/) { // Main scroll is shifted left (Position is less than 0 comparing to the viewport 0) mainScroll.moveTo(Math.min(newMainScrollX, currSlideMainScrollX), true); return true; } } else { // We are within pan bounds, so just pan this._setPanWithFriction(axis, newPan); } } } else { if (axis === 'y') { // Do not pan vertically if main scroll is shifted o if (!mainScroll.isShifted() && bounds.min.y !== bounds.max.y) { this._setPanWithFriction(axis, newPan); } } else { this._setPanWithFriction(axis, newPan); } } } // // If we move above - the ratio is negative // If we move below the ratio is positive /** * Relation between pan Y position and third of viewport height. * * When we are at initial position (center bounds) - the ratio is 0, * if position is shifted upwards - the ratio is negative, * if position is shifted downwards - the ratio is positive. * * @param {Number} panY The current pan Y position. */ _getVerticalDragRatio(panY) { return (panY - this.pswp.currSlide.bounds.center.y) / (this.pswp.viewportSize.y / 3); } /** * Set pan position of the current slide. * Apply friction if the position is beyond the pan bounds, * or if custom friction is defined. * * @param {String} axis * @param {Number} potentialPan * @param {Number|null} customFriction (0.1 - 1) */ _setPanWithFriction(axis, potentialPan, customFriction) { const { pan, bounds } = this.pswp.currSlide; const correctedPan = bounds.correctPan(axis, potentialPan); // If we are out of pan bounds if (correctedPan !== potentialPan || customFriction) { const delta = Math.round(potentialPan - pan[axis]); pan[axis] += delta * (customFriction || PAN_END_FRICTION); } else { pan[axis] = potentialPan; } } } const UPPER_ZOOM_FRICTION = 0.05; const LOWER_ZOOM_FRICTION = 0.15; /** * Get center point between two points * * @param {Point} p * @param {Point} p1 * @param {Point} p2 */ function getZoomPointsCenter(p, p1, p2) { p.x = (p1.x + p2.x) / 2; p.y = (p1.y + p2.y) / 2; return p; } class ZoomHandler { constructor(gestures) { this.gestures = gestures; this.pswp = this.gestures.pswp; this._startPan = {}; this._startZoomPoint = {}; this._zoomPoint = {}; } start() { this._startZoomLevel = this.pswp.currSlide.currZoomLevel; equalizePoints(this._startPan, this.pswp.currSlide.pan); this.pswp.animations.stopAllPan(); this._wasOverFitZoomLevel = false; } change() { const { p1, startP1, p2, startP2, pswp } = this.gestures; const { currSlide } = pswp; const minZoomLevel = currSlide.zoomLevels.min; const maxZoomLevel = currSlide.zoomLevels.max; if (!currSlide.isZoomable() || pswp.mainScroll.isShifted()) { return; } getZoomPointsCenter(this._startZoomPoint, startP1, startP2); getZoomPointsCenter(this._zoomPoint, p1, p2); let currZoomLevel = (1 / getDistanceBetween(startP1, startP2)) * getDistanceBetween(p1, p2) * this._startZoomLevel; // slightly over the zoom.fit if (currZoomLevel > currSlide.zoomLevels.initial + (currSlide.zoomLevels.initial / 15)) { this._wasOverFitZoomLevel = true; } if (currZoomLevel < minZoomLevel) { if (pswp.options.pinchToClose && !this._wasOverFitZoomLevel && this._startZoomLevel <= currSlide.zoomLevels.initial) { // fade out background if zooming out const bgOpacity = 1 - ((minZoomLevel - currZoomLevel) / (minZoomLevel / 1.2)); if (!pswp.dispatch('pinchClose', { bgOpacity }).defaultPrevented) { pswp.applyBgOpacity(bgOpacity); } } else { // Apply the friction if zoom level is below the min currZoomLevel = minZoomLevel - (minZoomLevel - currZoomLevel) * LOWER_ZOOM_FRICTION; } } else if (currZoomLevel > maxZoomLevel) { // Apply the friction if zoom level is above the max currZoomLevel = maxZoomLevel + (currZoomLevel - maxZoomLevel) * UPPER_ZOOM_FRICTION; } currSlide.pan.x = this._calculatePanForZoomLevel('x', currZoomLevel); currSlide.pan.y = this._calculatePanForZoomLevel('y', currZoomLevel); currSlide.setZoomLevel(currZoomLevel); currSlide.applyCurrentZoomPan(); } end() { const { pswp } = this; const { currSlide } = pswp; if (currSlide.currZoomLevel < currSlide.zoomLevels.initial && !this._wasOverFitZoomLevel && pswp.options.pinchToClose) { pswp.close(); } else { this.correctZoomPan(); } } _calculatePanForZoomLevel(axis, currZoomLevel) { const zoomFactor = currZoomLevel / this._startZoomLevel; return this._zoomPoint[axis] - ((this._startZoomPoint[axis] - this._startPan[axis]) * zoomFactor); } /** * Correct currZoomLevel and pan if they are * beyond minimum or maximum values. * With animation. * * @param {Boolean} ignoreGesture Wether gesture coordinates should be ignored * when calculating destination pan position. */ correctZoomPan(ignoreGesture) { const { pswp } = this; const { currSlide } = pswp; if (!currSlide.isZoomable()) { return; } if (this._zoomPoint.x === undefined) { ignoreGesture = true; } const prevZoomLevel = currSlide.currZoomLevel; let destinationZoomLevel; let currZoomLevelNeedsChange = true; if (prevZoomLevel < currSlide.zoomLevels.initial) { destinationZoomLevel = currSlide.zoomLevels.initial; // zoom to min } else if (prevZoomLevel > currSlide.zoomLevels.max) { destinationZoomLevel = currSlide.zoomLevels.max; // zoom to max } else { currZoomLevelNeedsChange = false; destinationZoomLevel = prevZoomLevel; } const initialBgOpacity = pswp.bgOpacity; const restoreBgOpacity = pswp.bgOpacity < 1; const initialPan = equalizePoints({}, currSlide.pan); let destinationPan = equalizePoints({}, initialPan); if (ignoreGesture) { this._zoomPoint.x = 0; this._zoomPoint.y = 0; this._startZoomPoint.x = 0; this._startZoomPoint.y = 0; this._startZoomLevel = prevZoomLevel; equalizePoints(this._startPan, initialPan); } if (currZoomLevelNeedsChange) { destinationPan = { x: this._calculatePanForZoomLevel('x', destinationZoomLevel), y: this._calculatePanForZoomLevel('y', destinationZoomLevel) }; } // set zoom level, so pan bounds are updated according to it currSlide.setZoomLevel(destinationZoomLevel); destinationPan = { x: currSlide.bounds.correctPan('x', destinationPan.x), y: currSlide.bounds.correctPan('y', destinationPan.y) }; // return zoom level and its bounds to initial currSlide.setZoomLevel(prevZoomLevel); let panNeedsChange = true; if (pointsEqual(destinationPan, initialPan)) { panNeedsChange = false; } if (!panNeedsChange && !currZoomLevelNeedsChange && !restoreBgOpacity) { // update resolution after gesture currSlide._setResolution(destinationZoomLevel); currSlide.applyCurrentZoomPan(); // nothing to animate return; } pswp.animations.stopAllPan(); pswp.animations.startSpring({ isPan: true, start: 0, end: 1000, velocity: 0, dampingRatio: 1, naturalFrequency: 40, onUpdate: (now) => { now /= 1000; // 0 - start, 1 - end if (panNeedsChange || currZoomLevelNeedsChange) { if (panNeedsChange) { currSlide.pan.x = initialPan.x + (destinationPan.x - initialPan.x) * now; currSlide.pan.y = initialPan.y + (destinationPan.y - initialPan.y) * now; } if (currZoomLevelNeedsChange) { const newZoomLevel = prevZoomLevel + (destinationZoomLevel - prevZoomLevel) * now; currSlide.setZoomLevel(newZoomLevel); } currSlide.applyCurrentZoomPan(); } // Restore background opacity if (restoreBgOpacity && pswp.bgOpacity < 1) { // We clamp opacity to keep it between 0 and 1. // As progress ratio can be larger than 1 due to overshoot, // and we do not want to bounce opacity. pswp.applyBgOpacity(clamp( initialBgOpacity + (1 - initialBgOpacity) * now, 0, 1 )); } }, onComplete: () => { // update resolution after transition ends currSlide._setResolution(destinationZoomLevel); currSlide.applyCurrentZoomPan(); } }); } } /** * Tap, double-tap handler. */ /** * Whether the tap was performed on the main slide * (rather than controls or caption). * * @param {Event} event */ function didTapOnMainContent(event) { return !!(event.target.closest('.pswp__container')); } class TapHandler { constructor(gestures) { this.gestures = gestures; } click(point, originalEvent) { const targetClassList = originalEvent.target.classList; const isImageClick = targetClassList.contains('pswp__img'); const isBackgroundClick = targetClassList.contains('pswp__item') || targetClassList.contains('pswp__zoom-wrap'); if (isImageClick) { this._doClickOrTapAction('imageClick', point, originalEvent); } else if (isBackgroundClick) { this._doClickOrTapAction('bgClick', point, originalEvent); } } tap(point, originalEvent) { if (didTapOnMainContent(originalEvent)) { this._doClickOrTapAction('tap', point, originalEvent); } } doubleTap(point, originalEvent) { if (didTapOnMainContent(originalEvent)) { this._doClickOrTapAction('doubleTap', point, originalEvent); } } _doClickOrTapAction(actionName, point, originalEvent) { const { pswp } = this.gestures; const { currSlide } = pswp; const optionValue = pswp.options[actionName + 'Action']; if (pswp.dispatch(actionName + 'Action', { point, originalEvent }).defaultPrevented) { return; } if (typeof optionValue === 'function') { optionValue.call(pswp, point, originalEvent); return; } switch (optionValue) { case 'close': case 'next': pswp[optionValue](); break; case 'zoom': currSlide.toggleZoom(point); break; case 'zoom-or-close': // by default click zooms current image, // if it can not be zoomed - gallery will be closed if (currSlide.isZoomable() && currSlide.zoomLevels.secondary !== currSlide.zoomLevels.initial) { currSlide.toggleZoom(point); } else if (pswp.options.clickToCloseNonZoomable) { pswp.close(); } break; case 'toggle-controls': this.gestures.pswp.element.classList.toggle('pswp--ui-visible'); // if (_controlsVisible) { // _ui.hideControls(); // } else { // _ui.showControls(); // } break; } } } /** * Gestures class bind touch, pointer or mouse events * and emits drag to drag-handler and zoom events zoom-handler. * * Drag and zoom events are emited in requestAnimationFrame, * and only when one of pointers was actually changed. */ // How far should user should drag // until we can determine that the gesture is swipe and its direction const AXIS_SWIPE_HYSTERISIS = 10; //const PAN_END_FRICTION = 0.35; const DOUBLE_TAP_DELAY = 300; // ms const MIN_TAP_DISTANCE = 25; // px class Gestures { constructor(pswp) { this.pswp = pswp; // point objects are defined once and reused // PhotoSwipe keeps track only of two pointers, others are ignored this.p1 = {}; // the first pressed pointer this.p2 = {}; // the second pressed pointer this.prevP1 = {}; this.prevP2 = {}; this.startP1 = {}; this.startP2 = {}; this.velocity = {}; this._lastStartP1 = {}; this._intervalP1 = {}; this._numActivePoints = 0; this._ongoingPointers = []; this._touchEventEnabled = 'ontouchstart' in window; this._pointerEventEnabled = !!(window.PointerEvent); this.supportsTouch = this._touchEventEnabled || (this._pointerEventEnabled && navigator.maxTouchPoints > 1); if (!this.supportsTouch) { // disable pan to next slide for non-touch devices pswp.options.allowPanToNext = false; } this.drag = new DragHandler(this); this.zoomLevels = new ZoomHandler(this); this.tapHandler = new TapHandler(this); pswp.on('bindEvents', () => { pswp.events.add(pswp.scrollWrap, 'click', e => this._onClick(e)); if (this._pointerEventEnabled) { this._bindEvents('pointer', 'down', 'up', 'cancel'); } else if (this._touchEventEnabled) { this._bindEvents('touch', 'start', 'end', 'cancel'); // In previous versions we also bound mouse event here, // in case device supports both touch and mouse events,