UNPKG

photoswipe

Version:
511 lines (435 loc) 13.6 kB
import { createElement, isSafari, LOAD_STATE, setWidthHeight } from '../util/util.js'; import Placeholder from './placeholder.js'; /** @typedef {import('./slide.js').default} Slide */ /** @typedef {import('./slide.js').SlideData} SlideData */ /** @typedef {import('../photoswipe.js').default} PhotoSwipe */ /** @typedef {import('../util/util.js').LoadState} LoadState */ class Content { /** * @param {SlideData} itemData Slide data * @param {PhotoSwipe} instance PhotoSwipe or PhotoSwipeLightbox instance * @param {number} index */ constructor(itemData, instance, index) { this.instance = instance; this.data = itemData; this.index = index; /** @type {HTMLImageElement | HTMLDivElement} */ this.element = undefined; this.displayedImageWidth = 0; this.displayedImageHeight = 0; this.width = Number(this.data.w) || Number(this.data.width) || 0; this.height = Number(this.data.h) || Number(this.data.height) || 0; this.isAttached = false; this.hasSlide = false; /** @type {LoadState} */ this.state = LOAD_STATE.IDLE; if (this.data.type) { this.type = this.data.type; } else if (this.data.src) { this.type = 'image'; } else { this.type = 'html'; } this.instance.dispatch('contentInit', { content: this }); } removePlaceholder() { if (this.placeholder && !this.keepPlaceholder()) { // With delay, as image might be loaded, but not rendered setTimeout(() => { if (this.placeholder) { this.placeholder.destroy(); this.placeholder = null; } }, 1000); } } /** * Preload content * * @param {boolean=} isLazy * @param {boolean=} reload */ load(isLazy, reload) { if (this.slide && this.usePlaceholder()) { if (!this.placeholder) { const placeholderSrc = this.instance.applyFilters( 'placeholderSrc', // use image-based placeholder only for the first slide, // as rendering (even small stretched thumbnail) is an expensive operation (this.data.msrc && this.slide.isFirstSlide) ? this.data.msrc : false, this ); this.placeholder = new Placeholder( placeholderSrc, this.slide.container ); } else { const placeholderEl = this.placeholder.element; // Add placeholder to DOM if it was already created if (placeholderEl && !placeholderEl.parentElement) { this.slide.container.prepend(placeholderEl); } } } if (this.element && !reload) { return; } if (this.instance.dispatch('contentLoad', { content: this, isLazy }).defaultPrevented) { return; } if (this.isImageContent()) { this.element = createElement('pswp__img', 'img'); // Start loading only after width is defined, as sizes might depend on it. // Due to Safari feature, we must define sizes before srcset. if (this.displayedImageWidth) { this.loadImage(isLazy); } } else { this.element = createElement('pswp__content'); this.element.innerHTML = this.data.html || ''; } if (reload && this.slide) { this.slide.updateContentSize(true); } } /** * Preload image * * @param {boolean} isLazy */ loadImage(isLazy) { const imageElement = /** @type HTMLImageElement */ (this.element); if (this.instance.dispatch('contentLoadImage', { content: this, isLazy }).defaultPrevented) { return; } this.updateSrcsetSizes(); if (this.data.srcset) { imageElement.srcset = this.data.srcset; } imageElement.src = this.data.src; imageElement.alt = this.data.alt || ''; this.state = LOAD_STATE.LOADING; if (imageElement.complete) { this.onLoaded(); } else { imageElement.onload = () => { this.onLoaded(); }; imageElement.onerror = () => { this.onError(); }; } } /** * Assign slide to content * * @param {Slide} slide */ setSlide(slide) { this.slide = slide; this.hasSlide = true; this.instance = slide.pswp; // todo: do we need to unset slide? } /** * Content load success handler */ onLoaded() { this.state = LOAD_STATE.LOADED; if (this.slide) { this.instance.dispatch('loadComplete', { slide: this.slide, content: this }); // if content is reloaded if (this.slide.isActive && this.slide.heavyAppended && !this.element.parentNode) { this.append(); this.slide.updateContentSize(true); } if (this.state === LOAD_STATE.LOADED || this.state === LOAD_STATE.ERROR) { this.removePlaceholder(); } } } /** * Content load error handler */ onError() { this.state = LOAD_STATE.ERROR; if (this.slide) { this.displayError(); this.instance.dispatch('loadComplete', { slide: this.slide, isError: true, content: this }); this.instance.dispatch('loadError', { slide: this.slide, content: this }); } } /** * @returns {Boolean} If the content is currently loading */ isLoading() { return this.instance.applyFilters( 'isContentLoading', this.state === LOAD_STATE.LOADING, this ); } isError() { return this.state === LOAD_STATE.ERROR; } /** * @returns {boolean} If the content is image */ isImageContent() { return this.type === 'image'; } /** * Update content size * * @param {Number} width * @param {Number} height */ setDisplayedSize(width, height) { if (!this.element) { return; } if (this.placeholder) { this.placeholder.setDisplayedSize(width, height); } // eslint-disable-next-line max-len if (this.instance.dispatch('contentResize', { content: this, width, height }).defaultPrevented) { return; } setWidthHeight(this.element, width, height); if (this.isImageContent() && !this.isError()) { const isInitialSizeUpdate = (!this.displayedImageWidth && width); this.displayedImageWidth = width; this.displayedImageHeight = height; if (isInitialSizeUpdate) { this.loadImage(false); } else { this.updateSrcsetSizes(); } if (this.slide) { // eslint-disable-next-line max-len this.instance.dispatch('imageSizeChange', { slide: this.slide, width, height, content: this }); } } } /** * @returns {boolean} If the content can be zoomed */ isZoomable() { return this.instance.applyFilters( 'isContentZoomable', this.isImageContent() && (this.state !== LOAD_STATE.ERROR), this ); } /** * Update image srcset sizes attribute based on width and height */ updateSrcsetSizes() { // Handle srcset sizes attribute. // // Never lower quality, if it was increased previously. // Chrome does this automatically, Firefox and Safari do not, // so we store largest used size in dataset. // Handle srcset sizes attribute. // // Never lower quality, if it was increased previously. // Chrome does this automatically, Firefox and Safari do not, // so we store largest used size in dataset. if (this.data.srcset) { const image = /** @type HTMLImageElement */ (this.element); const sizesWidth = this.instance.applyFilters( 'srcsetSizesWidth', this.displayedImageWidth, this ); if (!image.dataset.largestUsedSize || sizesWidth > parseInt(image.dataset.largestUsedSize, 10)) { image.sizes = sizesWidth + 'px'; image.dataset.largestUsedSize = String(sizesWidth); } } } /** * @returns {boolean} If content should use a placeholder (from msrc by default) */ usePlaceholder() { return this.instance.applyFilters( 'useContentPlaceholder', this.isImageContent(), this ); } /** * Preload content with lazy-loading param */ lazyLoad() { if (this.instance.dispatch('contentLazyLoad', { content: this }).defaultPrevented) { return; } this.load(true); } /** * @returns {boolean} If placeholder should be kept after content is loaded */ keepPlaceholder() { return this.instance.applyFilters( 'isKeepingPlaceholder', this.isLoading(), this ); } /** * Destroy the content */ destroy() { this.hasSlide = false; this.slide = null; if (this.instance.dispatch('contentDestroy', { content: this }).defaultPrevented) { return; } this.remove(); if (this.placeholder) { this.placeholder.destroy(); this.placeholder = null; } if (this.isImageContent() && this.element) { this.element.onload = null; this.element.onerror = null; this.element = null; } } /** * Display error message */ displayError() { if (this.slide) { /** @type {HTMLElement} */ let errorMsgEl = createElement('pswp__error-msg'); errorMsgEl.innerText = this.instance.options.errorMsg; errorMsgEl = this.instance.applyFilters( 'contentErrorElement', errorMsgEl, this ); this.element = createElement('pswp__content pswp__error-msg-container'); this.element.appendChild(errorMsgEl); this.slide.container.innerText = ''; this.slide.container.appendChild(this.element); this.slide.updateContentSize(true); this.removePlaceholder(); } } /** * Append the content */ append() { if (this.isAttached) { return; } this.isAttached = true; if (this.state === LOAD_STATE.ERROR) { this.displayError(); return; } if (this.instance.dispatch('contentAppend', { content: this }).defaultPrevented) { return; } const supportsDecode = ('decode' in this.element); if (this.isImageContent()) { // Use decode() on nearby slides // // Nearby slide images are in DOM and not hidden via display:none. // However, they are placed offscreen (to the left and right side). // // Some browsers do not composite the image until it's actually visible, // using decode() helps. // // You might ask "why dont you just decode() and then append all images", // that's because I want to show image before it's fully loaded, // as browser can render parts of image while it is loading. // We do not do this in Safari due to partial loading bug. if (supportsDecode && this.slide && (!this.slide.isActive || isSafari())) { this.isDecoding = true; // purposefully using finally instead of then, // as if srcset sizes changes dynamically - it may cause decode error /** @type {HTMLImageElement} */ (this.element).decode().catch(() => {}).finally(() => { this.isDecoding = false; this.appendImage(); }); } else { this.appendImage(); } } else if (this.element && !this.element.parentNode) { this.slide.container.appendChild(this.element); } } /** * Activate the slide, * active slide is generally the current one, * meaning the user can see it. */ activate() { if (this.instance.dispatch('contentActivate', { content: this }).defaultPrevented) { return; } if (this.slide) { if (this.isImageContent() && this.isDecoding && !isSafari()) { // add image to slide when it becomes active, // even if it's not finished decoding this.appendImage(); } else if (this.isError()) { this.load(false, true); // try to reload } if (this.slide.holderElement) { this.slide.holderElement.setAttribute('aria-hidden', 'false'); } } } /** * Deactivate the content */ deactivate() { this.instance.dispatch('contentDeactivate', { content: this }); if (this.slide && this.slide.holderElement) { this.slide.holderElement.setAttribute('aria-hidden', 'true'); } } /** * Remove the content from DOM */ remove() { this.isAttached = false; if (this.instance.dispatch('contentRemove', { content: this }).defaultPrevented) { return; } if (this.element && this.element.parentNode) { this.element.remove(); } if (this.placeholder && this.placeholder.element) { this.placeholder.element.remove(); } } /** * Append the image content to slide container */ appendImage() { if (!this.isAttached) { return; } if (this.instance.dispatch('contentAppendImage', { content: this }).defaultPrevented) { return; } // ensure that element exists and is not already appended if (this.slide && this.element && !this.element.parentNode) { this.slide.container.appendChild(this.element); } if (this.state === LOAD_STATE.LOADED || this.state === LOAD_STATE.ERROR) { this.removePlaceholder(); } } } export default Content;