rolly.js
Version:
Custom scroll with inertia, smooth parallax and scenes manager
1,722 lines (1,439 loc) • 50.5 kB
JavaScript
/*!
* rolly.js v0.4.0
* (c) 2020 Mickael Chanrion
* Released under the MIT license
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.rolly = factory());
}(this, function () { 'use strict';
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/* eslint-disable no-unused-vars */
var getOwnPropertySymbols = Object.getOwnPropertySymbols;
var hasOwnProperty = Object.prototype.hasOwnProperty;
var propIsEnumerable = Object.prototype.propertyIsEnumerable;
function toObject(val) {
if (val === null || val === undefined) {
throw new TypeError('Object.assign cannot be called with null or undefined');
}
return Object(val);
}
function shouldUseNative() {
try {
if (!Object.assign) {
return false;
}
// Detect buggy property enumeration order in older V8 versions.
// https://bugs.chromium.org/p/v8/issues/detail?id=4118
var test1 = new String('abc'); // eslint-disable-line no-new-wrappers
test1[5] = 'de';
if (Object.getOwnPropertyNames(test1)[0] === '5') {
return false;
}
// https://bugs.chromium.org/p/v8/issues/detail?id=3056
var test2 = {};
for (var i = 0; i < 10; i++) {
test2['_' + String.fromCharCode(i)] = i;
}
var order2 = Object.getOwnPropertyNames(test2).map(function (n) {
return test2[n];
});
if (order2.join('') !== '0123456789') {
return false;
}
// https://bugs.chromium.org/p/v8/issues/detail?id=3056
var test3 = {};
'abcdefghijklmnopqrst'.split('').forEach(function (letter) {
test3[letter] = letter;
});
if (Object.keys(Object.assign({}, test3)).join('') !==
'abcdefghijklmnopqrst') {
return false;
}
return true;
} catch (err) {
// We don't expect any of the above to throw, but better to be safe.
return false;
}
}
var objectAssign = shouldUseNative() ? Object.assign : function (target, source) {
var from;
var to = toObject(target);
var symbols;
for (var s = 1; s < arguments.length; s++) {
from = Object(arguments[s]);
for (var key in from) {
if (hasOwnProperty.call(from, key)) {
to[key] = from[key];
}
}
if (getOwnPropertySymbols) {
symbols = getOwnPropertySymbols(from);
for (var i = 0; i < symbols.length; i++) {
if (propIsEnumerable.call(from, symbols[i])) {
to[symbols[i]] = from[symbols[i]];
}
}
}
}
return to;
};
function E () {
// Keep this empty so it's easier to inherit from
// (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)
}
E.prototype = {
on: function (name, callback, ctx) {
var e = this.e || (this.e = {});
(e[name] || (e[name] = [])).push({
fn: callback,
ctx: ctx
});
return this;
},
once: function (name, callback, ctx) {
var self = this;
function listener () {
self.off(name, listener);
callback.apply(ctx, arguments);
}
listener._ = callback;
return this.on(name, listener, ctx);
},
emit: function (name) {
var data = [].slice.call(arguments, 1);
var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0;
var len = evtArr.length;
for (i; i < len; i++) {
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
return this;
},
off: function (name, callback) {
var e = this.e || (this.e = {});
var evts = e[name];
var liveEvents = [];
if (evts && callback) {
for (var i = 0, len = evts.length; i < len; i++) {
if (evts[i].fn !== callback && evts[i].fn._ !== callback)
liveEvents.push(evts[i]);
}
}
// Remove event from queue to prevent memory leak
// Suggested by https://github.com/lazd
// Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
(liveEvents.length)
? e[name] = liveEvents
: delete e[name];
return this;
}
};
var tinyEmitter = E;
var commonjsGlobal = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
function createCommonjsModule(fn, module) {
return module = { exports: {} }, fn(module, module.exports), module.exports;
}
var lethargy = createCommonjsModule(function (module, exports) {
// Generated by CoffeeScript 1.9.2
(function() {
var root;
root = exports !== null ? exports : this;
root.Lethargy = (function() {
function Lethargy(stability, sensitivity, tolerance, delay) {
this.stability = stability != null ? Math.abs(stability) : 8;
this.sensitivity = sensitivity != null ? 1 + Math.abs(sensitivity) : 100;
this.tolerance = tolerance != null ? 1 + Math.abs(tolerance) : 1.1;
this.delay = delay != null ? delay : 150;
this.lastUpDeltas = (function() {
var i, ref, results;
results = [];
for (i = 1, ref = this.stability * 2; 1 <= ref ? i <= ref : i >= ref; 1 <= ref ? i++ : i--) {
results.push(null);
}
return results;
}).call(this);
this.lastDownDeltas = (function() {
var i, ref, results;
results = [];
for (i = 1, ref = this.stability * 2; 1 <= ref ? i <= ref : i >= ref; 1 <= ref ? i++ : i--) {
results.push(null);
}
return results;
}).call(this);
this.deltasTimestamp = (function() {
var i, ref, results;
results = [];
for (i = 1, ref = this.stability * 2; 1 <= ref ? i <= ref : i >= ref; 1 <= ref ? i++ : i--) {
results.push(null);
}
return results;
}).call(this);
}
Lethargy.prototype.check = function(e) {
var lastDelta;
e = e.originalEvent || e;
if (e.wheelDelta != null) {
lastDelta = e.wheelDelta;
} else if (e.deltaY != null) {
lastDelta = e.deltaY * -40;
} else if ((e.detail != null) || e.detail === 0) {
lastDelta = e.detail * -40;
}
this.deltasTimestamp.push(Date.now());
this.deltasTimestamp.shift();
if (lastDelta > 0) {
this.lastUpDeltas.push(lastDelta);
this.lastUpDeltas.shift();
return this.isInertia(1);
} else {
this.lastDownDeltas.push(lastDelta);
this.lastDownDeltas.shift();
return this.isInertia(-1);
}
return false;
};
Lethargy.prototype.isInertia = function(direction) {
var lastDeltas, lastDeltasNew, lastDeltasOld, newAverage, newSum, oldAverage, oldSum;
lastDeltas = direction === -1 ? this.lastDownDeltas : this.lastUpDeltas;
if (lastDeltas[0] === null) {
return direction;
}
if (this.deltasTimestamp[(this.stability * 2) - 2] + this.delay > Date.now() && lastDeltas[0] === lastDeltas[(this.stability * 2) - 1]) {
return false;
}
lastDeltasOld = lastDeltas.slice(0, this.stability);
lastDeltasNew = lastDeltas.slice(this.stability, this.stability * 2);
oldSum = lastDeltasOld.reduce(function(t, s) {
return t + s;
});
newSum = lastDeltasNew.reduce(function(t, s) {
return t + s;
});
oldAverage = oldSum / lastDeltasOld.length;
newAverage = newSum / lastDeltasNew.length;
if (Math.abs(oldAverage) < Math.abs(newAverage * this.tolerance) && (this.sensitivity < Math.abs(newAverage))) {
return direction;
} else {
return false;
}
};
Lethargy.prototype.showLastUpDeltas = function() {
return this.lastUpDeltas;
};
Lethargy.prototype.showLastDownDeltas = function() {
return this.lastDownDeltas;
};
return Lethargy;
})();
}).call(commonjsGlobal);
});
var support = (function getSupport() {
return {
hasWheelEvent: 'onwheel' in document,
hasMouseWheelEvent: 'onmousewheel' in document,
hasTouch: 'ontouchstart' in document,
hasTouchWin: navigator.msMaxTouchPoints && navigator.msMaxTouchPoints > 1,
hasPointer: !!window.navigator.msPointerEnabled,
hasKeyDown: 'onkeydown' in document,
isFirefox: navigator.userAgent.indexOf('Firefox') > -1
};
})();
var toString = Object.prototype.toString,
hasOwnProperty$1 = Object.prototype.hasOwnProperty;
var bindallStandalone = function(object) {
if(!object) return console.warn('bindAll requires at least one argument.');
var functions = Array.prototype.slice.call(arguments, 1);
if (functions.length === 0) {
for (var method in object) {
if(hasOwnProperty$1.call(object, method)) {
if(typeof object[method] == 'function' && toString.call(object[method]) == "[object Function]") {
functions.push(method);
}
}
}
}
for(var i = 0; i < functions.length; i++) {
var f = functions[i];
object[f] = bind(object[f], object);
}
};
/*
Faster bind without specific-case checking. (see https://coderwall.com/p/oi3j3w).
bindAll is only needed for events binding so no need to make slow fixes for constructor
or partial application.
*/
function bind(func, context) {
return function() {
return func.apply(context, arguments);
};
}
var Lethargy = lethargy.Lethargy;
var EVT_ID = 'virtualscroll';
var src = VirtualScroll;
var keyCodes = {
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
SPACE: 32
};
function VirtualScroll(options) {
bindallStandalone(this, '_onWheel', '_onMouseWheel', '_onTouchStart', '_onTouchMove', '_onKeyDown');
this.el = window;
if (options && options.el) {
this.el = options.el;
delete options.el;
}
this.options = objectAssign({
mouseMultiplier: 1,
touchMultiplier: 2,
firefoxMultiplier: 15,
keyStep: 120,
preventTouch: false,
unpreventTouchClass: 'vs-touchmove-allowed',
limitInertia: false,
useKeyboard: true,
useTouch: true
}, options);
if (this.options.limitInertia) this._lethargy = new Lethargy();
this._emitter = new tinyEmitter();
this._event = {
y: 0,
x: 0,
deltaX: 0,
deltaY: 0
};
this.touchStartX = null;
this.touchStartY = null;
this.bodyTouchAction = null;
if (this.options.passive !== undefined) {
this.listenerOptions = {passive: this.options.passive};
}
}
VirtualScroll.prototype._notify = function(e) {
var evt = this._event;
evt.x += evt.deltaX;
evt.y += evt.deltaY;
this._emitter.emit(EVT_ID, {
x: evt.x,
y: evt.y,
deltaX: evt.deltaX,
deltaY: evt.deltaY,
originalEvent: e
});
};
VirtualScroll.prototype._onWheel = function(e) {
var options = this.options;
if (this._lethargy && this._lethargy.check(e) === false) return;
var evt = this._event;
// In Chrome and in Firefox (at least the new one)
evt.deltaX = e.wheelDeltaX || e.deltaX * -1;
evt.deltaY = e.wheelDeltaY || e.deltaY * -1;
// for our purpose deltamode = 1 means user is on a wheel mouse, not touch pad
// real meaning: https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent#Delta_modes
if(support.isFirefox && e.deltaMode == 1) {
evt.deltaX *= options.firefoxMultiplier;
evt.deltaY *= options.firefoxMultiplier;
}
evt.deltaX *= options.mouseMultiplier;
evt.deltaY *= options.mouseMultiplier;
this._notify(e);
};
VirtualScroll.prototype._onMouseWheel = function(e) {
if (this.options.limitInertia && this._lethargy.check(e) === false) return;
var evt = this._event;
// In Safari, IE and in Chrome if 'wheel' isn't defined
evt.deltaX = (e.wheelDeltaX) ? e.wheelDeltaX : 0;
evt.deltaY = (e.wheelDeltaY) ? e.wheelDeltaY : e.wheelDelta;
this._notify(e);
};
VirtualScroll.prototype._onTouchStart = function(e) {
var t = (e.targetTouches) ? e.targetTouches[0] : e;
this.touchStartX = t.pageX;
this.touchStartY = t.pageY;
};
VirtualScroll.prototype._onTouchMove = function(e) {
var options = this.options;
if(options.preventTouch
&& !e.target.classList.contains(options.unpreventTouchClass)) {
e.preventDefault();
}
var evt = this._event;
var t = (e.targetTouches) ? e.targetTouches[0] : e;
evt.deltaX = (t.pageX - this.touchStartX) * options.touchMultiplier;
evt.deltaY = (t.pageY - this.touchStartY) * options.touchMultiplier;
this.touchStartX = t.pageX;
this.touchStartY = t.pageY;
this._notify(e);
};
VirtualScroll.prototype._onKeyDown = function(e) {
var evt = this._event;
evt.deltaX = evt.deltaY = 0;
var windowHeight = window.innerHeight - 40;
switch(e.keyCode) {
case keyCodes.LEFT:
case keyCodes.UP:
evt.deltaY = this.options.keyStep;
break;
case keyCodes.RIGHT:
case keyCodes.DOWN:
evt.deltaY = - this.options.keyStep;
break;
case e.shiftKey:
evt.deltaY = windowHeight;
break;
case keyCodes.SPACE:
evt.deltaY = - windowHeight;
break;
default:
return;
}
this._notify(e);
};
VirtualScroll.prototype._bind = function() {
if(support.hasWheelEvent) this.el.addEventListener('wheel', this._onWheel, this.listenerOptions);
if(support.hasMouseWheelEvent) this.el.addEventListener('mousewheel', this._onMouseWheel, this.listenerOptions);
if(support.hasTouch && this.options.useTouch) {
this.el.addEventListener('touchstart', this._onTouchStart, this.listenerOptions);
this.el.addEventListener('touchmove', this._onTouchMove, this.listenerOptions);
}
if(support.hasPointer && support.hasTouchWin) {
this.bodyTouchAction = document.body.style.msTouchAction;
document.body.style.msTouchAction = 'none';
this.el.addEventListener('MSPointerDown', this._onTouchStart, true);
this.el.addEventListener('MSPointerMove', this._onTouchMove, true);
}
if(support.hasKeyDown && this.options.useKeyboard) document.addEventListener('keydown', this._onKeyDown);
};
VirtualScroll.prototype._unbind = function() {
if(support.hasWheelEvent) this.el.removeEventListener('wheel', this._onWheel);
if(support.hasMouseWheelEvent) this.el.removeEventListener('mousewheel', this._onMouseWheel);
if(support.hasTouch) {
this.el.removeEventListener('touchstart', this._onTouchStart);
this.el.removeEventListener('touchmove', this._onTouchMove);
}
if(support.hasPointer && support.hasTouchWin) {
document.body.style.msTouchAction = this.bodyTouchAction;
this.el.removeEventListener('MSPointerDown', this._onTouchStart, true);
this.el.removeEventListener('MSPointerMove', this._onTouchMove, true);
}
if(support.hasKeyDown && this.options.useKeyboard) document.removeEventListener('keydown', this._onKeyDown);
};
VirtualScroll.prototype.on = function(cb, ctx) {
this._emitter.on(EVT_ID, cb, ctx);
var events = this._emitter.e;
if (events && events[EVT_ID] && events[EVT_ID].length === 1) this._bind();
};
VirtualScroll.prototype.off = function(cb, ctx) {
this._emitter.off(EVT_ID, cb, ctx);
var events = this._emitter.e;
if (!events[EVT_ID] || events[EVT_ID].length <= 0) this._unbind();
};
VirtualScroll.prototype.reset = function() {
var evt = this._event;
evt.x = 0;
evt.y = 0;
};
VirtualScroll.prototype.destroy = function() {
this._emitter.off();
this._unbind();
};
// check document first so it doesn't error in node.js
var style = typeof document != 'undefined'
? document.createElement('p').style
: {};
var prefixes = ['O', 'ms', 'Moz', 'Webkit'];
var upper = /([A-Z])/g;
var memo = {};
/**
* prefix `key`
*
* prefix('transform') // => WebkitTransform
*
* @param {String} key
* @return {String}
* @api public
*/
function prefix(key){
// Camel case
key = key.replace(/-([a-z])/g, function(_, char){
return char.toUpperCase()
});
// Without prefix
if (style[key] !== undefined) return key
// With prefix
var Key = key.charAt(0).toUpperCase() + key.slice(1);
var i = prefixes.length;
while (i--) {
var name = prefixes[i] + Key;
if (style[name] !== undefined) return name
}
return key
}
/**
* Memoized version of `prefix`
*
* @param {String} key
* @return {String}
* @api public
*/
function prefixMemozied(key){
return key in memo
? memo[key]
: memo[key] = prefix(key)
}
/**
* Create a dashed prefix
*
* @param {String} key
* @return {String}
* @api public
*/
function prefixDashed(key){
key = prefix(key);
if (upper.test(key)) {
key = '-' + key.replace(upper, '-$1');
upper.lastIndex = 0;
}
return key.toLowerCase()
}
var prefix_1 = prefixMemozied;
var dash = prefixDashed;
prefix_1.dash = dash;
var utils = {
getCSSTransform: function getCSSTransform(value, vertical) {
return vertical
? ("translate3d(0, " + value + "px, 0)")
: ("translate3d(" + value + "px, 0, 0)");
},
getElements: function getElements(selector, context) {
if ( context === void 0 ) context = document;
return Array.from(context.querySelectorAll(selector));
},
};
function objectWithoutProperties (obj, exclude) { var target = {}; for (var k in obj) if (Object.prototype.hasOwnProperty.call(obj, k) && exclude.indexOf(k) === -1) target[k] = obj[k]; return target; }
var Scene = function Scene(context, options) {
this.options = options;
this.state = {
caching: false,
cache: null,
inView: false,
active: false,
progress: 0,
progressInView: 0,
};
this.DOM = { context: context };
};
/**
* A promise to get cache for the scene.
* The default cache object is as follow:
* - context: the DOM element of the scene.
* - type: the type of the scene.
* - top: distance between the top of the view and the top of the scene at the initial state.
* - bottom: distance between the top of the view and the bottom of the scene at the initial
* state.
* - left: distance between the left of the view and the left of the scene at the initial state.
* - right: distance between the left of the view and the right of the scene at the initial state.
* - size: height of the scene (or width on horizontal mode).
* - speed: the speed of the scene.
* - trigger: the trigger position (e.g.: 'middle', 'bottom', '100px', '10%').
*
* The cache of the scene is extendable by providing a method in options: `options.scenes.${sceneType}.cache`.
* This method gives an object that contains:
* - cache: the computed cache so far.
* - state: the state of the scene.
* - globalState: the state of the rolly instance
* Simply return new properties in an object to extend the cache.
*
* @param {object} globalState - The state of the rolly instance.
*/
Scene.prototype.cache = function cache (globalState) {
var this$1 = this;
return new Promise(function (resolve, reject) {
this$1.state.caching = true;
var ref = this$1.options;
var vertical = ref.vertical;
// TODO: see when we need this
// const scrollOffset = globalState.target;
var scrollOffset = 0;
var viewSize = vertical ? globalState.height : globalState.width;
var ref$1 = this$1.DOM;
var context = ref$1.context;
context.style.display = null;
var computedStyle = window.getComputedStyle(context);
if (computedStyle.display === 'none') {
this$1.state.cache = null;
resolve(this$1.state.cache);
}
if (computedStyle.display === 'inline') {
context.style.display = 'block';
}
context.style[globalState.transformPrefix] = null;
var bounding = context.getBoundingClientRect();
var type = context.getAttribute('data-scene');
var options = this$1.options.scenes;
var sceneOptions = options[type] || {};
var cache = {
context: context,
type: type,
top: vertical ? bounding.top + scrollOffset : bounding.top,
bottom: vertical ? bounding.bottom + scrollOffset : bounding.bottom,
left: vertical ? bounding.left : bounding.left + scrollOffset,
right: vertical ? bounding.right : bounding.right + scrollOffset,
size: vertical ? bounding.height : bounding.width,
speed:
parseFloat(context.getAttribute('data-speed'))
|| sceneOptions.speed
|| options.speed,
trigger:
context.getAttribute('data-trigger')
|| sceneOptions.trigger
|| options.trigger,
};
var trigger = cache.trigger;
var triggerOffset = 0;
if (trigger === 'middle') { triggerOffset = viewSize / 2; }
else if (trigger === 'end') { triggerOffset = viewSize; }
// px from top
else if (trigger.slice(-2) === 'px') { triggerOffset = parseFloat(trigger); }
// percentage
else if (trigger.slice(-1) === '%') {
triggerOffset = (viewSize * parseFloat(trigger)) / 100;
}
cache.triggerOffset = triggerOffset;
var start = vertical
? cache.top + cache.size / 2 - globalState.height / 2
: cache.left + cache.size / 2 - globalState.width / 2;
cache.offset = start - start * cache.speed;
// Cache for custom scenes
var getCache = sceneOptions.cache || options.cache;
if (getCache) {
if (getCache) {
var extendedCache = getCache.call(this$1, {
cache: cache,
globalState: globalState,
sceneState: this$1.state,
});
cache = Object.assign({}, cache, extendedCache);
}
}
this$1.state.cache = cache;
this$1.state.caching = false;
resolve(this$1.state.cache);
});
};
/**
* Animation frame callback (called at every frames).
* @param {object} globalState - The state of the rolly instance.
*/
Scene.prototype.change = function change (globalState) {
if (!this.state.cache || this.state.caching) { return false; }
var viewSize = this.options.vertical
? globalState.height
: globalState.width;
var ref = this.state;
var cache = ref.cache;
var active = ref.active;
var ref$1 = this.calc(globalState);
var inView = ref$1.inView;
var transform = ref$1.transform;
var start = ref$1.start;
this.state.progress = this.getProgress(transform);
this.state.progressInView = this.getProgressInView(start, viewSize);
var ref$2 = this.options.scenes;
var sceneOptions = ref$2[cache.type];
var rest = objectWithoutProperties( ref$2, [cache.type] );
var options = rest; // eslint-disable-line prefer-const
if (!sceneOptions) {
sceneOptions = {};
}
// The data we send to every custom functions
var data = { globalState: globalState, sceneState: this.state, transform: transform };
// Check if inView value changed
if (this.state.inView !== inView) {
// Trigger appear/disappear callbacks
var action = inView ? 'appear' : 'disappear';
if (sceneOptions[action]) { sceneOptions[action].call(this, data); }
else if (options[action]) { options[action].call(this, data); }
this.state.inView = inView;
}
// Check and then trigger callbacks
if (inView) {
this.DOM.context.style.willChange = 'transform';
// Run
if (sceneOptions.change) { sceneOptions.change.call(this, data); }
else if (options.change) { options.change.call(this, data); }
// Enter
if (this.checkEnter(active, this.state.progress)) {
this.state.active = true;
if (sceneOptions.enter) {
sceneOptions.enter.call(this, data);
} else if (options.enter) {
options.enter.call(this, data);
}
} else if (this.checkLeave(active, this.state.progress)) {
// Leave
this.state.active = false;
if (sceneOptions.leave) {
sceneOptions.leave.call(this, data);
} else if (options.leave) {
options.leave.call(this, data);
}
}
// Transform
if (sceneOptions.transform) { sceneOptions.transform.call(this, data); }
else if (options.transform) { options.transform.call(this, data); }
else {
this.DOM.context.style[
globalState.transformPrefix
] = utils.getCSSTransform(transform, this.options.vertical);
}
} else {
this.DOM.context.style[
globalState.transformPrefix
] = utils.getCSSTransform(Math.max(globalState.bounding, viewSize + 50), this.options.vertical);
this.DOM.context.style.willChange = null;
}
return true;
};
/**
* Computes useful values for the scene.
* @param {object} globalState - The state of the rolly instance
* @return {object} Values as follow:
* - transform: the transform value according to the speed
* - start: distance between the start position of the view and the start position of the scene context (top|left)
* - end: distance between the end position of the view and the end position of the scene context (bottom|right)
* - inView: whether the scene is in the viewport
*/
Scene.prototype.calc = function calc (globalState) {
var ref = this.options;
var vertical = ref.vertical;
var ref$1 = this.state.cache;
var top = ref$1.top;
var right = ref$1.right;
var bottom = ref$1.bottom;
var left = ref$1.left;
var speed = ref$1.speed;
var offset = ref$1.offset;
var width = globalState.width;
var height = globalState.height;
var current = globalState.current;
var transform = current * -speed - offset;
var start = Math.round((vertical ? top : left) + transform);
var end = Math.round((vertical ? bottom : right) + transform);
var inView = end > 0 && start < (vertical ? height : width);
return {
transform: transform, start: start, end: end, inView: inView,
};
};
/**
* Gets the progress of the scene in relation to its trigger (default trigger position is 'middle').
* @param {number} transform - The transform position of the scene.
* @return {number} The progress position.
*/
Scene.prototype.getProgress = function getProgress (transform) {
var ref = this.options;
var vertical = ref.vertical;
var ref$1 = this.state;
var cache = ref$1.cache;
var triggerOffset = cache.triggerOffset;
var position = -transform + triggerOffset;
var progress = (position - (vertical ? cache.top : cache.left)) / cache.size;
if (progress < 0 || progress > 1) { return -1; }
return progress;
};
/**
* Gets the progress of the scene in relation to the viewport.
* @param {number} start - The distance between the start position of the view and the start.
* @param {*} viewSize - The size of the view.
*/
Scene.prototype.getProgressInView = function getProgressInView (start, viewSize) {
return (viewSize - start) / (viewSize + this.state.cache.size);
};
/**
* Checks if the trigger met the scene.
* @param {boolean} active - Whether the scene is active.
* @param {number} progress - The progress position of the scene related to the trigger
* @return {boolean} The result.
*/
Scene.prototype.checkEnter = function checkEnter (active, progress) {
return !active && progress >= 0 && progress <= 1;
};
/**
* Checks if the trigger left the scene.
* @param {boolean} active - Whether the scene is active.
* @param {number} progress - The progress position of the scene related to the trigger
* @return {boolean} The result.
*/
Scene.prototype.checkLeave = function checkLeave (active, progress) {
return active && progress === -1;
};
var ScrollBar = function ScrollBar(parent, globalState, setTarget, options) {
this.options = options;
this.DOM = this.render(parent);
this.state = {
clicked: false,
thumb: { size: 0 },
};
this.cache(globalState);
this.setTarget = setTarget;
};
var prototypeAccessors = { thumbSize: { configurable: true } };
/**
* Sets cache for scroll bar.
* @param {object} globalState - The state of the rolly instance.
*/
ScrollBar.prototype.cache = function cache (globalState) {
this.state.cache = {
bounding: globalState.bounding,
viewSize: this.options.vertical ? globalState.height : globalState.width,
};
this.updateThumbSize();
};
/**
* Animation frame callback (called at every frames).
* @param {object} globalState - The state of the rolly instance.
*/
ScrollBar.prototype.change = function change (ref) {
var current = ref.current;
var transformPrefix = ref.transformPrefix;
var ref$1 = this.state.cache;
var bounding = ref$1.bounding;
var viewSize = ref$1.viewSize;
var ref$2 = this;
var thumbSize = ref$2.thumbSize;
var value = Math.abs(current) / (bounding / (viewSize - thumbSize))
+ thumbSize / 0.5
- thumbSize;
var clamp = Math.max(0, Math.min(value - thumbSize, value + thumbSize));
this.DOM.thumb.style[transformPrefix] = utils.getCSSTransform(
clamp.toFixed(2),
this.options.vertical
);
};
/**
* Computes the target value from the scroll bar (based on event client viewport position).
* @param {number} client - The client position.
* @return {number} The target.
*/
ScrollBar.prototype.calc = function calc (client) {
return client * (this.state.cache.bounding / this.state.cache.viewSize);
};
/**
* Renders the scroll bar.
* @param {object} parent - The parent DOM of the scroll bar.
* @return {object} - The list of DOM elements (parent, context, thumb).
*/
ScrollBar.prototype.render = function render (parent) {
var context = document.createElement('div');
var direction = this.options.vertical ? 'y' : 'x';
context.className = "rolly-scroll-bar " + direction + "-scroll";
var thumb = document.createElement('div');
thumb.className = 'rolly-scroll-bar-thumb';
context.appendChild(thumb);
parent.appendChild(context);
return { parent: parent, context: context, thumb: thumb };
};
/**
* Starts listening events (mouse interactions).
*/
ScrollBar.prototype.on = function on () {
this.boundFns = {
click: this.click.bind(this),
mouseDown: this.mouseDown.bind(this),
mouseMove: this.mouseMove.bind(this),
mouseUp: this.mouseUp.bind(this),
};
this.DOM.context.addEventListener('click', this.boundFns.click);
this.DOM.context.addEventListener('mousedown', this.boundFns.mouseDown);
document.addEventListener('mousemove', this.boundFns.mouseMove);
document.addEventListener('mouseup', this.boundFns.mouseUp);
};
/**
* Stops listening events (mouse interactions).
*/
ScrollBar.prototype.off = function off () {
if (!this.boundFns) { return false; }
this.DOM.context.removeEventListener('click', this.boundFns.click);
this.DOM.context.removeEventListener('mousedown', this.boundFns.mouseDown);
document.removeEventListener('mousemove', this.boundFns.mouseMove);
document.removeEventListener('mouseup', this.boundFns.mouseUp);
delete this.boundFns;
return true;
};
/**
* Click event callback.
* @param {object} event - The event data.
*/
ScrollBar.prototype.click = function click (event) {
var value = this.calc(this.options.vertical ? event.clientY : event.clientX);
this.setTarget(value);
};
/**
* Mouse down event callback.
* @param {object} event - The event data.
*/
ScrollBar.prototype.mouseDown = function mouseDown (event) {
event.preventDefault();
if (event.which === 1) {
this.state.clicked = true;
}
this.DOM.parent.classList.add('is-dragging-scroll-bar');
};
/**
* Mouse move event callback.
* @param {object} event - The event data.
*/
ScrollBar.prototype.mouseMove = function mouseMove (event) {
if (this.state.clicked) {
var value = this.calc(this.options.vertical ? event.clientY : event.clientX);
this.setTarget(value);
}
};
/**
* Mouse up event callback.
* @param {object} event - The event data.
*/
ScrollBar.prototype.mouseUp = function mouseUp (event) {
this.state.clicked = false;
this.DOM.parent.classList.remove('is-dragging-scroll-bar');
};
/**
* Gets the size of the thumb (heigh or width on horizontal mode).
* @return {number} - The size of the thumb.
*/
prototypeAccessors.thumbSize.get = function () {
return this.state.thumb.size;
};
/**
* Sets the size of the thumb (heigh or width on horizontal mode).
* @param {number} - The size of the thumb.
*/
prototypeAccessors.thumbSize.set = function (size) {
this.state.thumb.size = size;
var prop = this.options.vertical ? 'height' : 'width';
this.DOM.thumb.style[prop] = size + "px";
};
/**
* Updates the size of the thumb.
* This method is called when the content changes or on a resize for instance.
*/
ScrollBar.prototype.updateThumbSize = function updateThumbSize () {
var ref = this.state.cache;
var bounding = ref.bounding;
if (bounding <= 0) {
this.DOM.context.classList.add('is-hidden');
this.thumbSize = 0;
return;
}
this.DOM.context.classList.remove('is-hidden');
var ref$1 = this.state.cache;
var viewSize = ref$1.viewSize;
this.thumbSize = viewSize * (viewSize / (bounding + viewSize));
};
/**
* Destroy the scroll bar.
* - removes from the DOM.
* - calls {@link ScrollBar#off}.
*/
ScrollBar.prototype.destroy = function destroy () {
this.off();
this.DOM.parent.removeChild(this.DOM.context);
};
Object.defineProperties( ScrollBar.prototype, prototypeAccessors );
/*
** Private methods
*/
var privated = {
/**
* Gets all functions that needs to be bound with the rolly's scope
*/
getBoundFns: function getBoundFns() {
var this$1 = this;
var fns = {};
['resize', 'debounceScroll', 'virtualScroll'].map(
function (fn) { return (fns[fn] = privated[fn].bind(this$1)); } // eslint-disable-line no-return-assign
);
return fns;
},
/**
* Initializes the state of the rolly instance.
*/
initState: function initState() {
this.state = {
current: 0,
previous: 0,
target: null,
width: window.innerWidth,
height: window.innerHeight,
bounding: 0,
ready: false,
preLoaded: false,
changing: false,
// The transform property to use
transformPrefix: prefix_1('transform'),
};
this.privateState = {
// Animation frame
rAF: undefined,
/*
* It seems that under heavy load, Firefox will still call the RAF
* callback even though the RAF has been canceled. To prevent
* that we set a flag to prevent any callback to be executed when
* RAF is removed.
*/
isRAFCanceled: false,
// Native scroll
debounceScroll: { timer: null, tick: false },
scrollTo: {},
};
},
/**
* Initializes scenes
*/
initScenes: function initScenes() {
var this$1 = this;
this.scenes = [];
utils
.getElements(this.options.scenes.selector, this.DOM.view)
.forEach(function (scene) { return this$1.scenes.push(new Scene(scene, this$1.options)); });
},
/*
** Animation frame methods
*/
/**
* Animation frame callback (called at every frames).
* Automatically stops when |target - current| < 0.1.
*/
change: function change() {
var this$1 = this;
if (this.privateState.isRAFCanceled) { return; }
privated.rAF.call(this);
var diff = this.state.target - this.state.current;
var delta = diff * this.options.ease;
// If diff between target and current states is < 0.1, stop running animation
if (Math.abs(diff) < 0.1) {
privated.cAF.call(this);
delta = 0;
this.state.current = this.state.target;
if (this.state.changing) {
this.state.changing = false;
this.options.changeEnd(this.state);
}
} else {
this.state.current += delta;
if (!this.state.changing) {
this.state.changing = true;
this.options.changeStart(this.state);
}
}
if (Math.abs(diff) < 10 && this.privateState.scrollTo.callback) {
this.privateState.scrollTo.callback(this.state);
this.privateState.scrollTo.callback = null;
}
// Set scroll bar thumb position
if (this.scrollBar) {
this.scrollBar.change(this.state);
}
// Call custom change
this.options.change(this.state);
this.scenes.forEach(function (scene) { return scene.change(this$1.state); });
this.state.previous = this.state.current;
},
/**
* Request an animation frame.
*/
rAF: function rAF() {
this.privateState.isRAFCanceled = false;
this.privateState.rAF = requestAnimationFrame(privated.change.bind(this));
},
/**
* Cancel a requested animation frame.
*/
cAF: function cAF() {
this.privateState.isRAFCanceled = true;
this.privateState.rAF = cancelAnimationFrame(this.privateState.rAF);
},
/*
** Events
*/
/**
* Checks if rolly is ready.
*/
ready: function ready() {
if (
this.state.ready
&& (this.options.preload ? this.state.preLoaded : true)
) {
this.options.ready(this.state);
return true;
}
return false;
},
/**
* Virtual scroll event callback.
* @param {object} e - The event data.
*/
virtualScroll: function virtualScroll(e) {
if (this.privateState.scrollTo.callback) { return; }
var delta = this.options.vertical ? e.deltaY : e.deltaX;
privated.setTarget.call(this, this.state.target + delta * -1);
},
/**
* Native scroll event callback.
* @param {object} e - The event data.
*/
debounceScroll: function debounceScroll(e) {
var this$1 = this;
if (this.privateState.scrollTo.callback) { return; }
var isWindow = this.DOM.listener === document.body;
var target;
if (this.options.vertical) {
target = isWindow
? window.scrollY || window.pageYOffset
: this.DOM.listener.scrollTop;
} else {
target = isWindow
? window.scrollX || window.pageXOffset
: this.DOM.listener.scrollLeft;
}
privated.setTarget.call(this, target);
clearTimeout(this.privateState.debounceScroll.timer);
if (!this.privateState.debounceScroll.tick) {
this.privateState.debounceScroll.tick = true;
this.DOM.listener.classList.add('is-scrolling');
}
this.privateState.debounceScroll.timer = setTimeout(function () {
this$1.privateState.debounceScroll.tick = false;
this$1.DOM.listener.classList.remove('is-scrolling');
}, 200);
},
/**
* Resize event callback.
* @param {object} e - The event data.
*/
resize: function resize(e) {
var this$1 = this;
var prop = this.options.vertical ? 'height' : 'width';
this.state.height = window.innerHeight;
this.state.width = window.innerWidth;
// Calc bounding
var ref = this.options;
var native = ref.native;
var vertical = ref.vertical;
var bounding = this.DOM.view.getBoundingClientRect();
this.state.bounding = vertical
? bounding.height - (native ? 0 : this.state.height)
: bounding.right - (native ? 0 : this.state.width);
// Set scroll bar thumb height (according to view height)
if (this.scrollBar) {
this.scrollBar.cache(this.state);
} else if (native) {
this.DOM.scroll.style[prop] = (this.state.bounding) + "px";
}
privated.setTarget.call(this, this.state.target);
// Get cache for scenes
this.scenes.forEach(function (scene) { return scene.cache(this$1.state); });
},
/*
** Utils
*/
/**
* Extends options.
* @param {object} options - The options to extend.
* @return {object} The extended options.
*/
extendOptions: function extendOptions(options) {
var opts = this.options ? this.options : privated.getDefaults.call(this);
options.virtualScroll = Object.assign({}, opts.virtualScroll, options.virtualScroll);
options.scenes = Object.assign({}, opts.scenes, options.scenes);
return Object.assign({}, opts, options);
},
/**
* Preload images in the view of the rolly instance.
* Useful if the view contains images that might not have fully loaded when the instance is created (because when an
* image is loaded, the total height changes).
* @param {function} callback - The function to run when images are loaded.
*/
preloadImages: function preloadImages(callback) {
var images = utils.getElements('img', this.DOM.listener);
if (!images.length) {
if (callback) { callback(); }
return;
}
images.forEach(function (image) {
var img = document.createElement('img');
img.onload = function () {
images.splice(images.indexOf(image), 1);
if (images.length === 0) { callback(); }
};
img.src = image.currentSrc || image.src;
});
},
/**
* Adds a fake scroll height.
*/
addFakeScrollHeight: function addFakeScrollHeight() {
var scroll = document.createElement('div');
scroll.className = 'rolly-scroll-view';
this.DOM.scroll = scroll;
this.DOM.listener.appendChild(this.DOM.scroll);
},
/**
* Removes a fake scroll height.
*/
removeFakeScrollHeight: function removeFakeScrollHeight() {
this.DOM.listener.removeChild(this.DOM.scroll);
},
/**
* Adds a fake scroll bar.
*/
addFakeScrollBar: function addFakeScrollBar() {
this.scrollBar = new ScrollBar(
this.DOM.listener,
this.state,
privated.setTarget.bind(this),
this.options
);
},
/**
* Removes the fake scroll bar.
*/
removeFakeScrollBar: function removeFakeScrollBar() {
this.scrollBar.destroy();
},
/*
** Getters and setters
*/
/**
* Gets the default options for the rolly instance.
* @return {object} The default options.
*/
getDefaults: function getDefaults() {
return {
vertical: true,
listener: document.body,
view: utils.getElements('.rolly-view')[0] || null,
native: true,
preload: true,
autoUpdate: true,
ready: function () { },
change: function () { },
changeStart: function () { },
changeEnd: function () { },
ease: 0.075,
virtualScroll: {
limitInertia: false,
mouseMultiplier: 0.5,
touchMultiplier: 1.5,
firefoxMultiplier: 30,
preventTouch: true,
},
noScrollBar: false,
scenes: {
selector: '[data-scene]',
speed: 1,
trigger: 'middle',
},
};
},
/**
* Gets the node element on which will be attached the scroll event listener (in case of native behavior).
* @return {object} The node element.
*/
getNodeListener: function getNodeListener() {
return this.DOM.listener === document.body ? window : this.DOM.listener;
},
/**
* Sets the target position with auto clamping.
*/
setTarget: function setTarget(target) {
// if (target === null) return;
this.state.target = Math.round(
Math.max(0, Math.min(target, this.state.bounding))
);
!this.privateState.rAF && privated.rAF.call(this);
},
};
var Rolly = function Rolly(options) {
if ( options === void 0 ) options = {};
this.boundFns = privated.getBoundFns.call(this);
// Extend default options
this.options = privated.extendOptions.call(this, options);
this.DOM = {
listener: this.options.listener,
view: this.options.view,
};
privated.initScenes.call(this);
};
/**
* Initializes the rolly instance.
* - adds DOM classes.
* - if native, adds fake height.
* - else if `options.noScrollBar` is false, adds a fake scroll bar.
* - calls {@link Rolly#on}.
*/
Rolly.prototype.init = function init () {
var this$1 = this;
// Instantiate virtual scroll native option is false
this.virtualScroll = this.options.native
? null
: new src(this.options.virtualScroll);
privated.initState.call(this);
var type = this.options.native ? 'native' : 'virtual';
var direction = this.options.vertical ? 'y' : 'x';
this.DOM.listener.classList.add(("is-" + type + "-scroll"));
this.DOM.listener.classList.add((direction + "-scroll"));
this.DOM.view.classList.add('rolly-view');
this.options.native
? privated.addFakeScrollHeight.call(this)
: !this.options.noScrollBar && privated.addFakeScrollBar.call(this);
if (this.options.preload) {
privated.preloadImages.call(this, function () {
this$1.state.preLoaded = true;
this$1.boundFns.resize();
privated.ready.call(this$1);
});
}
this.on();
};
/**
* Enables the rolly instance.
* - starts listening events (scroll and resize),
* - requests an animation frame if {@param rAF} is true.
* @param {boolean} rAF - whether to request an animation frame.
*/
Rolly.prototype.on = function on (rAF) {
if ( rAF === void 0 ) rAF = true;
if (this.options.native) {
var listener = privated.getNodeListener.call(this);
listener.addEventListener('scroll', this.boundFns.debounceScroll);
} else if (this.virtualScroll) {
this.virtualScroll.on(this.boundFns.virtualScroll);
}
if (this.scrollBar) {
this.scrollBar.on();
}
rAF && privated.rAF.call(this);
privated.resize.call(this);
if (this.options.autoUpdate) {
window.addEventListener('resize', this.boundFns.resize);
}
this.state.ready = true;
privated.ready.call(this);
};
/**
* Disables the rolly instance.
* - stops listening events (scroll and resize),
* - cancels any requested animation frame if {@param cAF} is true.
* @param {boolean} cAF - whether to cancel a requested animation frame.
*/
Rolly.prototype.off = function off (cAF) {
if ( cAF === void 0 ) cAF = true;
if (this.options.native) {
var listener = privated.getNodeListener.call(this);
listener.removeEventListener('scroll', this.boundFns.debounceScroll);
} else if (this.virtualScroll) {
this.virtualScroll.off(this.boundFns.virtualScroll);
}
if (this.scrollBar) {
this.scrollBar.off();
}
cAF && privated.cAF.call(this);
if (this.options.autoUpdate) {
window.removeEventListener('resize', this.boundFns.resize);
}
this.state.ready = false;
};
/**
* Destroys the rolly instance.
* - removes DOM classes.
* - if native, removes the fake height for scroll.
* - else if `options.noScrollBar` is false, removes the fake scroll bar.
* - calls {@link Rolly#off}.
*/
Rolly.prototype.destroy = function destroy () {
var type = this.options.native ? 'native' : 'virtual';
var direction = this.options.vertical ? 'y' : 'x';
this.DOM.listener.classList.remove(("is-" + type + "-scroll"));
this.DOM.listener.classList.remove((direction + "-scroll"));
this.DOM.view.classList.remove('rolly-view');
if (this.virtualScroll) {
this.virtualScroll.destroy();
this.virtualScroll = null;
}
this.off();
this.options.native
? privated.removeFakeScrollHeight.call(this)
: !this.options.noScrollBar && privated.removeFakeScrollBar.call(this);
};
/**
* Reloads the rolly instance with new options.
* @param {object} options - Options of rolly.
*/
Rolly.prototype.reload = function reload (options) {
if ( options === void 0 ) options = this.options;
this.destroy();
this.boundFns = privated.getBoundFns.call(this);
// Extend default options
this.options = privated.extendOptions.call(this, options);
var ref = this;
var DOM = ref.DOM;
this.DOM = Object.assign({}, DOM,
{listener: this.options.listener,
view: this.options.view});
privated.initScenes.call(this);
this.init();
};
/**
* Scrolls to a target (number|DOM element).
* @param {number|object} target - The target to scroll to.
* @param {object} options - Options.
*/
Rolly.prototype.scrollTo = function scrollTo (target, options) {
var assign;
var defaultOptions = {
offset: 0,
position: 'start',
callback: null,
};
options = Object.assign({}, defaultOptions, options);
var ref = this.options;
var vertical = ref.vertical;
var scrollOffset = this.state.current;
var bounding = null;
var newPos = scrollOffset + options.offset;
if (typeof target === 'string') {
(assign = utils.getElements(target), target = assign[0]);
}
switch (typeof target) {
case 'number':
default:
newPos = target;
break;
case 'object':
if (!target) { return; }
bounding = target.getBoundingClientRect();
newPos += vertical ? bounding.top : bounding.left;
break;
}
switch (options.position) {
case 'center':
default:
newPos -= vertical ? this.state.height / 2 : this.state.width / 2;
break;
case 'end':
newPos -= vertical ? this.state.height : this.state.width;
break;
}
if (options.callback) {
this.privateState.scrollTo.callback = options.callback;
}
// FIXME: if the scrollable element is not the body, this won't work
i