├── .gitignore ├── documentation ├── infinite-scroll.html ├── css │ ├── reset.css │ ├── base-examples.css │ ├── railscasts.css │ └── base-index.css ├── from-data.html ├── custom-selector.html ├── basic.html ├── event-itemfocus.html ├── trigger-offset.html ├── autoactive.html ├── index.tmpl ├── data-attributes.html ├── events-and-callbacks.html ├── categories.html ├── huge-list.html ├── event-itemfilter.html ├── demo.html └── js │ └── highlight.pack.js ├── .jsbeautifyrc ├── .jshintrc ├── .editorconfig ├── bin └── buildIndex.js ├── LICENSE ├── package.json ├── dist ├── jquery.scrollstory.min.js └── jquery.scrollstory.js ├── README.md └── jquery.scrollstory.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /documentation/infinite-scroll.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.jsbeautifyrc: -------------------------------------------------------------------------------- 1 | { 2 | "js": { 3 | "indent_char": " ", 4 | "indent_size": 2 5 | } 6 | } -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "boss": true, 3 | "curly": true, 4 | "eqeqeq": true, 5 | "eqnull": true, 6 | "expr": true, 7 | "immed": true, 8 | "noarg": true, 9 | "onevar": false, 10 | "quotmark": "single", 11 | "smarttabs": true, 12 | "trailing": true, 13 | "unused": true, 14 | "node": true 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.html] 15 | 16 | # We recommend you to keep these unchanged 17 | end_of_line = lf 18 | charset = utf-8 19 | trim_trailing_whitespace = true 20 | insert_final_newline = true 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | -------------------------------------------------------------------------------- /bin/buildIndex.js: -------------------------------------------------------------------------------- 1 | var markx = require('markx'); 2 | var env = require('jsdom/lib/old-api.js').env; 3 | var beautify = require('js-beautify').html; 4 | 5 | // convert markdown to HTML, and then 6 | // wrap that html in section tags for 7 | // scrollStory's use 8 | markx({ 9 | input: 'README.md', 10 | template: 'documentation/index.tmpl' 11 | }, function(err, html) { 12 | if (err) { 13 | console.log('something went wrong', err); 14 | } else { 15 | env(html, function(err, window){ 16 | var $ = require('jquery')(window); 17 | 18 | $('h2, h1', '.content').each(function(){ 19 | $(this).nextUntil('h2').wrapAll('
'); 20 | $(this).prependTo($(this).next('section')); 21 | }); 22 | 23 | var html = '\n' + $('html')[0].outerHTML; 24 | // console.log(html); 25 | console.log(beautify(html)); 26 | }); 27 | } 28 | }); -------------------------------------------------------------------------------- /documentation/css/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video {margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline; } /* HTML5 display-role reset for older browsers */ article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section {display: block; } body {line-height: 1; } ol, ul {list-style: none; } blockquote, q {quotes: none; } blockquote:before, blockquote:after, q:before, q:after {content: ''; content: none; } table {border-collapse: collapse; border-spacing: 0; } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 - 2017 Josh Williams 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /documentation/css/base-examples.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fafafa; 3 | font-family: Arial, sans-serif; 4 | } 5 | #container { 6 | width: 80%; 7 | padding: 0 0 100px 0; 8 | margin: 30px auto; 9 | } 10 | 11 | 12 | .button, 13 | #status { 14 | display: inline; 15 | min-width: 60px; 16 | border-radius: 5px; 17 | padding: 5px; 18 | background-color: #000; 19 | color: #fff; 20 | user-select: none; 21 | } 22 | 23 | .button{ 24 | background-color: #EC6A0C; 25 | text-align: center; 26 | 27 | cursor: pointer; 28 | } 29 | 30 | #status{ 31 | position: fixed; 32 | right: 5%; 33 | top: 10px; 34 | z-index: 11; 35 | transform: translateZ(0); 36 | } 37 | 38 | .about { 39 | padding: 30px; 40 | margin: 0 auto; 41 | max-width: 700px; 42 | text-align: center; 43 | font-size: 17px; 44 | line-height: 22px; 45 | color: #3D3D3D; 46 | } 47 | .about a { 48 | color: #000; 49 | font-weight: bold; 50 | text-decoration: none; 51 | font-style: italic; 52 | } 53 | 54 | .story, 55 | .custom-selector { 56 | margin: 20px 0; 57 | background-color: #f1f1f1; 58 | font-size: 36px; 59 | line-height: 1.1em; 60 | } 61 | 62 | .story.inviewport, 63 | .custom-selector.inviewport { 64 | background-color: #92BDCA; 65 | transition: background-color 500ms ease; 66 | } 67 | 68 | .story.active, 69 | .custom-selector.active { 70 | background-color: #1B6F00; 71 | transition: background-color 0 ease; 72 | } -------------------------------------------------------------------------------- /documentation/from-data.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: External data 7 | 8 | 9 | 10 | 11 |
12 |

Generate elements from data.

13 | 14 | Our code here 15 | 16 |
17 | 18 |
19 |
20 | 21 | 22 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /documentation/custom-selector.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Custom Selector 7 | 8 | 9 | 10 | 11 |
12 |

You can trigger an action when new item becomes active.

13 |
14 |
1
15 |
2
16 |
3
17 |
4
18 |
5
19 |
6
20 |
7
21 |
8
22 |
9
23 |
10
24 |
11
25 |
12
26 |
13
27 |
28 |
29 | 30 | 31 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /documentation/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Basic 7 | 8 | 9 | 10 | 11 |
12 |

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.

13 | 14 | Our code here 15 | 16 |
17 |
1
18 |
2
19 |
3
20 |
4
21 |
5
22 |
6
23 |
7
24 |
8
25 |
9
26 |
10
27 |
11
28 |
12
29 |
13
30 |
31 |
32 | 33 | 34 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /documentation/event-itemfocus.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Item Focus Event 7 | 8 | 9 | 10 | 11 |
12 |
13 |

You can trigger an action when new item becomes active.

14 |
15 |
1
16 |
2
17 |
3
18 |
4
19 |
5
20 |
6
21 |
7
22 |
8
23 |
9
24 |
10
25 |
11
26 |
12
27 |
13
28 |
29 |
30 | 31 | 32 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /documentation/trigger-offset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Item Focus Event 7 | 8 | 9 | 10 | 11 |
12 |
13 |

You can customize the trigger offset.

14 |
15 |
1
16 |
2
17 |
3
18 |
4
19 |
5
20 |
6
21 |
7
22 |
8
23 |
9
24 |
10
25 |
11
26 |
12
27 |
13
28 |
29 |
30 | 31 | 32 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /documentation/autoactive.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Auto Activate First 7 | 8 | 9 | 10 | 11 |
12 |
13 |

You can set the first item active, even if it's below the trigger point.

