diff --git a/app/assets/javascripts/app/controllers/layout_ref.js.coffee b/app/assets/javascripts/app/controllers/layout_ref.js.coffee index 875932637..d5174b828 100644 --- a/app/assets/javascripts/app/controllers/layout_ref.js.coffee +++ b/app/assets/javascripts/app/controllers/layout_ref.js.coffee @@ -101,10 +101,23 @@ App.Config.set( 'layout_ref/content', Content, 'Routes' ) class CommunicationOverview extends App.ControllerContent + constructor: -> super @render() + @bindScrollPageHeader() + + bindScrollPageHeader: -> + pageHeader = @$('.page-header') + scrollHolder = pageHeader.scrollParent() + scrollBody = scrollHolder.get(0).scrollHeight - scrollHolder.height() + + if scrollBody > pageHeader.height() + skrollr.init + forceHeight: false + holder: scrollHolder.get(0) + render: -> @html App.view('layout_ref/communication_overview')() diff --git a/app/assets/javascripts/app/controllers/ticket_zoom.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom.js.coffee index 2ba1146dd..867b72098 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom.js.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom.js.coffee @@ -375,6 +375,18 @@ class App.TicketZoom extends App.Controller @userPopups() @autosaveStart() + + @bindScrollPageHeader() + + bindScrollPageHeader: -> + pageHeader = @$('.page-header') + scrollHolder = pageHeader.scrollParent() + scrollBody = scrollHolder.get(0).scrollHeight - scrollHolder.height() + + if scrollBody > pageHeader.height() + skrollr.init + forceHeight: false + holder: scrollHolder.get(0) ArticleView: => diff --git a/app/assets/javascripts/app/lib/animations/skrollr.js b/app/assets/javascripts/app/lib/animations/skrollr.js new file mode 100644 index 000000000..0dfe0280c --- /dev/null +++ b/app/assets/javascripts/app/lib/animations/skrollr.js @@ -0,0 +1,1773 @@ +/*! + * skrollr core + * + * Alexander Prinzhorn - https://github.com/Prinzhorn/skrollr + * + * Modified: Felix Niklas – add 'holder' option for scrolling inside a div + * + * 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.26' + }; + + //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_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 = options.holder || 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('skrollr-body'); + + //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(); + + //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: options.duration || DEFAULT_DURATION, + startTime: now, + endTime: now + (options.duration || DEFAULT_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 documentElement.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; + } + } + + //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); + } + + //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()); + + //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 = (_skrollrBody && _skrollrBody.offsetHeight || 0); + var 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: