(function($) { 'use strict'; let _defaults = { duration: 200, // ms dist: -100, // zoom scale TODO: make this more intuitive as an option shift: 0, // spacing for center image padding: 0, // Padding between non center items numVisible: 5, // Number of visible items in carousel fullWidth: false, // Change to full width styles indicators: false, // Toggle indicators noWrap: false, // Don't wrap around and cycle through items. onCycleTo: null // Callback for when a new slide is cycled to. }; /** * @class * */ class Carousel extends Component { /** * Construct Carousel instance * @constructor * @param {Element} el * @param {Object} options */ constructor(el, options) { super(Carousel, el, options); this.el.M_Carousel = this; /** * Options for the carousel * @member Carousel#options * @prop {Number} duration * @prop {Number} dist * @prop {Number} shift * @prop {Number} padding * @prop {Number} numVisible * @prop {Boolean} fullWidth * @prop {Boolean} indicators * @prop {Boolean} noWrap * @prop {Function} onCycleTo */ this.options = $.extend({}, Carousel.defaults, options); // Setup this.hasMultipleSlides = this.$el.find('.carousel-item').length > 1; this.showIndicators = this.options.indicators && this.hasMultipleSlides; this.noWrap = this.options.noWrap || !this.hasMultipleSlides; this.pressed = false; this.dragged = false; this.offset = this.target = 0; this.images = []; this.itemWidth = this.$el .find('.carousel-item') .first() .innerWidth(); this.itemHeight = this.$el .find('.carousel-item') .first() .innerHeight(); this.dim = this.itemWidth * 2 + this.options.padding || 1; // Make sure dim is non zero for divisions. this._autoScrollBound = this._autoScroll.bind(this); this._trackBound = this._track.bind(this); // Full Width carousel setup if (this.options.fullWidth) { this.options.dist = 0; this._setCarouselHeight(); // Offset fixed items when indicators. if (this.showIndicators) { this.$el.find('.carousel-fixed-item').addClass('with-indicators'); } } // Iterate through slides this.$indicators = $(''); this.$el.find('.carousel-item').each((el, i) => { this.images.push(el); if (this.showIndicators) { let $indicator = $('
  • '); // Add active to first by default. if (i === 0) { $indicator[0].classList.add('active'); } this.$indicators.append($indicator); } }); if (this.showIndicators) { this.$el.append(this.$indicators); } this.count = this.images.length; // Cap numVisible at count this.options.numVisible = Math.min(this.count, this.options.numVisible); // Setup cross browser string this.xform = 'transform'; ['webkit', 'Moz', 'O', 'ms'].every((prefix) => { var e = prefix + 'Transform'; if (typeof document.body.style[e] !== 'undefined') { this.xform = e; return false; } return true; }); this._setupEventHandlers(); this._scroll(this.offset); } static get defaults() { return _defaults; } static init(els, options) { return super.init(this, els, options); } /** * Get Instance */ static getInstance(el) { let domElem = !!el.jquery ? el[0] : el; return domElem.M_Carousel; } /** * Teardown component */ destroy() { this._removeEventHandlers(); this.el.M_Carousel = undefined; } /** * Setup Event Handlers */ _setupEventHandlers() { this._handleCarouselTapBound = this._handleCarouselTap.bind(this); this._handleCarouselDragBound = this._handleCarouselDrag.bind(this); this._handleCarouselReleaseBound = this._handleCarouselRelease.bind(this); this._handleCarouselClickBound = this._handleCarouselClick.bind(this); if (typeof window.ontouchstart !== 'undefined') { this.el.addEventListener('touchstart', this._handleCarouselTapBound); this.el.addEventListener('touchmove', this._handleCarouselDragBound); this.el.addEventListener('touchend', this._handleCarouselReleaseBound); } this.el.addEventListener('mousedown', this._handleCarouselTapBound); this.el.addEventListener('mousemove', this._handleCarouselDragBound); this.el.addEventListener('mouseup', this._handleCarouselReleaseBound); this.el.addEventListener('mouseleave', this._handleCarouselReleaseBound); this.el.addEventListener('click', this._handleCarouselClickBound); if (this.showIndicators && this.$indicators) { this._handleIndicatorClickBound = this._handleIndicatorClick.bind(this); this.$indicators.find('.indicator-item').each((el, i) => { el.addEventListener('click', this._handleIndicatorClickBound); }); } // Resize let throttledResize = M.throttle(this._handleResize, 200); this._handleThrottledResizeBound = throttledResize.bind(this); window.addEventListener('resize', this._handleThrottledResizeBound); } /** * Remove Event Handlers */ _removeEventHandlers() { if (typeof window.ontouchstart !== 'undefined') { this.el.removeEventListener('touchstart', this._handleCarouselTapBound); this.el.removeEventListener('touchmove', this._handleCarouselDragBound); this.el.removeEventListener('touchend', this._handleCarouselReleaseBound); } this.el.removeEventListener('mousedown', this._handleCarouselTapBound); this.el.removeEventListener('mousemove', this._handleCarouselDragBound); this.el.removeEventListener('mouseup', this._handleCarouselReleaseBound); this.el.removeEventListener('mouseleave', this._handleCarouselReleaseBound); this.el.removeEventListener('click', this._handleCarouselClickBound); if (this.showIndicators && this.$indicators) { this.$indicators.find('.indicator-item').each((el, i) => { el.removeEventListener('click', this._handleIndicatorClickBound); }); } window.removeEventListener('resize', this._handleThrottledResizeBound); } /** * Handle Carousel Tap * @param {Event} e */ _handleCarouselTap(e) { // Fixes firefox draggable image bug if (e.type === 'mousedown' && $(e.target).is('img')) { e.preventDefault(); } this.pressed = true; this.dragged = false; this.verticalDragged = false; this.reference = this._xpos(e); this.referenceY = this._ypos(e); this.velocity = this.amplitude = 0; this.frame = this.offset; this.timestamp = Date.now(); clearInterval(this.ticker); this.ticker = setInterval(this._trackBound, 100); } /** * Handle Carousel Drag * @param {Event} e */ _handleCarouselDrag(e) { let x, y, delta, deltaY; if (this.pressed) { x = this._xpos(e); y = this._ypos(e); delta = this.reference - x; deltaY = Math.abs(this.referenceY - y); if (deltaY < 30 && !this.verticalDragged) { // If vertical scrolling don't allow dragging. if (delta > 2 || delta < -2) { this.dragged = true; this.reference = x; this._scroll(this.offset + delta); } } else if (this.dragged) { // If dragging don't allow vertical scroll. e.preventDefault(); e.stopPropagation(); return false; } else { // Vertical scrolling. this.verticalDragged = true; } } if (this.dragged) { // If dragging don't allow vertical scroll. e.preventDefault(); e.stopPropagation(); return false; } } /** * Handle Carousel Release * @param {Event} e */ _handleCarouselRelease(e) { if (this.pressed) { this.pressed = false; } else { return; } clearInterval(this.ticker); this.target = this.offset; if (this.velocity > 10 || this.velocity < -10) { this.amplitude = 0.9 * this.velocity; this.target = this.offset + this.amplitude; } this.target = Math.round(this.target / this.dim) * this.dim; // No wrap of items. if (this.noWrap) { if (this.target >= this.dim * (this.count - 1)) { this.target = this.dim * (this.count - 1); } else if (this.target < 0) { this.target = 0; } } this.amplitude = this.target - this.offset; this.timestamp = Date.now(); requestAnimationFrame(this._autoScrollBound); if (this.dragged) { e.preventDefault(); e.stopPropagation(); } return false; } /** * Handle Carousel CLick * @param {Event} e */ _handleCarouselClick(e) { // Disable clicks if carousel was dragged. if (this.dragged) { e.preventDefault(); e.stopPropagation(); return false; } else if (!this.options.fullWidth) { let clickedIndex = $(e.target) .closest('.carousel-item') .index(); let diff = this._wrap(this.center) - clickedIndex; // Disable clicks if carousel was shifted by click if (diff !== 0) { e.preventDefault(); e.stopPropagation(); } this._cycleTo(clickedIndex); } } /** * Handle Indicator CLick * @param {Event} e */ _handleIndicatorClick(e) { e.stopPropagation(); let indicator = $(e.target).closest('.indicator-item'); if (indicator.length) { this._cycleTo(indicator.index()); } } /** * Handle Throttle Resize * @param {Event} e */ _handleResize(e) { if (this.options.fullWidth) { this.itemWidth = this.$el .find('.carousel-item') .first() .innerWidth(); this.imageHeight = this.$el.find('.carousel-item.active').height(); this.dim = this.itemWidth * 2 + this.options.padding; this.offset = this.center * 2 * this.itemWidth; this.target = this.offset; this._setCarouselHeight(true); } else { this._scroll(); } } /** * Set carousel height based on first slide * @param {Booleam} imageOnly - true for image slides */ _setCarouselHeight(imageOnly) { let firstSlide = this.$el.find('.carousel-item.active').length ? this.$el.find('.carousel-item.active').first() : this.$el.find('.carousel-item').first(); let firstImage = firstSlide.find('img').first(); if (firstImage.length) { if (firstImage[0].complete) { // If image won't trigger the load event let imageHeight = firstImage.height(); if (imageHeight > 0) { this.$el.css('height', imageHeight + 'px'); } else { // If image still has no height, use the natural dimensions to calculate let naturalWidth = firstImage[0].naturalWidth; let naturalHeight = firstImage[0].naturalHeight; let adjustedHeight = this.$el.width() / naturalWidth * naturalHeight; this.$el.css('height', adjustedHeight + 'px'); } } else { // Get height when image is loaded normally firstImage.one('load', (el, i) => { this.$el.css('height', el.offsetHeight + 'px'); }); } } else if (!imageOnly) { let slideHeight = firstSlide.height(); this.$el.css('height', slideHeight + 'px'); } } /** * Get x position from event * @param {Event} e */ _xpos(e) { // touch event if (e.targetTouches && e.targetTouches.length >= 1) { return e.targetTouches[0].clientX; } // mouse event return e.clientX; } /** * Get y position from event * @param {Event} e */ _ypos(e) { // touch event if (e.targetTouches && e.targetTouches.length >= 1) { return e.targetTouches[0].clientY; } // mouse event return e.clientY; } /** * Wrap index * @param {Number} x */ _wrap(x) { return x >= this.count ? x % this.count : x < 0 ? this._wrap(this.count + x % this.count) : x; } /** * Tracks scrolling information */ _track() { let now, elapsed, delta, v; now = Date.now(); elapsed = now - this.timestamp; this.timestamp = now; delta = this.offset - this.frame; this.frame = this.offset; v = 1000 * delta / (1 + elapsed); this.velocity = 0.8 * v + 0.2 * this.velocity; } /** * Auto scrolls to nearest carousel item. */ _autoScroll() { let elapsed, delta; if (this.amplitude) { elapsed = Date.now() - this.timestamp; delta = this.amplitude * Math.exp(-elapsed / this.options.duration); if (delta > 2 || delta < -2) { this._scroll(this.target - delta); requestAnimationFrame(this._autoScrollBound); } else { this._scroll(this.target); } } } /** * Scroll to target * @param {Number} x */ _scroll(x) { // Track scrolling state if (!this.$el.hasClass('scrolling')) { this.el.classList.add('scrolling'); } if (this.scrollingTimeout != null) { window.clearTimeout(this.scrollingTimeout); } this.scrollingTimeout = window.setTimeout(() => { this.$el.removeClass('scrolling'); }, this.options.duration); // Start actual scroll let i, half, delta, dir, tween, el, alignment, zTranslation, tweenedOpacity, centerTweenedOpacity; let lastCenter = this.center; let numVisibleOffset = 1 / this.options.numVisible; this.offset = typeof x === 'number' ? x : this.offset; this.center = Math.floor((this.offset + this.dim / 2) / this.dim); delta = this.offset - this.center * this.dim; dir = delta < 0 ? 1 : -1; tween = -dir * delta * 2 / this.dim; half = this.count >> 1; if (this.options.fullWidth) { alignment = 'translateX(0)'; centerTweenedOpacity = 1; } else { alignment = 'translateX(' + (this.el.clientWidth - this.itemWidth) / 2 + 'px) '; alignment += 'translateY(' + (this.el.clientHeight - this.itemHeight) / 2 + 'px)'; centerTweenedOpacity = 1 - numVisibleOffset * tween; } // Set indicator active if (this.showIndicators) { let diff = this.center % this.count; let activeIndicator = this.$indicators.find('.indicator-item.active'); if (activeIndicator.index() !== diff) { activeIndicator.removeClass('active'); this.$indicators .find('.indicator-item') .eq(diff)[0] .classList.add('active'); } } // center // Don't show wrapped items. if (!this.noWrap || (this.center >= 0 && this.center < this.count)) { el = this.images[this._wrap(this.center)]; // Add active class to center item. if (!$(el).hasClass('active')) { this.$el.find('.carousel-item').removeClass('active'); el.classList.add('active'); } let transformString = `${alignment} translateX(${-delta / 2}px) translateX(${dir * this.options.shift * tween * i}px) translateZ(${this.options.dist * tween}px)`; this._updateItemStyle(el, centerTweenedOpacity, 0, transformString); } for (i = 1; i <= half; ++i) { // right side if (this.options.fullWidth) { zTranslation = this.options.dist; tweenedOpacity = i === half && delta < 0 ? 1 - tween : 1; } else { zTranslation = this.options.dist * (i * 2 + tween * dir); tweenedOpacity = 1 - numVisibleOffset * (i * 2 + tween * dir); } // Don't show wrapped items. if (!this.noWrap || this.center + i < this.count) { el = this.images[this._wrap(this.center + i)]; let transformString = `${alignment} translateX(${this.options.shift + (this.dim * i - delta) / 2}px) translateZ(${zTranslation}px)`; this._updateItemStyle(el, tweenedOpacity, -i, transformString); } // left side if (this.options.fullWidth) { zTranslation = this.options.dist; tweenedOpacity = i === half && delta > 0 ? 1 - tween : 1; } else { zTranslation = this.options.dist * (i * 2 - tween * dir); tweenedOpacity = 1 - numVisibleOffset * (i * 2 - tween * dir); } // Don't show wrapped items. if (!this.noWrap || this.center - i >= 0) { el = this.images[this._wrap(this.center - i)]; let transformString = `${alignment} translateX(${-this.options.shift + (-this.dim * i - delta) / 2}px) translateZ(${zTranslation}px)`; this._updateItemStyle(el, tweenedOpacity, -i, transformString); } } // center // Don't show wrapped items. if (!this.noWrap || (this.center >= 0 && this.center < this.count)) { el = this.images[this._wrap(this.center)]; let transformString = `${alignment} translateX(${-delta / 2}px) translateX(${dir * this.options.shift * tween}px) translateZ(${this.options.dist * tween}px)`; this._updateItemStyle(el, centerTweenedOpacity, 0, transformString); } // onCycleTo callback let $currItem = this.$el.find('.carousel-item').eq(this._wrap(this.center)); if (lastCenter !== this.center && typeof this.options.onCycleTo === 'function') { this.options.onCycleTo.call(this, $currItem[0], this.dragged); } // One time callback if (typeof this.oneTimeCallback === 'function') { this.oneTimeCallback.call(this, $currItem[0], this.dragged); this.oneTimeCallback = null; } } /** * Cycle to target * @param {Element} el * @param {Number} opacity * @param {Number} zIndex * @param {String} transform */ _updateItemStyle(el, opacity, zIndex, transform) { el.style[this.xform] = transform; el.style.zIndex = zIndex; el.style.opacity = opacity; el.style.visibility = 'visible'; } /** * Cycle to target * @param {Number} n * @param {Function} callback */ _cycleTo(n, callback) { let diff = this.center % this.count - n; // Account for wraparound. if (!this.noWrap) { if (diff < 0) { if (Math.abs(diff + this.count) < Math.abs(diff)) { diff += this.count; } } else if (diff > 0) { if (Math.abs(diff - this.count) < diff) { diff -= this.count; } } } this.target = this.dim * Math.round(this.offset / this.dim); // Next if (diff < 0) { this.target += this.dim * Math.abs(diff); // Prev } else if (diff > 0) { this.target -= this.dim * diff; } // Set one time callback if (typeof callback === 'function') { this.oneTimeCallback = callback; } // Scroll if (this.offset !== this.target) { this.amplitude = this.target - this.offset; this.timestamp = Date.now(); requestAnimationFrame(this._autoScrollBound); } } /** * Cycle to next item * @param {Number} [n] */ next(n) { if (n === undefined || isNaN(n)) { n = 1; } let index = this.center + n; if (index >= this.count || index < 0) { if (this.noWrap) { return; } index = this._wrap(index); } this._cycleTo(index); } /** * Cycle to previous item * @param {Number} [n] */ prev(n) { if (n === undefined || isNaN(n)) { n = 1; } let index = this.center - n; if (index >= this.count || index < 0) { if (this.noWrap) { return; } index = this._wrap(index); } this._cycleTo(index); } /** * Cycle to nth item * @param {Number} [n] * @param {Function} callback */ set(n, callback) { if (n === undefined || isNaN(n)) { n = 0; } if (n > this.count || n < 0) { if (this.noWrap) { return; } n = this._wrap(n); } this._cycleTo(n, callback); } } M.Carousel = Carousel; if (M.jQueryLoaded) { M.initializeJqueryWrapper(Carousel, 'carousel', 'M_Carousel'); } })(cash);