Pass data into ScrollStory via data attributes.
13 |13 | Category (event): 14 |
18 | Our code here
19 |
20 | Category:
Each story item turns blue when it enters the viewport and green when it becomes "active" at the triggerpoint (red line), which by default is the top of the viewport. "Active" is exclusive, so only one item can be active at a time.
14 |
15 | Our code here
16 |
17 | Downloading Data
48 |Generate elements from data.
51 | 52 |You can 'filter' items, which takes them out of the active list.
36 |Fifth List
59 |")),e}function g(e,n,t){var r=n?E[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(r)&&a.push(r),a.join(" ").trim()}function p(e){var n=a(e);if(!/no(-?)highlight|plain|text/.test(n)){var t;x.useBR?(t=document.createElementNS("http://www.w3.org/1999/xhtml","div"),t.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/
/g,"\n")):t=e;var r=t.textContent,i=n?s(n,r,!0):l(r),c=o(t);if(c.length){var p=document.createElementNS("http://www.w3.org/1999/xhtml","div");p.innerHTML=i.value,i.value=u(c,o(p),r)}i.value=f(i.value),e.innerHTML=i.value,e.className=g(e.className,n,i.language),e.result={language:i.language,re:i.r},i.second_best&&(e.second_best={language:i.second_best.language,re:i.second_best.r})}}function d(e){x=i(x,e)}function h(){if(!h.called){h.called=!0;var e=document.querySelectorAll("pre code");Array.prototype.forEach.call(e,p)}}function b(){addEventListener("DOMContentLoaded",h,!1),addEventListener("load",h,!1)}function v(n,t){var r=w[n]=t(e);r.aliases&&r.aliases.forEach(function(e){E[e]=n})}function m(){return Object.keys(w)}function N(e){return w[e]||w[E[e]]}var x={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},w={},E={};return e.highlight=s,e.highlightAuto=l,e.fixMarkup=f,e.highlightBlock=p,e.configure=d,e.initHighlighting=h,e.initHighlightingOnLoad=b,e.registerLanguage=v,e.listLanguages=m,e.getLanguage=N,e.inherit=i,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="\\b(0[xX][a-fA-F0-9]+|(\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such)\b/},e.C=function(n,t,r){var a=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return a.c.push(e.PWM),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e});hljs.registerLanguage("xml",function(t){var e="[A-Za-z0-9\\._:-]+",s={b:/<\?(php)?(?!\w)/,e:/\?>/,sL:"php",subLanguageMode:"continuous"},c={eW:!0,i:/,r:0,c:[s,{cN:"attribute",b:e,r:0},{b:"=",r:0,c:[{cN:"value",c:[s],v:[{b:/"/,e:/"/},{b:/'/,e:/'/},{b:/[^\s\/>]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xsl","plist"],cI:!0,c:[{cN:"doctype",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},t.C("",{r:10}),{cN:"cdata",b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"",rE:!0,sL:"css"}},{cN:"tag",b:"",rE:!0,sL:""}},s,{cN:"pi",b:/<\?\w+/,e:/\?>/,r:10},{cN:"tag",b:"?",e:"/?>",c:[{cN:"title",b:/[^ \/><\n\t]+/,r:0},c]}]}});hljs.registerLanguage("json",function(e){var t={literal:"true false null"},i=[e.QSM,e.CNM],l={cN:"value",e:",",eW:!0,eE:!0,c:i,k:t},c={b:"{",e:"}",c:[{cN:"attribute",b:'\\s*"',e:'"\\s*:\\s*',eB:!0,eE:!0,c:[e.BE],i:"\\n",starts:l}],i:"\\S"},n={b:"\\[",e:"\\]",c:[e.inherit(l,{cN:null})],i:"\\S"};return i.splice(i.length,0,c,n),{c:i,k:t,i:"\\S"}});hljs.registerLanguage("javascript",function(e){return{aliases:["js"],k:{keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as await",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},c:[{cN:"pi",r:10,v:[{b:/^\s*('|")use strict('|")/},{b:/^\s*('|")use asm('|")/}]},e.ASM,e.QSM,{cN:"string",b:"`",e:"`",c:[e.BE,{cN:"subst",b:"\\$\\{",e:"\\}"}]},e.CLCM,e.CBCM,{cN:"number",b:"\\b(0[xXbBoO][a-fA-F0-9]+|(\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",r:0},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{b:/,e:/>\s*[);\]]/,r:0,sL:"xml"}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:/[A-Za-z$_][0-9A-Za-z$_]*/}),{cN:"params",b:/\(/,e:/\)/,c:[e.CLCM,e.CBCM],i:/["'\(]/}],i:/\[|%/},{b:/\$[(.]/},{b:"\\."+e.IR,r:0},{bK:"import",e:"[;$]",k:"import from as",c:[e.ASM,e.QSM]},{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]}]}});hljs.registerLanguage("makefile",function(e){var a={cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]};return{aliases:["mk","mak"],c:[e.HCM,{b:/^\w+\s*\W*=/,rB:!0,r:0,starts:{cN:"constant",e:/\s*\W*=/,eE:!0,starts:{e:/$/,r:0,c:[a]}}},{cN:"title",b:/^[\w]+:\s*$/},{cN:"phony",b:/^\.PHONY:/,e:/$/,k:".PHONY",l:/[\.\w]+/},{b:/^\t+/,e:/$/,r:0,c:[e.QSM,a]}]}});hljs.registerLanguage("css",function(e){var c="[a-zA-Z-][a-zA-Z0-9_-]*",a={cN:"function",b:c+"\\(",rB:!0,eE:!0,e:"\\("},r={cN:"rule",b:/[A-Z\_\.\-]+\s*:/,rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:/\S/,e:":",eE:!0,starts:{cN:"value",eW:!0,eE:!0,c:[a,e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"hexcolor",b:"#[0-9A-Fa-f]+"},{cN:"important",b:"!important"}]}}]};return{cI:!0,i:/[=\/|']/,c:[e.CBCM,r,{cN:"id",b:/\#[A-Za-z0-9_-]+/},{cN:"class",b:/\.[A-Za-z0-9_-]+/,r:0},{cN:"attr_selector",b:/\[/,e:/\]/,i:"$"},{cN:"pseudo",b:/:(:)?[a-zA-Z0-9\_\-\+\(\)"']+/},{cN:"at_rule",b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{cN:"at_rule",b:"@",e:"[{;]",c:[{cN:"keyword",b:/\S+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[a,e.ASM,e.QSM,e.CSSNM]}]},{cN:"tag",b:c,r:0},{cN:"rules",b:"{",e:"}",i:/\S/,r:0,c:[e.CBCM,r]}]}}); -------------------------------------------------------------------------------- /dist/jquery.scrollstory.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @preserve ScrollStory - v1.1.0 - 2018-09-20 3 | * https://github.com/sjwilliams/scrollstory 4 | * Copyright (c) 2017 Josh Williams; Licensed MIT 5 | */ 6 | (function(factory){if(typeof define==="function"&&define.amd){define(["jquery"],factory)}else{factory(jQuery)}})(function($,undefined){var pluginName="scrollStory";var eventNameSpace="."+pluginName;var defaults={content:null,contentSelector:".story",keyboard:true,scrollOffset:0,triggerOffset:0,scrollEvent:"scroll",autoActivateFirstItem:false,disablePastLastItem:true,speed:800,easing:"swing",throttleType:"throttle",scrollSensitivity:100,throttleTypeOptions:null,autoUpdateOffsets:true,debug:false,enabled:true,setup:$.noop,destroy:$.noop,itembuild:$.noop,itemfocus:$.noop,itemblur:$.noop,itemfilter:$.noop,itemunfilter:$.noop,itementerviewport:$.noop,itemexitviewport:$.noop,categoryfocus:$.noop,categeryblur:$.noop,containeractive:$.noop,containerinactive:$.noop,containerresize:$.noop,containerscroll:$.noop,updateoffsets:$.noop,triggeroffsetupdate:$.noop,scrolloffsetupdate:$.noop,complete:$.noop};var instanceCounter=0;var dateNow=Date.now||function(){return(new Date).getTime()};var debounce=function(func,wait,immediate){var result;var timeout=null;return function(){var context=this,args=arguments;var later=function(){timeout=null;if(!immediate){result=func.apply(context,args)}};var callNow=immediate&&!timeout;clearTimeout(timeout);timeout=setTimeout(later,wait);if(callNow){result=func.apply(context,args)}return result}};var throttle=function(func,wait,options){var context,args,result;var timeout=null;var previous=0;options||(options={});var later=function(){previous=options.leading===false?0:dateNow();timeout=null;result=func.apply(context,args)};return function(){var now=dateNow();if(!previous&&options.leading===false){previous=now}var remaining=wait-(now-previous);context=this;args=arguments;if(remaining<=0){clearTimeout(timeout);timeout=null;previous=now;result=func.apply(context,args)}else if(!timeout&&options.trailing!==false){timeout=setTimeout(later,remaining)}return result}};var $window=$(window);var winHeight=$window.height();var offsetToPx=function(offset){var pxOffset;if(offsetIsAPercentage(offset)){pxOffset=offset.slice(0,-1);pxOffset=Math.round(winHeight*(parseInt(pxOffset,10)/100))}else{pxOffset=parseInt(offset,10)}return pxOffset};var offsetIsAPercentage=function(offset){return typeof offset==="string"&&offset.slice(-1)==="%"};function ScrollStory(element,options){this.el=element;this.$el=$(element);this.options=$.extend({},defaults,options);this.useNativeScroll=typeof this.options.scrollEvent==="string"&&this.options.scrollEvent.indexOf("scroll")===0;this._defaults=defaults;this._name=pluginName;this._instanceId=function(){return pluginName+"_"+instanceCounter}();this.init()}ScrollStory.prototype={init:function(){this._items=[];this._itemsById={};this._categories=[];this._tags=[];this._isActive=false;this._activeItem;this._previousItems=[];this.$el.on("setup"+eventNameSpace,this._onSetup.bind(this));this.$el.on("destroy"+eventNameSpace,this._onDestroy.bind(this));this.$el.on("containeractive"+eventNameSpace,this._onContainerActive.bind(this));this.$el.on("containerinactive"+eventNameSpace,this._onContainerInactive.bind(this));this.$el.on("itemblur"+eventNameSpace,this._onItemBlur.bind(this));this.$el.on("itemfocus"+eventNameSpace,this._onItemFocus.bind(this));this.$el.on("itementerviewport"+eventNameSpace,this._onItemEnterViewport.bind(this));this.$el.on("itemexitviewport"+eventNameSpace,this._onItemExitViewport.bind(this));this.$el.on("itemfilter"+eventNameSpace,this._onItemFilter.bind(this));this.$el.on("itemunfilter"+eventNameSpace,this._onItemUnfilter.bind(this));this.$el.on("categoryfocus"+eventNameSpace,this._onCategoryFocus.bind(this));this.$el.on("triggeroffsetupdate"+eventNameSpace,this._onTriggerOffsetUpdate.bind(this));this._trigger("setup",null,this);this.addItems(this.options.content,{handleRepaint:false});this.updateOffsets();this._trigger("complete",null,this);if(this.options.enabled){this._handleRepaint()}if(this.options.keyboard){$(document).keydown(function(e){var captured=true;switch(e.keyCode){case 37:if(e.metaKey){return}this.previous();break;case 39:this.next();break;default:captured=false}return!captured}.bind(this))}this.$trigger=$('').css({position:"fixed",width:"100%",height:"1px",top:offsetToPx(this.options.triggerOffset)+"px",left:"0px",backgroundColor:"#ff0000","-webkit-transform":"translateZ(0)","-webkit-backface-visibility":"hidden",zIndex:1e3}).attr("id",pluginName+"Trigger-"+this._instanceId);if(this.options.debug){this.$trigger.appendTo("body")}var scrollThrottle,scrollHandler;if(this.useNativeScroll){scrollThrottle=this.options.throttleType==="throttle"?throttle:debounce;scrollHandler=scrollThrottle(this._handleScroll.bind(this),this.options.scrollSensitivity,this.options.throttleTypeOptions);$window.on("scroll"+eventNameSpace,scrollHandler)}else{scrollHandler=this._handleScroll.bind(this);if(typeof this.options.scrollEvent==="function"){this.options.scrollEvent(scrollHandler)}else{$window.on(this.options.scrollEvent+eventNameSpace,function(){scrollHandler()})}}var resizeThrottle=debounce(this._handleResize,100);$window.on("DOMContentLoaded"+eventNameSpace+" load"+eventNameSpace+" resize"+eventNameSpace,resizeThrottle.bind(this));instanceCounter=instanceCounter+1},index:function(index,callback){if(typeof index==="number"&&this.getItemByIndex(index)){this.setActiveItem(this.getItemByIndex(index),{},callback)}else{return this.getActiveItem().index}},next:function(_index){var currentIndex=_index||this.index();var nextItem;if(typeof currentIndex==="number"){nextItem=this.getItemByIndex(currentIndex+1);if(nextItem){if(!nextItem.filtered){this.index(currentIndex+1)}else{this.next(currentIndex+1)}}}},previous:function(_index){var currentIndex=_index||this.index();var previousItem;if(typeof currentIndex==="number"){previousItem=this.getItemByIndex(currentIndex-1);if(previousItem){if(!previousItem.filtered){this.index(currentIndex-1)}else{this.previous(currentIndex-1)}}}},getActiveItem:function(){return this._activeItem},setActiveItem:function(item,options,callback){options=options||{};if(item.id&&this.getItemById(item.id)){this._scrollToItem(item,options,callback)}},each:function(callback){this.applyToAllItems(callback)},getLength:function(){return this.getItems().length},getItems:function(){return this._items},getItemById:function(id){return this._itemsById[id]},getItemByIndex:function(index){return this._items[index]},getItemsBy:function(truthTest){if(typeof truthTest!=="function"){throw new Error("You must provide a truthTest function")}return this.getItems().filter(function(item){return truthTest(item)})},getItemsWhere:function(properties){var keys,items=[];if($.isPlainObject(properties)){keys=Object.keys(properties);items=this.getItemsBy(function(item){var isMatch=keys.every(function(key){var match;if(typeof properties[key]==="function"){match=properties[key](item[key]);if(typeof match!=="boolean"){match=item[key]===match}}else{match=item[key]===properties[key]}return match});if(isMatch){return item}})}return items},getItemsInViewport:function(){return this.getItemsWhere({inViewport:true})},getPreviousItem:function(){return this._previousItems[0]},getPreviousItems:function(){return this._previousItems},getPercentScrollToLastItem:function(){return this._percentScrollToLastItem||0},getScrollComplete:function(){return this._totalScrollComplete||0},getFilteredItems:function(){return this.getItemsWhere({filtered:true})},getUnFilteredItems:function(){return this.getItemsWhere({filtered:false})},getItemsByCategory:function(categorySlug){return this.getItemsWhere({category:categorySlug})},getCategorySlugs:function(){return this._categories},filter:function(item){if(!item.filtered){item.filtered=true;this._trigger("itemfilter",null,item)}},unfilter:function(item){if(item.filtered){item.filtered=false;this._trigger("itemunfilter",null,item)}},filterAll:function(callback){callback=$.isFunction(callback)?callback.bind(this):$.noop;var filterFnc=this.filter.bind(this);this.getItems().forEach(filterFnc)},unfilterAll:function(callback){callback=$.isFunction(callback)?callback.bind(this):$.noop;var unfilterFnc=this.unfilter.bind(this);this.getItems().forEach(unfilterFnc)},filterBy:function(truthTest,callback){callback=$.isFunction(callback)?callback.bind(this):$.noop;var filterFnc=this.filter.bind(this);this.getItemsBy(truthTest).forEach(filterFnc);callback()},filterWhere:function(properties,callback){callback=$.isFunction(callback)?callback.bind(this):$.noop;var filterFnc=this.filter.bind(this);this.getItemsWhere(properties).forEach(filterFnc);callback()},isContainerActive:function(){return this._isActive},disable:function(){this.options.enabled=false},enable:function(){this.options.enabled=true},updateTriggerOffset:function(offset){this.options.triggerOffset=offset;this.updateOffsets();this._trigger("triggeroffsetupdate",null,offsetToPx(offset))},updateScrollOffset:function(offset){this.options.scrollOffset=offset;this.updateOffsets();this._trigger("scrolloffsetupdate",null,offsetToPx(offset))},_setActiveItem:function(){var containerInActiveArea=this._distanceToFirstItemTopOffset<=0&&Math.abs(this._distanceToOffset)-this._height<0;var items=this.getItemsWhere({filtered:false});var activeItem;items.forEach(function(item){if(item.adjustedDistanceToOffset<=0){if(!activeItem){activeItem=item}else{if(activeItem.adjustedDistanceToOffset
Story 1
...
Story 2
...
"+item.data.organization+"
"); 211 | }, 212 | itemfocus: function(ev, item){ 213 | console.log(item.data.organization + ", founded in " + item.data.founded + ", is now active!"); 214 | } 215 | }); 216 | }); 217 | ``` 218 | 219 | ##### Post-instantiation DOM 220 | 221 | ```html 222 |The Washington Post
228 |My new content!
'); 549 | } 550 | }) 551 | ``` 552 | 553 | #### categoryfocus 554 | Fired when new active item is in a different category than previously active item. 555 | 556 | ```js 557 | $('#container').scrollStory({ 558 | categoryfocus: function(ev, category) { 559 | // do something 560 | } 561 | }) 562 | ``` 563 | 564 | 565 | #### containeractive 566 | Fired when the instance changes states from having no active item to an active item. Depending on instantiation options, this may or not be on instantiation. 567 | 568 | ```js 569 | $('#container').scrollStory({ 570 | containeractive: function() { 571 | // do something 572 | } 573 | }) 574 | ``` 575 | 576 | #### containerinactive 577 | Fired when the instance changes states from having an active item to not having an active item. 578 | 579 | ```js 580 | $('#container').scrollStory({ 581 | containerinactive: function() { 582 | // do something 583 | } 584 | }) 585 | ``` 586 | 587 | #### containerscroll 588 | Throttled scroll event. 589 | 590 | ```js 591 | $('#container').scrollStory({ 592 | containerscroll: function() { 593 | // do something 594 | } 595 | }) 596 | ``` 597 | 598 | #### updateoffsets 599 | Fired after offsets have been updated. 600 | 601 | ```js 602 | $('#container').scrollStory({ 603 | updateoffsets: function() { 604 | // do something 605 | } 606 | }) 607 | ``` 608 | 609 | #### triggeroffsetupdate 610 | Fired after a trigger offset as been updated via `.updateTriggerOffset()` 611 | 612 | ```js 613 | $('#container').scrollStory({ 614 | triggeroffsetupdate: function() { 615 | // do something 616 | } 617 | }) 618 | ``` 619 | 620 | #### scrolloffsetupdate 621 | Fired after a scroll offset as been updated via `.updateScrollOffset()` 622 | 623 | ```js 624 | $('#container').scrollStory({ 625 | scrolloffsetupdate: function() { 626 | // do something 627 | } 628 | }) 629 | ``` 630 | 631 | #### complete 632 | Fired when object's instantiation is complete. 633 | 634 | ```js 635 | $('#container').scrollStory({ 636 | complete: function() { 637 | // do something 638 | } 639 | }) 640 | ``` 641 | 642 | 643 | ### API 644 | ScrollStory exposes many methods for interacting with the instance. 645 | 646 | ```js 647 | // save instance object 648 | var scrollStory = $('#container').scrollStory().data('plugin_scrollStory'); 649 | 650 | // scroll to fourth item 651 | scrollStory.index(3); 652 | 653 | 654 | // or access the methods from within the object 655 | $('#container').scrollStory({ 656 | complete: function() { 657 | this.index(3); // scroll to fourth item 658 | } 659 | }) 660 | 661 | ``` 662 | 663 | #### isContainerActive() 664 | Whether or not any of the items are active. If so, the entire widget is considered to be 'active.' 665 | 666 | #### updateOffsets() 667 | 668 | Update the object's awareness of each item's distance to the trigger. This method is called internally after instantiation and automatically on window resize. It should also be called externally anytime DOM changes affect your items' position on the page, like when filtering changes the size of an element. 669 | 670 | #### index([index]) 671 | Get or set the current index of the active item. On set, also scroll to that item. 672 | 673 | ###### Arguments 674 | 675 | * *index:* (optional Number) - The zero-based index you want to activate. 676 | 677 | #### next() 678 | Convenience method to navigate to the item after the active one. 679 | 680 | 681 | #### previous() 682 | Convenience method to navigate to the item before the active one. 683 | 684 | #### each(callback) 685 | Iterate over each item, passing the item to a callback. 686 | 687 | ###### Arguments 688 | * *callback:* Function 689 | 690 | ```js 691 | this.each(function(item, index){ 692 | item.el.append(''+item.id+'
'); 693 | }); 694 | ``` 695 | 696 | 697 | #### getActiveItem() 698 | The currently active item object. 699 | 700 | #### setActiveItem(item, [options, callback]) 701 | Given an item object, make it active, including updating its scroll position. 702 | 703 | ###### Arguments 704 | 705 | * *item:* Object - The item object to activate 706 | * *options:* (optional Object) - _scrollToItem options object. TK details. 707 | * *callback:* (optional Function) - Post-scroll callback 708 | 709 | #### getItems() 710 | Return an array of all item objects. 711 | 712 | #### getItemsInViewport() 713 | Return an array of all item objects currently visible on the screen. 714 | 715 | #### getItemsByCategory(slug) 716 | Return an array of all item objects in the given category. 717 | 718 | ###### Arguments 719 | * *slug:* String - The category slug 720 | 721 | #### getFilteredItems() 722 | Return an array of all item objects whose filtered state has been set to true. 723 | 724 | #### getUnfilteredItems() 725 | Return an array of all item objects whose filtered state has been not been set to true. 726 | 727 | 728 | #### getItemById(id) 729 | Given anitem.id, return its data.
730 |
731 | ###### Arguments
732 | * *id:* String - The item.id for the object you want to retrieve.
733 |
734 | #### getItemByIndex(index)
735 | scrollStory.getItemByIndex(): Given an item's zero-based index, return its data.
736 |
737 | ###### Arguments
738 | * *index:* Number - Zero-based index for the item object you want to retrieve.
739 |
740 |
741 |
742 | #### getItemsBy(truthTest)
743 | Return an array of item objects that pass an aribitrary truth test.
744 |
745 | ###### Arguments
746 | * *truthTest:* Function - The function to check all items against
747 |
748 | ```js
749 | this.getItemsBy(function(item){
750 | return item.data.slug=='josh_williams';
751 | });
752 | ```
753 |
754 | #### getItemsWhere(properties)
755 | Returns an array of items where all the properties match an item's properties. Property tests can be any combination of values or truth tests.
756 |
757 | ###### Arguments
758 |
759 | * *properties:* Object
760 |
761 | ```js
762 | // Values
763 | this.getItemsWhere({index:2});
764 | this.getItemsWhere({filtered:false});
765 | this.getItemsWhere({category:'cats', width: 300});
766 |
767 | // Methods that return a value
768 | this.getItemsWhere({width: function(width){ return 216 + 300;}});
769 |
770 | // Methods that return a boolean
771 | this.getItemsWhere({index: function(index){ return index > 2; } });
772 |
773 | // Mix and match:
774 | this.getItemsWehre({filtered:false, index: function(index){ return index < 30;} })
775 | ```
776 |
777 | #### getPreviousItem()
778 | Most recently active item.
779 |
780 | #### getPreviousItems()
781 | Sorted array of items that were previously active, with most recently active at the front of the array.
782 |
783 | #### getFilteredItems()
784 | Return an array of all filtered items.
785 |
786 | #### getUnfilteredItems()
787 | Return an array of all unfiltered items.
788 |
789 |
790 | #### getLength()
791 | Return the number of items.
792 |
793 | #### getCategorySlugs()
794 | Return an array of category slugs.
795 |
796 | #### filter(item)
797 | Given an item, change its state to filtered.
798 |
799 | ###### Arguments
800 |
801 | * *item:* Object - item object
802 |
803 | #### unfilter(item)
804 | Given an item, change its state to unfiltered.
805 |
806 | ###### Arguments
807 |
808 | * *item:* Object - item object
809 |
810 | #### filterBy(truthTest, [callback])
811 | Filter items that pass an abritrary truth test.
812 |
813 | ###### Arguments
814 | * *truthTest:* Function - The function to check all items against
815 | * *callback:* (optional Function) - Post-filter callback
816 |
817 | ```js
818 | scrollStory.filterBy(function(item){
819 | return item.data.slug=='josh_williams';
820 | });
821 | ```
822 | #### filterAllItems([callback])
823 | Change all items' state to filtered.
824 |
825 | ###### Arguments
826 | * *callback:* (optional Function) - Post-filter callback
827 |
828 | #### unfilterAllItems([callback])
829 | Change all items' state to unfiltered.
830 |
831 | ###### Arguments
832 | * *callback:* (optional Function) - Post-filter callback
833 |
834 | #### disable()
835 | Disable scroll updates. This is useful in the rare case when you want to manipulate the page but not have ScrollStory continue to check positions, fire events, etc. Usually a `disable` is temporary and followed by an `enable`.
836 |
837 | #### enable()
838 | Enable scroll updates.
839 |
840 | ### Release History
841 | *1.0.0*
842 |
843 | * Bump to 1.0 release.
844 |
845 | *0.3.8*
846 |
847 | * Fixed [Issue 30](https://github.com/sjwilliams/scrollstory/issues/30): Uneeded `undefined` in module setup.
848 | * Fixed [Issue 28](https://github.com/sjwilliams/scrollstory/issues/28): Typo in documentation.
849 |
850 | *0.3.7*
851 |
852 | * Fixed critical typos in documentation.
853 |
854 | *0.3.6*
855 |
856 | * Added [PR 27](https://github.com/sjwilliams/scrollstory/pull/27) Calculate item's active scroll percent complete.
857 |
858 | *0.3.5*
859 |
860 | * Added [PR 26](https://github.com/sjwilliams/scrollstory/pull/26) Optionally to bind to event other than native scroll.
861 |
862 | *0.3.4*
863 |
864 | * Fixed missing 'index' passed to `.each()` callback that was original added in [Issue 7](https://github.com/sjwilliams/scrollstory/issues/7), but got lost in the 0.3 rewrite.
865 |
866 | *0.3.3*
867 |
868 | * Added [Issue 24](https://github.com/sjwilliams/scrollstory/issues/24) New `setup` event.
869 |
870 | *0.3.2*
871 |
872 | * Fixed [Issue 20](https://github.com/sjwilliams/scrollstory/issues/20): Item focus should fire after containeractive.
873 |
874 | *0.3.1 - Rewrite/Breaking changes*
875 |
876 | * A complete rewrite that drops jQuery UI and Underscore dependencies, removes many methods, standardizes naming and more.
877 |
878 | *0.2.1*
879 |
880 | * Fixed a bug in the name of the scroll event.
881 |
882 | *0.2.0*
883 |
884 | * Added [Issue 7](https://github.com/sjwilliams/scrollstory/issues/7): `.each` method iterates over each item, passing the item to a callback that is called with two arguments: `item` and `index`.
885 |
886 | *0.1.1*
887 |
888 | * Fixed [Issue 6](https://github.com/sjwilliams/scrollstory/issues/6): Prevent back arrow key from navigating back if the meta key is down, which browsers use to navigate previous history.
889 |
890 | *0.1.0*
891 |
892 | * Fixed a bug that allowed widget to go inactive but leave an item active.
893 |
894 | *0.0.3*
895 |
896 | * Fixed in-viewport bug caused by assumed global jQuery variable.
897 | * Trigger resize event
898 | * Debug mode to visually show trigger point
899 |
900 | *0.0.2*
901 |
902 | * Bower release
903 |
904 | *0.0.1*
905 |
906 | * Initial release
907 |
908 | ###License
909 | ScrollStory is licensed under the [MIT license](http://opensource.org/licenses/MIT).
910 |
--------------------------------------------------------------------------------
/jquery.scrollstory.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @preserve ScrollStory - vVERSIONXXX - YYYY-MM-DDXXX
3 | * https://github.com/sjwilliams/scrollstory
4 | * Copyright (c) 2017 Josh Williams; Licensed MIT
5 | */
6 |
7 | (function(factory) {
8 | if (typeof define === 'function' && define.amd) {
9 | define(['jquery'], factory);
10 | } else {
11 | factory(jQuery);
12 | }
13 | }(function($, undefined) {
14 |
15 | var pluginName = 'scrollStory';
16 | var eventNameSpace = '.' + pluginName;
17 | var defaults = {
18 |
19 | // jquery object, class selector string, or array of values, or null (to use existing DOM)
20 | content: null,
21 |
22 | // Only used if content null. Should be a class selector
23 | contentSelector: '.story',
24 |
25 | // Left/right keys to navigate
26 | keyboard: true,
27 |
28 | // Offset from top used in the programatic scrolling of an
29 | // item to the focus position. Useful in the case of thinks like
30 | // top nav that might obscure part of an item if it goes to 0.
31 | scrollOffset: 0,
32 |
33 | // Offset from top to trigger a change
34 | triggerOffset: 0,
35 |
36 | // Event to monitor. Can be a name for an event on the $(window), or
37 | // a function that defines custom behavior. Defaults to native scroll event.
38 | scrollEvent: 'scroll',
39 |
40 | // Automatically activate the first item on load,
41 | // regardless of its position relative to the offset
42 | autoActivateFirstItem: false,
43 |
44 | // Disable last item -- and the entire widget -- once it's scroll beyond the trigger point
45 | disablePastLastItem: true,
46 |
47 | // Automated scroll speed in ms. Set to 0 to remove animation.
48 | speed: 800,
49 |
50 | // Scroll easing. 'swing' or 'linear', unless an external plugin provides others
51 | // http://api.jquery.com/animate/
52 | easing: 'swing',
53 |
54 | // // scroll-based events are either 'debounce' or 'throttle'
55 | throttleType: 'throttle',
56 |
57 | // frequency in milliseconds to perform scroll-based functions. Scrolling functions
58 | // can be CPU intense, so higher number can help performance.
59 | scrollSensitivity: 100,
60 |
61 | // options to pass to underscore's throttle or debounce for scroll
62 | // see: http://underscorejs.org/#throttle && http://underscorejs.org/#debounce
63 | throttleTypeOptions: null,
64 |
65 | // Update offsets after likely repaints, like window resizes and filters
66 | autoUpdateOffsets: true,
67 |
68 | debug: false,
69 |
70 | // whether or not the scroll checking is enabled.
71 | enabled: true,
72 |
73 | setup: $.noop,
74 | destroy: $.noop,
75 | itembuild: $.noop,
76 | itemfocus: $.noop,
77 | itemblur: $.noop,
78 | itemfilter: $.noop,
79 | itemunfilter: $.noop,
80 | itementerviewport: $.noop,
81 | itemexitviewport: $.noop,
82 | categoryfocus: $.noop,
83 | categeryblur: $.noop,
84 | containeractive: $.noop,
85 | containerinactive: $.noop,
86 | containerresize: $.noop,
87 | containerscroll: $.noop,
88 | updateoffsets: $.noop,
89 | triggeroffsetupdate: $.noop,
90 | scrolloffsetupdate: $.noop,
91 | complete: $.noop
92 | };
93 |
94 | // static across all plugin instances
95 | // so we can uniquely ID elements
96 | var instanceCounter = 0;
97 |
98 |
99 |
100 |
101 | /**
102 | * Utility methods
103 | *
104 | * debounce() and throttle() are from on Underscore.js:
105 | * https://github.com/jashkenas/underscore
106 | */
107 |
108 | /**
109 | * Underscore's now:
110 | * http://underscorejs.org/now
111 | */
112 | var dateNow = Date.now || function() {
113 | return new Date().getTime();
114 | };
115 |
116 | /**
117 | * Underscore's debounce:
118 | * http://underscorejs.org/#debounce
119 | */
120 | var debounce = function(func, wait, immediate) {
121 | var result;
122 | var timeout = null;
123 | return function() {
124 | var context = this,
125 | args = arguments;
126 | var later = function() {
127 | timeout = null;
128 | if (!immediate) {
129 | result = func.apply(context, args);
130 | }
131 | };
132 | var callNow = immediate && !timeout;
133 | clearTimeout(timeout);
134 | timeout = setTimeout(later, wait);
135 | if (callNow) {
136 | result = func.apply(context, args);
137 | }
138 | return result;
139 | };
140 | };
141 |
142 | /**
143 | * Underscore's throttle:
144 | * http://underscorejs.org/#throttle
145 | */
146 | var throttle = function(func, wait, options) {
147 | var context, args, result;
148 | var timeout = null;
149 | var previous = 0;
150 | options || (options = {});
151 | var later = function() {
152 | previous = options.leading === false ? 0 : dateNow();
153 | timeout = null;
154 | result = func.apply(context, args);
155 | };
156 | return function() {
157 | var now = dateNow();
158 | if (!previous && options.leading === false) {
159 | previous = now;
160 | }
161 | var remaining = wait - (now - previous);
162 | context = this;
163 | args = arguments;
164 | if (remaining <= 0) {
165 | clearTimeout(timeout);
166 | timeout = null;
167 | previous = now;
168 | result = func.apply(context, args);
169 | } else if (!timeout && options.trailing !== false) {
170 | timeout = setTimeout(later, remaining);
171 | }
172 | return result;
173 | };
174 | };
175 |
176 | var $window = $(window);
177 | var winHeight = $window.height(); // cached. updated via _handleResize()
178 |
179 | /**
180 | * Given a scroll/trigger offset, determine
181 | * its pixel value from the top of the viewport.
182 | *
183 | * If number or number-like string (30 or '30'), return that
184 | * number. (30)
185 | *
186 | * If it's a percentage string ('30%'), convert to pixels
187 | * based on the height of the viewport. (eg: 395)
188 | *
189 | * @param {String/Number} offset
190 | * @return {Number}
191 | */
192 | var offsetToPx = function(offset){
193 | var pxOffset;
194 |
195 | if (offsetIsAPercentage(offset)) {
196 | pxOffset = offset.slice(0, -1);
197 | pxOffset = Math.round(winHeight * (parseInt(pxOffset, 10)/100) );
198 | } else {
199 | pxOffset = parseInt(offset, 10);
200 | }
201 |
202 | return pxOffset;
203 | };
204 |
205 | var offsetIsAPercentage = function(offset){
206 | return typeof offset === 'string' && offset.slice(-1) === '%';
207 | };
208 |
209 |
210 | function ScrollStory(element, options) {
211 | this.el = element;
212 | this.$el = $(element);
213 | this.options = $.extend({}, defaults, options);
214 |
215 | this.useNativeScroll = (typeof this.options.scrollEvent === 'string') && (this.options.scrollEvent.indexOf('scroll') === 0);
216 |
217 | this._defaults = defaults;
218 | this._name = pluginName;
219 | this._instanceId = (function() {
220 | return pluginName + '_' + instanceCounter;
221 | })();
222 |
223 | this.init();
224 | }
225 |
226 | ScrollStory.prototype = {
227 | init: function() {
228 |
229 | /**
230 | * List of all items, and a quick lockup hash
231 | * Data populated via _prepItems* methods
232 | */
233 | this._items = [];
234 | this._itemsById = {};
235 | this._categories = [];
236 | this._tags = [];
237 |
238 | this._isActive = false;
239 | this._activeItem;
240 | this._previousItems = [];
241 |
242 | /**
243 | * Attach handlers before any events are dispatched
244 | */
245 | this.$el.on('setup'+eventNameSpace, this._onSetup.bind(this));
246 | this.$el.on('destroy'+eventNameSpace, this._onDestroy.bind(this));
247 | this.$el.on('containeractive'+eventNameSpace, this._onContainerActive.bind(this));
248 | this.$el.on('containerinactive'+eventNameSpace, this._onContainerInactive.bind(this));
249 | this.$el.on('itemblur'+eventNameSpace, this._onItemBlur.bind(this));
250 | this.$el.on('itemfocus'+eventNameSpace, this._onItemFocus.bind(this));
251 | this.$el.on('itementerviewport'+eventNameSpace, this._onItemEnterViewport.bind(this));
252 | this.$el.on('itemexitviewport'+eventNameSpace, this._onItemExitViewport.bind(this));
253 | this.$el.on('itemfilter'+eventNameSpace, this._onItemFilter.bind(this));
254 | this.$el.on('itemunfilter'+eventNameSpace, this._onItemUnfilter.bind(this));
255 | this.$el.on('categoryfocus'+eventNameSpace, this._onCategoryFocus.bind(this));
256 | this.$el.on('triggeroffsetupdate'+eventNameSpace, this._onTriggerOffsetUpdate.bind(this));
257 |
258 |
259 | /**
260 | * Run before any items have been added, allows
261 | * for user manipulation of page before ScrollStory
262 | * acts on anything.
263 | */
264 | this._trigger('setup', null, this);
265 |
266 |
267 | /**
268 | * Convert data from outside of widget into
269 | * items and, if needed, categories of items.
270 | *
271 | * Don't 'handleRepaints' just yet, as that'll
272 | * set an active item. We want to do that after
273 | * our 'complete' event is triggered.
274 | */
275 | this.addItems(this.options.content, {
276 | handleRepaint: false
277 | });
278 |
279 | // 1. offsets need to be accurate before 'complete'
280 | this.updateOffsets();
281 |
282 | // 2. handle any user actions
283 | this._trigger('complete', null, this);
284 |
285 | // 3. Set active item, and double check
286 | // scroll position and offsets.
287 | if(this.options.enabled){
288 | this._handleRepaint();
289 | }
290 |
291 |
292 | /**
293 | * Bind keyboard events
294 | */
295 | if (this.options.keyboard) {
296 | $(document).keydown(function(e){
297 | var captured = true;
298 | switch (e.keyCode) {
299 | case 37:
300 | if (e.metaKey) {return;} // ignore ctrl/cmd left, as browsers use that to go back in history
301 | this.previous();
302 | break; // left arrow
303 | case 39:
304 | this.next();
305 | break; // right arrow
306 | default:
307 | captured = false;
308 | }
309 | return !captured;
310 | }.bind(this));
311 | }
312 |
313 |
314 |
315 | /**
316 | * Debug UI
317 | */
318 | this.$trigger = $('').css({
319 | position: 'fixed',
320 | width: '100%',
321 | height: '1px',
322 | top: offsetToPx(this.options.triggerOffset) + 'px',
323 | left: '0px',
324 | backgroundColor: '#ff0000',
325 | '-webkit-transform': 'translateZ(0)',
326 | '-webkit-backface-visibility': 'hidden',
327 | zIndex: 1000
328 | }).attr('id', pluginName + 'Trigger-' + this._instanceId);
329 |
330 | if (this.options.debug) {
331 | this.$trigger.appendTo('body');
332 | }
333 |
334 |
335 | /**
336 | * Watch either native scroll events, throttled by
337 | * this.options.scrollSensitivity, or a custom event
338 | * that implements its own throttling.
339 | *
340 | * Bind these events after 'complete' trigger so no
341 | * items are active when those callbacks runs.
342 | */
343 |
344 | var scrollThrottle, scrollHandler;
345 |
346 | if(this.useNativeScroll){
347 |
348 | // bind and throttle native scroll
349 | scrollThrottle = (this.options.throttleType === 'throttle') ? throttle : debounce;
350 | scrollHandler = scrollThrottle(this._handleScroll.bind(this), this.options.scrollSensitivity, this.options.throttleTypeOptions);
351 | $window.on('scroll'+eventNameSpace, scrollHandler);
352 | } else {
353 |
354 | // bind but don't throttle custom event
355 | scrollHandler = this._handleScroll.bind(this);
356 |
357 | // if custom event is a function, it'll need
358 | // to call the scroll handler manually, like so:
359 | //
360 | // $container.scrollStory({
361 | // scrollEvent: function(cb){
362 | // // custom scroll event on nytimes.com
363 | // PageManager.on('nyt:page-scroll', function(){
364 | // // do something interesting if you like
365 | // // and then call the passed in handler();
366 | // cb();
367 | // });
368 | // }
369 | // });
370 | //
371 | //
372 | // Otherwise, it's a string representing an event on the
373 | // window to subscribe to, like so:
374 | //
375 | // // some code dispatching throttled events
376 | // $window.trigger('nytg-scroll');
377 | //
378 | // $container.scrollStory({
379 | // scrollEvent: 'nytg-scroll'
380 | // });
381 | //
382 |
383 | if (typeof this.options.scrollEvent === 'function') {
384 | this.options.scrollEvent(scrollHandler);
385 | } else {
386 | $window.on(this.options.scrollEvent+eventNameSpace, function(){
387 | scrollHandler();
388 | });
389 | }
390 | }
391 |
392 | // anything that might cause a repaint
393 | var resizeThrottle = debounce(this._handleResize, 100);
394 | $window.on('DOMContentLoaded'+eventNameSpace + ' load'+eventNameSpace + ' resize'+eventNameSpace, resizeThrottle.bind(this));
395 |
396 | instanceCounter = instanceCounter + 1;
397 | },
398 |
399 |
400 | /**
401 | * Get current item's index,
402 | * or set the current item with an index.
403 | * @param {Number} index
404 | * @param {Function} callback
405 | * @return {Number} index of active item
406 | */
407 | index: function(index, callback) {
408 | if (typeof index === 'number' && this.getItemByIndex(index)) {
409 | this.setActiveItem(this.getItemByIndex(index), {}, callback);
410 | } else {
411 | return this.getActiveItem().index;
412 | }
413 | },
414 |
415 |
416 | /**
417 | * Convenience method to navigate to next item
418 | *
419 | * @param {Number} _index -- an optional index. Used to recursively find unflitered item
420 | */
421 | next: function(_index) {
422 | var currentIndex = _index || this.index();
423 | var nextItem;
424 |
425 | if (typeof currentIndex === 'number') {
426 | nextItem = this.getItemByIndex(currentIndex + 1);
427 |
428 | // valid index and item
429 | if (nextItem) {
430 |
431 | // proceed if not filtered. if filtered try the one after that.
432 | if (!nextItem.filtered) {
433 | this.index(currentIndex + 1);
434 | } else {
435 | this.next(currentIndex + 1);
436 | }
437 | }
438 | }
439 | },
440 |
441 |
442 | /**
443 | * Convenience method to navigate to previous item
444 | *
445 | * @param {Number} _index -- an optional index. Used to recursively find unflitered item
446 | */
447 | previous: function(_index) {
448 | var currentIndex = _index || this.index();
449 | var previousItem;
450 |
451 | if (typeof currentIndex === 'number') {
452 | previousItem = this.getItemByIndex(currentIndex - 1);
453 |
454 | // valid index and item
455 | if (previousItem) {
456 |
457 | // proceed if not filtered. if filtered try the one before that.
458 | if (!previousItem.filtered) {
459 | this.index(currentIndex - 1);
460 | } else {
461 | this.previous(currentIndex - 1);
462 | }
463 | }
464 | }
465 | },
466 |
467 |
468 | /**
469 | * The active item object.
470 | *
471 | * @return {Object}
472 | */
473 | getActiveItem: function() {
474 | return this._activeItem;
475 | },
476 |
477 |
478 | /**
479 | * Given an item object, make it active,
480 | * including updating its scroll position.
481 | *
482 | * @param {Object} item
483 | */
484 | setActiveItem: function(item, options, callback) {
485 | options = options || {};
486 |
487 | // verify item
488 | if (item.id && this.getItemById(item.id)) {
489 | this._scrollToItem(item, options, callback);
490 | }
491 | },
492 |
493 |
494 | /**
495 | * Iterate over each item, passing the item to a callback.
496 | *
497 | * this.each(function(item){ console.log(item.id) });
498 | *
499 | * @param {Function}
500 | */
501 | each: function(callback) {
502 | this.applyToAllItems(callback);
503 | },
504 |
505 |
506 | /**
507 | * Return number of items
508 | * @return {Number}
509 | */
510 | getLength: function() {
511 | return this.getItems().length;
512 | },
513 |
514 | /**
515 | * Return array of all items
516 | * @return {Array}
517 | */
518 | getItems: function() {
519 | return this._items;
520 | },
521 |
522 |
523 | /**
524 | * Given an item id, return item object with that id.
525 | *
526 | * @param {string} id
527 | * @return {Object}
528 | */
529 | getItemById: function(id) {
530 | return this._itemsById[id];
531 | },
532 |
533 |
534 | /**
535 | * Given an item index, return item object with that index.
536 | *
537 | * @param {Integer} index
538 | * @return {Object}
539 | */
540 | getItemByIndex: function(index) {
541 | return this._items[index];
542 | },
543 |
544 |
545 | /**
546 | * Return an array of items that pass an abritrary truth test.
547 | *
548 | * Example: this.getItemsBy(function(item){return item.data.slug=='josh_williams'})
549 | *
550 | * @param {Function} truthTest The function to check all items against
551 | * @return {Array} Array of item objects
552 | */
553 | getItemsBy: function(truthTest) {
554 | if (typeof truthTest !== 'function') {
555 | throw new Error('You must provide a truthTest function');
556 | }
557 |
558 | return this.getItems().filter(function(item) {
559 | return truthTest(item);
560 | });
561 | },
562 |
563 |
564 | /**
565 | * Returns an array of items where all the properties
566 | * match an item's properties. Property tests can be
567 | * any combination of:
568 | *
569 | * 1. Values
570 | * this.getItemsWhere({index:2});
571 | * this.getItemsWhere({filtered:false});
572 | * this.getItemsWhere({category:'cats', width: 300});
573 | *
574 | * 2. Methods that return a value
575 | * this.getItemsWhere({width: function(width){ return 216 + 300;}});
576 | *
577 | * 3. Methods that return a boolean
578 | * this.getItemsWhere({index: function(index){ return index > 2; } });
579 | *
580 | * Mix and match:
581 | * this.getItemsWehre({filtered:false, index: function(index){ return index < 30;} })
582 | *
583 | * @param {Object} properties
584 | * @return {Array} Array of item objects
585 | */
586 | getItemsWhere: function(properties) {
587 | var keys,
588 | items = []; // empty if properties obj not passed in
589 |
590 | if ($.isPlainObject(properties)) {
591 | keys = Object.keys(properties); // properties to check in each item
592 | items = this.getItemsBy(function(item) {
593 | var isMatch = keys.every(function(key) {
594 | var match;
595 |
596 | // type 3, method that runs a boolean
597 | if (typeof properties[key] === 'function') {
598 | match = properties[key](item[key]);
599 |
600 | // type 2, method that runs a value
601 | if (typeof match !== 'boolean') {
602 | match = item[key] === match;
603 | }
604 |
605 | } else {
606 |
607 | // type 1, value
608 | match = item[key] === properties[key];
609 | }
610 | return match;
611 | });
612 |
613 | if (isMatch) {
614 | return item;
615 | }
616 | });
617 | }
618 |
619 | return items;
620 | },
621 |
622 |
623 | /**
624 | * Array of items that are atleast partially visible
625 | *
626 | * @return {Array}
627 | */
628 | getItemsInViewport: function() {
629 | return this.getItemsWhere({
630 | inViewport: true
631 | });
632 | },
633 |
634 |
635 | /**
636 | * Most recently active item.
637 | *
638 | * @return {Object}
639 | */
640 | getPreviousItem: function() {
641 | return this._previousItems[0];
642 | },
643 |
644 |
645 | /**
646 | * Array of items that were previously
647 | * active, with most recently active
648 | * at the front of the array.
649 | *
650 | * @return {Array}
651 | */
652 | getPreviousItems: function() {
653 | return this._previousItems;
654 | },
655 |
656 |
657 | /**
658 | * Progress of the scroll needed to activate the
659 | * last item on a 0.0 - 1.0 scale.
660 | *
661 | * 0 means the first item isn't yet active,
662 | * and 1 means the last item is active, or
663 | * has already been scrolled beyond active.
664 | *
665 | * @return {[type]} [description]
666 | */
667 | getPercentScrollToLastItem: function() {
668 | return this._percentScrollToLastItem || 0;
669 | },
670 |
671 |
672 | /**
673 | * Progress of the entire scroll distance, from the start
674 | * of the first item a '0', until the very end of the last
675 | * item, which is '1';
676 | */
677 | getScrollComplete: function() {
678 | return this._totalScrollComplete || 0;
679 | },
680 |
681 | /**
682 | * Return an array of all filtered items.
683 | * @return {Array}
684 | */
685 | getFilteredItems: function() {
686 | return this.getItemsWhere({
687 | filtered: true
688 | });
689 | },
690 |
691 |
692 | /**
693 | * Return an array of all unfiltered items.
694 | * @return {Array}
695 | */
696 | getUnFilteredItems: function() {
697 | return this.getItemsWhere({
698 | filtered: false
699 | });
700 | },
701 |
702 |
703 | /**
704 | * Return an array of all items belonging to a category.
705 | *
706 | * @param {String} categorySlug
707 | * @return {Array}
708 | */
709 | getItemsByCategory: function(categorySlug) {
710 | return this.getItemsWhere({
711 | category: categorySlug
712 | });
713 | },
714 |
715 |
716 | /**
717 | * Return an array of all category slugs
718 | *
719 | * @return {Array}
720 | */
721 | getCategorySlugs: function() {
722 | return this._categories;
723 | },
724 |
725 |
726 | /**
727 | * Change an item's status to filtered.
728 | *
729 | * @param {Object} item
730 | */
731 | filter: function(item) {
732 | if (!item.filtered) {
733 | item.filtered = true;
734 | this._trigger('itemfilter', null, item);
735 | }
736 | },
737 |
738 |
739 | /**
740 | * Change an item's status to unfiltered.
741 | *
742 | * @param {Object} item
743 | */
744 | unfilter: function(item) {
745 | if (item.filtered) {
746 | item.filtered = false;
747 | this._trigger('itemunfilter', null, item);
748 | }
749 | },
750 |
751 | /**
752 | * Change all items' status to filtered.
753 | *
754 | * @param {Function} callback
755 | */
756 | filterAll: function(callback) {
757 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
758 | var filterFnc = this.filter.bind(this);
759 | this.getItems().forEach(filterFnc);
760 | },
761 |
762 | /**
763 | * Change all items' status to unfiltered.
764 | *
765 | * @param {Function} callback
766 | */
767 | unfilterAll: function(callback) {
768 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
769 | var unfilterFnc = this.unfilter.bind(this);
770 | this.getItems().forEach(unfilterFnc);
771 | },
772 |
773 |
774 | /**
775 | * Filter items that pass an abritrary truth test. This is a light
776 | * wrapper around `getItemsBy()` and `filter()`.
777 | *
778 | * Example: this.filterBy(function(item){return item.data.last_name === 'williams'})
779 | *
780 | * @param {Function} truthTest The function to check all items against
781 | * @param {Function} callback
782 | */
783 | filterBy: function(truthTest, callback) {
784 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
785 | var filterFnc = this.filter.bind(this);
786 | this.getItemsBy(truthTest).forEach(filterFnc);
787 | callback();
788 | },
789 |
790 |
791 | /**
792 | * Filter items where all the properties match an item's properties. This
793 | * is a light wrapper around `getItemsWhere()` and `filter()`. See `getItemsWhere()`
794 | * for more options and examples.
795 | *
796 | * Example: this.filterWhere({index:2})
797 | *
798 | * @param {Function} truthTest The function to check all items against
799 | * @param {Function} callback
800 | */
801 | filterWhere: function(properties, callback) {
802 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
803 | var filterFnc = this.filter.bind(this);
804 | this.getItemsWhere(properties).forEach(filterFnc);
805 | callback();
806 | },
807 |
808 |
809 | /**
810 | * Whether or not any of the item objects are active.
811 | *
812 | * @return {Boolean}
813 | */
814 | isContainerActive: function() {
815 | return this._isActive;
816 | },
817 |
818 |
819 | /**
820 | * Disable scroll updates. This is useful in the
821 | * rare case when you want to manipulate the page
822 | * but not have ScrollStory continue to check
823 | * positions, fire events, etc. Usually a `disable`
824 | * is temporary and followed by an `enable`.
825 | */
826 | disable: function() {
827 | this.options.enabled = false;
828 | },
829 |
830 |
831 | /**
832 | * Enable scroll updates
833 | */
834 | enable: function() {
835 | this.options.enabled = true;
836 | },
837 |
838 |
839 | /**
840 | * Update trigger offset. This is useful if a client
841 | * app needs to, post-instantiation, change the trigger
842 | * point, like after a window resize.
843 | *
844 | * @param {Number} offset
845 | */
846 | updateTriggerOffset: function(offset) {
847 | this.options.triggerOffset = offset;
848 | this.updateOffsets();
849 | this._trigger('triggeroffsetupdate', null, offsetToPx(offset));
850 | },
851 |
852 |
853 | /**
854 | * Update scroll offset. This is useful if a client
855 | * app needs to, post-instantiation, change the scroll
856 | * offset, like after a window resize.
857 | * @param {Number} offset
858 | */
859 | updateScrollOffset: function(offset) {
860 | this.options.scrollOffset = offset;
861 | this.updateOffsets();
862 | this._trigger('scrolloffsetupdate', null, offsetToPx(offset));
863 | },
864 |
865 |
866 | /**
867 | * Determine which item should be active,
868 | * and then make it so.
869 | */
870 | _setActiveItem: function() {
871 |
872 | // top of the container is above the trigger point and the bottom is still below trigger point.
873 | var containerInActiveArea = (this._distanceToFirstItemTopOffset <= 0 && (Math.abs(this._distanceToOffset) - this._height) < 0);
874 |
875 | // only check items that aren't filtered
876 | var items = this.getItemsWhere({
877 | filtered: false
878 | });
879 |
880 | var activeItem;
881 | items.forEach(function(item) {
882 |
883 | // item has to have crossed the trigger offset
884 | if (item.adjustedDistanceToOffset <= 0) {
885 | if (!activeItem) {
886 | activeItem = item;
887 | } else {
888 |
889 | // closer to trigger point than previously found item?
890 | if (activeItem.adjustedDistanceToOffset < item.adjustedDistanceToOffset) {
891 | activeItem = item;
892 | }
893 | }
894 | }
895 | });
896 |
897 | // double check conditions around an active item
898 | if (activeItem && !containerInActiveArea && this.options.disablePastLastItem) {
899 | activeItem = false;
900 |
901 | // not yet scrolled in, but auto-activate is set to true
902 | } else if (!activeItem && this.options.autoActivateFirstItem && items.length > 0) {
903 | activeItem = items[0];
904 | }
905 |
906 | if (activeItem) {
907 | this._focusItem(activeItem);
908 |
909 | // container
910 | if (!this._isActive) {
911 | this._isActive = true;
912 | this._trigger('containeractive');
913 | }
914 |
915 | } else {
916 | this._blurAllItems();
917 |
918 | // container
919 | if (this._isActive) {
920 | this._isActive = false;
921 | this._trigger('containerinactive');
922 | }
923 | }
924 | },
925 |
926 |
927 | /**
928 | * Scroll to an item, making it active.
929 | *
930 | * @param {Object} item
931 | * @param {Object} opts
932 | * @param {Function} callback
933 | */
934 | _scrollToItem: function(item, opts, callback) {
935 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
936 |
937 | /**
938 | * Allows global scroll options to be overridden
939 | * in one of two ways:
940 | *
941 | * 1. Higher priority: Passed in to scrollToItem directly via opts obj.
942 | * 2. Lower priority: options set as an item.* property
943 | */
944 | opts = $.extend(true, {
945 | // prefer item.scrollOffset over this.options.scrollOffset
946 | scrollOffset: (item.scrollOffset !== false) ? offsetToPx(item.scrollOffset) : offsetToPx(this.options.scrollOffset),
947 | speed: this.options.speed,
948 | easing: this.options.easing
949 | }, opts);
950 |
951 |
952 | // because we animate to body and html for maximum compatiblity,
953 | // we only want the callback to fire once. jQuery will call it
954 | // once for each element otherwise
955 | var debouncedCallback = debounce(callback, 100);
956 |
957 | // position to travel to
958 | var scrolllTop = item.el.offset().top - offsetToPx(opts.scrollOffset);
959 | $('html, body').stop(true).animate({
960 | scrollTop: scrolllTop
961 | }, opts.speed, opts.easing, debouncedCallback);
962 | },
963 |
964 |
965 | /**
966 | * Excecute a callback function that expects an
967 | * item as its paramamter for each items.
968 | *
969 | * Optionally, a item or array of items of exceptions
970 | * can be passed in. They'll not call the callback.
971 | *
972 | * @param {Function} callback Method to call, and pass in exepctions
973 | * @param {Object/Array} exceptions
974 | */
975 | applyToAllItems: function(callback, exceptions) {
976 | exceptions = ($.isArray(exceptions)) ? exceptions : [exceptions];
977 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
978 |
979 | var items = this.getItems();
980 | var i = 0;
981 | var length = items.length;
982 | var item;
983 |
984 | for (i = 0; i < length; i++) {
985 | item = items[i];
986 | if (exceptions.indexOf(item) === -1) {
987 | callback(item, i);
988 | }
989 | }
990 | },
991 |
992 |
993 | /**
994 | * Unfocus all items.
995 | *
996 | * @param {Object/Array} exceptions item or array of items to not blur
997 | */
998 | _blurAllItems: function(exceptions) {
999 | this.applyToAllItems(this._blurItem.bind(this), exceptions);
1000 |
1001 | if (!exceptions) {
1002 | this._activeItem = undefined;
1003 | }
1004 | },
1005 |
1006 | /**
1007 | * Unfocus an item
1008 | * @param {Object}
1009 | */
1010 | _blurItem: function(item) {
1011 | if (item.active) {
1012 | item.active = false;
1013 | this._trigger('itemblur', null, item);
1014 | }
1015 | },
1016 |
1017 |
1018 | /**
1019 | * Given an item, give it focus. Focus is exclusive
1020 | * so we unfocus any other item.
1021 | *
1022 | * @param {Object} item object
1023 | */
1024 | _focusItem: function(item) {
1025 | if (!item.active && !item.filtered) {
1026 | this._blurAllItems(item);
1027 |
1028 | // make active
1029 | this._activeItem = item;
1030 | item.active = true;
1031 |
1032 | // notify clients of changes
1033 | this._trigger('itemfocus', null, item);
1034 | }
1035 | },
1036 |
1037 |
1038 | /**
1039 | * Iterate through items and update their top offset.
1040 | * Useful if items have been added, removed,
1041 | * repositioned externally, and after window resize
1042 | *
1043 | * Based on:
1044 | * http://javascript.info/tutorial/coordinates
1045 | * http://stackoverflow.com/questions/123999/how-to-tell-if-a-dom-element-is-visible-in-the-current-viewport/7557433#7557433
1046 | */
1047 | updateOffsets: function() {
1048 | var bodyElem = document.body;
1049 | var docElem = document.documentElement;
1050 |
1051 | var scrollTop = window.pageYOffset || docElem.scrollTop || bodyElem.scrollTop;
1052 | var clientTop = docElem.clientTop || bodyElem.clientTop || 0;
1053 | var items = this.getItems();
1054 | var i = 0;
1055 | var length = items.length;
1056 | var item;
1057 | var box;
1058 |
1059 | // individual items
1060 | for (i = 0; i < length; i++) {
1061 | item = items[i];
1062 | box = item.el[0].getBoundingClientRect();
1063 |
1064 | // add or update item properties
1065 | item.width = box.width;
1066 | item.height = box.height;
1067 | item.topOffset = box.top + scrollTop - clientTop;
1068 | }
1069 |
1070 | // container
1071 | box = this.el.getBoundingClientRect();
1072 | this._height = box.height;
1073 | this._width = box.width;
1074 | this._topOffset = box.top + scrollTop - clientTop;
1075 |
1076 | this._trigger('updateoffsets');
1077 | },
1078 |
1079 | _updateScrollPositions: function() {
1080 | var bodyElem = document.body;
1081 | var docElem = document.documentElement;
1082 | var scrollTop = window.pageYOffset || docElem.scrollTop || bodyElem.scrollTop;
1083 | var wHeight = window.innerHeight || docElem.clientHeight;
1084 | var wWidth = window.innerWidth || docElem.clientWidth;
1085 | var triggerOffset = offsetToPx(this.options.triggerOffset);
1086 |
1087 |
1088 | // update item scroll positions
1089 | var items = this.getItems();
1090 | var length = items.length;
1091 | var lastItem = items[length -1];
1092 | var i = 0;
1093 | var item;
1094 | var rect;
1095 | var previouslyInViewport;
1096 |
1097 | // track total scroll across all items
1098 | var totalScrollComplete = 0;
1099 |
1100 | for (i = 0; i < length; i++) {
1101 | item = items[i];
1102 | rect = item.el[0].getBoundingClientRect();
1103 | item.distanceToOffset = Math.floor(item.topOffset - scrollTop - triggerOffset); // floor to prevent some off-by-fractional px in determining active item
1104 | item.adjustedDistanceToOffset = (item.triggerOffset === false) ? item.distanceToOffset : item.topOffset - scrollTop - item.triggerOffset;
1105 |
1106 | // percent through this item's active scroll. expressed 0 - 1;
1107 | if (item.distanceToOffset >= 0) {
1108 | item.percentScrollComplete = 0;
1109 | } else if (Math.abs(item.distanceToOffset) >= rect.height){
1110 | item.percentScrollComplete = 1;
1111 | } else {
1112 | item.percentScrollComplete = Math.abs(item.distanceToOffset) / rect.height;
1113 | }
1114 |
1115 | // track percent scroll
1116 | totalScrollComplete = totalScrollComplete + item.percentScrollComplete;
1117 |
1118 | // track viewport status
1119 | previouslyInViewport = item.inViewport;
1120 | item.inViewport = rect.bottom > 0 && rect.right > 0 && rect.left < wWidth && rect.top < wHeight;
1121 | item.fullyInViewport = rect.top >= 0 && rect.left >= 0 && rect.bottom <= wHeight && rect.right <= wWidth;
1122 |
1123 | if (item.inViewport && !previouslyInViewport) {
1124 | this._trigger('itementerviewport', null, item);
1125 | } else if (!item.inViewport && previouslyInViewport) {
1126 | this._trigger('itemexitviewport', null, item);
1127 | }
1128 | }
1129 |
1130 | // update container scroll position
1131 | this._distanceToFirstItemTopOffset = items[0].adjustedDistanceToOffset;
1132 |
1133 | // takes into account other elements that might make the top of the
1134 | // container different than the topoffset of the first item.
1135 | this._distanceToOffset = this._topOffset - scrollTop - triggerOffset;
1136 |
1137 |
1138 | // percent of the total scroll needed to activate the last item
1139 | var percentScrollToLastItem = 0;
1140 | if (this._distanceToOffset < 0) {
1141 | percentScrollToLastItem = 1 - (lastItem.distanceToOffset / (this._height - lastItem.height));
1142 | percentScrollToLastItem = (percentScrollToLastItem < 1) ? percentScrollToLastItem : 1; // restrict range
1143 | }
1144 |
1145 | this._percentScrollToLastItem = percentScrollToLastItem;
1146 |
1147 | this._totalScrollComplete = totalScrollComplete / length;
1148 | },
1149 |
1150 |
1151 | /**
1152 | * Add items to the running list given any of the
1153 | * following inputs:
1154 | *
1155 | * 1. jQuery selection. Items will be generated
1156 | * from the selection, and any data-* attributes
1157 | * will be added to the item's data object.
1158 | *
1159 | * 2. A string selector to search for elements
1160 | * within our container. Items will be generated
1161 | * from that selection, and any data-* attributes
1162 | * will be added to the item's data object.
1163 | *
1164 | * 3. Array of objects. All needed markup will
1165 | * be generated, and the data in each object will
1166 | * be added to the item's data object.
1167 | *
1168 | * 4. If no 'items' param, we search for items
1169 | * using the options.contentSelector string.
1170 | *
1171 | *
1172 | * TODO: ensure existing items aren't re-added.
1173 | * This is expecially important for the empty items
1174 | * option, and will give us the ability to do
1175 | * infinite scrolls, etc.
1176 | *
1177 | * @param {jQuery Object/String/Array} items
1178 | */
1179 | addItems: function(items, opts) {
1180 |
1181 | opts = $.extend(true, {
1182 | handleRepaint: true
1183 | }, opts);
1184 |
1185 | // use an existing jQuery selection
1186 | if (items instanceof $) {
1187 | this._prepItemsFromSelection(items);
1188 |
1189 | // a custom selector to use within our container
1190 | } else if (typeof items === 'string') {
1191 | this._prepItemsFromSelection(this.$el.find(items));
1192 |
1193 | // array objects, which will be used to create markup
1194 | } else if ($.isArray(items)) {
1195 | this._prepItemsFromData(items);
1196 |
1197 | // search for elements with the default selector
1198 | } else {
1199 | this._prepItemsFromSelection(this.$el.find(this.options.contentSelector));
1200 | }
1201 |
1202 | // after instantiation and any addItems, we must have
1203 | // atleast one valid item. If not, plugin is misconfigured.
1204 | if (this.getItems().length < 1) {
1205 | throw new Error('addItems found no valid items.');
1206 | }
1207 |
1208 | if (opts.handleRepaint) {
1209 | this._handleRepaint();
1210 | }
1211 | },
1212 |
1213 | /**
1214 | * Remove any classes added during
1215 | * use and unbind all events.
1216 | */
1217 | destroy: function(removeMarkup) {
1218 | removeMarkup = removeMarkup || false;
1219 |
1220 | if(removeMarkup){
1221 | this.each(function(item){
1222 | item.el.remove();
1223 | });
1224 | }
1225 |
1226 | // cleanup dom / events and
1227 | // run any user code
1228 | this._trigger('destroy');
1229 |
1230 | // plugin wrapper disallows multiple scrollstory
1231 | // instances on the same element. after a destory,
1232 | // allow plugin to reattach to this element.
1233 | var containerData = this.$el.data();
1234 | containerData['plugin_' + pluginName] = null;
1235 |
1236 | // TODO: destroy the *instance*?
1237 | },
1238 |
1239 |
1240 | /**
1241 | * Update items' scroll positions and
1242 | * determine which one is active based
1243 | * on those positions. Useful during
1244 | * scrolls, resizes and other events
1245 | * that repaint the page.
1246 | *
1247 | * updateOffsets should be used
1248 | * with caution, as it's CPU intensive,
1249 | * and only useful it item sizes or
1250 | * scrollOffsets have changed.
1251 | *
1252 | * @param {Boolean} updateOffsets
1253 | * @return {[type]} [description]
1254 | */
1255 | _handleRepaint: function(updateOffsets) {
1256 | updateOffsets = (updateOffsets === false) ? false : true;
1257 |
1258 | if (updateOffsets) {
1259 | this.updateOffsets(); // must be called first
1260 | }
1261 |
1262 | this._updateScrollPositions(); // must be called second
1263 | this._setActiveItem(); // must be called third
1264 | },
1265 |
1266 |
1267 | /**
1268 | * Keep state correct while scrolling
1269 | */
1270 | _handleScroll: function() {
1271 | if (this.options.enabled) {
1272 | this._handleRepaint(false);
1273 | this._trigger('containerscroll');
1274 | }
1275 | },
1276 |
1277 | /**
1278 | * Keep state correct while resizing
1279 | */
1280 | _handleResize: function() {
1281 | winHeight = $window.height();
1282 |
1283 | if (this.options.enabled && this.options.autoUpdateOffsets) {
1284 |
1285 | if (offsetIsAPercentage(this.options.triggerOffset)) {
1286 | this.updateTriggerOffset(this.options.triggerOffset);
1287 | }
1288 |
1289 | if (offsetIsAPercentage(this.options.scrollOffset)) {
1290 | this.updateScrollOffset(this.options.scrollOffset);
1291 | }
1292 |
1293 | this._debouncedHandleRepaint();
1294 | this._trigger('containerresize');
1295 | }
1296 | },
1297 |
1298 | // Handlers for public events that maintain state
1299 | // of the ScrollStory instance.
1300 |
1301 | _onSetup: function() {
1302 | this.$el.addClass(pluginName);
1303 | },
1304 |
1305 | _onDestroy: function() {
1306 |
1307 | // remove events
1308 | this.$el.off(eventNameSpace);
1309 | $window.off(eventNameSpace);
1310 |
1311 | // item classes
1312 | var itemClassesToRemove = ['scrollStoryItem', 'inviewport', 'active', 'filtered'].join(' ');
1313 | this.each(function(item){
1314 | item.el.removeClass(itemClassesToRemove);
1315 | });
1316 |
1317 | // container classes
1318 | this.$el.removeClass(function(i, classNames){
1319 | var classNamesToRemove = [];
1320 | classNames.split(' ').forEach(function(c){
1321 | if (c.lastIndexOf(pluginName) === 0 ){
1322 | classNamesToRemove.push(c);
1323 | }
1324 | });
1325 | return classNamesToRemove.join(' ');
1326 | });
1327 |
1328 | this.$trigger.remove();
1329 | },
1330 |
1331 | _onContainerActive: function() {
1332 | this.$el.addClass(pluginName + 'Active');
1333 | },
1334 |
1335 | _onContainerInactive: function() {
1336 | this.$el.removeClass(pluginName + 'Active');
1337 | },
1338 |
1339 | _onItemFocus: function(ev, item) {
1340 | item.el.addClass('active');
1341 | this._manageContainerClasses('scrollStoryActiveItem-',item.id);
1342 |
1343 | // trigger catgory change if not previously active or
1344 | // this item's category is different from the last
1345 | if (item.category) {
1346 | if ( (this.getPreviousItem() && this.getPreviousItem().category !== item.category) || !this.isContainerActive()) {
1347 | this._trigger('categoryfocus', null, item.category);
1348 |
1349 | if (this.getPreviousItem()) {
1350 | this._trigger('categoryblur', null, this.getPreviousItem().category);
1351 | }
1352 | }
1353 | }
1354 | },
1355 |
1356 | _onItemBlur: function(ev, item) {
1357 | this._previousItems.unshift(item);
1358 | item.el.removeClass('active');
1359 | },
1360 |
1361 | _onItemEnterViewport: function(ev, item) {
1362 | item.el.addClass('inviewport');
1363 | },
1364 |
1365 | _onItemExitViewport: function(ev, item) {
1366 | item.el.removeClass('inviewport');
1367 | },
1368 |
1369 | _onItemFilter: function(ev, item) {
1370 | item.el.addClass('filtered');
1371 | if (this.options.autoUpdateOffsets) {
1372 | this._debouncedHandleRepaint();
1373 | }
1374 | },
1375 |
1376 | _onItemUnfilter: function(ev, item) {
1377 | item.el.removeClass('filtered');
1378 | if (this.options.autoUpdateOffsets) {
1379 | this._debouncedHandleRepaint();
1380 | }
1381 | },
1382 |
1383 | _onCategoryFocus: function(ev, category) {
1384 | this._manageContainerClasses('scrollStoryActiveCategory-',category);
1385 | },
1386 |
1387 | _onTriggerOffsetUpdate: function(ev, offset) {
1388 | this.$trigger.css({
1389 | top: offset + 'px'
1390 | });
1391 | },
1392 |
1393 |
1394 |
1395 | /**
1396 | * Given a prefix string like 'scrollStoryActiveCategory-',
1397 | * and a value like 'fruit', add 'scrollStoryActiveCategory-fruit'
1398 | * class to the containing element after removing any other
1399 | * 'scrollStoryActiveCategory-*' classes
1400 | * @param {[type]} prefix [description]
1401 | * @param {[type]} value [description]
1402 | * @return {[type]} [description]
1403 | */
1404 | _manageContainerClasses: function(prefix, value) {
1405 | this.$el.removeClass(function(index, classes){
1406 | return classes.split(' ').filter(function(c) {
1407 | return c.lastIndexOf(prefix, 0) === 0;
1408 | }).join(' ');
1409 | });
1410 | this.$el.addClass(prefix+value);
1411 | },
1412 |
1413 |
1414 | /**
1415 | * Given a jQuery selection, add those elements
1416 | * to the internal items array.
1417 | *
1418 | * @param {Object} $jQuerySelection
1419 | */
1420 | _prepItemsFromSelection: function($selection) {
1421 | var that = this;
1422 | $selection.each(function() {
1423 | that._addItem({}, $(this));
1424 | });
1425 | },
1426 |
1427 |
1428 | /**
1429 | * Given array of data, append markup and add
1430 | * data to internal items array.
1431 | * @param {Array} items
1432 | */
1433 | _prepItemsFromData: function(items) {
1434 | var that = this;
1435 |
1436 | // drop period from the default selector, so we can
1437 | // add it to the class attr in markup
1438 | var selector = this.options.contentSelector.replace(/\./g, '');
1439 |
1440 | var frag = document.createDocumentFragment();
1441 | items.forEach(function(data) {
1442 | var $item = $('');
1443 | that._addItem(data, $item);
1444 | frag.appendChild($item.get(0));
1445 | });
1446 |
1447 | this.$el.append(frag);
1448 | },
1449 |
1450 |
1451 | /**
1452 | * Given item user data, and an aleady appended
1453 | * jQuery object, create an item for internal items array.
1454 | *
1455 | * @param {Object} data
1456 | * @param {jQuery Object} $el
1457 | */
1458 | _addItem: function(data, $el) {
1459 | var domData = $el.data();
1460 |
1461 | var item = {
1462 | index: this._items.length,
1463 | el: $el,
1464 | // id is from markup id attribute, data or dynamically generated
1465 | id: $el.attr('id') ? $el.attr('id') : (data.id) ? data.id : 'story' + instanceCounter + '-' + this._items.length,
1466 |
1467 | // item's data is from client data or data-* attrs. prefer data-* attrs over client data.
1468 | data: $.extend({}, data, domData),
1469 |
1470 | category: domData.category || data.category, // string. optional category slug this item belongs to. prefer data-category attribute
1471 | tags: data.tags || [], // optional tag or tags for this item. Can take an array of string, or a cvs string that'll be converted into array of strings.
1472 | scrollStory: this, // reference to this instance of scrollstory
1473 |
1474 | // in-focus item
1475 | active: false,
1476 |
1477 | // has item been filtered
1478 | filtered: false,
1479 |
1480 | // on occassion, the scrollToItem() offset may need to be adjusted for a
1481 | // particular item. this overrides this.options.scrollOffset set on instantiation
1482 | scrollOffset: false,
1483 |
1484 | // on occassion we want to trigger an item at a non-standard offset.
1485 | triggerOffset: false,
1486 |
1487 | // if any part is viewable in the viewport.
1488 | inViewport: false
1489 |
1490 | };
1491 |
1492 | // ensure id exist in dom
1493 | if (!$el.attr('id')) {
1494 | $el.attr('id', item.id);
1495 | }
1496 |
1497 | $el.addClass('scrollStoryItem');
1498 |
1499 | // global record
1500 | this._items.push(item);
1501 |
1502 | // quick lookup
1503 | this._itemsById[item.id] = item;
1504 |
1505 | this._trigger('itembuild', null, item);
1506 |
1507 | // An item's category is saved after the the itembuild event
1508 | // to allow for user code to specify a category client-side in
1509 | // that event callback or handler.
1510 | if (item.category && this._categories.indexOf(item.category) === -1) {
1511 | this._categories.push(item.category);
1512 | }
1513 |
1514 | // this._tags.push(item.tags);
1515 | },
1516 |
1517 |
1518 | /**
1519 | * Manage callbacks and event dispatching.
1520 | *
1521 | * Based very heavily on jQuery UI's implementaiton
1522 | * https://github.com/jquery/jquery-ui/blob/9d0f44fd7b16a66de1d9b0d8c5e4ab954d83790f/ui/widget.js#L492
1523 | *
1524 | * @param {String} eventType
1525 | * @param {Object} event
1526 | * @param {Object} data
1527 | */
1528 | _trigger: function(eventType, event, data) {
1529 | var callback = this.options[eventType];
1530 | var prop, orig;
1531 |
1532 | if ($.isFunction(callback)) {
1533 | data = data || {};
1534 |
1535 | event = $.Event(event);
1536 | event.target = this.el;
1537 | event.type = eventType;
1538 |
1539 | // copy original event properties over to the new event
1540 | orig = event.originalEvent;
1541 | if (orig) {
1542 | for (prop in orig) {
1543 | if (!(prop in event)) {
1544 | event[prop] = orig[prop];
1545 | }
1546 | }
1547 | }
1548 |
1549 | // fire event
1550 | this.$el.trigger(event, data);
1551 |
1552 | // call the callback
1553 | var boundCb = this.options[eventType].bind(this);
1554 | boundCb(event, data);
1555 | }
1556 | }
1557 | }; // end plugin.prototype
1558 |
1559 |
1560 | /**
1561 | * Debounced version of prototype methods
1562 | */
1563 | ScrollStory.prototype.debouncedUpdateOffsets = debounce(ScrollStory.prototype.updateOffsets, 100);
1564 | ScrollStory.prototype._debouncedHandleRepaint = debounce(ScrollStory.prototype._handleRepaint, 100);
1565 |
1566 |
1567 |
1568 | // A really lightweight plugin wrapper around the constructor,
1569 | // preventing multiple instantiations
1570 | $.fn[pluginName] = function(options) {
1571 | return this.each(function() {
1572 | if (!$.data(this, 'plugin_' + pluginName)) {
1573 | $.data(this, 'plugin_' + pluginName, new ScrollStory(this, options));
1574 | }
1575 | });
1576 | };
1577 | }));
--------------------------------------------------------------------------------
/dist/jquery.scrollstory.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @preserve ScrollStory - v1.1.0 - 2018-09-20
3 | * https://github.com/sjwilliams/scrollstory
4 | * Copyright (c) 2017 Josh Williams; Licensed MIT
5 | */
6 |
7 | (function(factory) {
8 | if (typeof define === 'function' && define.amd) {
9 | define(['jquery'], factory);
10 | } else {
11 | factory(jQuery);
12 | }
13 | }(function($, undefined) {
14 |
15 | var pluginName = 'scrollStory';
16 | var eventNameSpace = '.' + pluginName;
17 | var defaults = {
18 |
19 | // jquery object, class selector string, or array of values, or null (to use existing DOM)
20 | content: null,
21 |
22 | // Only used if content null. Should be a class selector
23 | contentSelector: '.story',
24 |
25 | // Left/right keys to navigate
26 | keyboard: true,
27 |
28 | // Offset from top used in the programatic scrolling of an
29 | // item to the focus position. Useful in the case of thinks like
30 | // top nav that might obscure part of an item if it goes to 0.
31 | scrollOffset: 0,
32 |
33 | // Offset from top to trigger a change
34 | triggerOffset: 0,
35 |
36 | // Event to monitor. Can be a name for an event on the $(window), or
37 | // a function that defines custom behavior. Defaults to native scroll event.
38 | scrollEvent: 'scroll',
39 |
40 | // Automatically activate the first item on load,
41 | // regardless of its position relative to the offset
42 | autoActivateFirstItem: false,
43 |
44 | // Disable last item -- and the entire widget -- once it's scroll beyond the trigger point
45 | disablePastLastItem: true,
46 |
47 | // Automated scroll speed in ms. Set to 0 to remove animation.
48 | speed: 800,
49 |
50 | // Scroll easing. 'swing' or 'linear', unless an external plugin provides others
51 | // http://api.jquery.com/animate/
52 | easing: 'swing',
53 |
54 | // // scroll-based events are either 'debounce' or 'throttle'
55 | throttleType: 'throttle',
56 |
57 | // frequency in milliseconds to perform scroll-based functions. Scrolling functions
58 | // can be CPU intense, so higher number can help performance.
59 | scrollSensitivity: 100,
60 |
61 | // options to pass to underscore's throttle or debounce for scroll
62 | // see: http://underscorejs.org/#throttle && http://underscorejs.org/#debounce
63 | throttleTypeOptions: null,
64 |
65 | // Update offsets after likely repaints, like window resizes and filters
66 | autoUpdateOffsets: true,
67 |
68 | debug: false,
69 |
70 | // whether or not the scroll checking is enabled.
71 | enabled: true,
72 |
73 | setup: $.noop,
74 | destroy: $.noop,
75 | itembuild: $.noop,
76 | itemfocus: $.noop,
77 | itemblur: $.noop,
78 | itemfilter: $.noop,
79 | itemunfilter: $.noop,
80 | itementerviewport: $.noop,
81 | itemexitviewport: $.noop,
82 | categoryfocus: $.noop,
83 | categeryblur: $.noop,
84 | containeractive: $.noop,
85 | containerinactive: $.noop,
86 | containerresize: $.noop,
87 | containerscroll: $.noop,
88 | updateoffsets: $.noop,
89 | triggeroffsetupdate: $.noop,
90 | scrolloffsetupdate: $.noop,
91 | complete: $.noop
92 | };
93 |
94 | // static across all plugin instances
95 | // so we can uniquely ID elements
96 | var instanceCounter = 0;
97 |
98 |
99 |
100 |
101 | /**
102 | * Utility methods
103 | *
104 | * debounce() and throttle() are from on Underscore.js:
105 | * https://github.com/jashkenas/underscore
106 | */
107 |
108 | /**
109 | * Underscore's now:
110 | * http://underscorejs.org/now
111 | */
112 | var dateNow = Date.now || function() {
113 | return new Date().getTime();
114 | };
115 |
116 | /**
117 | * Underscore's debounce:
118 | * http://underscorejs.org/#debounce
119 | */
120 | var debounce = function(func, wait, immediate) {
121 | var result;
122 | var timeout = null;
123 | return function() {
124 | var context = this,
125 | args = arguments;
126 | var later = function() {
127 | timeout = null;
128 | if (!immediate) {
129 | result = func.apply(context, args);
130 | }
131 | };
132 | var callNow = immediate && !timeout;
133 | clearTimeout(timeout);
134 | timeout = setTimeout(later, wait);
135 | if (callNow) {
136 | result = func.apply(context, args);
137 | }
138 | return result;
139 | };
140 | };
141 |
142 | /**
143 | * Underscore's throttle:
144 | * http://underscorejs.org/#throttle
145 | */
146 | var throttle = function(func, wait, options) {
147 | var context, args, result;
148 | var timeout = null;
149 | var previous = 0;
150 | options || (options = {});
151 | var later = function() {
152 | previous = options.leading === false ? 0 : dateNow();
153 | timeout = null;
154 | result = func.apply(context, args);
155 | };
156 | return function() {
157 | var now = dateNow();
158 | if (!previous && options.leading === false) {
159 | previous = now;
160 | }
161 | var remaining = wait - (now - previous);
162 | context = this;
163 | args = arguments;
164 | if (remaining <= 0) {
165 | clearTimeout(timeout);
166 | timeout = null;
167 | previous = now;
168 | result = func.apply(context, args);
169 | } else if (!timeout && options.trailing !== false) {
170 | timeout = setTimeout(later, remaining);
171 | }
172 | return result;
173 | };
174 | };
175 |
176 | var $window = $(window);
177 | var winHeight = $window.height(); // cached. updated via _handleResize()
178 |
179 | /**
180 | * Given a scroll/trigger offset, determine
181 | * its pixel value from the top of the viewport.
182 | *
183 | * If number or number-like string (30 or '30'), return that
184 | * number. (30)
185 | *
186 | * If it's a percentage string ('30%'), convert to pixels
187 | * based on the height of the viewport. (eg: 395)
188 | *
189 | * @param {String/Number} offset
190 | * @return {Number}
191 | */
192 | var offsetToPx = function(offset){
193 | var pxOffset;
194 |
195 | if (offsetIsAPercentage(offset)) {
196 | pxOffset = offset.slice(0, -1);
197 | pxOffset = Math.round(winHeight * (parseInt(pxOffset, 10)/100) );
198 | } else {
199 | pxOffset = parseInt(offset, 10);
200 | }
201 |
202 | return pxOffset;
203 | };
204 |
205 | var offsetIsAPercentage = function(offset){
206 | return typeof offset === 'string' && offset.slice(-1) === '%';
207 | };
208 |
209 |
210 | function ScrollStory(element, options) {
211 | this.el = element;
212 | this.$el = $(element);
213 | this.options = $.extend({}, defaults, options);
214 |
215 | this.useNativeScroll = (typeof this.options.scrollEvent === 'string') && (this.options.scrollEvent.indexOf('scroll') === 0);
216 |
217 | this._defaults = defaults;
218 | this._name = pluginName;
219 | this._instanceId = (function() {
220 | return pluginName + '_' + instanceCounter;
221 | })();
222 |
223 | this.init();
224 | }
225 |
226 | ScrollStory.prototype = {
227 | init: function() {
228 |
229 | /**
230 | * List of all items, and a quick lockup hash
231 | * Data populated via _prepItems* methods
232 | */
233 | this._items = [];
234 | this._itemsById = {};
235 | this._categories = [];
236 | this._tags = [];
237 |
238 | this._isActive = false;
239 | this._activeItem;
240 | this._previousItems = [];
241 |
242 | /**
243 | * Attach handlers before any events are dispatched
244 | */
245 | this.$el.on('setup'+eventNameSpace, this._onSetup.bind(this));
246 | this.$el.on('destroy'+eventNameSpace, this._onDestroy.bind(this));
247 | this.$el.on('containeractive'+eventNameSpace, this._onContainerActive.bind(this));
248 | this.$el.on('containerinactive'+eventNameSpace, this._onContainerInactive.bind(this));
249 | this.$el.on('itemblur'+eventNameSpace, this._onItemBlur.bind(this));
250 | this.$el.on('itemfocus'+eventNameSpace, this._onItemFocus.bind(this));
251 | this.$el.on('itementerviewport'+eventNameSpace, this._onItemEnterViewport.bind(this));
252 | this.$el.on('itemexitviewport'+eventNameSpace, this._onItemExitViewport.bind(this));
253 | this.$el.on('itemfilter'+eventNameSpace, this._onItemFilter.bind(this));
254 | this.$el.on('itemunfilter'+eventNameSpace, this._onItemUnfilter.bind(this));
255 | this.$el.on('categoryfocus'+eventNameSpace, this._onCategoryFocus.bind(this));
256 | this.$el.on('triggeroffsetupdate'+eventNameSpace, this._onTriggerOffsetUpdate.bind(this));
257 |
258 |
259 | /**
260 | * Run before any items have been added, allows
261 | * for user manipulation of page before ScrollStory
262 | * acts on anything.
263 | */
264 | this._trigger('setup', null, this);
265 |
266 |
267 | /**
268 | * Convert data from outside of widget into
269 | * items and, if needed, categories of items.
270 | *
271 | * Don't 'handleRepaints' just yet, as that'll
272 | * set an active item. We want to do that after
273 | * our 'complete' event is triggered.
274 | */
275 | this.addItems(this.options.content, {
276 | handleRepaint: false
277 | });
278 |
279 | // 1. offsets need to be accurate before 'complete'
280 | this.updateOffsets();
281 |
282 | // 2. handle any user actions
283 | this._trigger('complete', null, this);
284 |
285 | // 3. Set active item, and double check
286 | // scroll position and offsets.
287 | if(this.options.enabled){
288 | this._handleRepaint();
289 | }
290 |
291 |
292 | /**
293 | * Bind keyboard events
294 | */
295 | if (this.options.keyboard) {
296 | $(document).keydown(function(e){
297 | var captured = true;
298 | switch (e.keyCode) {
299 | case 37:
300 | if (e.metaKey) {return;} // ignore ctrl/cmd left, as browsers use that to go back in history
301 | this.previous();
302 | break; // left arrow
303 | case 39:
304 | this.next();
305 | break; // right arrow
306 | default:
307 | captured = false;
308 | }
309 | return !captured;
310 | }.bind(this));
311 | }
312 |
313 |
314 |
315 | /**
316 | * Debug UI
317 | */
318 | this.$trigger = $('').css({
319 | position: 'fixed',
320 | width: '100%',
321 | height: '1px',
322 | top: offsetToPx(this.options.triggerOffset) + 'px',
323 | left: '0px',
324 | backgroundColor: '#ff0000',
325 | '-webkit-transform': 'translateZ(0)',
326 | '-webkit-backface-visibility': 'hidden',
327 | zIndex: 1000
328 | }).attr('id', pluginName + 'Trigger-' + this._instanceId);
329 |
330 | if (this.options.debug) {
331 | this.$trigger.appendTo('body');
332 | }
333 |
334 |
335 | /**
336 | * Watch either native scroll events, throttled by
337 | * this.options.scrollSensitivity, or a custom event
338 | * that implements its own throttling.
339 | *
340 | * Bind these events after 'complete' trigger so no
341 | * items are active when those callbacks runs.
342 | */
343 |
344 | var scrollThrottle, scrollHandler;
345 |
346 | if(this.useNativeScroll){
347 |
348 | // bind and throttle native scroll
349 | scrollThrottle = (this.options.throttleType === 'throttle') ? throttle : debounce;
350 | scrollHandler = scrollThrottle(this._handleScroll.bind(this), this.options.scrollSensitivity, this.options.throttleTypeOptions);
351 | $window.on('scroll'+eventNameSpace, scrollHandler);
352 | } else {
353 |
354 | // bind but don't throttle custom event
355 | scrollHandler = this._handleScroll.bind(this);
356 |
357 | // if custom event is a function, it'll need
358 | // to call the scroll handler manually, like so:
359 | //
360 | // $container.scrollStory({
361 | // scrollEvent: function(cb){
362 | // // custom scroll event on nytimes.com
363 | // PageManager.on('nyt:page-scroll', function(){
364 | // // do something interesting if you like
365 | // // and then call the passed in handler();
366 | // cb();
367 | // });
368 | // }
369 | // });
370 | //
371 | //
372 | // Otherwise, it's a string representing an event on the
373 | // window to subscribe to, like so:
374 | //
375 | // // some code dispatching throttled events
376 | // $window.trigger('nytg-scroll');
377 | //
378 | // $container.scrollStory({
379 | // scrollEvent: 'nytg-scroll'
380 | // });
381 | //
382 |
383 | if (typeof this.options.scrollEvent === 'function') {
384 | this.options.scrollEvent(scrollHandler);
385 | } else {
386 | $window.on(this.options.scrollEvent+eventNameSpace, function(){
387 | scrollHandler();
388 | });
389 | }
390 | }
391 |
392 | // anything that might cause a repaint
393 | var resizeThrottle = debounce(this._handleResize, 100);
394 | $window.on('DOMContentLoaded'+eventNameSpace + ' load'+eventNameSpace + ' resize'+eventNameSpace, resizeThrottle.bind(this));
395 |
396 | instanceCounter = instanceCounter + 1;
397 | },
398 |
399 |
400 | /**
401 | * Get current item's index,
402 | * or set the current item with an index.
403 | * @param {Number} index
404 | * @param {Function} callback
405 | * @return {Number} index of active item
406 | */
407 | index: function(index, callback) {
408 | if (typeof index === 'number' && this.getItemByIndex(index)) {
409 | this.setActiveItem(this.getItemByIndex(index), {}, callback);
410 | } else {
411 | return this.getActiveItem().index;
412 | }
413 | },
414 |
415 |
416 | /**
417 | * Convenience method to navigate to next item
418 | *
419 | * @param {Number} _index -- an optional index. Used to recursively find unflitered item
420 | */
421 | next: function(_index) {
422 | var currentIndex = _index || this.index();
423 | var nextItem;
424 |
425 | if (typeof currentIndex === 'number') {
426 | nextItem = this.getItemByIndex(currentIndex + 1);
427 |
428 | // valid index and item
429 | if (nextItem) {
430 |
431 | // proceed if not filtered. if filtered try the one after that.
432 | if (!nextItem.filtered) {
433 | this.index(currentIndex + 1);
434 | } else {
435 | this.next(currentIndex + 1);
436 | }
437 | }
438 | }
439 | },
440 |
441 |
442 | /**
443 | * Convenience method to navigate to previous item
444 | *
445 | * @param {Number} _index -- an optional index. Used to recursively find unflitered item
446 | */
447 | previous: function(_index) {
448 | var currentIndex = _index || this.index();
449 | var previousItem;
450 |
451 | if (typeof currentIndex === 'number') {
452 | previousItem = this.getItemByIndex(currentIndex - 1);
453 |
454 | // valid index and item
455 | if (previousItem) {
456 |
457 | // proceed if not filtered. if filtered try the one before that.
458 | if (!previousItem.filtered) {
459 | this.index(currentIndex - 1);
460 | } else {
461 | this.previous(currentIndex - 1);
462 | }
463 | }
464 | }
465 | },
466 |
467 |
468 | /**
469 | * The active item object.
470 | *
471 | * @return {Object}
472 | */
473 | getActiveItem: function() {
474 | return this._activeItem;
475 | },
476 |
477 |
478 | /**
479 | * Given an item object, make it active,
480 | * including updating its scroll position.
481 | *
482 | * @param {Object} item
483 | */
484 | setActiveItem: function(item, options, callback) {
485 | options = options || {};
486 |
487 | // verify item
488 | if (item.id && this.getItemById(item.id)) {
489 | this._scrollToItem(item, options, callback);
490 | }
491 | },
492 |
493 |
494 | /**
495 | * Iterate over each item, passing the item to a callback.
496 | *
497 | * this.each(function(item){ console.log(item.id) });
498 | *
499 | * @param {Function}
500 | */
501 | each: function(callback) {
502 | this.applyToAllItems(callback);
503 | },
504 |
505 |
506 | /**
507 | * Return number of items
508 | * @return {Number}
509 | */
510 | getLength: function() {
511 | return this.getItems().length;
512 | },
513 |
514 | /**
515 | * Return array of all items
516 | * @return {Array}
517 | */
518 | getItems: function() {
519 | return this._items;
520 | },
521 |
522 |
523 | /**
524 | * Given an item id, return item object with that id.
525 | *
526 | * @param {string} id
527 | * @return {Object}
528 | */
529 | getItemById: function(id) {
530 | return this._itemsById[id];
531 | },
532 |
533 |
534 | /**
535 | * Given an item index, return item object with that index.
536 | *
537 | * @param {Integer} index
538 | * @return {Object}
539 | */
540 | getItemByIndex: function(index) {
541 | return this._items[index];
542 | },
543 |
544 |
545 | /**
546 | * Return an array of items that pass an abritrary truth test.
547 | *
548 | * Example: this.getItemsBy(function(item){return item.data.slug=='josh_williams'})
549 | *
550 | * @param {Function} truthTest The function to check all items against
551 | * @return {Array} Array of item objects
552 | */
553 | getItemsBy: function(truthTest) {
554 | if (typeof truthTest !== 'function') {
555 | throw new Error('You must provide a truthTest function');
556 | }
557 |
558 | return this.getItems().filter(function(item) {
559 | return truthTest(item);
560 | });
561 | },
562 |
563 |
564 | /**
565 | * Returns an array of items where all the properties
566 | * match an item's properties. Property tests can be
567 | * any combination of:
568 | *
569 | * 1. Values
570 | * this.getItemsWhere({index:2});
571 | * this.getItemsWhere({filtered:false});
572 | * this.getItemsWhere({category:'cats', width: 300});
573 | *
574 | * 2. Methods that return a value
575 | * this.getItemsWhere({width: function(width){ return 216 + 300;}});
576 | *
577 | * 3. Methods that return a boolean
578 | * this.getItemsWhere({index: function(index){ return index > 2; } });
579 | *
580 | * Mix and match:
581 | * this.getItemsWehre({filtered:false, index: function(index){ return index < 30;} })
582 | *
583 | * @param {Object} properties
584 | * @return {Array} Array of item objects
585 | */
586 | getItemsWhere: function(properties) {
587 | var keys,
588 | items = []; // empty if properties obj not passed in
589 |
590 | if ($.isPlainObject(properties)) {
591 | keys = Object.keys(properties); // properties to check in each item
592 | items = this.getItemsBy(function(item) {
593 | var isMatch = keys.every(function(key) {
594 | var match;
595 |
596 | // type 3, method that runs a boolean
597 | if (typeof properties[key] === 'function') {
598 | match = properties[key](item[key]);
599 |
600 | // type 2, method that runs a value
601 | if (typeof match !== 'boolean') {
602 | match = item[key] === match;
603 | }
604 |
605 | } else {
606 |
607 | // type 1, value
608 | match = item[key] === properties[key];
609 | }
610 | return match;
611 | });
612 |
613 | if (isMatch) {
614 | return item;
615 | }
616 | });
617 | }
618 |
619 | return items;
620 | },
621 |
622 |
623 | /**
624 | * Array of items that are atleast partially visible
625 | *
626 | * @return {Array}
627 | */
628 | getItemsInViewport: function() {
629 | return this.getItemsWhere({
630 | inViewport: true
631 | });
632 | },
633 |
634 |
635 | /**
636 | * Most recently active item.
637 | *
638 | * @return {Object}
639 | */
640 | getPreviousItem: function() {
641 | return this._previousItems[0];
642 | },
643 |
644 |
645 | /**
646 | * Array of items that were previously
647 | * active, with most recently active
648 | * at the front of the array.
649 | *
650 | * @return {Array}
651 | */
652 | getPreviousItems: function() {
653 | return this._previousItems;
654 | },
655 |
656 |
657 | /**
658 | * Progress of the scroll needed to activate the
659 | * last item on a 0.0 - 1.0 scale.
660 | *
661 | * 0 means the first item isn't yet active,
662 | * and 1 means the last item is active, or
663 | * has already been scrolled beyond active.
664 | *
665 | * @return {[type]} [description]
666 | */
667 | getPercentScrollToLastItem: function() {
668 | return this._percentScrollToLastItem || 0;
669 | },
670 |
671 |
672 | /**
673 | * Progress of the entire scroll distance, from the start
674 | * of the first item a '0', until the very end of the last
675 | * item, which is '1';
676 | */
677 | getScrollComplete: function() {
678 | return this._totalScrollComplete || 0;
679 | },
680 |
681 | /**
682 | * Return an array of all filtered items.
683 | * @return {Array}
684 | */
685 | getFilteredItems: function() {
686 | return this.getItemsWhere({
687 | filtered: true
688 | });
689 | },
690 |
691 |
692 | /**
693 | * Return an array of all unfiltered items.
694 | * @return {Array}
695 | */
696 | getUnFilteredItems: function() {
697 | return this.getItemsWhere({
698 | filtered: false
699 | });
700 | },
701 |
702 |
703 | /**
704 | * Return an array of all items belonging to a category.
705 | *
706 | * @param {String} categorySlug
707 | * @return {Array}
708 | */
709 | getItemsByCategory: function(categorySlug) {
710 | return this.getItemsWhere({
711 | category: categorySlug
712 | });
713 | },
714 |
715 |
716 | /**
717 | * Return an array of all category slugs
718 | *
719 | * @return {Array}
720 | */
721 | getCategorySlugs: function() {
722 | return this._categories;
723 | },
724 |
725 |
726 | /**
727 | * Change an item's status to filtered.
728 | *
729 | * @param {Object} item
730 | */
731 | filter: function(item) {
732 | if (!item.filtered) {
733 | item.filtered = true;
734 | this._trigger('itemfilter', null, item);
735 | }
736 | },
737 |
738 |
739 | /**
740 | * Change an item's status to unfiltered.
741 | *
742 | * @param {Object} item
743 | */
744 | unfilter: function(item) {
745 | if (item.filtered) {
746 | item.filtered = false;
747 | this._trigger('itemunfilter', null, item);
748 | }
749 | },
750 |
751 | /**
752 | * Change all items' status to filtered.
753 | *
754 | * @param {Function} callback
755 | */
756 | filterAll: function(callback) {
757 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
758 | var filterFnc = this.filter.bind(this);
759 | this.getItems().forEach(filterFnc);
760 | },
761 |
762 | /**
763 | * Change all items' status to unfiltered.
764 | *
765 | * @param {Function} callback
766 | */
767 | unfilterAll: function(callback) {
768 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
769 | var unfilterFnc = this.unfilter.bind(this);
770 | this.getItems().forEach(unfilterFnc);
771 | },
772 |
773 |
774 | /**
775 | * Filter items that pass an abritrary truth test. This is a light
776 | * wrapper around `getItemsBy()` and `filter()`.
777 | *
778 | * Example: this.filterBy(function(item){return item.data.last_name === 'williams'})
779 | *
780 | * @param {Function} truthTest The function to check all items against
781 | * @param {Function} callback
782 | */
783 | filterBy: function(truthTest, callback) {
784 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
785 | var filterFnc = this.filter.bind(this);
786 | this.getItemsBy(truthTest).forEach(filterFnc);
787 | callback();
788 | },
789 |
790 |
791 | /**
792 | * Filter items where all the properties match an item's properties. This
793 | * is a light wrapper around `getItemsWhere()` and `filter()`. See `getItemsWhere()`
794 | * for more options and examples.
795 | *
796 | * Example: this.filterWhere({index:2})
797 | *
798 | * @param {Function} truthTest The function to check all items against
799 | * @param {Function} callback
800 | */
801 | filterWhere: function(properties, callback) {
802 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
803 | var filterFnc = this.filter.bind(this);
804 | this.getItemsWhere(properties).forEach(filterFnc);
805 | callback();
806 | },
807 |
808 |
809 | /**
810 | * Whether or not any of the item objects are active.
811 | *
812 | * @return {Boolean}
813 | */
814 | isContainerActive: function() {
815 | return this._isActive;
816 | },
817 |
818 |
819 | /**
820 | * Disable scroll updates. This is useful in the
821 | * rare case when you want to manipulate the page
822 | * but not have ScrollStory continue to check
823 | * positions, fire events, etc. Usually a `disable`
824 | * is temporary and followed by an `enable`.
825 | */
826 | disable: function() {
827 | this.options.enabled = false;
828 | },
829 |
830 |
831 | /**
832 | * Enable scroll updates
833 | */
834 | enable: function() {
835 | this.options.enabled = true;
836 | },
837 |
838 |
839 | /**
840 | * Update trigger offset. This is useful if a client
841 | * app needs to, post-instantiation, change the trigger
842 | * point, like after a window resize.
843 | *
844 | * @param {Number} offset
845 | */
846 | updateTriggerOffset: function(offset) {
847 | this.options.triggerOffset = offset;
848 | this.updateOffsets();
849 | this._trigger('triggeroffsetupdate', null, offsetToPx(offset));
850 | },
851 |
852 |
853 | /**
854 | * Update scroll offset. This is useful if a client
855 | * app needs to, post-instantiation, change the scroll
856 | * offset, like after a window resize.
857 | * @param {Number} offset
858 | */
859 | updateScrollOffset: function(offset) {
860 | this.options.scrollOffset = offset;
861 | this.updateOffsets();
862 | this._trigger('scrolloffsetupdate', null, offsetToPx(offset));
863 | },
864 |
865 |
866 | /**
867 | * Determine which item should be active,
868 | * and then make it so.
869 | */
870 | _setActiveItem: function() {
871 |
872 | // top of the container is above the trigger point and the bottom is still below trigger point.
873 | var containerInActiveArea = (this._distanceToFirstItemTopOffset <= 0 && (Math.abs(this._distanceToOffset) - this._height) < 0);
874 |
875 | // only check items that aren't filtered
876 | var items = this.getItemsWhere({
877 | filtered: false
878 | });
879 |
880 | var activeItem;
881 | items.forEach(function(item) {
882 |
883 | // item has to have crossed the trigger offset
884 | if (item.adjustedDistanceToOffset <= 0) {
885 | if (!activeItem) {
886 | activeItem = item;
887 | } else {
888 |
889 | // closer to trigger point than previously found item?
890 | if (activeItem.adjustedDistanceToOffset < item.adjustedDistanceToOffset) {
891 | activeItem = item;
892 | }
893 | }
894 | }
895 | });
896 |
897 | // double check conditions around an active item
898 | if (activeItem && !containerInActiveArea && this.options.disablePastLastItem) {
899 | activeItem = false;
900 |
901 | // not yet scrolled in, but auto-activate is set to true
902 | } else if (!activeItem && this.options.autoActivateFirstItem && items.length > 0) {
903 | activeItem = items[0];
904 | }
905 |
906 | if (activeItem) {
907 | this._focusItem(activeItem);
908 |
909 | // container
910 | if (!this._isActive) {
911 | this._isActive = true;
912 | this._trigger('containeractive');
913 | }
914 |
915 | } else {
916 | this._blurAllItems();
917 |
918 | // container
919 | if (this._isActive) {
920 | this._isActive = false;
921 | this._trigger('containerinactive');
922 | }
923 | }
924 | },
925 |
926 |
927 | /**
928 | * Scroll to an item, making it active.
929 | *
930 | * @param {Object} item
931 | * @param {Object} opts
932 | * @param {Function} callback
933 | */
934 | _scrollToItem: function(item, opts, callback) {
935 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
936 |
937 | /**
938 | * Allows global scroll options to be overridden
939 | * in one of two ways:
940 | *
941 | * 1. Higher priority: Passed in to scrollToItem directly via opts obj.
942 | * 2. Lower priority: options set as an item.* property
943 | */
944 | opts = $.extend(true, {
945 | // prefer item.scrollOffset over this.options.scrollOffset
946 | scrollOffset: (item.scrollOffset !== false) ? offsetToPx(item.scrollOffset) : offsetToPx(this.options.scrollOffset),
947 | speed: this.options.speed,
948 | easing: this.options.easing
949 | }, opts);
950 |
951 |
952 | // because we animate to body and html for maximum compatiblity,
953 | // we only want the callback to fire once. jQuery will call it
954 | // once for each element otherwise
955 | var debouncedCallback = debounce(callback, 100);
956 |
957 | // position to travel to
958 | var scrolllTop = item.el.offset().top - offsetToPx(opts.scrollOffset);
959 | $('html, body').stop(true).animate({
960 | scrollTop: scrolllTop
961 | }, opts.speed, opts.easing, debouncedCallback);
962 | },
963 |
964 |
965 | /**
966 | * Excecute a callback function that expects an
967 | * item as its paramamter for each items.
968 | *
969 | * Optionally, a item or array of items of exceptions
970 | * can be passed in. They'll not call the callback.
971 | *
972 | * @param {Function} callback Method to call, and pass in exepctions
973 | * @param {Object/Array} exceptions
974 | */
975 | applyToAllItems: function(callback, exceptions) {
976 | exceptions = ($.isArray(exceptions)) ? exceptions : [exceptions];
977 | callback = ($.isFunction(callback)) ? callback.bind(this) : $.noop;
978 |
979 | var items = this.getItems();
980 | var i = 0;
981 | var length = items.length;
982 | var item;
983 |
984 | for (i = 0; i < length; i++) {
985 | item = items[i];
986 | if (exceptions.indexOf(item) === -1) {
987 | callback(item, i);
988 | }
989 | }
990 | },
991 |
992 |
993 | /**
994 | * Unfocus all items.
995 | *
996 | * @param {Object/Array} exceptions item or array of items to not blur
997 | */
998 | _blurAllItems: function(exceptions) {
999 | this.applyToAllItems(this._blurItem.bind(this), exceptions);
1000 |
1001 | if (!exceptions) {
1002 | this._activeItem = undefined;
1003 | }
1004 | },
1005 |
1006 | /**
1007 | * Unfocus an item
1008 | * @param {Object}
1009 | */
1010 | _blurItem: function(item) {
1011 | if (item.active) {
1012 | item.active = false;
1013 | this._trigger('itemblur', null, item);
1014 | }
1015 | },
1016 |
1017 |
1018 | /**
1019 | * Given an item, give it focus. Focus is exclusive
1020 | * so we unfocus any other item.
1021 | *
1022 | * @param {Object} item object
1023 | */
1024 | _focusItem: function(item) {
1025 | if (!item.active && !item.filtered) {
1026 | this._blurAllItems(item);
1027 |
1028 | // make active
1029 | this._activeItem = item;
1030 | item.active = true;
1031 |
1032 | // notify clients of changes
1033 | this._trigger('itemfocus', null, item);
1034 | }
1035 | },
1036 |
1037 |
1038 | /**
1039 | * Iterate through items and update their top offset.
1040 | * Useful if items have been added, removed,
1041 | * repositioned externally, and after window resize
1042 | *
1043 | * Based on:
1044 | * http://javascript.info/tutorial/coordinates
1045 | * http://stackoverflow.com/questions/123999/how-to-tell-if-a-dom-element-is-visible-in-the-current-viewport/7557433#7557433
1046 | */
1047 | updateOffsets: function() {
1048 | var bodyElem = document.body;
1049 | var docElem = document.documentElement;
1050 |
1051 | var scrollTop = window.pageYOffset || docElem.scrollTop || bodyElem.scrollTop;
1052 | var clientTop = docElem.clientTop || bodyElem.clientTop || 0;
1053 | var items = this.getItems();
1054 | var i = 0;
1055 | var length = items.length;
1056 | var item;
1057 | var box;
1058 |
1059 | // individual items
1060 | for (i = 0; i < length; i++) {
1061 | item = items[i];
1062 | box = item.el[0].getBoundingClientRect();
1063 |
1064 | // add or update item properties
1065 | item.width = box.width;
1066 | item.height = box.height;
1067 | item.topOffset = box.top + scrollTop - clientTop;
1068 | }
1069 |
1070 | // container
1071 | box = this.el.getBoundingClientRect();
1072 | this._height = box.height;
1073 | this._width = box.width;
1074 | this._topOffset = box.top + scrollTop - clientTop;
1075 |
1076 | this._trigger('updateoffsets');
1077 | },
1078 |
1079 | _updateScrollPositions: function() {
1080 | var bodyElem = document.body;
1081 | var docElem = document.documentElement;
1082 | var scrollTop = window.pageYOffset || docElem.scrollTop || bodyElem.scrollTop;
1083 | var wHeight = window.innerHeight || docElem.clientHeight;
1084 | var wWidth = window.innerWidth || docElem.clientWidth;
1085 | var triggerOffset = offsetToPx(this.options.triggerOffset);
1086 |
1087 |
1088 | // update item scroll positions
1089 | var items = this.getItems();
1090 | var length = items.length;
1091 | var lastItem = items[length -1];
1092 | var i = 0;
1093 | var item;
1094 | var rect;
1095 | var previouslyInViewport;
1096 |
1097 | // track total scroll across all items
1098 | var totalScrollComplete = 0;
1099 |
1100 | for (i = 0; i < length; i++) {
1101 | item = items[i];
1102 | rect = item.el[0].getBoundingClientRect();
1103 | item.distanceToOffset = Math.floor(item.topOffset - scrollTop - triggerOffset); // floor to prevent some off-by-fractional px in determining active item
1104 | item.adjustedDistanceToOffset = (item.triggerOffset === false) ? item.distanceToOffset : item.topOffset - scrollTop - item.triggerOffset;
1105 |
1106 | // percent through this item's active scroll. expressed 0 - 1;
1107 | if (item.distanceToOffset >= 0) {
1108 | item.percentScrollComplete = 0;
1109 | } else if (Math.abs(item.distanceToOffset) >= rect.height){
1110 | item.percentScrollComplete = 1;
1111 | } else {
1112 | item.percentScrollComplete = Math.abs(item.distanceToOffset) / rect.height;
1113 | }
1114 |
1115 | // track percent scroll
1116 | totalScrollComplete = totalScrollComplete + item.percentScrollComplete;
1117 |
1118 | // track viewport status
1119 | previouslyInViewport = item.inViewport;
1120 | item.inViewport = rect.bottom > 0 && rect.right > 0 && rect.left < wWidth && rect.top < wHeight;
1121 | item.fullyInViewport = rect.top >= 0 && rect.left >= 0 && rect.bottom <= wHeight && rect.right <= wWidth;
1122 |
1123 | if (item.inViewport && !previouslyInViewport) {
1124 | this._trigger('itementerviewport', null, item);
1125 | } else if (!item.inViewport && previouslyInViewport) {
1126 | this._trigger('itemexitviewport', null, item);
1127 | }
1128 | }
1129 |
1130 | // update container scroll position
1131 | this._distanceToFirstItemTopOffset = items[0].adjustedDistanceToOffset;
1132 |
1133 | // takes into account other elements that might make the top of the
1134 | // container different than the topoffset of the first item.
1135 | this._distanceToOffset = this._topOffset - scrollTop - triggerOffset;
1136 |
1137 |
1138 | // percent of the total scroll needed to activate the last item
1139 | var percentScrollToLastItem = 0;
1140 | if (this._distanceToOffset < 0) {
1141 | percentScrollToLastItem = 1 - (lastItem.distanceToOffset / (this._height - lastItem.height));
1142 | percentScrollToLastItem = (percentScrollToLastItem < 1) ? percentScrollToLastItem : 1; // restrict range
1143 | }
1144 |
1145 | this._percentScrollToLastItem = percentScrollToLastItem;
1146 |
1147 | this._totalScrollComplete = totalScrollComplete / length;
1148 | },
1149 |
1150 |
1151 | /**
1152 | * Add items to the running list given any of the
1153 | * following inputs:
1154 | *
1155 | * 1. jQuery selection. Items will be generated
1156 | * from the selection, and any data-* attributes
1157 | * will be added to the item's data object.
1158 | *
1159 | * 2. A string selector to search for elements
1160 | * within our container. Items will be generated
1161 | * from that selection, and any data-* attributes
1162 | * will be added to the item's data object.
1163 | *
1164 | * 3. Array of objects. All needed markup will
1165 | * be generated, and the data in each object will
1166 | * be added to the item's data object.
1167 | *
1168 | * 4. If no 'items' param, we search for items
1169 | * using the options.contentSelector string.
1170 | *
1171 | *
1172 | * TODO: ensure existing items aren't re-added.
1173 | * This is expecially important for the empty items
1174 | * option, and will give us the ability to do
1175 | * infinite scrolls, etc.
1176 | *
1177 | * @param {jQuery Object/String/Array} items
1178 | */
1179 | addItems: function(items, opts) {
1180 |
1181 | opts = $.extend(true, {
1182 | handleRepaint: true
1183 | }, opts);
1184 |
1185 | // use an existing jQuery selection
1186 | if (items instanceof $) {
1187 | this._prepItemsFromSelection(items);
1188 |
1189 | // a custom selector to use within our container
1190 | } else if (typeof items === 'string') {
1191 | this._prepItemsFromSelection(this.$el.find(items));
1192 |
1193 | // array objects, which will be used to create markup
1194 | } else if ($.isArray(items)) {
1195 | this._prepItemsFromData(items);
1196 |
1197 | // search for elements with the default selector
1198 | } else {
1199 | this._prepItemsFromSelection(this.$el.find(this.options.contentSelector));
1200 | }
1201 |
1202 | // after instantiation and any addItems, we must have
1203 | // atleast one valid item. If not, plugin is misconfigured.
1204 | if (this.getItems().length < 1) {
1205 | throw new Error('addItems found no valid items.');
1206 | }
1207 |
1208 | if (opts.handleRepaint) {
1209 | this._handleRepaint();
1210 | }
1211 | },
1212 |
1213 | /**
1214 | * Remove any classes added during
1215 | * use and unbind all events.
1216 | */
1217 | destroy: function(removeMarkup) {
1218 | removeMarkup = removeMarkup || false;
1219 |
1220 | if(removeMarkup){
1221 | this.each(function(item){
1222 | item.el.remove();
1223 | });
1224 | }
1225 |
1226 | // cleanup dom / events and
1227 | // run any user code
1228 | this._trigger('destroy');
1229 |
1230 | // plugin wrapper disallows multiple scrollstory
1231 | // instances on the same element. after a destory,
1232 | // allow plugin to reattach to this element.
1233 | var containerData = this.$el.data();
1234 | containerData['plugin_' + pluginName] = null;
1235 |
1236 | // TODO: destroy the *instance*?
1237 | },
1238 |
1239 |
1240 | /**
1241 | * Update items' scroll positions and
1242 | * determine which one is active based
1243 | * on those positions. Useful during
1244 | * scrolls, resizes and other events
1245 | * that repaint the page.
1246 | *
1247 | * updateOffsets should be used
1248 | * with caution, as it's CPU intensive,
1249 | * and only useful it item sizes or
1250 | * scrollOffsets have changed.
1251 | *
1252 | * @param {Boolean} updateOffsets
1253 | * @return {[type]} [description]
1254 | */
1255 | _handleRepaint: function(updateOffsets) {
1256 | updateOffsets = (updateOffsets === false) ? false : true;
1257 |
1258 | if (updateOffsets) {
1259 | this.updateOffsets(); // must be called first
1260 | }
1261 |
1262 | this._updateScrollPositions(); // must be called second
1263 | this._setActiveItem(); // must be called third
1264 | },
1265 |
1266 |
1267 | /**
1268 | * Keep state correct while scrolling
1269 | */
1270 | _handleScroll: function() {
1271 | if (this.options.enabled) {
1272 | this._handleRepaint(false);
1273 | this._trigger('containerscroll');
1274 | }
1275 | },
1276 |
1277 | /**
1278 | * Keep state correct while resizing
1279 | */
1280 | _handleResize: function() {
1281 | winHeight = $window.height();
1282 |
1283 | if (this.options.enabled && this.options.autoUpdateOffsets) {
1284 |
1285 | if (offsetIsAPercentage(this.options.triggerOffset)) {
1286 | this.updateTriggerOffset(this.options.triggerOffset);
1287 | }
1288 |
1289 | if (offsetIsAPercentage(this.options.scrollOffset)) {
1290 | this.updateScrollOffset(this.options.scrollOffset);
1291 | }
1292 |
1293 | this._debouncedHandleRepaint();
1294 | this._trigger('containerresize');
1295 | }
1296 | },
1297 |
1298 | // Handlers for public events that maintain state
1299 | // of the ScrollStory instance.
1300 |
1301 | _onSetup: function() {
1302 | this.$el.addClass(pluginName);
1303 | },
1304 |
1305 | _onDestroy: function() {
1306 |
1307 | // remove events
1308 | this.$el.off(eventNameSpace);
1309 | $window.off(eventNameSpace);
1310 |
1311 | // item classes
1312 | var itemClassesToRemove = ['scrollStoryItem', 'inviewport', 'active', 'filtered'].join(' ');
1313 | this.each(function(item){
1314 | item.el.removeClass(itemClassesToRemove);
1315 | });
1316 |
1317 | // container classes
1318 | this.$el.removeClass(function(i, classNames){
1319 | var classNamesToRemove = [];
1320 | classNames.split(' ').forEach(function(c){
1321 | if (c.lastIndexOf(pluginName) === 0 ){
1322 | classNamesToRemove.push(c);
1323 | }
1324 | });
1325 | return classNamesToRemove.join(' ');
1326 | });
1327 |
1328 | this.$trigger.remove();
1329 | },
1330 |
1331 | _onContainerActive: function() {
1332 | this.$el.addClass(pluginName + 'Active');
1333 | },
1334 |
1335 | _onContainerInactive: function() {
1336 | this.$el.removeClass(pluginName + 'Active');
1337 | },
1338 |
1339 | _onItemFocus: function(ev, item) {
1340 | item.el.addClass('active');
1341 | this._manageContainerClasses('scrollStoryActiveItem-',item.id);
1342 |
1343 | // trigger catgory change if not previously active or
1344 | // this item's category is different from the last
1345 | if (item.category) {
1346 | if ( (this.getPreviousItem() && this.getPreviousItem().category !== item.category) || !this.isContainerActive()) {
1347 | this._trigger('categoryfocus', null, item.category);
1348 |
1349 | if (this.getPreviousItem()) {
1350 | this._trigger('categoryblur', null, this.getPreviousItem().category);
1351 | }
1352 | }
1353 | }
1354 | },
1355 |
1356 | _onItemBlur: function(ev, item) {
1357 | this._previousItems.unshift(item);
1358 | item.el.removeClass('active');
1359 | },
1360 |
1361 | _onItemEnterViewport: function(ev, item) {
1362 | item.el.addClass('inviewport');
1363 | },
1364 |
1365 | _onItemExitViewport: function(ev, item) {
1366 | item.el.removeClass('inviewport');
1367 | },
1368 |
1369 | _onItemFilter: function(ev, item) {
1370 | item.el.addClass('filtered');
1371 | if (this.options.autoUpdateOffsets) {
1372 | this._debouncedHandleRepaint();
1373 | }
1374 | },
1375 |
1376 | _onItemUnfilter: function(ev, item) {
1377 | item.el.removeClass('filtered');
1378 | if (this.options.autoUpdateOffsets) {
1379 | this._debouncedHandleRepaint();
1380 | }
1381 | },
1382 |
1383 | _onCategoryFocus: function(ev, category) {
1384 | this._manageContainerClasses('scrollStoryActiveCategory-',category);
1385 | },
1386 |
1387 | _onTriggerOffsetUpdate: function(ev, offset) {
1388 | this.$trigger.css({
1389 | top: offset + 'px'
1390 | });
1391 | },
1392 |
1393 |
1394 |
1395 | /**
1396 | * Given a prefix string like 'scrollStoryActiveCategory-',
1397 | * and a value like 'fruit', add 'scrollStoryActiveCategory-fruit'
1398 | * class to the containing element after removing any other
1399 | * 'scrollStoryActiveCategory-*' classes
1400 | * @param {[type]} prefix [description]
1401 | * @param {[type]} value [description]
1402 | * @return {[type]} [description]
1403 | */
1404 | _manageContainerClasses: function(prefix, value) {
1405 | this.$el.removeClass(function(index, classes){
1406 | return classes.split(' ').filter(function(c) {
1407 | return c.lastIndexOf(prefix, 0) === 0;
1408 | }).join(' ');
1409 | });
1410 | this.$el.addClass(prefix+value);
1411 | },
1412 |
1413 |
1414 | /**
1415 | * Given a jQuery selection, add those elements
1416 | * to the internal items array.
1417 | *
1418 | * @param {Object} $jQuerySelection
1419 | */
1420 | _prepItemsFromSelection: function($selection) {
1421 | var that = this;
1422 | $selection.each(function() {
1423 | that._addItem({}, $(this));
1424 | });
1425 | },
1426 |
1427 |
1428 | /**
1429 | * Given array of data, append markup and add
1430 | * data to internal items array.
1431 | * @param {Array} items
1432 | */
1433 | _prepItemsFromData: function(items) {
1434 | var that = this;
1435 |
1436 | // drop period from the default selector, so we can
1437 | // add it to the class attr in markup
1438 | var selector = this.options.contentSelector.replace(/\./g, '');
1439 |
1440 | var frag = document.createDocumentFragment();
1441 | items.forEach(function(data) {
1442 | var $item = $('');
1443 | that._addItem(data, $item);
1444 | frag.appendChild($item.get(0));
1445 | });
1446 |
1447 | this.$el.append(frag);
1448 | },
1449 |
1450 |
1451 | /**
1452 | * Given item user data, and an aleady appended
1453 | * jQuery object, create an item for internal items array.
1454 | *
1455 | * @param {Object} data
1456 | * @param {jQuery Object} $el
1457 | */
1458 | _addItem: function(data, $el) {
1459 | var domData = $el.data();
1460 |
1461 | var item = {
1462 | index: this._items.length,
1463 | el: $el,
1464 | // id is from markup id attribute, data or dynamically generated
1465 | id: $el.attr('id') ? $el.attr('id') : (data.id) ? data.id : 'story' + instanceCounter + '-' + this._items.length,
1466 |
1467 | // item's data is from client data or data-* attrs. prefer data-* attrs over client data.
1468 | data: $.extend({}, data, domData),
1469 |
1470 | category: domData.category || data.category, // string. optional category slug this item belongs to. prefer data-category attribute
1471 | tags: data.tags || [], // optional tag or tags for this item. Can take an array of string, or a cvs string that'll be converted into array of strings.
1472 | scrollStory: this, // reference to this instance of scrollstory
1473 |
1474 | // in-focus item
1475 | active: false,
1476 |
1477 | // has item been filtered
1478 | filtered: false,
1479 |
1480 | // on occassion, the scrollToItem() offset may need to be adjusted for a
1481 | // particular item. this overrides this.options.scrollOffset set on instantiation
1482 | scrollOffset: false,
1483 |
1484 | // on occassion we want to trigger an item at a non-standard offset.
1485 | triggerOffset: false,
1486 |
1487 | // if any part is viewable in the viewport.
1488 | inViewport: false
1489 |
1490 | };
1491 |
1492 | // ensure id exist in dom
1493 | if (!$el.attr('id')) {
1494 | $el.attr('id', item.id);
1495 | }
1496 |
1497 | $el.addClass('scrollStoryItem');
1498 |
1499 | // global record
1500 | this._items.push(item);
1501 |
1502 | // quick lookup
1503 | this._itemsById[item.id] = item;
1504 |
1505 | this._trigger('itembuild', null, item);
1506 |
1507 | // An item's category is saved after the the itembuild event
1508 | // to allow for user code to specify a category client-side in
1509 | // that event callback or handler.
1510 | if (item.category && this._categories.indexOf(item.category) === -1) {
1511 | this._categories.push(item.category);
1512 | }
1513 |
1514 | // this._tags.push(item.tags);
1515 | },
1516 |
1517 |
1518 | /**
1519 | * Manage callbacks and event dispatching.
1520 | *
1521 | * Based very heavily on jQuery UI's implementaiton
1522 | * https://github.com/jquery/jquery-ui/blob/9d0f44fd7b16a66de1d9b0d8c5e4ab954d83790f/ui/widget.js#L492
1523 | *
1524 | * @param {String} eventType
1525 | * @param {Object} event
1526 | * @param {Object} data
1527 | */
1528 | _trigger: function(eventType, event, data) {
1529 | var callback = this.options[eventType];
1530 | var prop, orig;
1531 |
1532 | if ($.isFunction(callback)) {
1533 | data = data || {};
1534 |
1535 | event = $.Event(event);
1536 | event.target = this.el;
1537 | event.type = eventType;
1538 |
1539 | // copy original event properties over to the new event
1540 | orig = event.originalEvent;
1541 | if (orig) {
1542 | for (prop in orig) {
1543 | if (!(prop in event)) {
1544 | event[prop] = orig[prop];
1545 | }
1546 | }
1547 | }
1548 |
1549 | // fire event
1550 | this.$el.trigger(event, data);
1551 |
1552 | // call the callback
1553 | var boundCb = this.options[eventType].bind(this);
1554 | boundCb(event, data);
1555 | }
1556 | }
1557 | }; // end plugin.prototype
1558 |
1559 |
1560 | /**
1561 | * Debounced version of prototype methods
1562 | */
1563 | ScrollStory.prototype.debouncedUpdateOffsets = debounce(ScrollStory.prototype.updateOffsets, 100);
1564 | ScrollStory.prototype._debouncedHandleRepaint = debounce(ScrollStory.prototype._handleRepaint, 100);
1565 |
1566 |
1567 |
1568 | // A really lightweight plugin wrapper around the constructor,
1569 | // preventing multiple instantiations
1570 | $.fn[pluginName] = function(options) {
1571 | return this.each(function() {
1572 | if (!$.data(this, 'plugin_' + pluginName)) {
1573 | $.data(this, 'plugin_' + pluginName, new ScrollStory(this, options));
1574 | }
1575 | });
1576 | };
1577 | }));
1578 |
--------------------------------------------------------------------------------