@webcreate/infinite-ajax-scroll
Version:
Turn your existing pagination into infinite scrolling pages with ease
495 lines (379 loc) • 12.5 kB
JavaScript
import $ from 'tealight';
import extend from 'extend';
import throttle from 'lodash.throttle';
import defaults from './defaults';
import Assert from './assert';
import {scrollHandler} from "./event-handlers";
import Emitter from "tiny-emitter";
import {getDistanceToFold, getRootRect, getScrollPosition} from "./dimensions";
import {nextHandler} from './next-handler';
import {prevHandler} from './prev-handler';
import Pagination from './pagination';
import Spinner from './spinner';
import Logger from './logger';
import Paging from './paging';
import Trigger from './trigger';
import {appendFn} from './append';
import {prependFn} from './prepend';
import * as Events from './events';
import ResizeObserverFactory from './resize-observer';
import Prefill from "./prefill";
export default class InfiniteAjaxScroll {
constructor(container, options = {}) {
Assert.singleElement(container, 'container');
this.container = $(container)[0];
this.options = extend({}, defaults, options);
this.emitter = new Emitter();
this.options.loadOnScroll ? this.enableLoadOnScroll() : this.disableLoadOnScroll();
this.negativeMargin = Math.abs(this.options.negativeMargin);
this.scrollContainer = this.options.scrollContainer;
if (this.options.scrollContainer !== window) {
Assert.singleElement(this.options.scrollContainer, 'options.scrollContainer');
this.scrollContainer = $(this.options.scrollContainer)[0];
}
this.nextHandler = nextHandler;
this.prevHandler = prevHandler;
if (this.options.next === false) {
this.nextHandler = function() {}
} else if (typeof this.options.next === 'function') {
this.nextHandler = this.options.next;
}
if (this.options.prev === false) {
this.prevHandler = function() {}
} else if (typeof this.options.prev === 'function') {
this.prevHandler = this.options.prev;
}
this.resizeObserver = ResizeObserverFactory(this, this.scrollContainer);
this._scrollListener = throttle(scrollHandler, 200).bind(this);
this.ready = false;
this.bindOnReady = true;
this.binded = false;
this.paused = false;
this.pageIndexPrev = 0;
this.pageIndex = this.pageIndexNext = this.sentinel() ? 0 : -1;
this.on(Events.HIT, () => {
if (!this.loadOnScroll) {
return;
}
this.next();
});
this.on(Events.TOP, () => {
if (!this.loadOnScroll) {
return;
}
this.prev();
});
this.on(Events.SCROLLED, this.measure);
this.on(Events.RESIZED, this.measure);
// initialize extensions
this.pagination = new Pagination(this, this.options.pagination);
this.spinner = new Spinner(this, this.options.spinner);
this.logger = new Logger(this, this.options.logger);
this.paging = new Paging(this);
this.trigger = new Trigger(this, this.options.trigger);
this.prefill = new Prefill(this, this.options.prefill);
// prefill/measure after all plugins are done binding
this.on(Events.BINDED, this.prefill.prefill.bind(this.prefill));
this.hitFirst = this.hitLast = false;
this.on(Events.LAST, () => this.hitLast = true);
this.on(Events.FIRST, () => this.hitFirst = true);
let ready = () => {
if (this.ready) {
return;
}
this.ready = true;
this.emitter.emit(Events.READY);
if (this.bindOnReady && this.options.bind) {
this.bind();
}
};
if (document.readyState === "complete" || document.readyState === "interactive") {
setTimeout(ready, 1);
} else {
window.addEventListener('DOMContentLoaded', ready);
}
}
bind() {
if (this.binded) {
return;
}
// If we manually call bind before the dom is ready, we assume that we want
// to take control over the bind flow.
if (!this.ready) {
this.bindOnReady = false;
}
this.scrollContainer.addEventListener('scroll', this._scrollListener);
this.resizeObserver.observe();
this.binded = true;
this.emitter.emit(Events.BINDED);
}
unbind() {
if (!this.binded) {
if (!this.ready) {
this.once(Events.BINDED, this.unbind);
}
return;
}
this.resizeObserver.unobserve();
this.scrollContainer.removeEventListener('scroll', this._scrollListener);
this.binded = false;
this.emitter.emit(Events.UNBINDED);
}
next() {
if (this.hitLast) {
return;
}
if (!this.binded) {
if (!this.ready) {
return this.once(Events.BINDED, this.next);
}
return;
}
this.pause();
const pageIndex = this.pageIndexNext + 1;
this.emitter.emit(Events.NEXT, {pageIndex: this.pageIndexNext + 1});
return Promise.resolve(this.nextHandler(pageIndex))
.then((hasNextUrl) => {
this.pageIndexNext = pageIndex;
if (!hasNextUrl) {
this.emitter.emit(Events.LAST);
}
this.resume();
return hasNextUrl;
}).then((hasNextUrl) => {
this.emitter.emit(Events.NEXTED, {pageIndex: this.pageIndexNext});
return hasNextUrl;
});
}
prev() {
if (!this.binded || this.hitFirst) {
return;
}
this.pause();
const pageIndex = this.pageIndexPrev - 1;
this.emitter.emit(Events.PREV, {pageIndex: this.pageIndexPrev - 1});
return Promise.resolve(this.prevHandler(pageIndex))
.then((hasPrevUrl) => {
this.pageIndexPrev = pageIndex;
this.resume();
if (!hasPrevUrl) {
this.emitter.emit(Events.FIRST);
}
return hasPrevUrl;
}).then((hasPrevUrl) => {
this.emitter.emit(Events.PREVED, {pageIndex: this.pageIndexPrev});
return hasPrevUrl;
});
}
/**
* @param {string} url
* @returns {Promise} returns LOADED event on success
*/
load(url) {
let ias = this;
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
let loadEvent = {
url,
xhr,
method: 'GET',
body: null,
nocache: false,
responseType: ias.options.responseType,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
};
// event properties are mutable
ias.emitter.emit(Events.LOAD, loadEvent);
let finalUrl = loadEvent.url;
let method = loadEvent.method;
let responseType = loadEvent.responseType;
let headers = loadEvent.headers;
let body = loadEvent.body;
if (!loadEvent.nocache) {
// @see https://developer.mozilla.org/nl/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
finalUrl = finalUrl + ((/\?/).test(finalUrl) ? "&" : "?") + (new Date()).getTime();
}
xhr.onreadystatechange = function() {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
}
if (xhr.status === 0) {
// weird status happening during Cypress tests
}
else if (xhr.status === 200) {
let items = xhr.response;
if (responseType === 'document') {
items = $(ias.options.item, xhr.response);
// @todo assert there actually are items in the response
}
// we don't use a shared loadedEvent variable here, because these values should be immutable
ias.emitter.emit(Events.LOADED, {items, url: finalUrl, xhr});
resolve({items, url: finalUrl, xhr});
} else {
ias.emitter.emit(Events.ERROR, {url: finalUrl, method, xhr});
reject(xhr);
}
};
xhr.onerror = function() {
ias.emitter.emit(Events.ERROR, {url: finalUrl, method, xhr});
reject(xhr);
}
xhr.open(method, finalUrl, true);
xhr.responseType = responseType;
for (let header in headers) {
xhr.setRequestHeader(header, headers[header]);
}
xhr.send(body);
});
}
/**
* @param {array<Element>} items
* @param {Element|null} parent
*/
append(items, parent) {
let ias = this;
parent = parent || ias.container;
let event = {
items,
parent,
appendFn
};
ias.emitter.emit(Events.APPEND, event);
let executor = (resolve) => {
window.requestAnimationFrame(() => {
Promise.resolve(event.appendFn(event.items, event.parent, ias.sentinel())).then(() => {
resolve({items, parent});
});
});
};
return (new Promise(executor)).then((event) => {
ias.emitter.emit(Events.APPENDED, event);
});
}
/**
* @param {array<Element>} items
* @param {Element|null} parent
*/
prepend(items, parent) {
let ias = this;
parent = parent || ias.container;
let event = {
items,
parent,
prependFn
};
ias.emitter.emit(Events.PREPEND, event);
let executor = (resolve) => {
window.requestAnimationFrame(() => {
const first = ias.first();
const scrollPositionStart = getScrollPosition(this.scrollContainer);
const topStart = first.getBoundingClientRect().top + scrollPositionStart.y;
Promise.resolve(event.prependFn(event.items, event.parent, ias.first()))
.then(() => {
const scrollPositionEnd = getScrollPosition(this.scrollContainer);
const topEnd = first.getBoundingClientRect().top + scrollPositionEnd.y;
let deltaY = topEnd - topStart;
this.scrollContainer.scrollTo(scrollPositionEnd.x, deltaY);
})
.then(() => {
resolve({items, parent});
});
});
};
return (new Promise(executor)).then((event) => {
ias.emitter.emit(Events.PREPENDED, event);
});
}
sentinel() {
const items = $(this.options.item, this.container);
if (!items.length) {
return null;
}
return items[items.length-1];
}
first() {
const items = $(this.options.item, this.container);
if (!items.length) {
return null;
}
return items[0];
}
pause() {
this.paused = true;
}
resume() {
this.paused = false;
}
enableLoadOnScroll() {
this.loadOnScroll = true;
}
disableLoadOnScroll() {
this.loadOnScroll = false;
}
/**
* @deprecated replaced by distanceBottom
*/
distance(rootRect, sentinel) {
return this.distanceBottom(rootRect, sentinel);
}
distanceBottom(rootRect, sentinel) {
const _rootRect = rootRect || getRootRect(this.scrollContainer);
const _sentinel = sentinel || this.sentinel();
const scrollPosition = getScrollPosition(this.scrollContainer);
let distance = getDistanceToFold(_sentinel, scrollPosition, _rootRect);
// apply negative margin
distance -= this.negativeMargin;
return distance;
}
distanceTop() {
const scrollPosition = getScrollPosition(this.scrollContainer);
return scrollPosition.y - this.negativeMargin;
}
measure() {
if (this.paused || (this.hitFirst && this.hitLast)) {
return;
}
const rootRect = getRootRect(this.scrollContainer);
// When the scroll container has no height, this could indicate that
// the element is not visible (display = none). Without a height
// we cannot calculate the distance to fold. On the other hand we don't
// have to, because it's not visible anyway. Our resize observer will
// monitor the height, once it's greater than 0 everything will resume as normal.
if (rootRect.height === 0) {
// @todo DX: show warning in console that this is happening
return;
}
if (!this.hitFirst) {
let distanceTop = this.distanceTop();
if (distanceTop <= 0) {
this.emitter.emit(Events.TOP, {distance: distanceTop});
}
}
if (!this.hitLast) {
let distanceBottom = this.distanceBottom(rootRect, this.sentinel());
if (distanceBottom <= 0) {
this.emitter.emit(Events.HIT, {distance: distanceBottom});
}
}
}
on(event, callback) {
this.emitter.on(event, callback, this);
if (event === Events.BINDED && this.binded) {
callback.bind(this)();
}
}
off(event, callback) {
this.emitter.off(event, callback, this);
}
once(event, callback) {
return new Promise((resolve) => {
this.emitter.once(event, function() { Promise.resolve(callback.apply(this, arguments)).then(resolve) }, this);
if (event === Events.BINDED && this.binded) {
callback.bind(this)();
resolve()
}
})
}
}