14 |
15 |
1
16 |
2
17 |
3
18 |
4
19 |
5
20 |
6
21 |
7
22 |
8
23 |
9
24 |
10
25 |
11
26 |
12
27 |
13
28 |
29 |
30 | 31 | 32 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /documentation/index.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory, A jQuery plugin for building scroll-based stories and interactions 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 |
17 |
<%- body %>
18 | 19 | 20 | 21 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /documentation/data-attributes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Passing data via attributes 7 | 8 | 9 | 10 | 11 |
12 |

Pass data into ScrollStory via data attributes.

13 |
14 |
1
15 |
2
16 |
3
17 |
4
18 |
5
19 |
6
20 |
7
21 |
8
22 |
9
23 |
10
24 |
11
25 |
12
26 |
13
27 |
28 |
29 | 30 | 31 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /documentation/events-and-callbacks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Events and Callbacks 7 | 8 | 9 | 10 | 11 |
12 | Item (callback):
13 | Category (event): 14 |
15 |
16 |

17 | 18 | Our code here 19 | 20 |
21 |
Apple
22 |
Orange
23 |
Bananna
24 |
Pear
25 |
Peach
26 |
Squash
27 |
Broccoli
28 |
Spinach
29 |
Carrots
30 |
Pizza
31 |
Pancakes
32 |
Subs
33 |
Cake
34 |
35 |
36 | 37 | 38 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scrollstory", 3 | "description": "A jQuery plugin for building simple, scroll-based pages and interactions.", 4 | "version": "1.1.0", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/sjwilliams/scrollstory.git" 8 | }, 9 | "keywords": [ 10 | "jquery-plugin", 11 | "ecosystem:jquery", 12 | "scroll", 13 | "scrolling" 14 | ], 15 | "license": "MIT", 16 | "author": [{ 17 | "name": "Josh Williams", 18 | "email": "contact@joshwilliams.com" 19 | }], 20 | "devDependencies": { 21 | "autoprefixer": "^5.2.0", 22 | "http-server": "^0.8.0", 23 | "js-beautify": "^1.5.6", 24 | "jsdom": "^11.1.0", 25 | "jshint": "^2.7.0", 26 | "markx": "sjwilliams/markx", 27 | "nodemon": "^1.3.7", 28 | "parallelshell": "^1.1.1", 29 | "postcss-cli": "^1.3.1", 30 | "uglify-js": "^2.4.23" 31 | }, 32 | "scripts": { 33 | "clean": "mkdir -p build && rm -rf build/* && mkdir -p build/js && mkdir -p build/css", 34 | "clean:docsjs": "mkdir -p build/js && rm -rf build/js/*", 35 | "clean:docscss": "mkdir -p build/css && rm -rf build/css/*", 36 | "clean:docshtml": "mkdir -p build && rm -f build/index.html", 37 | "docs:html": "npm run clean:docshtml && node bin/buildIndex.js > build/index.html && cp documentation/*.html build/", 38 | "docs:css": "npm run clean:docscss && cp -R documentation/css build && postcss --use autoprefixer --autoprefixer.browsers '> 5%' -d build/css/ build/css/base-*", 39 | "docs:js": "npm run clean:docsjs && cp -R documentation/js build && cp jquery.scrollstory.js build/js", 40 | "watch:docscss": "nodemon -e css --ignore build/ --ignore node_modules -x 'npm run docs:css'", 41 | "watch:docsjs": "nodemon -e js --ignore build/ --ignore node_modules --ignore dist -x 'npm run docs:js'", 42 | "watch:docshtml": "nodemon -e html,json,md --ignore build/ --ignore node_modules -x 'npm run docs:html'", 43 | "watch": "parallelshell 'npm run watch:docsjs' 'npm run watch:docshtml' 'npm run watch:docscss'", 44 | "lint": "jshint jquery.scrollstory.js", 45 | "server": "http-server build/", 46 | "start": "parallelshell 'npm run watch' 'npm run server'", 47 | "build": "npm run docs:html && npm run docs:css && npm run docs:js", 48 | "dist": "npm run lint && npm run build && currentdate=`date +%Y-%m-%d` && version=`grep '\"version\"' package.json | sed 's/[^0-9\\.]//g'` && sed \"s/VERSIONXXX/$version/g\" jquery.scrollstory.js | sed \"s/YYYY-MM-DDXXX/$currentdate/g\" > dist/jquery.scrollstory.js && uglifyjs --comments -o dist/jquery.scrollstory.min.js dist/jquery.scrollstory.js", 49 | "preserver": "npm run clean && npm run build" 50 | } 51 | } -------------------------------------------------------------------------------- /documentation/categories.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Categories 7 | 8 | 9 | 10 | 11 |
Active item index:
Category:
12 |
13 |

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 |
18 |
Apple
19 |
Orange
20 |
Bananna
21 |
Pear
22 |
Peach
23 |
Squash
24 |
Broccoli
25 |
Spinach
26 |
Carrots
27 |
Pizza
28 |
Pancakes
29 |
Subs
30 |
Cake
31 |
32 |
33 | 34 | 35 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /documentation/huge-list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: One thousand items 7 | 43 | 44 | 45 | 46 | 49 |
50 |

Generate elements from data.

51 |
52 |
53 | 54 | 55 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /documentation/event-itemfilter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ScrollStory: Item Filter Event 7 | 8 | 9 | 29 | 30 | 31 | 32 |
Filter Odd Numbers
33 |
Unfilter All
34 |
35 |

You can 'filter' items, which takes them out of the active list.

36 |
37 |
1
38 |
2
39 |
3
40 |
4
41 |
5
42 |
6
43 |
7
44 |
8
45 |
9
46 |
10
47 |
11
48 |
12
49 |
13
50 |
51 |
52 | 53 | 54 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /documentation/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | jQuery Boilerplate 5 | 21 | 22 | 23 | 24 | 56 | 57 |
58 |

Fifth List

