JavaScript UI component library, includes the latest Fancybox
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

914 lines
25 KiB

3 years ago
import { Base } from "../shared/Base/Base.js";
import { Panzoom } from "../Panzoom/Panzoom.js";
import { extend } from "../shared/utils/extend.js";
import { round } from "../shared/utils/round.js";
import { throttle } from "../shared/utils/throttle.js";
import { Plugins } from "./plugins/index.js";
const defaults = {
// Virtual slides. Each object should have at least `html` property that will be used to set content,
// example: `slides: [{html: 'First slide'}, {html: 'Second slide'}]`
slides: [],
// Number of slides to preload before/after visible slides
preload: 0,
// Number of slides to group into the page,
// if `auto` - group all slides that fit into the viewport
slidesPerPage: "auto",
// Index of initial page
initialPage: 0,
// Panzoom friction while changing page
friction: 0.92,
// Should center active page
center: true,
// Should carousel scroll infinitely
infinite: true,
// Should the gap be filled before first and after last slide if `infinite: false`
fill: true,
// Should carousel settle at specific page after drag event
dragFree: false,
classNames: {
viewport: "carousel__viewport",
track: "carousel__track",
slide: "carousel__slide",
// Classname toggled for slides inside current page
slideSelected: "is-selected",
},
// Translations
l10n: {
NEXT: "Next slide",
PREV: "Previous slide",
GOTO: "Go to slide %d",
},
};
export class Carousel extends Base {
/**
* Carousel constructor
* @constructs Carousel
* @param {HTMLElement} $element - Carousel container
* @param {Object} [options] - Options for Carousel
*/
constructor($element, options = {}) {
options = extend(true, {}, defaults, options);
super(options);
this.state = "init";
this.$element = $element;
$element.Carousel = this;
this.page = this.pageIndex = null;
this.prevPage = this.prevPageIndex = null;
this.slideNext = throttle(this.slideNext.bind(this), 250, true);
this.slidePrev = throttle(this.slidePrev.bind(this), 250, true);
this.attachPlugins(Carousel.Plugins);
this.trigger("init");
this.initLayout();
this.initSlides();
this.initPanzoom();
this.state = "ready";
this.trigger("ready");
}
/**
* Initialize layout; create necessary elements
*/
initLayout() {
if (!(this.$element instanceof HTMLElement)) {
throw new Error("No root element provided");
}
const classNames = this.option("classNames");
this.$viewport = this.option("viewport") || this.$element.querySelector("." + classNames.viewport);
if (!this.$viewport) {
this.$viewport = document.createElement("div");
this.$viewport.classList.add(classNames.viewport);
this.$viewport.append(...this.$element.childNodes);
this.$element.appendChild(this.$viewport);
}
this.$track = this.option("track") || this.$element.querySelector("." + classNames.track);
if (!this.$track) {
this.$track = document.createElement("div");
this.$track.classList.add(classNames.track);
this.$track.append(...this.$viewport.childNodes);
this.$viewport.appendChild(this.$track);
}
}
/**
* Fill `slides` array with objects from existing nodes and/or `slides` option
*/
initSlides() {
this.slides = [];
// Get existing slides from the DOM
const elems = this.$viewport.querySelectorAll("." + this.option("classNames.slide"));
elems.forEach((el) => {
const slide = {
$el: el,
isDom: true,
};
this.slides.push(slide);
this.trigger("createSlide", slide, this.slides.length);
});
// Add virtual slides, but do not create DOM elements yet,
// because they will be created dynamically based on current carousel position
if (Array.isArray(this.options.slides)) {
this.slides = extend(true, [...this.slides], this.options.slides);
}
}
/**
* Recalculate and center current page
*/
updatePage() {
let page = this.page;
if (page === null) {
page = this.page = this.option("initialPage");
}
this.updateMetrics();
const pages = this.pages;
if (!pages[page]) {
page = pages.length ? pages[pages.length - 1].index : 0;
}
this.slideTo(page, { friction: 0 });
}
/**
* Tweak panzoom boundaries
*/
updateBounds() {
let panzoom = this.Panzoom;
// Enable `infinite` options
const infinite = this.option("infinite");
const infiniteX = this.option("infiniteX", infinite);
const infiniteY = this.option("infiniteY", infinite);
if (infiniteX) {
panzoom.boundX = null;
}
if (infiniteY) {
panzoom.boundY = null;
}
if (infiniteX || infiniteY) {
return;
}
// if (this.option("center") && !this.option("fill")) {
3 years ago
panzoom.boundX = {
from: this.pages[this.pages.length - 1].left * -1,
to: this.pages[0].left * -1,
};
// }
3 years ago
}
initPanzoom() {
// Create fresh object containing options for Pazoom instance
const options = extend(
true,
{},
{
// Track element will be set as Panzoom $content
content: this.$track,
// Disable any user interaction
click: false,
doubleClick: false,
wheel: false,
pinchToZoom: false,
// Right now, only horizontal navigation is supported
lockAxis: "x",
// Make `textSelection` option more easy to customize
textSelection: () => this.option("textSelection", false),
// Disable dragging if content (e.g. all slides) fits inside viewport
panOnlyZoomed: () => this.option("panOnlyZoomed", this.elemDimWidth < this.wrapDimWidth),
on: {
// Bubble events
"*": (name, ...details) => this.trigger(`Panzoom.${name}`, ...details),
// Expose panzoom instance as soon as possible
init: (panzoom) => (this.Panzoom = panzoom),
// The rest of events to be processed
updateMetrics: () => {
this.updatePage();
},
updateBounds: () => {
this.updateBounds();
},
beforeTransform: this.onBeforeTransform.bind(this),
afterAnimate: this.onAfterAnimate.bind(this),
touchEnd: this.onTouchEnd.bind(this),
},
},
this.option("Panzoom")
);
// Create new Panzoom instance
new Panzoom(this.$viewport, options);
}
/**
* Process `Panzoom.beforeTransform` event to remove slides moved out of viewport and
* to create necessary ones
*/
onBeforeTransform() {
if (this.option("infiniteX", this.option("infinite"))) {
this.manageInfiniteTrack();
}
this.manageSlideVisiblity();
}
/**
* Process `Panzoom.afterAnimate` event
* @param {Object} panzoom
* @param {Boolean} [isInstant=false]
*/
onAfterAnimate(panzoom, isInstant) {
// If `isInstant === true` then it means the position is set without any animation
if (!isInstant) {
this.trigger("settle");
}
}
/**
* Process `Panzoom.touchEnd` event; slide to next/prev page if needed
* @param {object} panzoom
*/
onTouchEnd(panzoom) {
const dragFree = this.option("dragFree");
// If this is a quick horizontal flick, slide to next/prev slide
if (
!dragFree &&
this.pages.length > 1 &&
panzoom.drag.elapsedTime < 350 &&
Math.abs(panzoom.drag.distanceY) < 1 &&
Math.abs(panzoom.drag.distanceX) > 5
) {
this[panzoom.drag.distanceX < 0 ? "slideNext" : "slidePrev"]();
return;
}
// Set the slide at the end of the animation as the current one,
// or slide to closest page
if (dragFree) {
const [, nextPageIndex] = this.getPageFromPosition(this.Panzoom.pan.x * -1);
this.setPage(nextPageIndex);
} else {
this.slideToClosest();
}
}
/**
* Seamlessly flips position of infinite carousel, if needed; this way x position stays low
*/
manageInfiniteTrack() {
if (
!this.option("infiniteX", this.option("infinite")) ||
this.pages.length < 2 ||
this.elemDimWidth < this.wrapDimWidth
) {
return;
}
const panzoom = this.Panzoom;
let isFlipped = false;
if (panzoom.current.x < (panzoom.contentDim.width - panzoom.viewportDim.width) * -1) {
panzoom.current.x += panzoom.contentDim.width;
if (panzoom.drag.firstPosition) {
panzoom.drag.firstPosition.x += panzoom.contentDim.width;
}
this.pageIndex = this.pageIndex - this.pages.length;
isFlipped = true;
}
if (panzoom.current.x > panzoom.viewportDim.width) {
panzoom.current.x -= panzoom.contentDim.width;
if (panzoom.drag.firstPosition) {
panzoom.drag.firstPosition.x -= panzoom.contentDim.width;
}
this.pageIndex = this.pageIndex + this.pages.length;
isFlipped = true;
}
if (isFlipped && panzoom.state === "dragging") {
panzoom.resetDragState();
}
return isFlipped;
}
/**
* Creates or moves existing slides that are visible or should be preloaded,
* removes unnecessary virtual slides
*/
manageSlideVisiblity() {
const contentWidth = this.elemDimWidth;
const viewportWidth = this.wrapDimWidth;
let currentX = this.Panzoom.current.x * -1;
if (Math.abs(currentX) < 0.1) {
currentX = 0;
}
const preload = this.option("preload");
const infinite = this.option("infiniteX", this.option("infinite"));
const paddingLeft = parseFloat(window.getComputedStyle(this.$viewport, null).getPropertyValue("padding-left"));
const paddingRight = parseFloat(window.getComputedStyle(this.$viewport, null).getPropertyValue("padding-right"));
// Check visibility of each slide
this.slides.forEach((slide) => {
let leftBoundary, rightBoundary;
let hasDiff = 0;
// #1 - slides in current viewport; this does not include infinite items
leftBoundary = currentX - paddingLeft;
rightBoundary = currentX + viewportWidth + paddingRight;
leftBoundary -= preload * (viewportWidth + paddingLeft + paddingRight);
rightBoundary += preload * (viewportWidth + paddingLeft + paddingRight);
const insideCurrentInterval = slide.left + slide.width > leftBoundary && slide.left < rightBoundary;
// #2 - infinite items inside current viewport; from previous interval
leftBoundary = currentX + contentWidth - paddingLeft;
rightBoundary = currentX + contentWidth + viewportWidth + paddingRight;
// Include slides that have to be preloaded
leftBoundary -= preload * (viewportWidth + paddingLeft + paddingRight);
const insidePrevInterval = infinite && slide.left + slide.width > leftBoundary && slide.left < rightBoundary;
// #2 - infinite items inside current viewport; from next interval
leftBoundary = currentX - contentWidth - paddingLeft;
rightBoundary = currentX - contentWidth + viewportWidth + paddingRight;
// Include slides that have to be preloaded
leftBoundary -= preload * (viewportWidth + paddingLeft + paddingRight);
const insideNextInterval = infinite && slide.left + slide.width > leftBoundary && slide.left < rightBoundary;
// Create virtual slides that should be visible or preloaded, remove others
if (insidePrevInterval || insideCurrentInterval || insideNextInterval) {
this.createSlideEl(slide);
if (insideCurrentInterval) {
hasDiff = 0;
}
if (insidePrevInterval) {
hasDiff = -1;
}
if (insideNextInterval) {
hasDiff = 1;
}
// Bring preloaded slides back to viewport, if needed
if (slide.left + slide.width > currentX && slide.left <= currentX + viewportWidth + paddingRight) {
hasDiff = 0;
}
} else {
this.removeSlideEl(slide);
}
slide.hasDiff = hasDiff;
});
// Reposition slides for continuity
let nextIndex = 0;
let nextPos = 0;
this.slides.forEach((slide, index) => {
let updatedX = 0;
if (slide.$el) {
if (index !== nextIndex || slide.hasDiff) {
//} || slide.hasDiff !== undefined) {
updatedX = nextPos + slide.hasDiff * contentWidth;
} else {
nextPos = 0;
}
slide.$el.style.left = Math.abs(updatedX) > 0.1 ? `${nextPos + slide.hasDiff * contentWidth}px` : "";
nextIndex++;
} else {
nextPos += slide.width;
}
});
// Update content height to avoid double firing of resize event callback
this.Panzoom.viewportDim.height = this.Panzoom.$content.clientHeight;
this.markSelectedSlides();
}
/**
* Toggles selected class name and aria-hidden attribute for slides based on visibility
*/
markSelectedSlides() {
const selectedClass = this.option("classNames.slideSelected");
const attr = "aria-hidden";
this.slides.forEach((slide, index) => {
const $el = slide.$el;
if (!$el) {
return;
}
const page = this.pages[this.page];
if (page && page.indexes && page.indexes.indexOf(index) > -1) {
if (selectedClass && !$el.classList.contains(selectedClass)) {
$el.classList.add(selectedClass);
this.trigger("selectSlide", slide);
}
$el.removeAttribute(attr);
} else {
if (selectedClass && $el.classList.contains(selectedClass)) {
$el.classList.remove(selectedClass);
this.trigger("unselectSlide", slide);
}
$el.setAttribute(attr, true);
}
});
}
/**
* Creates main DOM element for virtual slides,
* lazy loads images inside regular slides
* @param {Object} slide
*/
createSlideEl(slide) {
if (!slide) {
return;
}
if (slide.$el) {
let curentIndex = parseInt(slide.$el.dataset.index, 10);
if (curentIndex !== slide.index) {
slide.$el.dataset.index = slide.index;
// Lazy load images
const $lazyNodes = slide.$el.querySelectorAll("[data-lazy-src]");
$lazyNodes.forEach((node) => {
let lazySrc = node.dataset.lazySrc;
if (node instanceof HTMLImageElement) {
node.src = lazySrc;
} else {
node.style.backgroundImage = `url('${lazySrc}')`;
}
});
let lazySrc;
if ((lazySrc = slide.$el.dataset.lazySrc)) {
slide.$el.style.backgroundImage = `url('${lazySrc}')`;
}
slide.state = "ready";
}
return;
}
const div = document.createElement("div");
div.dataset.index = slide.index;
div.classList.add(this.option("classNames.slide"));
if (slide.customClass) {
div.classList.add(...slide.customClass.split(" "));
}
if (slide.html) {
div.innerHTML = slide.html;
}
const allElelements = [];
this.slides.forEach((slide, index) => {
if (slide.$el) {
allElelements.push(index);
}
});
// Find a place in DOM to insert an element
const goal = slide.index;
let refSlide = null;
if (allElelements.length) {
let refIndex = allElelements.reduce((prev, curr) =>
Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev
);
refSlide = this.slides[refIndex];
}
this.$track.insertBefore(
div,
refSlide && refSlide.$el ? (refSlide.index < slide.index ? refSlide.$el.nextSibling : refSlide.$el) : null
);
slide.$el = div;
this.trigger("createSlide", slide, goal);
return slide;
}
/**
* Calculate slide element width (including left, right margins)
* @param {Object} node
* @returns {Number} Width in px
*/
getSlideMetrics(node) {
if (!node) {
const firstSlide = this.slides[0];
node = document.createElement("div");
node.dataset.isTestEl = 1;
node.style.visibility = "hidden";
node.classList.add(this.option("classNames.slide"));
// Assume all slides have the same custom class, if any
if (firstSlide.customClass) {
node.classList.add(...firstSlide.customClass.split(" "));
}
this.$track.prepend(node);
}
let width = round(node.getBoundingClientRect().width);
// Add left/right margin
const style = node.currentStyle || window.getComputedStyle(node);
width = width + (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0);
// width = node.clientWidth;
// Proportionally scale if viewport is scaled (mobile devices)
if (window.visualViewport) {
width *= window.visualViewport.scale;
}
if (node.dataset.isTestEl) {
node.remove();
}
return width;
}
/**
* Calculate dimensions of all slides and fill pages
*/
updateMetrics() {
let totalWidth = 0;
let indexes = [];
let lastSlideWidth;
this.slides.forEach((slide, index) => {
const $el = slide.$el;
const slideWidth = slide.isDom || !lastSlideWidth ? this.getSlideMetrics($el) : lastSlideWidth;
slide.index = index;
slide.width = slideWidth;
slide.left = totalWidth;
lastSlideWidth = slideWidth;
totalWidth += slideWidth;
indexes.push(index);
});
this.elemDimWidth = round(totalWidth);
this.Panzoom.contentDim.width = this.elemDimWidth;
this.wrapDimWidth = round(this.$viewport.getBoundingClientRect().width);
var styles = window.getComputedStyle(this.$viewport);
var padding = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight);
this.wrapDimWidth = this.wrapDimWidth - padding;
if (window.visualViewport) {
this.wrapDimWidth *= window.visualViewport.scale;
}
this.Panzoom.viewportDim.width = this.wrapDimWidth;
const pages = [];
const slidesPerPage = this.option("slidesPerPage");
// Split slides into pages
if (Number.isInteger(slidesPerPage) && this.elemDimWidth > this.wrapDimWidth) {
// Fixed number of slides in the page
for (let i = 0; i < this.slides.length; i += slidesPerPage) {
pages.push({
indexes: indexes.slice(i, i + slidesPerPage),
slides: this.slides.slice(i, i + slidesPerPage),
});
}
} else {
// Slides that fit inside viewport
let currentPage = 0;
let currentWidth = 0;
for (let i = 0; i < this.slides.length; i += 1) {
let slide = this.slides[i];
// Add next page
if (!pages.length || currentWidth + slide.width > this.wrapDimWidth) {
pages.push({
indexes: [],
slides: [],
});
currentPage = pages.length - 1;
currentWidth = 0;
}
currentWidth += slide.width;
pages[currentPage].indexes.push(i);
pages[currentPage].slides.push(slide);
}
}
const shouldCenter = this.option("center");
const shouldFill = this.option("fill");
// Calculate width and start position for each page
pages.forEach((page, index) => {
page.index = index;
page.width = page.slides.reduce((sum, slide) => sum + slide.width, 0);
page.left = page.slides[0].left;
if (shouldCenter) {
page.left += (this.wrapDimWidth - page.width) * 0.5 * -1;
}
if (shouldFill && !this.option("infiniteX", this.option("infinite")) && this.elemDimWidth > this.wrapDimWidth) {
page.left = Math.max(page.left, 0);
page.left = Math.min(page.left, this.elemDimWidth - this.wrapDimWidth);
}
});
const rez = [];
let prevPage;
pages.forEach((page) => {
if (prevPage && page.left === prevPage.left) {
prevPage.width += page.width;
prevPage.slides = [...prevPage.slides, ...page.slides];
prevPage.indexes = [...prevPage.indexes, ...page.indexes];
} else {
page.index = rez.length;
prevPage = page;
rez.push(page);
}
});
this.pages = rez;
this.manageSlideVisiblity();
this.trigger("refresh");
}
/**
* Changes active page
* @param {Number} page - New index of active page
* @param {Boolean} toClosest - to closest page based on scroll distance (for infinite navigation)
*/
setPage(page, toClosest) {
let nextPosition = 0;
let pageIndex = parseInt(page, 10) || 0;
const prevPage = this.page,
prevPageIndex = this.pageIndex,
pageCount = this.pages.length;
page = ((pageIndex % pageCount) + pageCount) % pageCount;
if (this.option("infiniteX", this.option("infinite")) && this.elemDimWidth > this.wrapDimWidth) {
const nextInterval = Math.floor(pageIndex / pageCount) || 0,
elemDimWidth = this.elemDimWidth;
nextPosition = this.pages[page].left + nextInterval * elemDimWidth;
if (toClosest === true && pageCount > 2) {
let currPosition = this.Panzoom.current.x * -1;
// * Find closest interval
const decreasedPosition = nextPosition - elemDimWidth,
increasedPosition = nextPosition + elemDimWidth,
diff1 = Math.abs(currPosition - nextPosition),
diff2 = Math.abs(currPosition - decreasedPosition),
diff3 = Math.abs(currPosition - increasedPosition);
if (diff3 < diff1 && diff3 <= diff2) {
nextPosition = increasedPosition;
pageIndex += pageCount;
} else if (diff2 < diff1 && diff2 < diff3) {
nextPosition = decreasedPosition;
pageIndex -= pageCount;
}
}
} else {
page = pageIndex = Math.max(0, Math.min(pageIndex, pageCount - 1));
nextPosition = this.pages[page].left;
}
this.page = page;
this.pageIndex = pageIndex;
if (prevPage !== null && page !== prevPage) {
this.prevPage = prevPage;
this.prevPageIndex = prevPageIndex;
this.trigger("change", page, prevPage);
}
return nextPosition;
}
/**
* Slides carousel to given page
* @param {Number} page - New index of active page
* @param {Object} [params] - Additional options
*/
slideTo(page, params = {}) {
const { friction = this.option("friction") } = params;
this.Panzoom.panTo({ x: this.setPage(page, true) * -1, y: 0, friction });
}
/**
* Slides to the closest page (useful, if carousel is changed manually)
* @param {Object} [params] - Object containing additional options
*/
slideToClosest(params = {}) {
let [, nextPageIndex] = this.getPageFromPosition(this.Panzoom.pan.x * -1);
this.slideTo(nextPageIndex, params);
}
/**
* Slide to next page, if possible
*/
slideNext() {
this.slideTo(this.pageIndex + 1);
}
/**
* Slide to previous page, if possible
*/
slidePrev() {
this.slideTo(this.pageIndex - 1);
}
/**
*
* @param {Integer} index Index of the slide
* @returns {Integer|null} Index of the page if found, or null
*/
getPageforSlide(index) {
const page = this.pages.find((page) => {
return page.indexes.indexOf(index) > -1;
});
return page ? page.index : null;
}
/**
* Returns index of closest page to given x position
* @param {Number} xPos
*/
getPageFromPosition(xPos) {
const pageCount = this.pages.length;
const center = this.option("center");
if (center) {
xPos += this.wrapDimWidth * 0.5;
}
const interval = Math.floor(xPos / this.elemDimWidth);
xPos -= interval * this.elemDimWidth;
let slide = this.slides.find((slide) => slide.left < xPos && slide.left + slide.width > xPos);
if (slide) {
let pageIndex = this.getPageforSlide(slide.index);
return [pageIndex, pageIndex + interval * pageCount];
}
return [0, 0];
}
/**
* Removes main DOM element of given slide
* @param {Object} slide
*/
removeSlideEl(slide) {
if (slide.$el && !slide.isDom) {
this.trigger("deleteSlide", slide);
slide.$el.remove();
slide.$el = null;
}
}
destroy() {
this.state = "destroy";
this.slides.forEach((slide) => {
this.removeSlideEl(slide);
});
this.Panzoom.destroy();
this.options = {};
this.events = {};
}
}
// Expose version
Carousel.version = "__VERSION__";
// Static properties are a recent addition that dont work in all browsers yet
Carousel.Plugins = Plugins;