├── .editorconfig ├── .jshintrc ├── README.md ├── component.json ├── dist ├── jquery.espy.js └── jquery.espy.min.js ├── espy.jquery.json ├── grunt.js └── src └── espy.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef" : [ 3 | "jQuery" 4 | ], 5 | 6 | "bitwise": false, 7 | "camelcase": false, 8 | "curly": true, 9 | "eqeqeq": true, 10 | "forin": false, 11 | "immed": true, 12 | "latedef": true, 13 | "newcap": true, 14 | "noarg": true, 15 | "noempty": true, 16 | "nonew": false, 17 | "plusplus": false, 18 | "quotmark": false, 19 | "regexp": false, 20 | "undef": true, 21 | "unused": true, 22 | "strict": true, 23 | "trailing": true, 24 | 25 | "asi": false, 26 | "boss": false, 27 | "debug": false, 28 | "eqnull": true, 29 | "es5": false, 30 | "esnext": false, 31 | "evil": false, 32 | "expr": false, 33 | "funcscope": false, 34 | "globalstrict": false, 35 | "iterator": false, 36 | "lastsemic": false, 37 | "laxbreak": false, 38 | "laxcomma": true, 39 | "loopfunc": false, 40 | "multistr": false, 41 | "onecase": true, 42 | "proto": false, 43 | "regexdash": false, 44 | "scripturl": false, 45 | "smarttabs": true, 46 | "shadow": false, 47 | "sub": false, 48 | "supernew": false, 49 | 50 | "browser": true 51 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Espy 2 | 3 | jQuery plugin for on-scroll detecting whether the element entered or left the viewport. 4 | 5 | Any time you scroll, Espy checks whether the element entered or left the trigger area (window viewport by default) 6 | specified in the options. On each change in state, callback will be fired with all necessary diagnostics of the element 7 | position relative to the trigger area. 8 | 9 | What is considered a trigger area is configurable. By default, the trigger area is the visible part of the screen. 10 | 11 | #### Dependencies 12 | 13 | - jQuery 1.7+ 14 | 15 | #### Compatibility 16 | 17 | So simple, it should work everywhere. Briefly tested in IE6+, Chrome, FF, Opera, Safari. 18 | 19 | ### [Changelog](https://github.com/Darsain/espy/wiki/Changelog) 20 | 21 | Espy upholds the [Semantic Versioning Specification](http://semver.org/), and currently is in **Beta**. 22 | 23 | ## API documentation 24 | 25 | ```js 26 | $(selector).espy( callback [, options ] ); 27 | ``` 28 | 29 | #### callback 30 | 31 | Function to be executed when element enters or leaves the trigger area. 32 | 33 | Function context - *this* - is the concerned element. 34 | 35 | Receives these arguments: 36 | 37 | - **entered** `Boolean` True when element entered the viewport, false when it left. 38 | - **state** `String` Specifying in which direction is the element in regard to the trigger area. 39 | Can be: `up` & `down` in vertical mode, `left` & `right` in horizontal mode, and `inside` when covering, or contained in 40 | the trigger area. 41 | 42 | Example: 43 | 44 | ```js 45 | $(selector).espy(function (entered, state) { 46 | if (entered) { 47 | // element entered the viewport 48 | // state === 'inside' 49 | } else { 50 | // element left the viewport 51 | if (state === 'up') { 52 | // element is now above the trigger area 53 | } else if (state === 'down') { 54 | // element is now below the trigger area 55 | } 56 | } 57 | }); 58 | ``` 59 | 60 | #### [ options ] 61 | 62 | **context**: `Node` `window` Element scrolling context. 63 | 64 | **horizontal**: `Bool` `0` Enables the horizontal scrolling mode. 65 | 66 | **offset**: `Int` `0` Offset from start of the context (top in vertical, left in horizontal) specifying the trigger 67 | area. Can be integer for offset in pixels, % for offset relative to the size of the context, positive number for offset 68 | from start, and negative for offset from end. 69 | 70 | **size**: `Mixed` `100%` Size of the trigger area. Can be integer for size in pixels, or % string for size relative to 71 | the size of the context. 72 | 73 | **contain**: `Bool` `0` By enabling this, the callback with `entered=true` will be called only when the whole element is 74 | completely contained within the trigger area, as opposed to just partially covering it. If the element is bigger than 75 | the trigger area itself, the callback with `entered=true` will never be fired. 76 | 77 | ## Using the Espy class 78 | 79 | `jQuery.fn.espy` (described above) is basically just a simple proxy for Espy class declared in `jQuery.Espy` namespace. 80 | You can use this class directly to get more power over the Espy functionality. 81 | 82 | ```js 83 | jQuery.Espy( context [, callback ] [, options ] ); 84 | ``` 85 | 86 | Arguments: 87 | 88 | **context**: `Node` Scrolling context. 89 | 90 | **callback**: `Function` Global function to be called for each element that changes state. Receives the same arguments 91 | as `callback` documented above. 92 | 93 | **options**: `Object` Global options for this object instance. Same properties as options defined above. Extends default 94 | options, and can be further overridden for each element. Has one extra property called `delay`, specifying the minimum 95 | delay in milliseconds that throttles the frequency of `scroll` events to once per `delay`. Default `delay` is `100`. 96 | 97 | Example: 98 | 99 | Create an Espy object for window context: 100 | 101 | ```js 102 | var windowSpy = new jQuery.Espy(window, callback); 103 | ``` 104 | 105 | ### Methods 106 | 107 | --- 108 | 109 | #### add 110 | 111 | ```js 112 | windowSpy.add( element [, callback ] [, options ] ); 113 | ``` 114 | 115 | Add element(s) to spying list. Arguments: 116 | 117 | **element**: `Mixed` Can be a selector, element node, or a jQuery object with one or multiple elements. 118 | 119 | **callback**: `Function` Function to be executed on **element** state change. Same arguments as callbacks documented 120 | above. Doesn't override the main instance's callback, but is triggered along with it. 121 | 122 | **options**: `Object` Object with options. Same properties as options defined above. 123 | 124 | Examples: 125 | 126 | ```js 127 | windowSpy.add('#element'); // jQuery selector 128 | windowSpy.add(document.getElementById('element')); // Direct element node 129 | windowSpy.add(jQuery('.elements')); // Multiple elements in jQuery object 130 | ``` 131 | 132 | **Note!**: When you add an item multiple times, you won't produce duplicates, you will just override it's callback and 133 | options. 134 | 135 | --- 136 | 137 | #### reload 138 | 139 | ```js 140 | windowSpy.reload( element ); 141 | ``` 142 | 143 | Reload element(s)'s dimensions. Call this on element that has changed it's position or size. Arguments: 144 | 145 | **element**: `Mixed` Can be a selector, element node, or a jQuery object with one or multiple elements. 146 | 147 | --- 148 | 149 | #### remove 150 | 151 | ```js 152 | windowSpy.remove( element ); 153 | ``` 154 | 155 | Remove element(s) from spying list. Arguments: 156 | 157 | **element**: `Mixed` Can be a selector, element node, or a jQuery object with one or multiple elements. 158 | 159 | --- 160 | 161 | #### destroy 162 | 163 | ```js 164 | windowSpy.destroy(); 165 | ``` 166 | 167 | Destroy Espy object. Removes registered events, clears spying list, ... 168 | 169 | 170 | ## Contributing 171 | 172 | Contributions are welcome! But please: 173 | 174 | - Maintain the coding style used throughout the project, and defined in the `.editorconfig` file. 175 | [Editorcofig plugin for SublimText 2](https://github.com/sindresorhus/editorconfig-sublime). 176 | - Resulting code has to pass JSHint with options defined in the `.jshintrc` file. You can install 177 | [SublimeLinter plugin for SublimText 2](https://github.com/SublimeLinter/SublimeLinter) to do it automatically, or 178 | run `grunt lint` task. 179 | 180 | ## Credits 181 | 182 | - Inspired by the need for something like [imakewebthings/jquery-waypoints](https://github.com/imakewebthings/jquery-waypoints), 183 | but more functional, with better API, and simple - not bloated to 8KB minified o.O. 184 | - Espy is internally using simplified [jquery-throttle-debounce](https://github.com/cowboy/jquery-throttle-debounce/) 185 | from [Ben "Cowboy" Alman](https://github.com/cowboy). 186 | -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Espy", 3 | "version": "0.1.0", 4 | "main": "./dist/jquery.espy.js", 5 | "dependencies": { 6 | "jquery": ">=1.7" 7 | }, 8 | "description": "jQuery plugin for on-scroll detecting whether the element entered or left the viewport.", 9 | "homepage": "https://github.com/Darsain/espy", 10 | "bugs": "https://github.com/Darsain/espy/issues", 11 | "repository": { 12 | "type": "git", 13 | "url": "http://github.com/Darsain/espy.git" 14 | }, 15 | "licenses": [ 16 | { 17 | "type": "MIT", 18 | "url": "http://opensource.org/licenses/MIT" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /dist/jquery.espy.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Espy 0.1.0 - 2nd Feb 2013 3 | * https://github.com/Darsain/espy 4 | * 5 | * Licensed under the MIT license. 6 | * http://opensource.org/licenses/MIT 7 | */ 8 | 9 | ;(function ($, w, undefined) { 10 | 'use strict'; 11 | 12 | // Plugin names 13 | var pluginName = 'espy', 14 | pluginClass = 'Espy', 15 | namespace = pluginName; 16 | 17 | $[pluginClass] = function(context, callback, options) { 18 | // Optional arguments delay 19 | if (typeof callback !== 'function') { 20 | options = callback; 21 | callback = 0; 22 | } 23 | 24 | // Private variables 25 | var self = this; 26 | var $context = $(context); 27 | var defaults = $.extend({}, $.fn[pluginName].defaults, options); 28 | var spies = {}; 29 | var lastId = 0; 30 | var pos = { 31 | top: $context.scrollTop(), 32 | left: $context.scrollLeft(), 33 | width: $context.innerWidth(), 34 | height: $context.innerHeight(), 35 | offset: $context.offset() || { 36 | top: 0, 37 | left: 0 38 | } 39 | }; 40 | 41 | /** 42 | * Add new element(s) to spies list. 43 | * 44 | * @param {Node} element 45 | * @param {Function} callback 46 | * @param {Object} options 47 | * 48 | * @return {Object} Spy object with basic control methods. 49 | */ 50 | self.add = function (element, callback, options) { 51 | // Optional arguments logic 52 | if ($.isPlainObject(callback)) { 53 | options = callback; 54 | callback = 0; 55 | } 56 | 57 | $(element).each(function (i, el) { 58 | var spyId = getId(el) || 's' + lastId++; 59 | 60 | // Add new element to spying list 61 | spies[spyId] = $.extend({ 62 | id: spyId, 63 | el: el, 64 | $el: $(el), 65 | callback: callback 66 | }, defaults, options); 67 | 68 | // Load the element data 69 | load(spyId); 70 | }); 71 | }; 72 | 73 | /** 74 | * (Re)Load spy object's dimensions. 75 | * 76 | * @param {Mixed} element 77 | * 78 | * @return {Void} 79 | */ 80 | function load(element) { 81 | var spy = getSpy(element); 82 | if (!spy) { 83 | return; 84 | } 85 | 86 | var start = spy.$el.offset()[spy.horizontal ? 'left' : 'top'] - pos.offset[spy.horizontal ? 'left' : 'top']; 87 | var size = spy.$el[spy.horizontal ? 'outerWidth' : 'outerHeight'](); 88 | 89 | // Add new element to spying list 90 | $.extend(spy, { 91 | start: start, 92 | elSize: size, 93 | end: start + size 94 | }); 95 | 96 | // Check the element for its state 97 | check(spy); 98 | } 99 | 100 | /** 101 | * Reload element's dimensions. 102 | * 103 | * @param {Node} element 104 | * 105 | * @return {Void} 106 | */ 107 | self.reload = function (element) { 108 | $(element).each(function (i, el) { 109 | var spyId = getId(el); 110 | if (spyId) { 111 | load(spyId); 112 | } 113 | }); 114 | }; 115 | 116 | /** 117 | * Remove element(s) from spying list. 118 | * 119 | * @param {Node} element 120 | * 121 | * @return {Void} 122 | */ 123 | self.remove = function (element) { 124 | $(element).each(function (i, el) { 125 | var spyId = getId(el); 126 | if (spyId) { 127 | delete spies[spyId]; 128 | } 129 | }); 130 | }; 131 | 132 | /** 133 | * Check element state, and trigger callback on change. 134 | * 135 | * @param {Mixed} element Can be element node, spy ID, or spy object. Omit to check all elements. 136 | * 137 | * @return {Void} 138 | */ 139 | function check(element) { 140 | if (element === undefined) { 141 | $.each(spies, check); 142 | return; 143 | } 144 | 145 | // Check whether the element/ID exist in spying list. 146 | var spy = getSpy(element); 147 | if (!spy) { 148 | return; 149 | } 150 | 151 | // Variables necessary for determination. 152 | var viewSize = pos[spy.horizontal ? 'width' : 'height']; 153 | var triggerSize = parseRatio(spy.size, viewSize); 154 | var triggerStart = pos[spy.horizontal ? 'left' : 'top'] + parseRatio(spy.offset, viewSize, -triggerSize); 155 | var triggerEnd = triggerStart + triggerSize; 156 | var newState; 157 | 158 | // Calculate element state in relation to trigger area. 159 | if (spy.contain) { 160 | if (triggerStart <= spy.start && triggerEnd >= spy.end) { 161 | newState = 'inside'; 162 | } else if (triggerStart + triggerSize / 2 > spy.start + spy.elSize / 2) { 163 | newState = spy.horizontal ? 'left' : 'up'; 164 | } else { 165 | newState = spy.horizontal ? 'right' : 'down'; 166 | } 167 | } else { 168 | if ( 169 | triggerStart > spy.start && triggerStart < spy.end || 170 | triggerEnd > spy.start && triggerEnd < spy.end || 171 | triggerStart <= spy.start && triggerEnd >= spy.start || 172 | triggerStart <= spy.end && triggerEnd >= spy.end 173 | ) { 174 | newState = 'inside'; 175 | } else if (triggerStart > spy.end) { 176 | newState = spy.horizontal ? 'left' : 'up'; 177 | } else { 178 | newState = spy.horizontal ? 'right' : 'down'; 179 | } 180 | } 181 | 182 | // Trigger callbacks on change. 183 | if (spy.state !== newState) { 184 | spy.state = newState; 185 | if (typeof callback === 'function') { 186 | callback.call(spy.el, newState === 'inside', newState); 187 | } 188 | if (typeof spy.callback === 'function') { 189 | spy.callback.call(spy.el, newState === 'inside', newState); 190 | } 191 | } 192 | } 193 | 194 | /** 195 | * Check whether the element is already spied on, and return the spy ID. 196 | * 197 | * @param {Node} element 198 | * 199 | * @return {Mixed} Spy ID string, or false. 200 | */ 201 | function getId(element) { 202 | // Return when ID has been passed. 203 | if (spies.hasOwnProperty(element)) { 204 | return element; 205 | } 206 | 207 | // Return ID when spy object has been passed. 208 | if ($.isPlainObject(element) && spies.hasOwnProperty(element.id)) { 209 | return element.id; 210 | } 211 | 212 | // Ensure the element is a single Node 213 | element = $(element)[0]; 214 | 215 | // Check for existence and return the ID. 216 | var is = false; 217 | $.each(spies, function (id, spy) { 218 | if (spy.el === element) { 219 | is = id; 220 | } 221 | }); 222 | return is; 223 | } 224 | 225 | /** 226 | * Return spy object of an element. 227 | * 228 | * @param {Node} element 229 | * 230 | * @return {Object} Spy ID string, or false. 231 | */ 232 | function getSpy(element) { 233 | var spyId = getId(element); 234 | return spyId ? spies[spyId] : false; 235 | } 236 | 237 | /** 238 | * Destroy Espy instance. 239 | * 240 | * @return {Void} 241 | */ 242 | self.destroy = function () { 243 | $context.off('.' + namespace); 244 | spies = {}; 245 | self = undefined; 246 | }; 247 | 248 | // Register scroll handler 249 | $context.on('scroll.' + namespace, throttle(defaults.delay, function () { 250 | pos.top = $context.scrollTop(); 251 | pos.left = $context.scrollLeft(); 252 | check(); 253 | })); 254 | 255 | // Register resize handler 256 | $context.on('resize.' + namespace, throttle(defaults.delay, function () { 257 | pos.width = $context.innerWidth(); 258 | pos.height = $context.innerHeight(); 259 | check(); 260 | })); 261 | }; 262 | 263 | /** 264 | * Parse string like -200% and return the final dimension. 265 | * 266 | * @param {Mixed} value Integer, or percent string. 267 | * @param {Integer} total Total value representing 100%. 268 | * @param {Integer} offset Optional offset for negative numbers. 269 | * 270 | * @return {Integer} 271 | */ 272 | function parseRatio(value, total, offset) { 273 | var matches = (value+'').match(/^(-?[0-9]+)(%)?$/); 274 | if (!matches) { 275 | return false; 276 | } 277 | var num = parseInt(matches[1], 10); 278 | if (matches[2]) { 279 | num = total / 100 * num; 280 | } 281 | return num < 0 ? total + num + (offset || 0) : num; 282 | } 283 | 284 | /** 285 | * Create a throttled version of a callback function. 286 | * 287 | * Copied & pasted with slight adjustments from 288 | * https://github.com/cowboy/jquery-throttle-debounce/ 289 | * 290 | * @param {Integer} delay 291 | * @param {Function} callback 292 | * 293 | * @return {Function} 294 | */ 295 | function throttle(delay, callback) { 296 | var timeoutId; 297 | var lastExec = 0; 298 | 299 | // The `wrapper` function encapsulates all of the throttling functionality 300 | // and when executed will limit the rate at which `callback` is executed. 301 | function wrapper() { 302 | /*jshint validthis:true */ 303 | var that = this; 304 | var elapsed = +new Date() - lastExec; 305 | var args = arguments; 306 | 307 | function clear() { 308 | if (timeoutId) { 309 | timeoutId = clearTimeout(timeoutId); 310 | } 311 | } 312 | 313 | function exec() { 314 | lastExec = +new Date(); 315 | callback.apply(that, args); 316 | clear(); 317 | } 318 | 319 | clear(); 320 | 321 | if (elapsed > delay) { 322 | exec(); 323 | } else { 324 | timeoutId = setTimeout(exec, delay - elapsed); 325 | } 326 | } 327 | 328 | // Set the guid of `wrapper` function to the same of original callback, so it can be 329 | // removed in jQuery 1.4+ .unbind or .off by using the original callback as a reference. 330 | if ($.guid) { 331 | wrapper.guid = callback.guid = callback.guid || $.guid++; 332 | } 333 | 334 | // Return the wrapper function. 335 | return wrapper; 336 | } 337 | 338 | // Extend jQuery 339 | $.fn[pluginName] = function (callback, options) { 340 | var method, methodArgs; 341 | var context = options && options.context || w; 342 | var espy = $.data(context, namespace) || $.data(context, namespace, new $[pluginClass](context)); 343 | 344 | // Attributes logic 345 | if (typeof callback === 'string') { 346 | method = options === false || options === 'destroy' ? 'remove' : options; 347 | methodArgs = Array.prototype.slice.call(arguments, 1); 348 | options = {}; 349 | } 350 | 351 | // Apply to all elements 352 | return this.each(function (i, element) { 353 | if (!method) { 354 | // Adding element to spy on 355 | espy.add(element, callback, options); 356 | } else { 357 | // Call plugin method 358 | if (typeof espy[method] === 'function') { 359 | espy[method].apply(espy, methodArgs); 360 | } 361 | } 362 | }); 363 | }; 364 | 365 | // Default options 366 | $.fn[pluginName].defaults = { 367 | delay: 100, // Events throttling delay in milliseconds. 368 | context: window, // Scrolling context. 369 | horizontal: 0, // Enable for horizontal scrolling. 370 | offset: 0, // Target area offset from start (top in vert., left in hor.). 371 | size: '100%', // Target area size (height in vert., width in hor.). 372 | contain: 0 // Trigger as entered only when element is completely within the target area. 373 | }; 374 | }(jQuery, window)); -------------------------------------------------------------------------------- /dist/jquery.espy.min.js: -------------------------------------------------------------------------------- 1 | /*! Espy 0.1.0 - 2nd Feb 2013 | https://github.com/Darsain/espy */ 2 | (function(b,q,s){function t(b,c,e){b=(b+"").match(/^(-?[0-9]+)(%)?$/);if(!b)return!1;var d=parseInt(b[1],10);b[2]&&(d*=c/100);return 0>d?c+d+(e||0):d}function u(n,c){function e(){function b(){j=+new Date;c.apply(e,f);d&&(d=clearTimeout(d))}var e=this,l=+new Date-j,f=arguments;d&&(d=clearTimeout(d));l>n?b():d=setTimeout(b,n-l)}var d,j=0;b.guid&&(e.guid=c.guid=c.guid||b.guid++);return e}b.Espy=function(n,c,e){function d(a){if(a=v(a)){var h=a.$el.offset()[a.horizontal?"left":"top"]-p.offset[a.horizontal? 3 | "left":"top"],g=a.$el[a.horizontal?"outerWidth":"outerHeight"]();b.extend(a,{start:h,elSize:g,end:h+g});j(a)}}function j(a){if(a===s)b.each(m,j);else if(a=v(a)){var h=p[a.horizontal?"width":"height"],g=t(a.size,h),h=p[a.horizontal?"left":"top"]+t(a.offset,h,-g),d=h+g,g=a.contain?h<=a.start&&d>=a.end?"inside":h+g/2>a.start+a.elSize/2?a.horizontal?"left":"up":a.horizontal?"right":"down":h>a.start&&ha.start&&d=a.start||h<=a.end&&d>=a.end?"inside":h>a.end?a.horizontal? 4 | "left":"up":a.horizontal?"right":"down";a.state!==g&&(a.state=g,"function"===typeof c&&c.call(a.el,"inside"===g,g),"function"===typeof a.callback&&a.callback.call(a.el,"inside"===g,g))}}function k(a){if(m.hasOwnProperty(a))return a;if(b.isPlainObject(a)&&m.hasOwnProperty(a.id))return a.id;a=b(a)[0];var d=!1;b.each(m,function(b,c){c.el===a&&(d=b)});return d}function v(a){return(a=k(a))?m[a]:!1}"function"!==typeof c&&(e=c,c=0);var l=this,f=b(n),r=b.extend({},b.fn.espy.defaults,e),m={},q=0,p={top:f.scrollTop(), 5 | left:f.scrollLeft(),width:f.innerWidth(),height:f.innerHeight(),offset:f.offset()||{top:0,left:0}};l.add=function(a,c,g){b.isPlainObject(c)&&(g=c,c=0);b(a).each(function(a,e){var f=k(e)||"s"+q++;m[f]=b.extend({id:f,el:e,$el:b(e),callback:c},r,g);d(f)})};l.reload=function(a){b(a).each(function(a,b){var c=k(b);c&&d(c)})};l.remove=function(a){b(a).each(function(a,b){var c=k(b);c&&delete m[c]})};l.destroy=function(){f.off(".espy");m={};l=s};f.on("scroll.espy",u(r.delay,function(){p.top=f.scrollTop(); 6 | p.left=f.scrollLeft();j()}));f.on("resize.espy",u(r.delay,function(){p.width=f.innerWidth();p.height=f.innerHeight();j()}))};b.fn.espy=function(n,c){var e,d,j=c&&c.context||q,k=b.data(j,"espy")||b.data(j,"espy",new b.Espy(j));"string"===typeof n&&(e=!1===c||"destroy"===c?"remove":c,d=Array.prototype.slice.call(arguments,1),c={});return this.each(function(b,j){e?"function"===typeof k[e]&&k[e].apply(k,d):k.add(j,n,c)})};b.fn.espy.defaults={delay:100,context:window,horizontal:0,offset:0,size:"100%", 7 | contain:0}})(jQuery,window); 8 | -------------------------------------------------------------------------------- /espy.jquery.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "espy", 3 | "title": "Espy", 4 | "description": "On-scroll detecting whether the element entered or left the viewport.", 5 | "keywords": [ 6 | "scroll", 7 | "spy", 8 | "element", 9 | "viewport" 10 | ], 11 | "version": "0.1.0", 12 | "author": { 13 | "name": "Darsain", 14 | "url": "http://darsa.in" 15 | }, 16 | "bugs": "https://github.com/Darsain/espy/issues", 17 | "homepage": "https://github.com/Darsain/espy", 18 | "docs": "https://github.com/Darsain/espy/wiki", 19 | "download": "https://raw.github.com/Darsain/espy/master/dist/jquery.espy.min.js", 20 | "dependencies": { 21 | "jquery": ">=1.7" 22 | }, 23 | "licenses": [ 24 | { 25 | "type": "MIT", 26 | "url": "http://opensource.org/licenses/MIT" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /grunt.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | /*global module */ 3 | module.exports = function(grunt) { 4 | 'use strict'; 5 | 6 | var fs = require('fs'), 7 | jshintOptions = JSON.parse(fs.readFileSync('.jshintrc')); 8 | 9 | grunt.initConfig({ 10 | pkg: '', 11 | meta: { 12 | banner: '/*!\n' + 13 | ' * <%= pkg.name %> <%= pkg.version %> - <%= grunt.template.today("dS mmm yyyy") %>\n' + 14 | ' * <%= pkg.homepage %>\n' + 15 | ' *\n' + 16 | ' * Licensed under the <%= pkg.licenses[0].type %> license.\n' + 17 | ' * <%= pkg.licenses[0].url %>\n' + 18 | ' */', 19 | bannerLight: '/*! <%= pkg.name %> <%= pkg.version %>' + 20 | ' - <%= grunt.template.today("dS mmm yyyy") %> | <%= pkg.homepage %> */' 21 | }, 22 | jshint: { 23 | options: jshintOptions 24 | }, 25 | lint: { 26 | files: 'src/espy.js' 27 | }, 28 | concat: { 29 | dist: { 30 | src: [ 31 | '', 32 | 'src/espy.js' 33 | ], 34 | dest: 'dist/jquery.espy.js' 35 | } 36 | }, 37 | gcc: { 38 | dist: { 39 | options: { 40 | banner: '<%= meta.bannerLight %>' 41 | }, 42 | src: 'src/espy.js', 43 | dest: 'dist/jquery.espy.min.js' 44 | } 45 | } 46 | }); 47 | 48 | // These plugins provide necessary tasks. 49 | grunt.loadNpmTasks('grunt-gcc'); 50 | 51 | // Defined tasks 52 | grunt.registerTask('default', 'lint'); 53 | grunt.registerTask('release', 'lint concat gcc'); 54 | }; -------------------------------------------------------------------------------- /src/espy.js: -------------------------------------------------------------------------------- 1 | ;(function ($, w, undefined) { 2 | 'use strict'; 3 | 4 | // Plugin names 5 | var pluginName = 'espy', 6 | pluginClass = 'Espy', 7 | namespace = pluginName; 8 | 9 | $[pluginClass] = function(context, callback, options) { 10 | // Optional arguments delay 11 | if (typeof callback !== 'function') { 12 | options = callback; 13 | callback = 0; 14 | } 15 | 16 | // Private variables 17 | var self = this; 18 | var $context = $(context); 19 | var defaults = $.extend({}, $.fn[pluginName].defaults, options); 20 | var spies = {}; 21 | var lastId = 0; 22 | var pos = { 23 | top: $context.scrollTop(), 24 | left: $context.scrollLeft(), 25 | width: $context.innerWidth(), 26 | height: $context.innerHeight(), 27 | offset: $context.offset() || { 28 | top: 0, 29 | left: 0 30 | } 31 | }; 32 | 33 | /** 34 | * Add new element(s) to spies list. 35 | * 36 | * @param {Node} element 37 | * @param {Function} callback 38 | * @param {Object} options 39 | * 40 | * @return {Object} Spy object with basic control methods. 41 | */ 42 | self.add = function (element, callback, options) { 43 | // Optional arguments logic 44 | if ($.isPlainObject(callback)) { 45 | options = callback; 46 | callback = 0; 47 | } 48 | 49 | $(element).each(function (i, el) { 50 | var spyId = getId(el) || 's' + lastId++; 51 | 52 | // Add new element to spying list 53 | spies[spyId] = $.extend({ 54 | id: spyId, 55 | el: el, 56 | $el: $(el), 57 | callback: callback 58 | }, defaults, options); 59 | 60 | // Load the element data 61 | load(spyId); 62 | }); 63 | }; 64 | 65 | /** 66 | * (Re)Load spy object's dimensions. 67 | * 68 | * @param {Mixed} element 69 | * 70 | * @return {Void} 71 | */ 72 | function load(element) { 73 | var spy = getSpy(element); 74 | if (!spy) { 75 | return; 76 | } 77 | 78 | var start = spy.$el.offset()[spy.horizontal ? 'left' : 'top'] - pos.offset[spy.horizontal ? 'left' : 'top']; 79 | var size = spy.$el[spy.horizontal ? 'outerWidth' : 'outerHeight'](); 80 | 81 | // Add new element to spying list 82 | $.extend(spy, { 83 | start: start, 84 | elSize: size, 85 | end: start + size 86 | }); 87 | 88 | // Check the element for its state 89 | check(spy); 90 | } 91 | 92 | /** 93 | * Reload element's dimensions. 94 | * 95 | * @param {Node} element 96 | * 97 | * @return {Void} 98 | */ 99 | self.reload = function (element) { 100 | $(element).each(function (i, el) { 101 | var spyId = getId(el); 102 | if (spyId) { 103 | load(spyId); 104 | } 105 | }); 106 | }; 107 | 108 | /** 109 | * Remove element(s) from spying list. 110 | * 111 | * @param {Node} element 112 | * 113 | * @return {Void} 114 | */ 115 | self.remove = function (element) { 116 | $(element).each(function (i, el) { 117 | var spyId = getId(el); 118 | if (spyId) { 119 | delete spies[spyId]; 120 | } 121 | }); 122 | }; 123 | 124 | /** 125 | * Check element state, and trigger callback on change. 126 | * 127 | * @param {Mixed} element Can be element node, spy ID, or spy object. Omit to check all elements. 128 | * 129 | * @return {Void} 130 | */ 131 | function check(element) { 132 | if (element === undefined) { 133 | $.each(spies, check); 134 | return; 135 | } 136 | 137 | // Check whether the element/ID exist in spying list. 138 | var spy = getSpy(element); 139 | if (!spy) { 140 | return; 141 | } 142 | 143 | // Variables necessary for determination. 144 | var viewSize = pos[spy.horizontal ? 'width' : 'height']; 145 | var triggerSize = parseRatio(spy.size, viewSize); 146 | var triggerStart = pos[spy.horizontal ? 'left' : 'top'] + parseRatio(spy.offset, viewSize, -triggerSize); 147 | var triggerEnd = triggerStart + triggerSize; 148 | var newState; 149 | 150 | // Calculate element state in relation to trigger area. 151 | if (spy.contain) { 152 | if (triggerStart <= spy.start && triggerEnd >= spy.end) { 153 | newState = 'inside'; 154 | } else if (triggerStart + triggerSize / 2 > spy.start + spy.elSize / 2) { 155 | newState = spy.horizontal ? 'left' : 'up'; 156 | } else { 157 | newState = spy.horizontal ? 'right' : 'down'; 158 | } 159 | } else { 160 | if ( 161 | triggerStart > spy.start && triggerStart < spy.end || 162 | triggerEnd > spy.start && triggerEnd < spy.end || 163 | triggerStart <= spy.start && triggerEnd >= spy.start || 164 | triggerStart <= spy.end && triggerEnd >= spy.end 165 | ) { 166 | newState = 'inside'; 167 | } else if (triggerStart > spy.end) { 168 | newState = spy.horizontal ? 'left' : 'up'; 169 | } else { 170 | newState = spy.horizontal ? 'right' : 'down'; 171 | } 172 | } 173 | 174 | // Trigger callbacks on change. 175 | if (spy.state !== newState) { 176 | spy.state = newState; 177 | if (typeof callback === 'function') { 178 | callback.call(spy.el, newState === 'inside', newState); 179 | } 180 | if (typeof spy.callback === 'function') { 181 | spy.callback.call(spy.el, newState === 'inside', newState); 182 | } 183 | } 184 | } 185 | 186 | /** 187 | * Check whether the element is already spied on, and return the spy ID. 188 | * 189 | * @param {Node} element 190 | * 191 | * @return {Mixed} Spy ID string, or false. 192 | */ 193 | function getId(element) { 194 | // Return when ID has been passed. 195 | if (spies.hasOwnProperty(element)) { 196 | return element; 197 | } 198 | 199 | // Return ID when spy object has been passed. 200 | if ($.isPlainObject(element) && spies.hasOwnProperty(element.id)) { 201 | return element.id; 202 | } 203 | 204 | // Ensure the element is a single Node 205 | element = $(element)[0]; 206 | 207 | // Check for existence and return the ID. 208 | var is = false; 209 | $.each(spies, function (id, spy) { 210 | if (spy.el === element) { 211 | is = id; 212 | } 213 | }); 214 | return is; 215 | } 216 | 217 | /** 218 | * Return spy object of an element. 219 | * 220 | * @param {Node} element 221 | * 222 | * @return {Object} Spy ID string, or false. 223 | */ 224 | function getSpy(element) { 225 | var spyId = getId(element); 226 | return spyId ? spies[spyId] : false; 227 | } 228 | 229 | /** 230 | * Destroy Espy instance. 231 | * 232 | * @return {Void} 233 | */ 234 | self.destroy = function () { 235 | $context.off('.' + namespace); 236 | spies = {}; 237 | self = undefined; 238 | }; 239 | 240 | // Register scroll handler 241 | $context.on('scroll.' + namespace, throttle(defaults.delay, function () { 242 | pos.top = $context.scrollTop(); 243 | pos.left = $context.scrollLeft(); 244 | check(); 245 | })); 246 | 247 | // Register resize handler 248 | $context.on('resize.' + namespace, throttle(defaults.delay, function () { 249 | pos.width = $context.innerWidth(); 250 | pos.height = $context.innerHeight(); 251 | check(); 252 | })); 253 | }; 254 | 255 | /** 256 | * Parse string like -200% and return the final dimension. 257 | * 258 | * @param {Mixed} value Integer, or percent string. 259 | * @param {Integer} total Total value representing 100%. 260 | * @param {Integer} offset Optional offset for negative numbers. 261 | * 262 | * @return {Integer} 263 | */ 264 | function parseRatio(value, total, offset) { 265 | var matches = (value+'').match(/^(-?[0-9]+)(%)?$/); 266 | if (!matches) { 267 | return false; 268 | } 269 | var num = parseInt(matches[1], 10); 270 | if (matches[2]) { 271 | num = total / 100 * num; 272 | } 273 | return num < 0 ? total + num + (offset || 0) : num; 274 | } 275 | 276 | /** 277 | * Create a throttled version of a callback function. 278 | * 279 | * Copied & pasted with slight adjustments from 280 | * https://github.com/cowboy/jquery-throttle-debounce/ 281 | * 282 | * @param {Integer} delay 283 | * @param {Function} callback 284 | * 285 | * @return {Function} 286 | */ 287 | function throttle(delay, callback) { 288 | var timeoutId; 289 | var lastExec = 0; 290 | 291 | // The `wrapper` function encapsulates all of the throttling functionality 292 | // and when executed will limit the rate at which `callback` is executed. 293 | function wrapper() { 294 | /*jshint validthis:true */ 295 | var that = this; 296 | var elapsed = +new Date() - lastExec; 297 | var args = arguments; 298 | 299 | function clear() { 300 | if (timeoutId) { 301 | timeoutId = clearTimeout(timeoutId); 302 | } 303 | } 304 | 305 | function exec() { 306 | lastExec = +new Date(); 307 | callback.apply(that, args); 308 | clear(); 309 | } 310 | 311 | clear(); 312 | 313 | if (elapsed > delay) { 314 | exec(); 315 | } else { 316 | timeoutId = setTimeout(exec, delay - elapsed); 317 | } 318 | } 319 | 320 | // Set the guid of `wrapper` function to the same of original callback, so it can be 321 | // removed in jQuery 1.4+ .unbind or .off by using the original callback as a reference. 322 | if ($.guid) { 323 | wrapper.guid = callback.guid = callback.guid || $.guid++; 324 | } 325 | 326 | // Return the wrapper function. 327 | return wrapper; 328 | } 329 | 330 | // Extend jQuery 331 | $.fn[pluginName] = function (callback, options) { 332 | var method, methodArgs; 333 | var context = options && options.context || w; 334 | var espy = $.data(context, namespace) || $.data(context, namespace, new $[pluginClass](context)); 335 | 336 | // Attributes logic 337 | if (typeof callback === 'string') { 338 | method = options === false || options === 'destroy' ? 'remove' : options; 339 | methodArgs = Array.prototype.slice.call(arguments, 1); 340 | options = {}; 341 | } 342 | 343 | // Apply to all elements 344 | return this.each(function (i, element) { 345 | if (!method) { 346 | // Adding element to spy on 347 | espy.add(element, callback, options); 348 | } else { 349 | // Call plugin method 350 | if (typeof espy[method] === 'function') { 351 | espy[method].apply(espy, methodArgs); 352 | } 353 | } 354 | }); 355 | }; 356 | 357 | // Default options 358 | $.fn[pluginName].defaults = { 359 | delay: 100, // Events throttling delay in milliseconds. 360 | context: window, // Scrolling context. 361 | horizontal: 0, // Enable for horizontal scrolling. 362 | offset: 0, // Target area offset from start (top in vert., left in hor.). 363 | size: '100%', // Target area size (height in vert., width in hor.). 364 | contain: 0 // Trigger as entered only when element is completely within the target area. 365 | }; 366 | }(jQuery, window)); --------------------------------------------------------------------------------