59 |
North America
60 |
Asia
61 |
Antarctica
62 |
63 | 64 | 65 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /documentation/css/railscasts.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Railscasts-like style (c) Visoft, Inc. (Damien White) 4 | 5 | */ 6 | 7 | .hljs { 8 | display: block; 9 | overflow-x: auto; 10 | padding: 0.5em; 11 | background: #232323; 12 | color: #e6e1dc; 13 | -webkit-text-size-adjust: none; 14 | } 15 | 16 | .hljs-comment, 17 | .hljs-javadoc, 18 | .hljs-shebang { 19 | color: #bc9458; 20 | font-style: italic; 21 | } 22 | 23 | .hljs-keyword, 24 | .ruby .hljs-function .hljs-keyword, 25 | .hljs-request, 26 | .hljs-status, 27 | .nginx .hljs-title, 28 | .method, 29 | .hljs-list .hljs-title { 30 | color: #c26230; 31 | } 32 | 33 | .hljs-string, 34 | .hljs-number, 35 | .hljs-regexp, 36 | .hljs-tag .hljs-value, 37 | .hljs-cdata, 38 | .hljs-filter .hljs-argument, 39 | .hljs-attr_selector, 40 | .apache .hljs-cbracket, 41 | .hljs-date, 42 | .tex .hljs-command, 43 | .asciidoc .hljs-link_label, 44 | .markdown .hljs-link_label { 45 | color: #a5c261; 46 | } 47 | 48 | .hljs-subst { 49 | color: #519f50; 50 | } 51 | 52 | .hljs-tag, 53 | .hljs-tag .hljs-keyword, 54 | .hljs-tag .hljs-title, 55 | .hljs-doctype, 56 | .hljs-sub .hljs-identifier, 57 | .hljs-pi, 58 | .input_number { 59 | color: #e8bf6a; 60 | } 61 | 62 | .hljs-identifier { 63 | color: #d0d0ff; 64 | } 65 | 66 | .hljs-class .hljs-title, 67 | .hljs-type, 68 | .smalltalk .hljs-class, 69 | .hljs-javadoctag, 70 | .hljs-yardoctag, 71 | .hljs-phpdoc, 72 | .hljs-dartdoc { 73 | text-decoration: none; 74 | } 75 | 76 | .hljs-constant, 77 | .hljs-name { 78 | color: #da4939; 79 | } 80 | 81 | 82 | .hljs-symbol, 83 | .hljs-built_in, 84 | .ruby .hljs-symbol .hljs-string, 85 | .ruby .hljs-symbol .hljs-identifier, 86 | .asciidoc .hljs-link_url, 87 | .markdown .hljs-link_url, 88 | .hljs-attribute { 89 | color: #6d9cbe; 90 | } 91 | 92 | .asciidoc .hljs-link_url, 93 | .markdown .hljs-link_url { 94 | text-decoration: underline; 95 | } 96 | 97 | 98 | 99 | .hljs-params, 100 | .hljs-variable, 101 | .clojure .hljs-attribute { 102 | color: #d0d0ff; 103 | } 104 | 105 | .css .hljs-tag, 106 | .hljs-rule .hljs-property, 107 | .hljs-pseudo, 108 | .tex .hljs-special { 109 | color: #cda869; 110 | } 111 | 112 | .css .hljs-class { 113 | color: #9b703f; 114 | } 115 | 116 | .hljs-rule .hljs-keyword { 117 | color: #c5af75; 118 | } 119 | 120 | .hljs-rule .hljs-value { 121 | color: #cf6a4c; 122 | } 123 | 124 | .css .hljs-id { 125 | color: #8b98ab; 126 | } 127 | 128 | .hljs-annotation, 129 | .apache .hljs-sqbracket, 130 | .nginx .hljs-built_in { 131 | color: #9b859d; 132 | } 133 | 134 | .hljs-preprocessor, 135 | .hljs-preprocessor *, 136 | .hljs-pragma { 137 | color: #8996a8 !important; 138 | } 139 | 140 | .hljs-hexcolor, 141 | .css .hljs-value .hljs-number { 142 | color: #a5c261; 143 | } 144 | 145 | .hljs-title, 146 | .hljs-decorator, 147 | .css .hljs-function { 148 | color: #ffc66d; 149 | } 150 | 151 | .diff .hljs-header, 152 | .hljs-chunk { 153 | background-color: #2f33ab; 154 | color: #e6e1dc; 155 | display: inline-block; 156 | width: 100%; 157 | } 158 | 159 | .diff .hljs-change { 160 | background-color: #4a410d; 161 | color: #f8f8f8; 162 | display: inline-block; 163 | width: 100%; 164 | } 165 | 166 | .hljs-addition { 167 | background-color: #144212; 168 | color: #e6e1dc; 169 | display: inline-block; 170 | width: 100%; 171 | } 172 | 173 | .hljs-deletion { 174 | background-color: #600; 175 | color: #e6e1dc; 176 | display: inline-block; 177 | width: 100%; 178 | } 179 | 180 | .coffeescript .javascript, 181 | .javascript .xml, 182 | .tex .hljs-formula, 183 | .xml .javascript, 184 | .xml .vbscript, 185 | .xml .css, 186 | .xml .hljs-cdata { 187 | opacity: 0.7; 188 | } 189 | -------------------------------------------------------------------------------- /documentation/css/base-index.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | *, *:before, *:after { 5 | box-sizing: inherit; 6 | } 7 | 8 | body{ 9 | font-size: 16px; 10 | line-height: 1; 11 | font-family: Georgia, Times, serif; 12 | color: #000; 13 | background-color: #FFFDEE; 14 | transition: color 0.3s ease, background-color 0.3s ease; 15 | } 16 | 17 | .page-active body{ 18 | background-color: #fff; 19 | color: #1a1a1a; 20 | } 21 | 22 | 23 | 24 | 25 | 26 | /* boxes */ 27 | header{ 28 | position: fixed; 29 | top: 0; 30 | z-index: 1; 31 | width: 100%; 32 | padding: 15px 0; 33 | background-color: #000000; 34 | color: #ffffff; 35 | font-family: "helvetica neue", helvetica, sans-serif; 36 | font-weight: 300; 37 | text-align: center; 38 | 39 | opacity: 0; 40 | transition: 0.3s opacity ease; 41 | transform: translate3d(0,0,0); 42 | } 43 | 44 | .page-active header{ 45 | opacity: 1; 46 | } 47 | 48 | header li{ 49 | display: inline-block; 50 | margin-left: 9px; 51 | padding: 5px 9px; 52 | border-radius: 3px; 53 | background-color: #353535; 54 | cursor: pointer; 55 | transition: 0.3s background-color ease; 56 | 57 | font-size: 12px; 58 | text-transform: uppercase; 59 | } 60 | 61 | header li:first-child{ 62 | margin-left: 0; 63 | } 64 | 65 | header li.active, 66 | header li:hover{ 67 | background-color: #7B0303; 68 | } 69 | 70 | header li:active{ 71 | background-color: #000; 72 | } 73 | 74 | .content{ 75 | width: 100%; 76 | padding: 0 30px; 77 | margin: 80px auto 0 auto; 78 | } 79 | 80 | @media (min-width: 768px) { 81 | .content{ 82 | margin-top: 130px; 83 | } 84 | } 85 | 86 | .content img{ 87 | width: 100%; 88 | } 89 | 90 | section{ 91 | margin-bottom: 5.4em; 92 | } 93 | 94 | 95 | 96 | 97 | 98 | /* text width */ 99 | section > .example, 100 | section > pre, 101 | section > p, 102 | section > ul, 103 | section > ol, 104 | section > h1, 105 | section > h2, 106 | section > h3, 107 | section > h4, 108 | section > h5, 109 | section > h6{ 110 | max-width: 690px; 111 | margin: 0 auto 20px auto; 112 | } 113 | 114 | section > h3, 115 | section > h4, 116 | section > h5{ 117 | margin-bottom: 7px; 118 | } 119 | 120 | 121 | 122 | /* typography */ 123 | h1{ 124 | font-size: 1.9em; 125 | } 126 | 127 | @media (min-width: 768px) { 128 | h1{ 129 | font-size: 3.1em; 130 | } 131 | } 132 | 133 | h2, 134 | h3{ 135 | font-family: "helvetica neue", helvetica, sans-serif; 136 | font-size: 1.6em; 137 | font-weight: 500; 138 | } 139 | 140 | h3{ 141 | font-size: 1.1em; 142 | text-transform: uppercase; 143 | color: #7b7b7b; 144 | } 145 | 146 | h3:before{ 147 | display: block; 148 | content: ' '; 149 | border-top: 1px solid #ccc; 150 | padding-bottom: 25px; 151 | margin-top: 50px; 152 | } 153 | 154 | h2 + h3:before{ 155 | display: none; 156 | } 157 | 158 | /* api reference labels */ 159 | section h4{ 160 | font-family: "helvetica neue", helvetica, sans-serif; 161 | font-size: 0.9em; 162 | line-height: 1em; 163 | font-weight: 700; 164 | margin-top: 30px; 165 | margin-bottom: 2px; 166 | color: #808080; 167 | } 168 | 169 | h4 + p, 170 | h4 + p + p { 171 | font-family: "helvetica neue", helvetica, sans-serif; 172 | font-size: 0.9em; 173 | line-height: 1.1em; 174 | margin-bottom: 2px; 175 | } 176 | 177 | h4 + p + p { 178 | margin-bottom: 7px; 179 | } 180 | 181 | /* agruments label */ 182 | section h6{ 183 | font-family: "helvetica neue", helvetica, sans-serif; 184 | font-size: 0.75em; 185 | line-height: 1em; 186 | font-weight: 700; 187 | margin-top: 16px; 188 | margin-bottom: 2px; 189 | padding-left: 12px; 190 | text-transform: uppercase; 191 | } 192 | 193 | /* arguments list */ 194 | h6 + ul li { 195 | margin-left: 28px; 196 | font-size: 0.9em; 197 | list-style-type: circle; 198 | } 199 | 200 | /* code kicker label */ 201 | h5{ 202 | font-family: "helvetica neue", helvetica, sans-serif; 203 | font-size: 0.9em; 204 | font-weight: 700; 205 | margin-bottom: 4px; 206 | } 207 | 208 | p{ 209 | font-size: 1.2em; 210 | line-height: 1.55em; 211 | } 212 | 213 | section li{ 214 | font-family: "helvetica neue", helvetica, sans-serif; 215 | font-size: 1.1em; 216 | line-height: 1.3em; 217 | list-style-type: disc; 218 | margin-left: 1em; 219 | } 220 | 221 | /* highlighter */ 222 | strong{ 223 | background-color: #7B0303; 224 | border-radius: 3px; 225 | padding: 1px 3px; 226 | color: #fff; 227 | } 228 | 229 | .page-active strong{ 230 | background-color: rgb(255, 244, 202); 231 | color: inherit; 232 | } 233 | 234 | a{ 235 | color: #000; 236 | } 237 | 238 | em{ 239 | font-style: italic; 240 | } 241 | 242 | /* inline code */ 243 | li code, 244 | h2 code, 245 | p code{ 246 | padding: 0.2em; 247 | font-style: italic; 248 | font-size: 0.92em; 249 | } 250 | 251 | 252 | /* section overrides */ 253 | .examples li{ 254 | margin-top: 30px; 255 | } 256 | 257 | .examples li p { 258 | font-family: "helvetica neue", helvetica, sans-serif; 259 | font-size: 0.9em; 260 | color: #8B8B8B; 261 | } 262 | 263 | 264 | .content .documentation h2 + h3{ 265 | margin-top: inherit; 266 | } 267 | 268 | .content .documentation h3{ 269 | margin-top: 70px; 270 | } -------------------------------------------------------------------------------- /documentation/js/highlight.pack.js: -------------------------------------------------------------------------------- 1 | !function(e){"undefined"!=typeof exports?e(exports):(window.hljs=e({}),"function"==typeof define&&define.amd&&define([],function(){return window.hljs}))}(function(e){function n(e){return e.replace(/&/gm,"&").replace(//gm,">")}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0==t.index}function a(e){var n=(e.className+" "+(e.parentNode?e.parentNode.className:"")).split(/\s+/);return n=n.map(function(e){return e.replace(/^lang(uage)?-/,"")}),n.filter(function(e){return N(e)||/no(-?)highlight|plain|text/.test(e)})[0]}function i(e,n){var t,r={};for(t in e)r[t]=e[t];if(n)for(t in n)r[t]=n[t];return r}function o(e){var n=[];return function r(e,a){for(var i=e.firstChild;i;i=i.nextSibling)3==i.nodeType?a+=i.nodeValue.length:1==i.nodeType&&(n.push({event:"start",offset:a,node:i}),a=r(i,a),t(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:a,node:i}));return a}(e,0),n}function u(e,r,a){function i(){return e.length&&r.length?e[0].offset!=r[0].offset?e[0].offset"}function u(e){l+=""}function c(e){("start"==e.event?o:u)(e.node)}for(var s=0,l="",f=[];e.length||r.length;){var g=i();if(l+=n(a.substr(s,g[0].offset-s)),s=g[0].offset,g==e){f.reverse().forEach(u);do c(g.splice(0,1)[0]),g=i();while(g==e&&g.length&&g[0].offset==s);f.reverse().forEach(o)}else"start"==g[0].event?f.push(g[0].node):f.pop(),c(g.splice(0,1)[0])}return l+n(a.substr(s))}function c(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,o){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var u={},c=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");u[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof a.k?c("keyword",a.k):Object.keys(a.k).forEach(function(e){c(e,a.k[e])}),a.k=u}a.lR=t(a.l||/\b\w+\b/,!0),o&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=n(a.e)||"",a.eW&&o.tE&&(a.tE+=(a.e?"|":"")+o.tE)),a.i&&(a.iR=t(a.i)),void 0===a.r&&(a.r=1),a.c||(a.c=[]);var s=[];a.c.forEach(function(e){e.v?e.v.forEach(function(n){s.push(i(e,n))}):s.push("self"==e?a:e)}),a.c=s,a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,o);var l=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(n).filter(Boolean);a.t=l.length?t(l.join("|"),!0):{exec:function(){return null}}}}r(e)}function s(e,t,a,i){function o(e,n){for(var t=0;t";return i+=e+'">',i+n+o}function d(){if(!L.k)return n(y);var e="",t=0;L.lR.lastIndex=0;for(var r=L.lR.exec(y);r;){e+=n(y.substr(t,r.index-t));var a=g(L,r);a?(B+=a[1],e+=p(a[0],n(r[0]))):e+=n(r[0]),t=L.lR.lastIndex,r=L.lR.exec(y)}return e+n(y.substr(t))}function h(){if(L.sL&&!w[L.sL])return n(y);var e=L.sL?s(L.sL,y,!0,M[L.sL]):l(y);return L.r>0&&(B+=e.r),"continuous"==L.subLanguageMode&&(M[L.sL]=e.top),p(e.language,e.value,!1,!0)}function b(){return void 0!==L.sL?h():d()}function v(e,t){var r=e.cN?p(e.cN,"",!0):"";e.rB?(k+=r,y=""):e.eB?(k+=n(t)+r,y=""):(k+=r,y=t),L=Object.create(e,{parent:{value:L}})}function m(e,t){if(y+=e,void 0===t)return k+=b(),0;var r=o(t,L);if(r)return k+=b(),v(r,t),r.rB?0:t.length;var a=u(L,t);if(a){var i=L;i.rE||i.eE||(y+=t),k+=b();do L.cN&&(k+=""),B+=L.r,L=L.parent;while(L!=a.parent);return i.eE&&(k+=n(t)),y="",a.starts&&v(a.starts,""),i.rE?0:t.length}if(f(t,L))throw new Error('Illegal lexeme "'+t+'" for mode "'+(L.cN||"")+'"');return y+=t,t.length||1}var E=N(e);if(!E)throw new Error('Unknown language: "'+e+'"');c(E);var R,L=i||E,M={},k="";for(R=L;R!=E;R=R.parent)R.cN&&(k=p(R.cN,"",!0)+k);var y="",B=0;try{for(var C,j,I=0;;){if(L.t.lastIndex=I,C=L.t.exec(t),!C)break;j=m(t.substr(I,C.index-I),C[0]),I=C.index+j}for(m(t.substr(I)),R=L;R.parent;R=R.parent)R.cN&&(k+="");return{r:B,value:k,language:e,top:L}}catch(S){if(-1!=S.message.indexOf("Illegal"))return{r:0,value:n(t)};throw S}}function l(e,t){t=t||x.languages||Object.keys(w);var r={r:0,value:n(e)},a=r;return t.forEach(function(n){if(N(n)){var t=s(n,e,!1);t.language=n,t.r>a.r&&(a=t),t.r>r.r&&(a=r,r=t)}}),a.language&&(r.second_best=a),r}function f(e){return x.tabReplace&&(e=e.replace(/^((<[^>]+>|\t)+)/gm,function(e,n){return n.replace(/\t/g,x.tabReplace)})),x.useBR&&(e=e.replace(/\n/g,"
")),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:/]+/}]}]}]};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:"|$)",e:">",k:{title:"style"},c:[c],starts:{e:"",rE:!0,sL:"css"}},{cN:"tag",b:"|$)",e:">",k:{title:"script"},c:[c],starts:{e:"",rE:!0,sL:""}},s,{cN:"pi",b:/<\?\w+/,e:/\?>/,r:10},{cN:"tag",b:"",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:/\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.adjustedDistanceToOffset0){activeItem=items[0]}if(activeItem){this._focusItem(activeItem);if(!this._isActive){this._isActive=true;this._trigger("containeractive")}}else{this._blurAllItems();if(this._isActive){this._isActive=false;this._trigger("containerinactive")}}},_scrollToItem:function(item,opts,callback){callback=$.isFunction(callback)?callback.bind(this):$.noop;opts=$.extend(true,{scrollOffset:item.scrollOffset!==false?offsetToPx(item.scrollOffset):offsetToPx(this.options.scrollOffset),speed:this.options.speed,easing:this.options.easing},opts);var debouncedCallback=debounce(callback,100);var scrolllTop=item.el.offset().top-offsetToPx(opts.scrollOffset);$("html, body").stop(true).animate({scrollTop:scrolllTop},opts.speed,opts.easing,debouncedCallback)},applyToAllItems:function(callback,exceptions){exceptions=$.isArray(exceptions)?exceptions:[exceptions];callback=$.isFunction(callback)?callback.bind(this):$.noop;var items=this.getItems();var i=0;var length=items.length;var item;for(i=0;i=0){item.percentScrollComplete=0}else if(Math.abs(item.distanceToOffset)>=rect.height){item.percentScrollComplete=1}else{item.percentScrollComplete=Math.abs(item.distanceToOffset)/rect.height}totalScrollComplete=totalScrollComplete+item.percentScrollComplete;previouslyInViewport=item.inViewport;item.inViewport=rect.bottom>0&&rect.right>0&&rect.left=0&&rect.left>=0&&rect.bottom<=wHeight&&rect.right<=wWidth;if(item.inViewport&&!previouslyInViewport){this._trigger("itementerviewport",null,item)}else if(!item.inViewport&&previouslyInViewport){this._trigger("itemexitviewport",null,item)}}this._distanceToFirstItemTopOffset=items[0].adjustedDistanceToOffset;this._distanceToOffset=this._topOffset-scrollTop-triggerOffset;var percentScrollToLastItem=0;if(this._distanceToOffset<0){percentScrollToLastItem=1-lastItem.distanceToOffset/(this._height-lastItem.height);percentScrollToLastItem=percentScrollToLastItem<1?percentScrollToLastItem:1}this._percentScrollToLastItem=percentScrollToLastItem;this._totalScrollComplete=totalScrollComplete/length},addItems:function(items,opts){opts=$.extend(true,{handleRepaint:true},opts);if(items instanceof $){this._prepItemsFromSelection(items)}else if(typeof items==="string"){this._prepItemsFromSelection(this.$el.find(items))}else if($.isArray(items)){this._prepItemsFromData(items)}else{this._prepItemsFromSelection(this.$el.find(this.options.contentSelector))}if(this.getItems().length<1){throw new Error("addItems found no valid items.")}if(opts.handleRepaint){this._handleRepaint()}},destroy:function(removeMarkup){removeMarkup=removeMarkup||false;if(removeMarkup){this.each(function(item){item.el.remove()})}this._trigger("destroy");var containerData=this.$el.data();containerData["plugin_"+pluginName]=null},_handleRepaint:function(updateOffsets){updateOffsets=updateOffsets===false?false:true;if(updateOffsets){this.updateOffsets()}this._updateScrollPositions();this._setActiveItem()},_handleScroll:function(){if(this.options.enabled){this._handleRepaint(false);this._trigger("containerscroll")}},_handleResize:function(){winHeight=$window.height();if(this.options.enabled&&this.options.autoUpdateOffsets){if(offsetIsAPercentage(this.options.triggerOffset)){this.updateTriggerOffset(this.options.triggerOffset)}if(offsetIsAPercentage(this.options.scrollOffset)){this.updateScrollOffset(this.options.scrollOffset)}this._debouncedHandleRepaint();this._trigger("containerresize")}},_onSetup:function(){this.$el.addClass(pluginName)},_onDestroy:function(){this.$el.off(eventNameSpace);$window.off(eventNameSpace);var itemClassesToRemove=["scrollStoryItem","inviewport","active","filtered"].join(" ");this.each(function(item){item.el.removeClass(itemClassesToRemove)});this.$el.removeClass(function(i,classNames){var classNamesToRemove=[];classNames.split(" ").forEach(function(c){if(c.lastIndexOf(pluginName)===0){classNamesToRemove.push(c)}});return classNamesToRemove.join(" ")});this.$trigger.remove()},_onContainerActive:function(){this.$el.addClass(pluginName+"Active")},_onContainerInactive:function(){this.$el.removeClass(pluginName+"Active")},_onItemFocus:function(ev,item){item.el.addClass("active");this._manageContainerClasses("scrollStoryActiveItem-",item.id);if(item.category){if(this.getPreviousItem()&&this.getPreviousItem().category!==item.category||!this.isContainerActive()){this._trigger("categoryfocus",null,item.category);if(this.getPreviousItem()){this._trigger("categoryblur",null,this.getPreviousItem().category)}}}},_onItemBlur:function(ev,item){this._previousItems.unshift(item);item.el.removeClass("active")},_onItemEnterViewport:function(ev,item){item.el.addClass("inviewport")},_onItemExitViewport:function(ev,item){item.el.removeClass("inviewport")},_onItemFilter:function(ev,item){item.el.addClass("filtered");if(this.options.autoUpdateOffsets){this._debouncedHandleRepaint()}},_onItemUnfilter:function(ev,item){item.el.removeClass("filtered");if(this.options.autoUpdateOffsets){this._debouncedHandleRepaint()}},_onCategoryFocus:function(ev,category){this._manageContainerClasses("scrollStoryActiveCategory-",category)},_onTriggerOffsetUpdate:function(ev,offset){this.$trigger.css({top:offset+"px"})},_manageContainerClasses:function(prefix,value){this.$el.removeClass(function(index,classes){return classes.split(" ").filter(function(c){return c.lastIndexOf(prefix,0)===0}).join(" ")});this.$el.addClass(prefix+value)},_prepItemsFromSelection:function($selection){var that=this;$selection.each(function(){that._addItem({},$(this))})},_prepItemsFromData:function(items){var that=this;var selector=this.options.contentSelector.replace(/\./g,"");var frag=document.createDocumentFragment();items.forEach(function(data){var $item=$('
');that._addItem(data,$item);frag.appendChild($item.get(0))});this.$el.append(frag)},_addItem:function(data,$el){var domData=$el.data();var item={index:this._items.length,el:$el,id:$el.attr("id")?$el.attr("id"):data.id?data.id:"story"+instanceCounter+"-"+this._items.length,data:$.extend({},data,domData),category:domData.category||data.category,tags:data.tags||[],scrollStory:this,active:false,filtered:false,scrollOffset:false,triggerOffset:false,inViewport:false};if(!$el.attr("id")){$el.attr("id",item.id)}$el.addClass("scrollStoryItem");this._items.push(item);this._itemsById[item.id]=item;this._trigger("itembuild",null,item);if(item.category&&this._categories.indexOf(item.category)===-1){this._categories.push(item.category)}},_trigger:function(eventType,event,data){var callback=this.options[eventType];var prop,orig;if($.isFunction(callback)){data=data||{};event=$.Event(event);event.target=this.el;event.type=eventType;orig=event.originalEvent;if(orig){for(prop in orig){if(!(prop in event)){event[prop]=orig[prop]}}}this.$el.trigger(event,data);var boundCb=this.options[eventType].bind(this);boundCb(event,data)}}};ScrollStory.prototype.debouncedUpdateOffsets=debounce(ScrollStory.prototype.updateOffsets,100);ScrollStory.prototype._debouncedHandleRepaint=debounce(ScrollStory.prototype._handleRepaint,100);$.fn[pluginName]=function(options){return this.each(function(){if(!$.data(this,"plugin_"+pluginName)){$.data(this,"plugin_"+pluginName,new ScrollStory(this,options))}})}}); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScrollStory 2 | 3 | ScrollStory is a jQuery plugin for building scroll-based stories. Rather than doing a specific task, it aims to be a tool to help solve general problems. 4 | 5 | For example, it can help you **update your nav** as you scroll down the page. It can **auto-scroll** to sections of your story on a mouse click or custom event. It can trigger **custom callbacks** that manipulate the page as you scroll, like **lazy loading** media. It can dynamically insert markup into the page using an array of data on instantiation, or use pre-existing markup. Additionally, it **maintains data** associated with all these custom interactions. 6 | 7 | ## Examples 8 | * Controlling scroll-based graphic in [111 N.F.L. Brains. All But One Had C.T.E.](https://www.nytimes.com/interactive/2017/07/25/sports/football/nfl-cte.html), nytimes.com 9 | * Triggering zoomy photo in [Fleeing Boko Haram, Thousands Cling to a Road to Nowhere](https://www.nytimes.com/interactive/2017/03/30/world/africa/the-road-to-nowhere-niger.html), nytimes.com 10 | * Triggering animations in the desktop version of [This Is Your Life, Brought to You by Private Equity](https://www.nytimes.com/interactive/2016/08/02/business/dealbook/this-is-your-life-private-equity.html), nytimes.com 11 | * ScrollStory compared in [How to implement scrollytelling with six different libraries](https://pudding.cool/process/how-to-implement-scrollytelling/demo/scrollstory/), pudding.cool 12 | * Lazy loading 360º video in [52 Places to Go in 2017](https://www.nytimes.com/interactive/2017/travel/places-to-visit.html), nytimes.com 13 | * Revealing text in [A Gift to New York, in Time for the Pope](http://www.nytimes.com/interactive/2015/09/17/nyregion/st-patricks-cathedral-pope-francis-visit.html), nytimes.com 14 | 15 | ## Overview 16 | 17 | ScrollStory is built on the idea that scrolling stories often comprise discrete elements stacked on a page that exclusively require a reader’s focus. These elements — or `items` in ScrollStory speak — can be anything: sections of text (like the sections of this page), a video, a photo and caption, or any HTML element that can be scrolled to. 18 | 19 | ScrollStory follows these items, internally tracking the scroll distance until an item requires the reader’s focus, at which point custom code can be executed to manipulate the experience, like updating the navigation bar and fading the background color on this page. Additionally, custom code can be run whenever any `item` enters the viewport; any `item` within a ScrollStory collection is activated (or, inversely, when none are activated); when an item is `filtered`, a ScrollStory construct meaning it is no longer an active part of a collection; or any of **17 custom events**. 20 | 21 | ScrollStory `items` aren't just DOM nodes. Rather, they’re data objects that have a corresponding representation in the DOM. ScrollStory instances maintain data about each `item` object in a collection and provides numerous methods of accessing, querying and modifying that data. 22 | 23 | 24 | ## Documentation 25 | 26 | ### Download 27 | * [Development](https://raw.githubusercontent.com/sjwilliams/scrollstory/master/dist/jquery.scrollstory.js) 28 | * [Production](https://raw.githubusercontent.com/sjwilliams/scrollstory/master/dist/jquery.scrollstory.min.js) 29 | * `npm install scrollstory` 30 | 31 | 32 | ### Basic Usage 33 | 34 | In its most basic form, ScrollStory takes a container element and searches for `.story` child elements. 35 | 36 | 37 | ##### The code: 38 | 39 | ```html 40 | 41 | 42 |
43 |

