photoswipe
Version:
JavaScript gallery
511 lines (435 loc) • 13.6 kB
JavaScript
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;