photoswipe
Version:
JavaScript gallery
772 lines (665 loc) • 22.1 kB
JavaScript
import {
createElement,
equalizePoints,
pointsEqual,
clamp,
} from './util/util.js';
import DOMEvents from './util/dom-events.js';
import Slide from './slide/slide.js';
import Gestures from './gestures/gestures.js';
import MainScroll from './main-scroll.js';
import Keyboard from './keyboard.js';
import Animations from './util/animations.js';
import ScrollWheel from './scroll-wheel.js';
import UI from './ui/ui.js';
import { getViewportSize } from './util/viewport-size.js';
import { getThumbBounds } from './slide/get-thumb-bounds.js';
import PhotoSwipeBase from './core/base.js';
import Opener from './opener.js';
import ContentLoader from './slide/loader.js';
/**
* @template T
* @typedef {import('./types.js').Type<T>} Type<T>
*/
/** @typedef {import('./slide/slide.js').SlideData} SlideData */
/** @typedef {import('./slide/zoom-level.js').ZoomLevelOption} ZoomLevelOption */
/** @typedef {import('./ui/ui-element.js').UIElementData} UIElementData */
/** @typedef {import('./main-scroll.js').ItemHolder} ItemHolder */
/** @typedef {import('./core/eventable.js').PhotoSwipeEventsMap} PhotoSwipeEventsMap */
/** @typedef {import('./core/eventable.js').PhotoSwipeFiltersMap} PhotoSwipeFiltersMap */
/**
* @template T
* @typedef {import('./core/eventable.js').EventCallback<T>} EventCallback<T>
*/
/**
* @template T
* @typedef {import('./core/eventable.js').AugmentedEvent<T>} AugmentedEvent<T>
*/
/** @typedef {{ x?: number; y?: number; id?: string | number }} Point */
/** @typedef {{ x?: number; y?: number }} Size */
/** @typedef {{ top: number; bottom: number; left: number; right: number }} Padding */
/** @typedef {SlideData[]} DataSourceArray */
/** @typedef {{ gallery: HTMLElement; items?: HTMLElement[] }} DataSourceObject */
/** @typedef {DataSourceArray | DataSourceObject} DataSource */
/** @typedef {(point: Point, originalEvent: PointerEvent) => void} ActionFn */
/** @typedef {'close' | 'next' | 'zoom' | 'zoom-or-close' | 'toggle-controls'} ActionType */
/** @typedef {Type<PhotoSwipe> | { default: Type<PhotoSwipe> }} PhotoSwipeModule */
/** @typedef {PhotoSwipeModule | Promise<PhotoSwipeModule> | (() => Promise<PhotoSwipeModule>)} PhotoSwipeModuleOption */
/**
* @typedef {string | NodeListOf<HTMLElement> | HTMLElement[] | HTMLElement} ElementProvider
*/
/**
* @typedef {Object} PhotoSwipeOptions https://photoswipe.com/options/
*
* @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: Size, 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=} 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: PhotoSwipe) => { x: number; y: number }} [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]
*/
/** @type {PhotoSwipeOptions} */
const defaultOptions = {
allowPanToNext: true,
spacing: 0.1,
loop: true,
pinchToClose: true,
closeOnVerticalDrag: true,
hideAnimationDuration: 333,
showAnimationDuration: 333,
zoomAnimationDuration: 333,
escKey: true,
arrowKeys: 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._prepareOptions(options);
/**
* offset of viewport relative to document
*
* @type {{ x?: number; y?: number }}
*/
this.offset = {};
/**
* @type {{ x?: number; y?: number }}
* @private
*/
this._prevViewportSize = {};
/**
* Size of scrollable PhotoSwipe viewport
*
* @type {{ x?: number; y?: number }}
*/
this.viewportSize = {};
/**
* background (backdrop) opacity
*
* @type {number}
*/
this.bgOpacity = 1;
/** @type {HTMLDivElement} */
this.topBar = undefined;
this.events = new DOMEvents();
/** @type {Animations} */
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);
}
init() {
if (this.isOpen || this.isDestroying) {
return;
}
this.isOpen = true;
this.dispatch('init'); // legacy
this.dispatch('beforeOpen');
this._createMainStructure();
// 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;
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.currIndex = 0;
}
if (!this.gestures.supportsTouch) {
// enable mouse features if no touch support detected
this.mouseDetected();
}
// causes forced synchronous layout
this.updateSize();
this.offset.y = window.pageYOffset;
this._initialItemData = this.getItemData(this.currIndex);
this.dispatch('gettingData', {
index: this.currIndex,
data: this._initialItemData,
slide: undefined
});
// *Layout* - calculate size and position of elements here
this._initialThumbBounds = this.getThumbBounds();
this.dispatch('initialLayout');
this.on('openingAnimationEnd', () => {
this.mainScroll.itemHolders[0].el.style.display = 'block';
this.mainScroll.itemHolders[2].el.style.display = 'block';
// 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);
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)
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 {number} index
*/
getLoopedIndex(index) {
const numSlides = this.getNumItems();
if (this.options.loop) {
if (index > numSlides - 1) {
index -= numSlides;
}
if (index < 0) {
index += numSlides;
}
}
index = clamp(index, 0, numSlides - 1);
return index;
}
appendHeavy() {
this.mainScroll.itemHolders.forEach((itemHolder) => {
if (itemHolder.slide) {
itemHolder.slide.appendHeavy();
}
});
}
/**
* Change the slide
* @param {number} index New index
*/
goTo(index) {
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);
}
/**
* @see slide/slide.js toggleZoom
*/
toggleZoom() {
this.currSlide.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() {
if (!this.isDestroying) {
this.options.showHideAnimationType = 'none';
this.close();
return;
}
this.dispatch('destroy');
this.listeners = null;
this.scrollWrap.ontouchmove = null;
this.scrollWrap.ontouchend = null;
this.element.remove();
this.mainScroll.itemHolders.forEach((itemHolder) => {
if (itemHolder.slide) {
itemHolder.slide.destroy();
}
});
this.contentLoader.destroy();
this.events.removeAll();
}
/**
* Refresh/reload content of a slide by its index
*
* @param {number} slideIndex
*/
refreshSlideContent(slideIndex) {
this.contentLoader.removeByIndex(slideIndex);
this.mainScroll.itemHolders.forEach((itemHolder, i) => {
let potentialHolderIndex = this.currSlide.index - 1 + i;
if (this.canLoop()) {
potentialHolderIndex = this.getLoopedIndex(potentialHolderIndex);
}
if (potentialHolderIndex === slideIndex) {
// set the new slide content
this.setContent(itemHolder, slideIndex, true);
// activate the new slide if it's current
if (i === 1) {
/** @type {Slide} */
this.currSlide = itemHolder.slide;
itemHolder.slide.setIsActive(true);
}
}
});
this.dispatch('change');
}
/**
* Set slide content
*
* @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
holder.slide.destroy();
holder.slide = null;
}
// 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);
// set current slide
if (index === this.currIndex) {
this.currSlide = holder.slide;
}
holder.slide.append(holder.el);
}
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.
*/
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 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.y = newHeight;
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
// 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 = String(this.bgOpacity * this.options.bgOpacity);
}
/**
* Whether mouse is detected
*/
mouseDetected() {
if (!this.hasMouse) {
this.hasMouse = true;
this.element.classList.add('pswp--has_mouse');
}
}
/**
* Page resize event handler
*
* @private
*/
_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');
// template is legacy prop
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', 'section', this.element);
this.container = createElement('pswp__container', false, 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();
// 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)
*/
getThumbBounds() {
return getThumbBounds(
this.currIndex,
this.currSlide ? this.currSlide.data : this._initialItemData,
this
);
}
/**
* If the PhotoSwipe can have continious loop
* @returns Boolean
*/
canLoop() {
return (this.options.loop && this.getNumItems() > 2);
}
/**
* @param {PhotoSwipeOptions} options
* @private
*/
_prepareOptions(options) {
if (window.matchMedia('(prefers-reduced-motion), (update: slow)').matches) {
options.showHideAnimationType = 'none';
options.zoomAnimationDuration = 0;
}
/** @type {PhotoSwipeOptions}*/
this.options = {
...defaultOptions,
...options
};
}
}
export default PhotoSwipe;