UNPKG

photoswipe

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