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} Type */ /** @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 */ /** @typedef {import('./slide/get-thumb-bounds').Bounds} Bounds */ /** * @template {keyof PhotoSwipeEventsMap} T * @typedef {import('./core/eventable.js').EventCallback} EventCallback */ /** * @template {keyof PhotoSwipeEventsMap} T * @typedef {import('./core/eventable.js').AugmentedEvent} AugmentedEvent */ /** @typedef {{ x: number; y: number; id?: string | number }} Point */ /** @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 | { default: Type }} PhotoSwipeModule */ /** @typedef {PhotoSwipeModule | Promise | (() => Promise)} PhotoSwipeModuleOption */ /** * @typedef {string | NodeListOf | HTMLElement[] | HTMLElement} ElementProvider */ /** @typedef {Partial} PhotoSwipeOptions https://photoswipe.com/options/ */ /** * @typedef {Object} PreparedPhotoSwipeOptions * * @prop {DataSource} [dataSource] * Pass an array of any items via dataSource option. Its length will determine amount of slides * (which may be modified further from numItems event). * * Each item should contain data that you need to generate slide * (for image slide it would be src (image URL), width (image width), height, srcset, alt). * * If these properties are not present in your initial array, you may "pre-parse" each item from itemData filter. * * @prop {number} bgOpacity * Background backdrop opacity, always define it via this option and not via CSS rgba color. * * @prop {number} spacing * Spacing between slides. Defined as ratio relative to the viewport width (0.1 = 10% of viewport). * * @prop {boolean} allowPanToNext * Allow swipe navigation to the next slide when the current slide is zoomed. Does not apply to mouse events. * * @prop {boolean} loop * If set to true you'll be able to swipe from the last to the first image. * Option is always false when there are less than 3 slides. * * @prop {boolean} [wheelToZoom] * By default PhotoSwipe zooms image with ctrl-wheel, if you enable this option - image will zoom just via wheel. * * @prop {boolean} pinchToClose * Pinch touch gesture to close the gallery. * * @prop {boolean} closeOnVerticalDrag * Vertical drag gesture to close the PhotoSwipe. * * @prop {Padding} [padding] * Slide area padding (in pixels). * * @prop {(viewportSize: Point, itemData: SlideData, index: number) => Padding} [paddingFn] * The option is checked frequently, so make sure it's performant. Overrides padding option if defined. For example: * * @prop {number | false} hideAnimationDuration * Transition duration in milliseconds, can be 0. * * @prop {number | false} showAnimationDuration * Transition duration in milliseconds, can be 0. * * @prop {number | false} zoomAnimationDuration * Transition duration in milliseconds, can be 0. * * @prop {string} easing * String, 'cubic-bezier(.4,0,.22,1)'. CSS easing function for open/close/zoom transitions. * * @prop {boolean} escKey * Esc key to close. * * @prop {boolean} arrowKeys * Left/right arrow keys for navigation. * * @prop {boolean} trapFocus * Trap focus within PhotoSwipe element while it's open. * * @prop {boolean} returnFocus * Restore focus the last active element after PhotoSwipe is closed. * * @prop {boolean} clickToCloseNonZoomable * If image is not zoomable (for example, smaller than viewport) it can be closed by clicking on it. * * @prop {ActionType | ActionFn | false} imageClickAction * Refer to click and tap actions page. * * @prop {ActionType | ActionFn | false} bgClickAction * Refer to click and tap actions page. * * @prop {ActionType | ActionFn | false} tapAction * Refer to click and tap actions page. * * @prop {ActionType | ActionFn | false} doubleTapAction * Refer to click and tap actions page. * * @prop {number} preloaderDelay * Delay before the loading indicator will be displayed, * if image is loaded during it - the indicator will not be displayed at all. Can be zero. * * @prop {string} indexIndicatorSep * Used for slide count indicator ("1 of 10 "). * * @prop {(options: PhotoSwipeOptions, pswp: PhotoSwipeBase) => Point} [getViewportSizeFn] * A function that should return slide viewport width and height, in format {x: 100, y: 100}. * * @prop {string} errorMsg * Message to display when the image wasn't able to load. If you need to display HTML - use contentErrorElement filter. * * @prop {[number, number]} preload * Lazy loading of nearby slides based on direction of movement. Should be an array with two integers, * first one - number of items to preload before the current image, second one - after the current image. * Two nearby images are always loaded. * * @prop {string} [mainClass] * Class that will be added to the root element of PhotoSwipe, may contain multiple separated by space. * Example on Styling page. * * @prop {HTMLElement} [appendToEl] * Element to which PhotoSwipe dialog will be appended when it opens. * * @prop {number} maxWidthToAnimate * Maximum width of image to animate, if initial rendered image width * is larger than this value - the opening/closing transition will be automatically disabled. * * @prop {string} [closeTitle] * Translating * * @prop {string} [zoomTitle] * Translating * * @prop {string} [arrowPrevTitle] * Translating * * @prop {string} [arrowNextTitle] * Translating * * @prop {'zoom' | 'fade' | 'none'} [showHideAnimationType] * To adjust opening or closing transition type use lightbox option `showHideAnimationType` (`String`). * It supports three values - `zoom` (default), `fade` (default if there is no thumbnail) and `none`. * * Animations are automatically disabled if user `(prefers-reduced-motion: reduce)`. * * @prop {number} index * Defines start slide index. * * @prop {(e: MouseEvent) => number} [getClickedIndexFn] * * @prop {boolean} [arrowPrev] * @prop {boolean} [arrowNext] * @prop {boolean} [zoom] * @prop {boolean} [close] * @prop {boolean} [counter] * * @prop {string} [arrowPrevSVG] * @prop {string} [arrowNextSVG] * @prop {string} [zoomSVG] * @prop {string} [closeSVG] * @prop {string} [counterSVG] * * @prop {string} [arrowPrevTitle] * @prop {string} [arrowNextTitle] * @prop {string} [zoomTitle] * @prop {string} [closeTitle] * @prop {string} [counterTitle] * * @prop {ZoomLevelOption} [initialZoomLevel] * @prop {ZoomLevelOption} [secondaryZoomLevel] * @prop {ZoomLevelOption} [maxZoomLevel] * * @prop {boolean} [mouseMovePan] * @prop {Point | null} [initialPointerPos] * @prop {boolean} [showHideOpacity] * * @prop {PhotoSwipeModuleOption} [pswpModule] * @prop {() => Promise} [openPromise] * @prop {boolean} [preloadFirstSlide] * @prop {ElementProvider} [gallery] * @prop {string} [gallerySelector] * @prop {ElementProvider} [children] * @prop {string} [childSelector] * @prop {string | false} [thumbSelector] */ /** @type {PreparedPhotoSwipeOptions} */ const defaultOptions = { allowPanToNext: true, spacing: 0.1, loop: true, pinchToClose: true, closeOnVerticalDrag: true, hideAnimationDuration: 333, showAnimationDuration: 333, zoomAnimationDuration: 333, escKey: true, arrowKeys: true, trapFocus: true, returnFocus: true, maxWidthToAnimate: 4000, clickToCloseNonZoomable: true, imageClickAction: 'zoom-or-close', bgClickAction: 'close', tapAction: 'toggle-controls', doubleTapAction: 'zoom', indexIndicatorSep: ' / ', preloaderDelay: 2000, bgOpacity: 0.8, index: 0, errorMsg: 'The image cannot be loaded', preload: [1, 2], easing: 'cubic-bezier(.4,0,.22,1)' }; /** * PhotoSwipe Core */ class PhotoSwipe extends PhotoSwipeBase { /** * @param {PhotoSwipeOptions} [options] */ constructor(options) { super(); this.options = this._prepareOptions(options || {}); /** * offset of viewport relative to document * * @type {Point} */ this.offset = { x: 0, y: 0 }; /** * @type {Point} * @private */ this._prevViewportSize = { x: 0, y: 0 }; /** * Size of scrollable PhotoSwipe viewport * * @type {Point} */ this.viewportSize = { x: 0, y: 0 }; /** * background (backdrop) opacity */ this.bgOpacity = 1; this.currIndex = 0; this.potentialIndex = 0; this.isOpen = false; this.isDestroying = false; this.hasMouse = false; /** * @private * @type {SlideData} */ this._initialItemData = {}; /** @type {Bounds | undefined} */ this._initialThumbBounds = undefined; /** @type {HTMLDivElement | undefined} */ this.topBar = undefined; /** @type {HTMLDivElement | undefined} */ this.element = undefined; /** @type {HTMLDivElement | undefined} */ this.template = undefined; /** @type {HTMLDivElement | undefined} */ this.container = undefined; /** @type {HTMLElement | undefined} */ this.scrollWrap = undefined; /** @type {Slide | undefined} */ this.currSlide = undefined; this.events = new DOMEvents(); this.animations = new Animations(); this.mainScroll = new MainScroll(this); this.gestures = new Gestures(this); this.opener = new Opener(this); this.keyboard = new Keyboard(this); this.contentLoader = new ContentLoader(this); } /** @returns {boolean} */ init() { if (this.isOpen || this.isDestroying) { return false; } 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; } if (this.element) { this.element.className += ' ' + rootClasses; } this.currIndex = this.options.index || 0; this.potentialIndex = this.currIndex; this.dispatch('firstUpdate'); // starting index can be modified here // initialize scroll wheel handler to block the scroll this.scrollWheel = new ScrollWheel(this); // sanitize index if (Number.isNaN(this.currIndex) || this.currIndex < 0 || this.currIndex >= this.getNumItems()) { this.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', () => { const { itemHolders } = this.mainScroll; // Add content to the previous and next slide if (itemHolders[0]) { itemHolders[0].el.style.display = 'block'; this.setContent(itemHolders[0], this.currIndex - 1); } if (itemHolders[2]) { itemHolders[2].el.style.display = 'block'; this.setContent(itemHolders[2], this.currIndex + 1); } this.appendHeavy(); this.contentLoader.updateLazy(); this.events.add(window, 'resize', this._handlePageResize.bind(this)); this.events.add(window, 'scroll', this._updatePageScrollOffset.bind(this)); this.dispatch('bindEvents'); }); // set content for center slide (first time) if (this.mainScroll.itemHolders[1]) { this.setContent(this.mainScroll.itemHolders[1], this.currIndex); } this.dispatch('change'); this.opener.open(); this.dispatch('afterInit'); return true; } /** * Get looped slide index * (for example, -1 will return the last slide) * * @param {number} index * @returns {number} */ getLoopedIndex(index) { const numSlides = this.getNumItems(); if (this.options.loop) { if (index > numSlides - 1) { index -= numSlides; } if (index < 0) { index += numSlides; } } return clamp(index, 0, numSlides - 1); } appendHeavy() { this.mainScroll.itemHolders.forEach((itemHolder) => { 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} 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 = {}; if (this.scrollWrap) { this.scrollWrap.ontouchmove = null; this.scrollWrap.ontouchend = null; } this.element?.remove(); this.mainScroll.itemHolders.forEach((itemHolder) => { 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 ?? 0) - 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) { 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 = undefined; } // 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); } /** @returns {Point} */ getViewportCenterPoint() { return { x: this.viewportSize.x / 2, y: this.viewportSize.y / 2 }; } /** * Update size of all elements. * Executed on init and on page resize. * * @param {boolean} [force] Update size even if size of viewport was not changed. */ 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); if (this.bg) { 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', 'div'); 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', 'div', this.element); this.scrollWrap = createElement('pswp__scroll-wrap', 'section', this.element); this.container = createElement('pswp__container', 'div', this.scrollWrap); // aria pattern: carousel this.scrollWrap.setAttribute('aria-roledescription', 'carousel'); this.container.setAttribute('aria-live', 'off'); this.container.setAttribute('id', 'pswp__items'); this.mainScroll.appendHolders(); this.ui = new UI(this); this.ui.init(); // append to DOM (this.options.appendToEl || document.body).appendChild(this.element); } /** * Get position and dimensions of small thumbnail * {x:,y:,w:} * * Height is optional (calculated based on the large image) * * @returns {Bounds | undefined} */ getThumbBounds() { return getThumbBounds( this.currIndex, this.currSlide ? this.currSlide.data : this._initialItemData, this ); } /** * If the PhotoSwipe can have continuous loop * @returns Boolean */ canLoop() { return (this.options.loop && this.getNumItems() > 2); } /** * @private * @param {PhotoSwipeOptions} options * @returns {PreparedPhotoSwipeOptions} */ _prepareOptions(options) { if (window.matchMedia('(prefers-reduced-motion), (update: slow)').matches) { options.showHideAnimationType = 'none'; options.zoomAnimationDuration = 0; } /** @type {PreparedPhotoSwipeOptions} */ return { ...defaultOptions, ...options }; } } export default PhotoSwipe;