Story 1

...

44 |

Story 2

...

45 |
46 | 47 | 48 | 49 | 50 | 51 | 57 | 58 | ``` 59 | 60 | Internally, ScrollStory turns those elements into `item` objects and assigns them several default properties, like its index position in the list, its `inViewport` status and a `data` object for user data. 61 | 62 | ##### The item object: 63 | 64 | ```js 65 | { 66 | id: 'story0-2', // globally unique across every instance of ScrollStory. User-assigned or auto generated. 67 | index: 0, // zero-based index for this item within this instance of ScrollStory 68 | el: $(), // jQuery object containing the item node 69 | data: {}, // user data for this item 70 | inViewport: true, 71 | fullyInViewport: false, 72 | active: false, // is this the exclusively active item 73 | filtered: false, // has this item been removed from the collection 74 | category: 'people', // optional. single, top-level association. 75 | tags: ['friend', 'relative'], // optional. array of strings defining lose relationships between items. 76 | distanceToOffset: -589, // px distance to global trigger, 77 | adjustedDistanceToOffet: -589, //px distance to trigger taking into account any local adjustments for this item 78 | scrollStory: {}, // reference to the scrollstory instance this item belongs to 79 | height: 582, // item element height 80 | width: 1341, // item element width 81 | scrollOffset: false, // a number if the scrollOffset for this item is different from the global one 82 | triggerOffset: false // a number if the triggerOffset for this item is different from the global one 83 | } 84 | ``` 85 | 86 | In addition to creating item objects on instantiation, ScrollStory modifies the DOM to reflect various states. 87 | 88 | * A class of `scrollStory` is added to the container element. 89 | * A class of `scrollStoryActive` is added to the container element if any item is active. 90 | * A class of `scrollStoryActiveItem-{itemId}` is added to the container element to reflect currently "active" item. 91 | * A class of `scrollStoryItem` is added to every item element. 92 | * A class of `active` is added to the currently active item element. 93 | * A class of `inviewport` is added to item elements partially or fully in the viewport. 94 | * An ID attribute is added to any story item element that didn't have one. 95 | 96 | ##### Post-instantiation DOM 97 | 98 | ```html 99 |
100 |
...
101 |
...
102 |
...
103 |
...
104 |
105 | ``` 106 | 107 | ### Pass In Data Via Attributes 108 | 109 | Data can be dynamically added to individual item objects by adding it as data attributes in markup. Combined with ScrollStory's API methods, some very dynamic applications can be built. 110 | 111 | ##### The code: 112 | 113 | ```html 114 |
115 |
116 |
117 | ... 118 |
119 | 124 | ``` 125 | 126 | Internally, ScrollStory turns those elements into item objects and assigns them several default properties, like its index position in the list, its inViewport status and a data object for user data. 127 | 128 | ##### The item objects: 129 | 130 | ```js 131 | [{ 132 | id: 'story0-0', 133 | index: 0 134 | inViewport: true, 135 | active: true, 136 | ... 137 | data: { 138 | organization: "The New York Times", 139 | founded: "1851" 140 | } 141 | },{ 142 | id: 'story0-1', 143 | index: 1 144 | inViewport: false, 145 | active: false, 146 | ... 147 | data: { 148 | organization: "The Washington Post", 149 | founded: "1877" 150 | } 151 | }] 152 | ``` 153 | 154 | 155 | ##### Post-instantiation 156 | 157 | ```html 158 |
159 |
...
160 |
...
161 |
162 | ``` 163 | 164 | ### Build From Data 165 | 166 | A ScrollStory instance can be built with an array of data objects instead of markup, which will be used to generate all the ScrollStory items and elements on the page. The items array and rendered markup are idential to the example above. 167 | 168 | ##### The Code 169 | 170 | ```js 171 | $(function(){ 172 | 173 | // data 174 | var newspapers=[{ 175 | organization: "The New York Times", 176 | founded: "1851" 177 | },{ 178 | organization: "The Washington Post", 179 | founded: "1877" 180 | }]; 181 | 182 | // pass in the data 183 | $("#container").scrollStory({content: newspapers}); 184 | }); 185 | ``` 186 | 187 | 188 | 189 | ##### Post-instantiation DOM 190 | 191 | ```html 192 |
193 |
...
194 |
...
195 |
196 | ``` 197 | 198 | ### Using Data 199 | 200 | Item data can be used in most ScrollStory events and callbacks. For example, you can to use the data to dynamically generate markup during instantiation. 201 | 202 | 203 | ```js 204 | $(function(){ 205 | var newspapers=[{organization: "The New York Times", founded: "1851"},{organization: "The Washington Post", founded: "1877"}]; 206 | 207 | $("#container").scrollStory({ 208 | content: newspapers, 209 | itembuild: function(ev, item){ 210 | item.el.append("

"+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 |
223 |
224 |

The New York Times

225 |
226 |
227 |

The Washington Post

228 |
229 |
230 | ``` 231 | 232 | You could also, for example, manipulate the styles of items as they gain and lose focus. Here we'll interact with the same instance as before, but instead of callbacks we'll use events, which are available after instantiation. 233 | 234 | ```js 235 | $("container").on('itemfocus', function(item){ 236 | if(item.index === 0){ 237 | item.el.css('background-color', 'purple'); 238 | } else { 239 | item.el.css('background-color', 'red'); 240 | } 241 | }); 242 | 243 | $("container").on('itemblur', function(ev, item){ 244 | item.el.css('background-color', 'white'); 245 | }); 246 | ``` 247 | 248 | Admittedly this example is a bit contrived as we could have done the same thing in CSS alone: 249 | ```css 250 | .story{ 251 | background-color: white; 252 | } 253 | 254 | .story.active{ 255 | background-color: red; 256 | } 257 | 258 | .scrollStoryActiveItem-story0-0 .story.active{ 259 | background-color: purple; 260 | } 261 | 262 | ``` 263 | 264 | ### Instantiation Options 265 | 266 | #### content 267 | Type: `jQuery Object`, `String`, or `array` 268 | Default value: 'null' 269 | 270 | ```js 271 | $('#container').scrollStory({ 272 | content: [{name:'Josh', town: 'San Francisco'}] 273 | }); 274 | ``` 275 | If given a jQuery object, class selector string, or array of values, use the cooresponding data to build items in this instance. 276 | 277 | #### contentSelector 278 | Type: `String` 279 | Default value: '.story' 280 | 281 | ```js 282 | $('#container').scrollStory({ 283 | contentSelector: '.story' 284 | }); 285 | ``` 286 | A jQuery selector to find story items within your widget. 287 | 288 | #### keyboard 289 | Type: `Boolean` 290 | Default value: true 291 | 292 | ```js 293 | $('#container').scrollStory({ 294 | keyboard: true 295 | }); 296 | ``` 297 | Enable left and right arrow keys to move between story items. 298 | 299 | #### triggerOffset 300 | Type: `Number` 301 | Default value: 0 302 | 303 | ```js 304 | $('#container').scrollStory({ 305 | triggerOffset: 0 306 | }); 307 | ``` 308 | The trigger offset is the distance from the top of the page used to determine which item is active. 309 | 310 | 311 | #### scrollOffset 312 | Type: `Number` 313 | Default value: 0 314 | 315 | ```js 316 | $('#container').scrollStory({ 317 | scrollOffset: 0 318 | }); 319 | ``` 320 | When programatically scrolled, this is the position in pixels from the top the item is scrolled to. 321 | 322 | #### autoActivateFirstItem 323 | Type: `Boolean` 324 | 325 | Default value: false 326 | 327 | ```js 328 | $('#container').scrollStory({ 329 | autoActivateFirstItem: false 330 | }); 331 | ``` 332 | Automatically activate the first item on page load, regardless of its position relative to the offset and the 'preOffsetActivation' setting. Common case: you want to disable 'preOffsetActivation' to ensure late scroll activations but need the first item to be enabled on load. With 'preOffsetActivation:true', this is ignored. 333 | 334 | #### disablePastLastItem 335 | Type: `Boolean` 336 | Default value: true 337 | 338 | ```js 339 | $('#container').scrollStory({ 340 | disablePastLastItem: true 341 | }); 342 | ``` 343 | Disable last item -- and the entire widget -- once the last item has scrolled beyond the trigger point. 344 | 345 | #### speed 346 | Type: `Number` 347 | 348 | Default value: 800 349 | 350 | ```js 351 | $('#container').scrollStory({ 352 | speed: 800 353 | }); 354 | ``` 355 | Automated scroll speed in ms. Set to 0 to remove animation. 356 | 357 | #### easing 358 | Type: `String` 359 | 360 | Default value: 'swing' 361 | 362 | ```js 363 | $('#container').scrollStory({ 364 | easing: 'swing' 365 | }); 366 | ``` 367 | The easing, 'swing' or 'linear', to use during programatic scrolls. 368 | 369 | 370 | #### scrollSensitivity 371 | Type: `Number` 372 | 373 | Default value: 100 374 | 375 | ```js 376 | $('#container').scrollStory({ 377 | scrollSensitivity: 100 378 | }); 379 | ``` 380 | How often in milliseconds to check for the active item during a scroll. Use a higher number if performance becomes an issue. 381 | 382 | #### throttleType 383 | Type: `String` 384 | 385 | Default value: 'debounce' 386 | 387 | ```js 388 | $('#container').scrollStory({ 389 | throttleType: 'debounce' // debounce or throttle 390 | }); 391 | ``` 392 | Set the throttle -- or rate-limiting -- method used when testing items' active state. These are wrappers around Underscore's [throttle](http://underscorejs.org/#throttle) and [debounce](http://underscorejs.org/#debounce) functions. Use 'throttle' to trigger active state on the leading edge of the scroll event. Use 'debounce' to trigger on the trailing edge. 393 | 394 | #### throttleTypeOptions 395 | Type: `Boolean\Object` 396 | 397 | Default value: null 398 | 399 | ```js 400 | $('#container').scrollStory({ 401 | throttleTypeOptions: null 402 | }); 403 | ``` 404 | Options to pass to Underscore's throttle or debounce for scroll. Type/functionality dependent on 'throttleType' 405 | 406 | #### autoUpdateOffsets 407 | Type: `Boolean` 408 | 409 | Default value: true 410 | 411 | ```js 412 | $('#container').scrollStory({ 413 | autoUpdateOffsets: true 414 | }); 415 | ``` 416 | Update offsets after likely repaints, like window resizes and filters. If updates aren't offset, the triggering of scroll events may be inaccurate. 417 | 418 | #### enabled 419 | Type: `Boolean` 420 | 421 | Default value: true 422 | 423 | ```js 424 | $('#container').scrollStory({ 425 | enabled: true 426 | }); 427 | ``` 428 | Whether or not the scroll checking is enabled. 429 | 430 | #### debug 431 | Type: `Boolean` 432 | 433 | Default value: false 434 | 435 | ```js 436 | $('#container').scrollStory({ 437 | debug: true 438 | }); 439 | ``` 440 | Whether or not the scroll trigger point should be visible on the page. 441 | 442 | 443 | ### Events and Callbacks 444 | 445 | Most of ScrollStory's functionality is available via callbacks and events. 446 | 447 | ```js 448 | 449 | // via callbacks on instantiation 450 | $('#container').scrollStory({ 451 | itemfocus: function(ev, item) { 452 | // do something 453 | } 454 | }) 455 | 456 | // or via events on the container 457 | $('#container').on('itemfocus', function(ev, item){ 458 | // do something 459 | }); 460 | 461 | ``` 462 | 463 | #### setup 464 | Fired early in instantiation, before any items are added or offsets calculated. Usefull for manipulating the page before ScrollStory does 465 | anything. 466 | 467 | ```js 468 | $('#container').scrollStory({ 469 | setup: function() { 470 | // do something 471 | } 472 | }) 473 | ``` 474 | 475 | 476 | #### itemfocus 477 | Fired when an item gains 'focus', which can happen from a scroll-based activation (most commonly), or externally via this.index(). 478 | 479 | ```js 480 | $('#container').scrollStory({ 481 | itemfocus: function(ev, item) { 482 | // do something 483 | } 484 | }) 485 | ``` 486 | 487 | #### itemblur 488 | Fired when an item loses 'focus'. 489 | 490 | ```js 491 | $('#container').scrollStory({ 492 | itemblur: function(ev, item) { 493 | // do something 494 | } 495 | }) 496 | ``` 497 | 498 | #### itemfilter 499 | Fired when an item is filtered, which means it is no longer considered when ScrollStory determines which item is currently active. By default, there is no visual change on filter, but you can achive visual changes through this event and css rules. 500 | 501 | ```js 502 | $('#container').scrollStory({ 503 | itemfilter: function(ev, item) { 504 | // do something 505 | } 506 | }) 507 | ``` 508 | 509 | #### itemunfilter 510 | Fired when an item is unfiltered. 511 | 512 | ```js 513 | $('#container').scrollStory({ 514 | itemunfilter: function(ev, item) { 515 | // do something 516 | } 517 | }) 518 | ``` 519 | 520 | #### itementerviewport 521 | Fired when an item enters the visible portion of the screen. This is useful for triggering things like lazy loads. 522 | 523 | ```js 524 | $('#container').scrollStory({ 525 | itementerviewport: function(ev, item) { 526 | // do something 527 | } 528 | }) 529 | ``` 530 | 531 | #### itemexitviewport 532 | Fired when an item leaves the visible portion of the screen. 533 | 534 | ```js 535 | $('#container').scrollStory({ 536 | itemexitviewport: function(ev, item) { 537 | // do something 538 | } 539 | }) 540 | ``` 541 | 542 | #### itembuild 543 | Fired when the widget is made aware of an individual item during instantiation. This is a good time to add additional properties to the object. If you're passing in data to build the DOM via the 'content' property, you should append HTML to the item here, as the item hasn't yet been added to the page and the render will be faster. 544 | 545 | ```js 546 | $('#container').scrollStory({ 547 | itembuild: function(ev, item) { 548 | item.el.html('

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 an item.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 | --------------------------------------------------------------------------------