/*! * skrollr core * * Alexander Prinzhorn - https://github.com/Prinzhorn/skrollr * * Free to use under terms of MIT license */ (function(window, document, undefined) { 'use strict'; /* * Global api. */ var skrollr = { get: function() { return _instance; }, //Main entry point. init: function(options) { return _instance || new Skrollr(options); }, VERSION: '0.6.30' }; //Minify optimization. var hasProp = Object.prototype.hasOwnProperty; var Math = window.Math; var getStyle = window.getComputedStyle; //They will be filled when skrollr gets initialized. var documentElement; var body; var EVENT_TOUCHSTART = 'touchstart'; var EVENT_TOUCHMOVE = 'touchmove'; var EVENT_TOUCHCANCEL = 'touchcancel'; var EVENT_TOUCHEND = 'touchend'; var SKROLLABLE_CLASS = 'skrollable'; var SKROLLABLE_BEFORE_CLASS = SKROLLABLE_CLASS + '-before'; var SKROLLABLE_BETWEEN_CLASS = SKROLLABLE_CLASS + '-between'; var SKROLLABLE_AFTER_CLASS = SKROLLABLE_CLASS + '-after'; var SKROLLR_CLASS = 'skrollr'; var NO_SKROLLR_CLASS = 'no-' + SKROLLR_CLASS; var SKROLLR_DESKTOP_CLASS = SKROLLR_CLASS + '-desktop'; var SKROLLR_MOBILE_CLASS = SKROLLR_CLASS + '-mobile'; var DEFAULT_EASING = 'linear'; var DEFAULT_DURATION = 1000;//ms var DEFAULT_MOBILE_DECELERATION = 0.004;//pixel/ms虏 var DEFAULT_SKROLLRBODY = 'skrollr-body'; var DEFAULT_SMOOTH_SCROLLING_DURATION = 200;//ms var ANCHOR_START = 'start'; var ANCHOR_END = 'end'; var ANCHOR_CENTER = 'center'; var ANCHOR_BOTTOM = 'bottom'; //The property which will be added to the DOM element to hold the ID of the skrollable. var SKROLLABLE_ID_DOM_PROPERTY = '___skrollable_id'; var rxTouchIgnoreTags = /^(?:input|textarea|button|select)$/i; var rxTrim = /^\s+|\s+$/g; //Find all data-attributes. data-[_constant]-[offset]-[anchor]-[anchor]. var rxKeyframeAttribute = /^data(?:-(_\w+))?(?:-?(-?\d*\.?\d+p?))?(?:-?(start|end|top|center|bottom))?(?:-?(top|center|bottom))?$/; var rxPropValue = /\s*(@?[\w\-\[\]]+)\s*:\s*(.+?)\s*(?:;|$)/gi; //Easing function names follow the property in square brackets. var rxPropEasing = /^(@?[a-z\-]+)\[(\w+)\]$/; var rxCamelCase = /-([a-z0-9_])/g; var rxCamelCaseFn = function(str, letter) { return letter.toUpperCase(); }; //Numeric values with optional sign. var rxNumericValue = /[\-+]?[\d]*\.?[\d]+/g; //Used to replace occurences of {?} with a number. var rxInterpolateString = /\{\?\}/g; //Finds rgb(a) colors, which don't use the percentage notation. var rxRGBAIntegerColor = /rgba?\(\s*-?\d+\s*,\s*-?\d+\s*,\s*-?\d+/g; //Finds all gradients. var rxGradient = /[a-z\-]+-gradient/g; //Vendor prefix. Will be set once skrollr gets initialized. var theCSSPrefix = ''; var theDashedCSSPrefix = ''; //Will be called once (when skrollr gets initialized). var detectCSSPrefix = function() { //Only relevant prefixes. May be extended. //Could be dangerous if there will ever be a CSS property which actually starts with "ms". Don't hope so. var rxPrefixes = /^(?:O|Moz|webkit|ms)|(?:-(?:o|moz|webkit|ms)-)/; //Detect prefix for current browser by finding the first property using a prefix. if(!getStyle) { return; } var style = getStyle(body, null); for(var k in style) { //We check the key and if the key is a number, we check the value as well, because safari's getComputedStyle returns some weird array-like thingy. theCSSPrefix = (k.match(rxPrefixes) || (+k == k && style[k].match(rxPrefixes))); if(theCSSPrefix) { break; } } //Did we even detect a prefix? if(!theCSSPrefix) { theCSSPrefix = theDashedCSSPrefix = ''; return; } theCSSPrefix = theCSSPrefix[0]; //We could have detected either a dashed prefix or this camelCaseish-inconsistent stuff. if(theCSSPrefix.slice(0,1) === '-') { theDashedCSSPrefix = theCSSPrefix; //There's no logic behind these. Need a look up. theCSSPrefix = ({ '-webkit-': 'webkit', '-moz-': 'Moz', '-ms-': 'ms', '-o-': 'O' })[theCSSPrefix]; } else { theDashedCSSPrefix = '-' + theCSSPrefix.toLowerCase() + '-'; } }; var polyfillRAF = function() { var requestAnimFrame = window.requestAnimationFrame || window[theCSSPrefix.toLowerCase() + 'RequestAnimationFrame']; var lastTime = _now(); if(_isMobile || !requestAnimFrame) { requestAnimFrame = function(callback) { //How long did it take to render? var deltaTime = _now() - lastTime; var delay = Math.max(0, 1000 / 60 - deltaTime); return window.setTimeout(function() { lastTime = _now(); callback(); }, delay); }; } return requestAnimFrame; }; var polyfillCAF = function() { var cancelAnimFrame = window.cancelAnimationFrame || window[theCSSPrefix.toLowerCase() + 'CancelAnimationFrame']; if(_isMobile || !cancelAnimFrame) { cancelAnimFrame = function(timeout) { return window.clearTimeout(timeout); }; } return cancelAnimFrame; }; //Built-in easing functions. var easings = { begin: function() { return 0; }, end: function() { return 1; }, linear: function(p) { return p; }, quadratic: function(p) { return p * p; }, cubic: function(p) { return p * p * p; }, swing: function(p) { return (-Math.cos(p * Math.PI) / 2) + 0.5; }, sqrt: function(p) { return Math.sqrt(p); }, outCubic: function(p) { return (Math.pow((p - 1), 3) + 1); }, //see https://www.desmos.com/calculator/tbr20s8vd2 for how I did this bounce: function(p) { var a; if(p <= 0.5083) { a = 3; } else if(p <= 0.8489) { a = 9; } else if(p <= 0.96208) { a = 27; } else if(p <= 0.99981) { a = 91; } else { return 1; } return 1 - Math.abs(3 * Math.cos(p * a * 1.028) / a); } }; /** * Constructor. */ function Skrollr(options) { documentElement = document.documentElement; body = document.body; detectCSSPrefix(); _instance = this; options = options || {}; _constants = options.constants || {}; //We allow defining custom easings or overwrite existing. if(options.easing) { for(var e in options.easing) { easings[e] = options.easing[e]; } } _edgeStrategy = options.edgeStrategy || 'set'; _listeners = { //Function to be called right before rendering. beforerender: options.beforerender, //Function to be called right after finishing rendering. render: options.render, //Function to be called whenever an element with the `data-emit-events` attribute passes a keyframe. keyframe: options.keyframe }; //forceHeight is true by default _forceHeight = options.forceHeight !== false; if(_forceHeight) { _scale = options.scale || 1; } _mobileDeceleration = options.mobileDeceleration || DEFAULT_MOBILE_DECELERATION; _smoothScrollingEnabled = options.smoothScrolling !== false; _smoothScrollingDuration = options.smoothScrollingDuration || DEFAULT_SMOOTH_SCROLLING_DURATION; //Dummy object. Will be overwritten in the _render method when smooth scrolling is calculated. _smoothScrolling = { targetTop: _instance.getScrollTop() }; //A custom check function may be passed. _isMobile = ((options.mobileCheck || function() { return (/Android|iPhone|iPad|iPod|BlackBerry/i).test(navigator.userAgent || navigator.vendor || window.opera); })()); if(_isMobile) { _skrollrBody = document.getElementById(options.skrollrBody || DEFAULT_SKROLLRBODY); //Detect 3d transform if there's a skrollr-body (only needed for #skrollr-body). if(_skrollrBody) { _detect3DTransforms(); } _initMobile(); _updateClass(documentElement, [SKROLLR_CLASS, SKROLLR_MOBILE_CLASS], [NO_SKROLLR_CLASS]); } else { _updateClass(documentElement, [SKROLLR_CLASS, SKROLLR_DESKTOP_CLASS], [NO_SKROLLR_CLASS]); } //Triggers parsing of elements and a first reflow. _instance.refresh(); _addEvent(window, 'resize orientationchange', function() { var width = documentElement.clientWidth; var height = documentElement.clientHeight; //Only reflow if the size actually changed (#271). if(height !== _lastViewportHeight || width !== _lastViewportWidth) { _lastViewportHeight = height; _lastViewportWidth = width; _requestReflow = true; } }); var requestAnimFrame = polyfillRAF(); //Let's go. (function animloop(){ _render(); _animFrame = requestAnimFrame(animloop); }()); return _instance; } /** * (Re)parses some or all elements. */ Skrollr.prototype.refresh = function(elements) { var elementIndex; var elementsLength; var ignoreID = false; //Completely reparse anything without argument. if(elements === undefined) { //Ignore that some elements may already have a skrollable ID. ignoreID = true; _skrollables = []; _skrollableIdCounter = 0; elements = document.getElementsByTagName('*'); } else if(elements.length === undefined) { //We also accept a single element as parameter. elements = [elements]; } elementIndex = 0; elementsLength = elements.length; for(; elementIndex < elementsLength; elementIndex++) { var el = elements[elementIndex]; var anchorTarget = el; var keyFrames = []; //If this particular element should be smooth scrolled. var smoothScrollThis = _smoothScrollingEnabled; //The edge strategy for this particular element. var edgeStrategy = _edgeStrategy; //If this particular element should emit keyframe events. var emitEvents = false; //If we're reseting the counter, remove any old element ids that may be hanging around. if(ignoreID && SKROLLABLE_ID_DOM_PROPERTY in el) { delete el[SKROLLABLE_ID_DOM_PROPERTY]; } if(!el.attributes) { continue; } //Iterate over all attributes and search for key frame attributes. var attributeIndex = 0; var attributesLength = el.attributes.length; for (; attributeIndex < attributesLength; attributeIndex++) { var attr = el.attributes[attributeIndex]; if(attr.name === 'data-anchor-target') { anchorTarget = document.querySelector(attr.value); if(anchorTarget === null) { throw 'Unable to find anchor target "' + attr.value + '"'; } continue; } //Global smooth scrolling can be overridden by the element attribute. if(attr.name === 'data-smooth-scrolling') { smoothScrollThis = attr.value !== 'off'; continue; } //Global edge strategy can be overridden by the element attribute. if(attr.name === 'data-edge-strategy') { edgeStrategy = attr.value; continue; } //Is this element tagged with the `data-emit-events` attribute? if(attr.name === 'data-emit-events') { emitEvents = true; continue; } var match = attr.name.match(rxKeyframeAttribute); if(match === null) { continue; } var kf = { props: attr.value, //Point back to the element as well. element: el, //The name of the event which this keyframe will fire, if emitEvents is eventType: attr.name.replace(rxCamelCase, rxCamelCaseFn) }; keyFrames.push(kf); var constant = match[1]; if(constant) { //Strip the underscore prefix. kf.constant = constant.substr(1); } //Get the key frame offset. var offset = match[2]; //Is it a percentage offset? if(/p$/.test(offset)) { kf.isPercentage = true; kf.offset = (offset.slice(0, -1) | 0) / 100; } else { kf.offset = (offset | 0); } var anchor1 = match[3]; //If second anchor is not set, the first will be taken for both. var anchor2 = match[4] || anchor1; //"absolute" (or "classic") mode, where numbers mean absolute scroll offset. if(!anchor1 || anchor1 === ANCHOR_START || anchor1 === ANCHOR_END) { kf.mode = 'absolute'; //data-end needs to be calculated after all key frames are known. if(anchor1 === ANCHOR_END) { kf.isEnd = true; } else if(!kf.isPercentage) { //For data-start we can already set the key frame w/o calculations. //#59: "scale" options should only affect absolute mode. kf.offset = kf.offset * _scale; } } //"relative" mode, where numbers are relative to anchors. else { kf.mode = 'relative'; kf.anchors = [anchor1, anchor2]; } } //Does this element have key frames? if(!keyFrames.length) { continue; } //Will hold the original style and class attributes before we controlled the element (see #80). var styleAttr, classAttr; var id; if(!ignoreID && SKROLLABLE_ID_DOM_PROPERTY in el) { //We already have this element under control. Grab the corresponding skrollable id. id = el[SKROLLABLE_ID_DOM_PROPERTY]; styleAttr = _skrollables[id].styleAttr; classAttr = _skrollables[id].classAttr; } else { //It's an unknown element. Asign it a new skrollable id. id = (el[SKROLLABLE_ID_DOM_PROPERTY] = _skrollableIdCounter++); styleAttr = el.style.cssText; classAttr = _getClass(el); } _skrollables[id] = { element: el, styleAttr: styleAttr, classAttr: classAttr, anchorTarget: anchorTarget, keyFrames: keyFrames, smoothScrolling: smoothScrollThis, edgeStrategy: edgeStrategy, emitEvents: emitEvents, lastFrameIndex: -1 }; _updateClass(el, [SKROLLABLE_CLASS], []); } //Reflow for the first time. _reflow(); //Now that we got all key frame numbers right, actually parse the properties. elementIndex = 0; elementsLength = elements.length; for(; elementIndex < elementsLength; elementIndex++) { var sk = _skrollables[elements[elementIndex][SKROLLABLE_ID_DOM_PROPERTY]]; if(sk === undefined) { continue; } //Parse the property string to objects _parseProps(sk); //Fill key frames with missing properties from left and right _fillProps(sk); } return _instance; }; /** * Transform "relative" mode to "absolute" mode. * That is, calculate anchor position and offset of element. */ Skrollr.prototype.relativeToAbsolute = function(element, viewportAnchor, elementAnchor) { var viewportHeight = documentElement.clientHeight; var box = element.getBoundingClientRect(); var absolute = box.top; //#100: IE doesn't supply "height" with getBoundingClientRect. var boxHeight = box.bottom - box.top; if(viewportAnchor === ANCHOR_BOTTOM) { absolute -= viewportHeight; } else if(viewportAnchor === ANCHOR_CENTER) { absolute -= viewportHeight / 2; } if(elementAnchor === ANCHOR_BOTTOM) { absolute += boxHeight; } else if(elementAnchor === ANCHOR_CENTER) { absolute += boxHeight / 2; } //Compensate scrolling since getBoundingClientRect is relative to viewport. absolute += _instance.getScrollTop(); return (absolute + 0.5) | 0; }; /** * Animates scroll top to new position. */ Skrollr.prototype.animateTo = function(top, options) { options = options || {}; var now = _now(); var scrollTop = _instance.getScrollTop(); var duration = options.duration === undefined ? DEFAULT_DURATION : options.duration; //Setting this to a new value will automatically cause the current animation to stop, if any. _scrollAnimation = { startTop: scrollTop, topDiff: top - scrollTop, targetTop: top, duration: duration, startTime: now, endTime: now + duration, easing: easings[options.easing || DEFAULT_EASING], done: options.done }; //Don't queue the animation if there's nothing to animate. if(!_scrollAnimation.topDiff) { if(_scrollAnimation.done) { _scrollAnimation.done.call(_instance, false); } _scrollAnimation = undefined; } return _instance; }; /** * Stops animateTo animation. */ Skrollr.prototype.stopAnimateTo = function() { if(_scrollAnimation && _scrollAnimation.done) { _scrollAnimation.done.call(_instance, true); } _scrollAnimation = undefined; }; /** * Returns if an animation caused by animateTo is currently running. */ Skrollr.prototype.isAnimatingTo = function() { return !!_scrollAnimation; }; Skrollr.prototype.isMobile = function() { return _isMobile; }; Skrollr.prototype.setScrollTop = function(top, force) { _forceRender = (force === true); if(_isMobile) { _mobileOffset = Math.min(Math.max(top, 0), _maxKeyFrame); } else { window.scrollTo(0, top); } return _instance; }; Skrollr.prototype.getScrollTop = function() { if(_isMobile) { return _mobileOffset; } else { return window.pageYOffset || documentElement.scrollTop || body.scrollTop || 0; } }; Skrollr.prototype.getMaxScrollTop = function() { return _maxKeyFrame; }; Skrollr.prototype.on = function(name, fn) { _listeners[name] = fn; return _instance; }; Skrollr.prototype.off = function(name) { delete _listeners[name]; return _instance; }; Skrollr.prototype.destroy = function() { var cancelAnimFrame = polyfillCAF(); cancelAnimFrame(_animFrame); _removeAllEvents(); _updateClass(documentElement, [NO_SKROLLR_CLASS], [SKROLLR_CLASS, SKROLLR_DESKTOP_CLASS, SKROLLR_MOBILE_CLASS]); var skrollableIndex = 0; var skrollablesLength = _skrollables.length; for(; skrollableIndex < skrollablesLength; skrollableIndex++) { _reset(_skrollables[skrollableIndex].element); } documentElement.style.overflow = body.style.overflow = ''; documentElement.style.height = body.style.height = ''; if(_skrollrBody) { skrollr.setStyle(_skrollrBody, 'transform', 'none'); } _instance = undefined; _skrollrBody = undefined; _listeners = undefined; _forceHeight = undefined; _maxKeyFrame = 0; _scale = 1; _constants = undefined; _mobileDeceleration = undefined; _direction = 'down'; _lastTop = -1; _lastViewportWidth = 0; _lastViewportHeight = 0; _requestReflow = false; _scrollAnimation = undefined; _smoothScrollingEnabled = undefined; _smoothScrollingDuration = undefined; _smoothScrolling = undefined; _forceRender = undefined; _skrollableIdCounter = 0; _edgeStrategy = undefined; _isMobile = false; _mobileOffset = 0; _translateZ = undefined; }; /* Private methods. */ var _initMobile = function() { var initialElement; var initialTouchY; var initialTouchX; var currentElement; var currentTouchY; var currentTouchX; var lastTouchY; var deltaY; var initialTouchTime; var currentTouchTime; var lastTouchTime; var deltaTime; _addEvent(documentElement, [EVENT_TOUCHSTART, EVENT_TOUCHMOVE, EVENT_TOUCHCANCEL, EVENT_TOUCHEND].join(' '), function(e) { var touch = e.changedTouches[0]; currentElement = e.target; //We don't want text nodes. while(currentElement.nodeType === 3) { currentElement = currentElement.parentNode; } currentTouchY = touch.clientY; currentTouchX = touch.clientX; currentTouchTime = e.timeStamp; if(!rxTouchIgnoreTags.test(currentElement.tagName)) { e.preventDefault(); } switch(e.type) { case EVENT_TOUCHSTART: //The last element we tapped on. if(initialElement) { initialElement.blur(); } _instance.stopAnimateTo(); initialElement = currentElement; initialTouchY = lastTouchY = currentTouchY; initialTouchX = currentTouchX; initialTouchTime = currentTouchTime; break; case EVENT_TOUCHMOVE: //Prevent default event on touchIgnore elements in case they don't have focus yet. if(rxTouchIgnoreTags.test(currentElement.tagName) && document.activeElement !== currentElement) { e.preventDefault(); } deltaY = currentTouchY - lastTouchY; deltaTime = currentTouchTime - lastTouchTime; _instance.setScrollTop(_mobileOffset - deltaY, true); lastTouchY = currentTouchY; lastTouchTime = currentTouchTime; break; default: case EVENT_TOUCHCANCEL: case EVENT_TOUCHEND: var distanceY = initialTouchY - currentTouchY; var distanceX = initialTouchX - currentTouchX; var distance2 = distanceX * distanceX + distanceY * distanceY; //Check if it was more like a tap (moved less than 7px). if(distance2 < 49) { if(!rxTouchIgnoreTags.test(initialElement.tagName)) { initialElement.focus(); //It was a tap, click the element. var clickEvent = document.createEvent('MouseEvents'); clickEvent.initMouseEvent('click', true, true, e.view, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey, 0, null); initialElement.dispatchEvent(clickEvent); } return; } initialElement = undefined; var speed = deltaY / deltaTime; //Cap speed at 3 pixel/ms. speed = Math.max(Math.min(speed, 3), -3); var duration = Math.abs(speed / _mobileDeceleration); var targetOffset = speed * duration + 0.5 * _mobileDeceleration * duration * duration; var targetTop = _instance.getScrollTop() - targetOffset; //Relative duration change for when scrolling above bounds. var targetRatio = 0; //Change duration proportionally when scrolling would leave bounds. if(targetTop > _maxKeyFrame) { targetRatio = (_maxKeyFrame - targetTop) / targetOffset; targetTop = _maxKeyFrame; } else if(targetTop < 0) { targetRatio = -targetTop / targetOffset; targetTop = 0; } duration = duration * (1 - targetRatio); _instance.animateTo((targetTop + 0.5) | 0, {easing: 'outCubic', duration: duration}); break; } }); //Just in case there has already been some native scrolling, reset it. window.scrollTo(0, 0); documentElement.style.overflow = body.style.overflow = 'hidden'; }; /** * Updates key frames which depend on others / need to be updated on resize. * That is "end" in "absolute" mode and all key frames in "relative" mode. * Also handles constants, because they may change on resize. */ var _updateDependentKeyFrames = function() { var viewportHeight = documentElement.clientHeight; var processedConstants = _processConstants(); var skrollable; var element; var anchorTarget; var keyFrames; var keyFrameIndex; var keyFramesLength; var kf; var skrollableIndex; var skrollablesLength; var offset; var constantValue; //First process all relative-mode elements and find the max key frame. skrollableIndex = 0; skrollablesLength = _skrollables.length; for(; skrollableIndex < skrollablesLength; skrollableIndex++) { skrollable = _skrollables[skrollableIndex]; element = skrollable.element; anchorTarget = skrollable.anchorTarget; keyFrames = skrollable.keyFrames; keyFrameIndex = 0; keyFramesLength = keyFrames.length; for(; keyFrameIndex < keyFramesLength; keyFrameIndex++) { kf = keyFrames[keyFrameIndex]; offset = kf.offset; constantValue = processedConstants[kf.constant] || 0; kf.frame = offset; if(kf.isPercentage) { //Convert the offset to percentage of the viewport height. offset = offset * viewportHeight; //Absolute + percentage mode. kf.frame = offset; } if(kf.mode === 'relative') { _reset(element); kf.frame = _instance.relativeToAbsolute(anchorTarget, kf.anchors[0], kf.anchors[1]) - offset; _reset(element, true); } kf.frame += constantValue; //Only search for max key frame when forceHeight is enabled. if(_forceHeight) { //Find the max key frame, but don't use one of the data-end ones for comparison. if(!kf.isEnd && kf.frame > _maxKeyFrame) { _maxKeyFrame = kf.frame; } } } } //#133: The document can be larger than the maxKeyFrame we found. _maxKeyFrame = Math.max(_maxKeyFrame, _getDocumentHeight()); //Now process all data-end keyframes. skrollableIndex = 0; skrollablesLength = _skrollables.length; for(; skrollableIndex < skrollablesLength; skrollableIndex++) { skrollable = _skrollables[skrollableIndex]; keyFrames = skrollable.keyFrames; keyFrameIndex = 0; keyFramesLength = keyFrames.length; for(; keyFrameIndex < keyFramesLength; keyFrameIndex++) { kf = keyFrames[keyFrameIndex]; constantValue = processedConstants[kf.constant] || 0; if(kf.isEnd) { kf.frame = _maxKeyFrame - kf.offset + constantValue; } } skrollable.keyFrames.sort(_keyFrameComparator); } }; /** * Calculates and sets the style properties for the element at the given frame. * @param fakeFrame The frame to render at when smooth scrolling is enabled. * @param actualFrame The actual frame we are at. */ var _calcSteps = function(fakeFrame, actualFrame) { //Iterate over all skrollables. var skrollableIndex = 0; var skrollablesLength = _skrollables.length; for(; skrollableIndex < skrollablesLength; skrollableIndex++) { var skrollable = _skrollables[skrollableIndex]; var element = skrollable.element; var frame = skrollable.smoothScrolling ? fakeFrame : actualFrame; var frames = skrollable.keyFrames; var framesLength = frames.length; var firstFrame = frames[0]; var lastFrame = frames[frames.length - 1]; var beforeFirst = frame < firstFrame.frame; var afterLast = frame > lastFrame.frame; var firstOrLastFrame = beforeFirst ? firstFrame : lastFrame; var emitEvents = skrollable.emitEvents; var lastFrameIndex = skrollable.lastFrameIndex; var key; var value; //If we are before/after the first/last frame, set the styles according to the given edge strategy. if(beforeFirst || afterLast) { //Check if we already handled this edge case last time. //Note: using setScrollTop it's possible that we jumped from one edge to the other. if(beforeFirst && skrollable.edge === -1 || afterLast && skrollable.edge === 1) { continue; } //Add the skrollr-before or -after class. if(beforeFirst) { _updateClass(element, [SKROLLABLE_BEFORE_CLASS], [SKROLLABLE_AFTER_CLASS, SKROLLABLE_BETWEEN_CLASS]); //This handles the special case where we exit the first keyframe. if(emitEvents && lastFrameIndex > -1) { _emitEvent(element, firstFrame.eventType, _direction); skrollable.lastFrameIndex = -1; } } else { _updateClass(element, [SKROLLABLE_AFTER_CLASS], [SKROLLABLE_BEFORE_CLASS, SKROLLABLE_BETWEEN_CLASS]); //This handles the special case where we exit the last keyframe. if(emitEvents && lastFrameIndex < framesLength) { _emitEvent(element, lastFrame.eventType, _direction); skrollable.lastFrameIndex = framesLength; } } //Remember that we handled the edge case (before/after the first/last keyframe). skrollable.edge = beforeFirst ? -1 : 1; switch(skrollable.edgeStrategy) { case 'reset': _reset(element); continue; case 'ease': //Handle this case like it would be exactly at first/last keyframe and just pass it on. frame = firstOrLastFrame.frame; break; default: case 'set': var props = firstOrLastFrame.props; for(key in props) { if(hasProp.call(props, key)) { value = _interpolateString(props[key].value); //Set style or attribute. if(key.indexOf('@') === 0) { element.setAttribute(key.substr(1), value); } else { skrollr.setStyle(element, key, value); } } } continue; } } else { //Did we handle an edge last time? if(skrollable.edge !== 0) { _updateClass(element, [SKROLLABLE_CLASS, SKROLLABLE_BETWEEN_CLASS], [SKROLLABLE_BEFORE_CLASS, SKROLLABLE_AFTER_CLASS]); skrollable.edge = 0; } } //Find out between which two key frames we are right now. var keyFrameIndex = 0; for(; keyFrameIndex < framesLength - 1; keyFrameIndex++) { if(frame >= frames[keyFrameIndex].frame && frame <= frames[keyFrameIndex + 1].frame) { var left = frames[keyFrameIndex]; var right = frames[keyFrameIndex + 1]; for(key in left.props) { if(hasProp.call(left.props, key)) { var progress = (frame - left.frame) / (right.frame - left.frame); //Transform the current progress using the given easing function. progress = left.props[key].easing(progress); //Interpolate between the two values value = _calcInterpolation(left.props[key].value, right.props[key].value, progress); value = _interpolateString(value); //Set style or attribute. if(key.indexOf('@') === 0) { element.setAttribute(key.substr(1), value); } else { skrollr.setStyle(element, key, value); } } } //Are events enabled on this element? //This code handles the usual cases of scrolling through different keyframes. //The special cases of before first and after last keyframe are handled above. if(emitEvents) { //Did we pass a new keyframe? if(lastFrameIndex !== keyFrameIndex) { if(_direction === 'down') { _emitEvent(element, left.eventType, _direction); } else { _emitEvent(element, right.eventType, _direction); } skrollable.lastFrameIndex = keyFrameIndex; } } break; } } } }; /** * Renders all elements. */ var _render = function() { if(_requestReflow) { _requestReflow = false; _reflow(); } //We may render something else than the actual scrollbar position. var renderTop = _instance.getScrollTop(); //If there's an animation, which ends in current render call, call the callback after rendering. var afterAnimationCallback; var now = _now(); var progress; //Before actually rendering handle the scroll animation, if any. if(_scrollAnimation) { //It's over if(now >= _scrollAnimation.endTime) { renderTop = _scrollAnimation.targetTop; afterAnimationCallback = _scrollAnimation.done; _scrollAnimation = undefined; } else { //Map the current progress to the new progress using given easing function. progress = _scrollAnimation.easing((now - _scrollAnimation.startTime) / _scrollAnimation.duration); renderTop = (_scrollAnimation.startTop + progress * _scrollAnimation.topDiff) | 0; } _instance.setScrollTop(renderTop, true); } //Smooth scrolling only if there's no animation running and if we're not forcing the rendering. else if(!_forceRender) { var smoothScrollingDiff = _smoothScrolling.targetTop - renderTop; //The user scrolled, start new smooth scrolling. if(smoothScrollingDiff) { _smoothScrolling = { startTop: _lastTop, topDiff: renderTop - _lastTop, targetTop: renderTop, startTime: _lastRenderCall, endTime: _lastRenderCall + _smoothScrollingDuration }; } //Interpolate the internal scroll position (not the actual scrollbar). if(now <= _smoothScrolling.endTime) { //Map the current progress to the new progress using easing function. progress = easings.sqrt((now - _smoothScrolling.startTime) / _smoothScrollingDuration); renderTop = (_smoothScrolling.startTop + progress * _smoothScrolling.topDiff) | 0; } } //Did the scroll position even change? if(_forceRender || _lastTop !== renderTop) { //Remember in which direction are we scrolling? _direction = (renderTop > _lastTop) ? 'down' : (renderTop < _lastTop ? 'up' : _direction); _forceRender = false; var listenerParams = { curTop: renderTop, lastTop: _lastTop, maxTop: _maxKeyFrame, direction: _direction }; //Tell the listener we are about to render. var continueRendering = _listeners.beforerender && _listeners.beforerender.call(_instance, listenerParams); //The beforerender listener function is able the cancel rendering. if(continueRendering !== false) { //Now actually interpolate all the styles. _calcSteps(renderTop, _instance.getScrollTop()); //That's were we actually "scroll" on mobile. if(_isMobile && _skrollrBody) { //Set the transform ("scroll it"). skrollr.setStyle(_skrollrBody, 'transform', 'translate(0, ' + -(_mobileOffset) + 'px) ' + _translateZ); } //Remember when we last rendered. _lastTop = renderTop; if(_listeners.render) { _listeners.render.call(_instance, listenerParams); } } if(afterAnimationCallback) { afterAnimationCallback.call(_instance, false); } } _lastRenderCall = now; }; /** * Parses the properties for each key frame of the given skrollable. */ var _parseProps = function(skrollable) { //Iterate over all key frames var keyFrameIndex = 0; var keyFramesLength = skrollable.keyFrames.length; for(; keyFrameIndex < keyFramesLength; keyFrameIndex++) { var frame = skrollable.keyFrames[keyFrameIndex]; var easing; var value; var prop; var props = {}; var match; while((match = rxPropValue.exec(frame.props)) !== null) { prop = match[1]; value = match[2]; easing = prop.match(rxPropEasing); //Is there an easing specified for this prop? if(easing !== null) { prop = easing[1]; easing = easing[2]; } else { easing = DEFAULT_EASING; } //Exclamation point at first position forces the value to be taken literal. value = value.indexOf('!') ? _parseProp(value) : [value.slice(1)]; //Save the prop for this key frame with his value and easing function props[prop] = { value: value, easing: easings[easing] }; } frame.props = props; } }; /** * Parses a value extracting numeric values and generating a format string * for later interpolation of the new values in old string. * * @param val The CSS value to be parsed. * @return Something like ["rgba(?%,?%, ?%,?)", 100, 50, 0, .7] * where the first element is the format string later used * and all following elements are the numeric value. */ var _parseProp = function(val) { var numbers = []; //One special case, where floats don't work. //We replace all occurences of rgba colors //which don't use percentage notation with the percentage notation. rxRGBAIntegerColor.lastIndex = 0; val = val.replace(rxRGBAIntegerColor, function(rgba) { return rgba.replace(rxNumericValue, function(n) { return n / 255 * 100 + '%'; }); }); //Handle prefixing of "gradient" values. //For now only the prefixed value will be set. Unprefixed isn't supported anyway. if(theDashedCSSPrefix) { rxGradient.lastIndex = 0; val = val.replace(rxGradient, function(s) { return theDashedCSSPrefix + s; }); } //Now parse ANY number inside this string and create a format string. val = val.replace(rxNumericValue, function(n) { numbers.push(+n); return '{?}'; }); //Add the formatstring as first value. numbers.unshift(val); return numbers; }; /** * Fills the key frames with missing left and right hand properties. * If key frame 1 has property X and key frame 2 is missing X, * but key frame 3 has X again, then we need to assign X to key frame 2 too. * * @param sk A skrollable. */ var _fillProps = function(sk) { //Will collect the properties key frame by key frame var propList = {}; var keyFrameIndex; var keyFramesLength; //Iterate over all key frames from left to right keyFrameIndex = 0; keyFramesLength = sk.keyFrames.length; for(; keyFrameIndex < keyFramesLength; keyFrameIndex++) { _fillPropForFrame(sk.keyFrames[keyFrameIndex], propList); } //Now do the same from right to fill the last gaps propList = {}; //Iterate over all key frames from right to left keyFrameIndex = sk.keyFrames.length - 1; for(; keyFrameIndex >= 0; keyFrameIndex--) { _fillPropForFrame(sk.keyFrames[keyFrameIndex], propList); } }; var _fillPropForFrame = function(frame, propList) { var key; //For each key frame iterate over all right hand properties and assign them, //but only if the current key frame doesn't have the property by itself for(key in propList) { //The current frame misses this property, so assign it. if(!hasProp.call(frame.props, key)) { frame.props[key] = propList[key]; } } //Iterate over all props of the current frame and collect them for(key in frame.props) { propList[key] = frame.props[key]; } }; /** * Calculates the new values for two given values array. */ var _calcInterpolation = function(val1, val2, progress) { var valueIndex; var val1Length = val1.length; //They both need to have the same length if(val1Length !== val2.length) { throw 'Can\'t interpolate between "' + val1[0] + '" and "' + val2[0] + '"'; } //Add the format string as first element. var interpolated = [val1[0]]; valueIndex = 1; for(; valueIndex < val1Length; valueIndex++) { //That's the line where the two numbers are actually interpolated. interpolated[valueIndex] = val1[valueIndex] + ((val2[valueIndex] - val1[valueIndex]) * progress); } return interpolated; }; /** * Interpolates the numeric values into the format string. */ var _interpolateString = function(val) { var valueIndex = 1; rxInterpolateString.lastIndex = 0; return val[0].replace(rxInterpolateString, function() { return val[valueIndex++]; }); }; /** * Resets the class and style attribute to what it was before skrollr manipulated the element. * Also remembers the values it had before reseting, in order to undo the reset. */ var _reset = function(elements, undo) { //We accept a single element or an array of elements. elements = [].concat(elements); var skrollable; var element; var elementsIndex = 0; var elementsLength = elements.length; for(; elementsIndex < elementsLength; elementsIndex++) { element = elements[elementsIndex]; skrollable = _skrollables[element[SKROLLABLE_ID_DOM_PROPERTY]]; //Couldn't find the skrollable for this DOM element. if(!skrollable) { continue; } if(undo) { //Reset class and style to the "dirty" (set by skrollr) values. element.style.cssText = skrollable.dirtyStyleAttr; _updateClass(element, skrollable.dirtyClassAttr); } else { //Remember the "dirty" (set by skrollr) class and style. skrollable.dirtyStyleAttr = element.style.cssText; skrollable.dirtyClassAttr = _getClass(element); //Reset class and style to what it originally was. element.style.cssText = skrollable.styleAttr; _updateClass(element, skrollable.classAttr); } } }; /** * Detects support for 3d transforms by applying it to the skrollr-body. */ var _detect3DTransforms = function() { _translateZ = 'translateZ(0)'; skrollr.setStyle(_skrollrBody, 'transform', _translateZ); var computedStyle = getStyle(_skrollrBody); var computedTransform = computedStyle.getPropertyValue('transform'); var computedTransformWithPrefix = computedStyle.getPropertyValue(theDashedCSSPrefix + 'transform'); var has3D = (computedTransform && computedTransform !== 'none') || (computedTransformWithPrefix && computedTransformWithPrefix !== 'none'); if(!has3D) { _translateZ = ''; } }; /** * Set the CSS property on the given element. Sets prefixed properties as well. */ skrollr.setStyle = function(el, prop, val) { var style = el.style; //Camel case. prop = prop.replace(rxCamelCase, rxCamelCaseFn).replace('-', ''); //Make sure z-index gets a . //This is the only case we need to handle. if(prop === 'zIndex') { if(isNaN(val)) { //If it's not a number, don't touch it. //It could for example be "auto" (#351). style[prop] = val; } else { //Floor the number. style[prop] = '' + (val | 0); } } //#64: "float" can't be set across browsers. Needs to use "cssFloat" for all except IE. else if(prop === 'float') { style.styleFloat = style.cssFloat = val; } else { //Need try-catch for old IE. try { //Set prefixed property if there's a prefix. if(theCSSPrefix) { style[theCSSPrefix + prop.slice(0,1).toUpperCase() + prop.slice(1)] = val; } //Set unprefixed. style[prop] = val; } catch(ignore) {} } }; /** * Cross browser event handling. */ var _addEvent = skrollr.addEvent = function(element, names, callback) { var intermediate = function(e) { //Normalize IE event stuff. e = e || window.event; if(!e.target) { e.target = e.srcElement; } if(!e.preventDefault) { e.preventDefault = function() { e.returnValue = false; e.defaultPrevented = true; }; } return callback.call(this, e); }; names = names.split(' '); var name; var nameCounter = 0; var namesLength = names.length; for(; nameCounter < namesLength; nameCounter++) { name = names[nameCounter]; if(element.addEventListener) { element.addEventListener(name, callback, false); } else { element.attachEvent('on' + name, intermediate); } //Remember the events to be able to flush them later. _registeredEvents.push({ element: element, name: name, listener: callback }); } }; var _removeEvent = skrollr.removeEvent = function(element, names, callback) { names = names.split(' '); var nameCounter = 0; var namesLength = names.length; for(; nameCounter < namesLength; nameCounter++) { if(element.removeEventListener) { element.removeEventListener(names[nameCounter], callback, false); } else { element.detachEvent('on' + names[nameCounter], callback); } } }; var _removeAllEvents = function() { var eventData; var eventCounter = 0; var eventsLength = _registeredEvents.length; for(; eventCounter < eventsLength; eventCounter++) { eventData = _registeredEvents[eventCounter]; _removeEvent(eventData.element, eventData.name, eventData.listener); } _registeredEvents = []; }; var _emitEvent = function(element, name, direction) { if(_listeners.keyframe) { _listeners.keyframe.call(_instance, element, name, direction); } }; var _reflow = function() { var pos = _instance.getScrollTop(); //Will be recalculated by _updateDependentKeyFrames. _maxKeyFrame = 0; if(_forceHeight && !_isMobile) { //un-"force" the height to not mess with the calculations in _updateDependentKeyFrames (#216). body.style.height = ''; } _updateDependentKeyFrames(); if(_forceHeight && !_isMobile) { //"force" the height. body.style.height = (_maxKeyFrame + documentElement.clientHeight) + 'px'; } //The scroll offset may now be larger than needed (on desktop the browser/os prevents scrolling farther than the bottom). if(_isMobile) { _instance.setScrollTop(Math.min(_instance.getScrollTop(), _maxKeyFrame)); } else { //Remember and reset the scroll pos (#217). _instance.setScrollTop(pos, true); } _forceRender = true; }; /* * Returns a copy of the constants object where all functions and strings have been evaluated. */ var _processConstants = function() { var viewportHeight = documentElement.clientHeight; var copy = {}; var prop; var value; for(prop in _constants) { value = _constants[prop]; if(typeof value === 'function') { value = value.call(_instance); } //Percentage offset. else if((/p$/).test(value)) { value = (value.slice(0, -1) / 100) * viewportHeight; } copy[prop] = value; } return copy; }; /* * Returns the height of the document. */ var _getDocumentHeight = function() { var skrollrBodyHeight = 0; var bodyHeight; if(_skrollrBody) { skrollrBodyHeight = Math.max(_skrollrBody.offsetHeight, _skrollrBody.scrollHeight); } bodyHeight = Math.max(skrollrBodyHeight, body.scrollHeight, body.offsetHeight, documentElement.scrollHeight, documentElement.offsetHeight, documentElement.clientHeight); return bodyHeight - documentElement.clientHeight; }; /** * Returns a string of space separated classnames for the current element. * Works with SVG as well. */ var _getClass = function(element) { var prop = 'className'; //SVG support by using className.baseVal instead of just className. if(window.SVGElement && element instanceof window.SVGElement) { element = element[prop]; prop = 'baseVal'; } return element[prop]; }; /** * Adds and removes a CSS classes. * Works with SVG as well. * add and remove are arrays of strings, * or if remove is ommited add is a string and overwrites all classes. */ var _updateClass = function(element, add, remove) { var prop = 'className'; //SVG support by using className.baseVal instead of just className. if(window.SVGElement && element instanceof window.SVGElement) { element = element[prop]; prop = 'baseVal'; } //When remove is ommited, we want to overwrite/set the classes. if(remove === undefined) { element[prop] = add; return; } //Cache current classes. We will work on a string before passing back to DOM. var val = element[prop]; //All classes to be removed. var classRemoveIndex = 0; var removeLength = remove.length; for(; classRemoveIndex < removeLength; classRemoveIndex++) { val = _untrim(val).replace(_untrim(remove[classRemoveIndex]), ' '); } val = _trim(val); //All classes to be added. var classAddIndex = 0; var addLength = add.length; for(; classAddIndex < addLength; classAddIndex++) { //Only add if el not already has class. if(_untrim(val).indexOf(_untrim(add[classAddIndex])) === -1) { val += ' ' + add[classAddIndex]; } } element[prop] = _trim(val); }; var _trim = function(a) { return a.replace(rxTrim, ''); }; /** * Adds a space before and after the string. */ var _untrim = function(a) { return ' ' + a + ' '; }; var _now = Date.now || function() { return +new Date(); }; var _keyFrameComparator = function(a, b) { return a.frame - b.frame; }; /* * Private variables. */ //Singleton var _instance; /* A list of all elements which should be animated associated with their the metadata. Exmaple skrollable with two key frames animating from 100px width to 20px: skrollable = { element: , styleAttr: