├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── angular-inview.js ├── angular-inview.spec.js ├── bower.json ├── examples ├── basic.html ├── container.html ├── fixed.html └── throttle.html ├── karma.conf.js ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | node_modules/ 3 | libpeerconnection.log 4 | npm-debug.log 5 | .idea 6 | docs 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs 2 | examples 3 | angular-inview.spec.js 4 | bower.json 5 | karma.conf.js 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Nicola Peduzzi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InView Directive for AngularJS [![CircleCI](https://circleci.com/gh/thenikso/angular-inview.svg?style=svg)](https://circleci.com/gh/thenikso/angular-inview) 2 | 3 | Check if a DOM element is or not in the browser current visible viewport. 4 | 5 | ```html 6 |
7 | ``` 8 | 9 | **This is a directive for AngularJS 1, support for Angular 2 is not in the works yet (PRs are welcome!)** 10 | 11 | > Version 2 of this directive uses a lightwight embedded reactive framework and 12 | it is a complete rewrite of v1 13 | 14 | ## Installation 15 | 16 | ### With npm 17 | 18 | ``` 19 | npm install angular-inview 20 | ``` 21 | 22 | ### With bower 23 | 24 | ``` 25 | bower install angular-inview 26 | ``` 27 | 28 | ## Setup 29 | 30 | In your document include this scripts: 31 | 32 | ```html 33 | 34 | 35 | ``` 36 | 37 | In your AngularJS app, you'll need to import the `angular-inview` module: 38 | 39 | ```javascript 40 | angular.module('myModule', ['angular-inview']); 41 | ``` 42 | 43 | Or with a module loader setup like Webpack/Babel you can do: 44 | 45 | ```javascript 46 | import angularInview from 'angular-inview'; 47 | 48 | angular.module('myModule', [angularInview]); 49 | ``` 50 | 51 | ## Usage 52 | 53 | This module will define two directives: `in-view` and `in-view-container`. 54 | 55 | ### InView 56 | 57 | ```html 58 | 59 | ``` 60 | 61 | The `in-view` attribute must contain a valid [AngularJS expression](http://docs.angularjs.org/guide/expression) 62 | to work. When the DOM element enters or exits the viewport, the expression will 63 | be evaluated. To actually check if the element is in view, the following data is 64 | available in the expression: 65 | 66 | - `$inview` is a boolean value indicating if the DOM element is in view. 67 | If using this directive for infinite scrolling, you may want to use this like 68 | ``. 69 | 70 | - `$inviewInfo` is an object containing extra info regarding the event 71 | 72 | ``` 73 | { 74 | changed: , 75 | event: , 76 | element: , 77 | elementRect: { 78 | top: , 79 | left: , 80 | bottom: , 81 | right: , 82 | }, 83 | viewportRect: { 84 | top: , 85 | left: , 86 | bottom: , 87 | right: , 88 | }, 89 | direction: { // if generateDirection option is true 90 | vertical: , 91 | horizontal: , 92 | }, 93 | parts: { // if generateParts option is true 94 | top: , 95 | left: , 96 | bottom: , 97 | right: , 98 | }, 99 | } 100 | ``` 101 | 102 | - `changed` indicates if the inview value changed with this event 103 | - `event` the DOM event that triggered the inview check 104 | - `element` the DOM element subject of the inview check 105 | - `elementRect` a rectangle with the virtual (considering offset) position of 106 | the element used for the inview check 107 | - `viewportRect` a rectangle with the virtual (considering offset) viewport 108 | dimensions used for the inview check 109 | - `direction` an indication of how the element has moved from the last event 110 | relative to the viewport. Ie. if you scoll the page down by 100 pixels, the 111 | value of `direction.vertical` will be `-100` 112 | - `parts` an indication of which side of the element are fully visible. Ie. if 113 | `parts.top=false` and `parts.bottom=true` it means that the bottom part of 114 | the element is visible at the top of the viewport (but its top part is 115 | hidden behind the browser bar) 116 | 117 | An additional attribute `in-view-options` can be specified with an object value containing: 118 | 119 | - `offset`: An expression returning an array of values to offset the element position. 120 | 121 | Offsets are expressed as arrays of 4 values `[top, right, bottom, left]`. 122 | Like CSS, you can also specify only 2 values `[top/bottom, left/right]`. 123 | 124 | Values can be either a string with a percentage or numbers (in pixel). 125 | Positive values are offsets outside the element rectangle and 126 | negative values are offsets to the inside. 127 | 128 | Example valid values for the offset are: `100`, `[200, 0]`, 129 | `[100, 0, 200, 50]`, `'20%'`, `['50%', 30]` 130 | 131 | - `viewportOffset`: Like the element offset but appied to the viewport. You may 132 | want to use this to shrink the virtual viewport effectivelly checking if your 133 | element is visible (i.e.) in the bottom part of the screen `['-50%', 0, 0]`. 134 | 135 | - `generateDirection`: Indicate if the `direction` information should 136 | be included in `$inviewInfo` (default false). 137 | 138 | - `generateParts`: Indicate if the `parts` information should 139 | be included in `$inviewInfo` (default false). 140 | 141 | - `throttle`: a number indicating a millisecond value of throttle which will 142 | limit the in-view event firing rate to happen every that many milliseconds 143 | 144 | ### InViewContainer 145 | 146 | Use `in-view-container` when you have a scrollable container that contains `in-view` 147 | elements. When an `in-view` element is inside such container, it will properly 148 | trigger callbacks when the container scrolls as well as when the window scrolls. 149 | 150 | ```html 151 |
152 |
153 |
154 | ``` 155 | 156 | ## Examples 157 | 158 | The following triggers the `lineInView` when the line comes in view: 159 | 160 | ```html 161 |
  • This is test line #{{$index}}
  • 162 | ``` 163 | 164 | **See more examples in the [`examples` folder](./examples).** 165 | 166 | ## Migrate from v1 167 | 168 | Version 1 of this directive can still be installed with 169 | `npm install angular-inview@1.5.7`. If you already have v1 and want to 170 | upgrade to v2 here are some tips: 171 | 172 | - `throttle` option replaces `debounce`. You can just change the name. Notice that 173 | the functioning has changed as well, a debounce waits until there are no more 174 | events for the given amount of time before triggering; throttle instead stabilizes 175 | the event triggering only once every amount of time. In practival terms this 176 | should not affect negativelly your app. 177 | - `offset` and `viewportOffset` replace the old offset options in a more structured 178 | and flexible way. `offsetTop: 100` becomes `offset: [100, 0, 0, 0]`. 179 | - `$inviewInfo.event` replaces `$event` in the expression. 180 | - `generateParts` in the options has now to be set to `true` to have 181 | `$inviewInfo.parts` available. 182 | 183 | ## Contribute 184 | 185 | 1. Fork this repo 186 | 2. Setup your new repo with `npm install` and `npm install angular` 187 | 3. Edit `angular-inview.js` and `angular-inview.spec.js` to add your feature 188 | 4. Run `npm test` to check that all is good 189 | 5. Create a [PR](https://github.com/thenikso/angular-inview/pulls) 190 | 191 | If you want to become a contributor with push access open an issue asking that 192 | or contact the author directly. 193 | -------------------------------------------------------------------------------- /angular-inview.js: -------------------------------------------------------------------------------- 1 | // # Angular-Inview 2 | // - Author: [Nicola Peduzzi](https://github.com/thenikso) 3 | // - Repository: https://github.com/thenikso/angular-inview 4 | // - Install with: `npm install angular-inview` 5 | // - Version: **3.0.0** 6 | (function() { 7 | 'use strict'; 8 | 9 | // An [angular.js](https://angularjs.org) directive to evaluate an expression if 10 | // a DOM element is or not in the current visible browser viewport. 11 | // Use it in your AngularJS app by including the javascript and requireing it: 12 | // 13 | // `angular.module('myApp', ['angular-inview'])` 14 | var moduleName = 'angular-inview'; 15 | angular.module(moduleName, []) 16 | 17 | // ## in-view directive 18 | // 19 | // ### Usage 20 | // ```html 21 | // 22 | // ``` 23 | .directive('inView', ['$parse', inViewDirective]) 24 | 25 | // ## in-view-container directive 26 | .directive('inViewContainer', inViewContainerDirective); 27 | 28 | // ## Implementation 29 | function inViewDirective ($parse) { 30 | return { 31 | // Evaluate the expression passed to the attribute `in-view` when the DOM 32 | // element is visible in the viewport. 33 | restrict: 'A', 34 | require: '?^^inViewContainer', 35 | link: function inViewDirectiveLink (scope, element, attrs, container) { 36 | // in-view-options attribute can be specified with an object expression 37 | // containing: 38 | // - `offset`: An array of values to offset the element position. 39 | // Offsets are expressed as arrays of 4 numbers [top, right, bottom, left]. 40 | // Like CSS, you can also specify only 2 numbers [top/bottom, left/right]. 41 | // Instead of numbers, some array elements can be a string with a percentage. 42 | // Positive numbers are offsets outside the element rectangle and 43 | // negative numbers are offsets to the inside. 44 | // - `viewportOffset`: Like the element offset but appied to the viewport. 45 | // - `generateDirection`: Indicate if the `direction` information should 46 | // be included in `$inviewInfo` (default false). 47 | // - `generateParts`: Indicate if the `parts` information should 48 | // be included in `$inviewInfo` (default false). 49 | // - `throttle`: Specify a number of milliseconds by which to limit the 50 | // number of incoming events. 51 | var options = {}; 52 | if (attrs.inViewOptions) { 53 | options = scope.$eval(attrs.inViewOptions); 54 | } 55 | if (options.offset) { 56 | options.offset = normalizeOffset(options.offset); 57 | } 58 | if (options.viewportOffset) { 59 | options.viewportOffset = normalizeOffset(options.viewportOffset); 60 | } 61 | 62 | // Build reactive chain from an initial event 63 | var viewportEventSignal = signalSingle({ type: 'initial' }) 64 | 65 | // Merged with the window events 66 | .merge(signalFromEvent(window, 'checkInView click ready wheel mousewheel DomMouseScroll MozMousePixelScroll resize scroll touchmove mouseup keydown')) 67 | 68 | // Merge with container's events signal 69 | if (container) { 70 | viewportEventSignal = viewportEventSignal.merge(container.eventsSignal); 71 | } 72 | 73 | // Throttle if option specified 74 | if (options.throttle) { 75 | viewportEventSignal = viewportEventSignal.throttle(options.throttle); 76 | } 77 | 78 | // Map to viewport intersection and in-view informations 79 | var inviewInfoSignal = viewportEventSignal 80 | 81 | // Inview information structure contains: 82 | // - `inView`: a boolean value indicating if the element is 83 | // visible in the viewport; 84 | // - `changed`: a boolean value indicating if the inview status 85 | // changed after the last event; 86 | // - `event`: the event that initiated the in-view check; 87 | .map(function(event) { 88 | var viewportRect; 89 | if (container) { 90 | viewportRect = container.getViewportRect(); 91 | // TODO merge with actual window! 92 | } else { 93 | viewportRect = getViewportRect(); 94 | } 95 | viewportRect = offsetRect(viewportRect, options.viewportOffset); 96 | var elementRect = offsetRect(element[0].getBoundingClientRect(), options.offset); 97 | var isVisible = !!(element[0].offsetWidth || element[0].offsetHeight || element[0].getClientRects().length); 98 | var info = { 99 | inView: isVisible && intersectRect(elementRect, viewportRect), 100 | event: event, 101 | element: element, 102 | elementRect: elementRect, 103 | viewportRect: viewportRect 104 | }; 105 | // Add inview parts 106 | if (options.generateParts && info.inView) { 107 | info.parts = {}; 108 | info.parts.top = elementRect.top >= viewportRect.top; 109 | info.parts.left = elementRect.left >= viewportRect.left; 110 | info.parts.bottom = elementRect.bottom <= viewportRect.bottom; 111 | info.parts.right = elementRect.right <= viewportRect.right; 112 | } 113 | return info; 114 | }) 115 | 116 | // Add the changed information to the inview structure. 117 | .scan({}, function (lastInfo, newInfo) { 118 | // Add inview direction info 119 | if (options.generateDirection && newInfo.inView && lastInfo.elementRect) { 120 | newInfo.direction = { 121 | horizontal: newInfo.elementRect.left - lastInfo.elementRect.left, 122 | vertical: newInfo.elementRect.top - lastInfo.elementRect.top 123 | }; 124 | } 125 | // Calculate changed flag 126 | newInfo.changed = 127 | newInfo.inView !== lastInfo.inView || 128 | !angular.equals(newInfo.parts, lastInfo.parts) || 129 | !angular.equals(newInfo.direction, lastInfo.direction); 130 | return newInfo; 131 | }) 132 | 133 | // Filters only informations that should be forwarded to the callback 134 | .filter(function (info) { 135 | // Don't forward if no relevant infomation changed 136 | if (!info.changed) { 137 | return false; 138 | } 139 | // Don't forward if not initially in-view 140 | if (info.event.type === 'initial' && !info.inView) { 141 | return false; 142 | } 143 | return true; 144 | }); 145 | 146 | // Execute in-view callback 147 | var inViewExpression = $parse(attrs.inView); 148 | var dispose = inviewInfoSignal.subscribe(function (info) { 149 | scope.$applyAsync(function () { 150 | inViewExpression(scope, { 151 | '$inview': info.inView, 152 | '$inviewInfo': info 153 | }); 154 | }); 155 | }); 156 | 157 | // Dispose of reactive chain 158 | scope.$on('$destroy', dispose); 159 | } 160 | } 161 | } 162 | 163 | function inViewContainerDirective () { 164 | return { 165 | restrict: 'A', 166 | controller: ['$element', function ($element) { 167 | this.element = $element; 168 | this.eventsSignal = signalFromEvent($element, 'scroll'); 169 | this.getViewportRect = function () { 170 | return $element[0].getBoundingClientRect(); 171 | }; 172 | }] 173 | } 174 | } 175 | 176 | // ## Utilities 177 | 178 | function getViewportRect () { 179 | var result = { 180 | top: 0, 181 | left: 0, 182 | width: window.innerWidth, 183 | right: window.innerWidth, 184 | height: window.innerHeight, 185 | bottom: window.innerHeight 186 | }; 187 | if (result.height) { 188 | return result; 189 | } 190 | var mode = document.compatMode; 191 | if (mode === 'CSS1Compat') { 192 | result.width = result.right = document.documentElement.clientWidth; 193 | result.height = result.bottom = document.documentElement.clientHeight; 194 | } else { 195 | result.width = result.right = document.body.clientWidth; 196 | result.height = result.bottom = document.body.clientHeight; 197 | } 198 | return result; 199 | } 200 | 201 | function intersectRect (r1, r2) { 202 | return !(r2.left > r1.right || 203 | r2.right < r1.left || 204 | r2.top > r1.bottom || 205 | r2.bottom < r1.top); 206 | } 207 | 208 | function normalizeOffset (offset) { 209 | if (!angular.isArray(offset)) { 210 | return [offset, offset, offset, offset]; 211 | } 212 | if (offset.length == 2) { 213 | return offset.concat(offset); 214 | } 215 | else if (offset.length == 3) { 216 | return offset.concat([offset[1]]); 217 | } 218 | return offset; 219 | } 220 | 221 | function offsetRect (rect, offset) { 222 | if (!offset) { 223 | return rect; 224 | } 225 | var offsetObject = { 226 | top: isPercent(offset[0]) ? (parseFloat(offset[0]) * rect.height / 100) : offset[0], 227 | right: isPercent(offset[1]) ? (parseFloat(offset[1]) * rect.width / 100) : offset[1], 228 | bottom: isPercent(offset[2]) ? (parseFloat(offset[2]) * rect.height / 100) : offset[2], 229 | left: isPercent(offset[3]) ? (parseFloat(offset[3]) * rect.width / 100) : offset[3] 230 | }; 231 | // Note: ClientRect object does not allow its properties to be written to therefore a new object has to be created. 232 | return { 233 | top: rect.top - offsetObject.top, 234 | left: rect.left - offsetObject.left, 235 | bottom: rect.bottom + offsetObject.bottom, 236 | right: rect.right + offsetObject.right, 237 | height: rect.height + offsetObject.top + offsetObject.bottom, 238 | width: rect.width + offsetObject.left + offsetObject.right 239 | }; 240 | } 241 | 242 | function isPercent (n) { 243 | return angular.isString(n) && n.indexOf('%') > 0; 244 | } 245 | 246 | // ## QuickSignal FRP 247 | // A quick and dirty implementation of Rx to have a streamlined code in the 248 | // directives. 249 | 250 | // ### QuickSignal 251 | // 252 | // - `didSubscribeFunc`: a function receiving a `subscriber` as described below 253 | // 254 | // Usage: 255 | // var mySignal = new QuickSignal(function(subscriber) { ... }) 256 | function QuickSignal (didSubscribeFunc) { 257 | this.didSubscribeFunc = didSubscribeFunc; 258 | } 259 | 260 | // Subscribe to a signal and consume the steam of data. 261 | // 262 | // Returns a function that can be called to stop the signal stream of data and 263 | // perform cleanup. 264 | // 265 | // A `subscriber` is a function that will be called when a new value arrives. 266 | // a `subscriber.$dispose` property can be set to a function to be called uppon 267 | // disposal. When setting the `$dispose` function, the previously set function 268 | // should be chained. 269 | QuickSignal.prototype.subscribe = function (subscriber) { 270 | this.didSubscribeFunc(subscriber); 271 | var dispose = function () { 272 | if (subscriber.$dispose) { 273 | subscriber.$dispose(); 274 | subscriber.$dispose = null; 275 | } 276 | } 277 | return dispose; 278 | } 279 | 280 | QuickSignal.prototype.map = function (f) { 281 | var s = this; 282 | return new QuickSignal(function (subscriber) { 283 | subscriber.$dispose = s.subscribe(function (nextValue) { 284 | subscriber(f(nextValue)); 285 | }); 286 | }); 287 | }; 288 | 289 | QuickSignal.prototype.filter = function (f) { 290 | var s = this; 291 | return new QuickSignal(function (subscriber) { 292 | subscriber.$dispose = s.subscribe(function (nextValue) { 293 | if (f(nextValue)) { 294 | subscriber(nextValue); 295 | } 296 | }); 297 | }); 298 | }; 299 | 300 | QuickSignal.prototype.scan = function (initial, scanFunc) { 301 | var s = this; 302 | return new QuickSignal(function (subscriber) { 303 | var last = initial; 304 | subscriber.$dispose = s.subscribe(function (nextValue) { 305 | last = scanFunc(last, nextValue); 306 | subscriber(last); 307 | }); 308 | }); 309 | } 310 | 311 | QuickSignal.prototype.merge = function (signal) { 312 | return signalMerge(this, signal); 313 | }; 314 | 315 | QuickSignal.prototype.throttle = function (threshhold) { 316 | var s = this, last, deferTimer; 317 | return new QuickSignal(function (subscriber) { 318 | var chainDisposable = s.subscribe(function () { 319 | var now = +new Date, 320 | args = arguments; 321 | if (last && now < last + threshhold) { 322 | clearTimeout(deferTimer); 323 | deferTimer = setTimeout(function () { 324 | last = now; 325 | subscriber.apply(null, args); 326 | }, threshhold); 327 | } else { 328 | last = now; 329 | subscriber.apply(null, args); 330 | } 331 | }); 332 | subscriber.$dispose = function () { 333 | clearTimeout(deferTimer); 334 | if (chainDisposable) chainDisposable(); 335 | }; 336 | }); 337 | }; 338 | 339 | function signalMerge () { 340 | var signals = arguments; 341 | return new QuickSignal(function (subscriber) { 342 | var disposables = []; 343 | for (var i = signals.length - 1; i >= 0; i--) { 344 | disposables.push(signals[i].subscribe(function () { 345 | subscriber.apply(null, arguments); 346 | })); 347 | } 348 | subscriber.$dispose = function () { 349 | for (var i = disposables.length - 1; i >= 0; i--) { 350 | if (disposables[i]) disposables[i](); 351 | } 352 | } 353 | }); 354 | } 355 | 356 | // Returns a signal from DOM events of a target. 357 | function signalFromEvent (target, event) { 358 | return new QuickSignal(function (subscriber) { 359 | var handler = function (e) { 360 | subscriber(e); 361 | }; 362 | var el = angular.element(target); 363 | event.split(' ').map(function (e) { 364 | el[0].addEventListener(e, handler, true); 365 | }); 366 | subscriber.$dispose = function () { 367 | event.split(' ').map(function (e) { 368 | el[0].removeEventListener(e, handler, true); 369 | }); 370 | }; 371 | }); 372 | } 373 | 374 | function signalSingle (value) { 375 | return new QuickSignal(function (subscriber) { 376 | setTimeout(function() { subscriber(value); }); 377 | }); 378 | } 379 | 380 | // Module loaders exports 381 | if (typeof define === 'function' && define.amd) { 382 | define(['angular'], moduleName); 383 | } else if (typeof module !== 'undefined' && module && module.exports) { 384 | module.exports = moduleName; 385 | } 386 | 387 | })(); 388 | -------------------------------------------------------------------------------- /angular-inview.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe("angular-inview", function() { 4 | 5 | var $rootScope, $compile, $q; 6 | 7 | beforeEach(function () { 8 | module('angular-inview'); 9 | 10 | inject(function (_$rootScope_, _$compile_, _$q_) { 11 | $rootScope = _$rootScope_; 12 | $compile = _$compile_; 13 | $q = _$q_; 14 | }); 15 | }); 16 | 17 | describe("in-view directive", function() { 18 | 19 | it("should trigger in-view expression with `$inview` local", function(done) { 20 | makeTestForHtml( 21 | '
    ' 22 | ) 23 | .then(function (test) { 24 | expect(test.spy.calls.count()).toBe(1); 25 | expect(test.spy).toHaveBeenCalledWith(true); 26 | }) 27 | .then(done); 28 | }); 29 | 30 | it("should not trigger in-view expression if out of viewport", function(done) { 31 | makeTestForHtml( 32 | '
    ' 33 | ) 34 | .then(function (test) { 35 | expect(test.spy.calls.count()).toBe(0); 36 | }) 37 | .then(done); 38 | }); 39 | 40 | it("should change inview status when scrolling out of view", function(done) { 41 | makeTestForHtml( 42 | '
    ' + 43 | '
    ' 44 | ) 45 | .then(lazyScrollTo(100)) 46 | .then(function (test) { 47 | expect(test.spy.calls.count()).toBe(2); 48 | expect(test.spy).toHaveBeenCalledWith(true); 49 | expect(test.spy).toHaveBeenCalledWith(false); 50 | }) 51 | .then(done); 52 | }); 53 | 54 | describe("informations object", function() { 55 | 56 | it("should return an info object with additional informations", function(done) { 57 | makeTestForHtml( 58 | '
    ' 59 | ) 60 | .then(function (test) { 61 | expect(test.spy.calls.count()).toBe(1); 62 | var info = test.spy.calls.mostRecent().args[0]; 63 | expect(info.inView).toEqual(true); 64 | expect(info.changed).toEqual(true); 65 | expect(info.elementRect).toBeDefined(); 66 | expect(info.viewportRect).toBeDefined(); 67 | expect(info.direction).not.toBeDefined(); 68 | expect(info.parts).not.toBeDefined(); 69 | }) 70 | .then(done); 71 | }); 72 | 73 | it("should return proper `parts` informations", function(done) { 74 | makeTestForHtml( 75 | '
    ' + 76 | '
    ' 77 | ) 78 | .then(function (test) { 79 | expect(test.spy.calls.count()).toBe(1); 80 | var info = test.spy.calls.argsFor(0)[0]; 81 | expect(info.parts).toEqual({ 82 | top: true, 83 | left: true, 84 | bottom: true, 85 | right: true 86 | }); 87 | return test; 88 | }) 89 | .then(lazyScrollTo([400, 400])) 90 | .then(function (test) { 91 | var info = test.spy.calls.argsFor(1)[0]; 92 | expect(test.spy.calls.count()).toBe(2); 93 | expect(info.parts).toEqual(undefined); 94 | return test; 95 | }) 96 | .then(lazyScrollTo([100, 100])) 97 | .then(function (test) { 98 | var info = test.spy.calls.argsFor(2)[0]; 99 | expect(test.spy.calls.count()).toBe(3); 100 | expect(info.parts).toEqual({ 101 | top: false, 102 | left: false, 103 | bottom: true, 104 | right: true 105 | }); 106 | return test; 107 | }) 108 | .then(lazyScrollTo([0, 0])) 109 | .then(function (test) { 110 | var info = test.spy.calls.argsFor(3)[0]; 111 | expect(test.spy.calls.count()).toBe(4); 112 | expect(info.parts).toEqual({ 113 | top: true, 114 | left: true, 115 | bottom: true, 116 | right: true 117 | }); 118 | }) 119 | .then(done); 120 | }); 121 | 122 | it("should return proper `direction` informations", function(done) { 123 | makeTestForHtml( 124 | '
    ' + 125 | '
    ' 126 | ) 127 | .then(function (test) { 128 | var info = test.spy.calls.argsFor(0)[0]; 129 | expect(info.direction).toEqual(undefined); 130 | return test; 131 | }) 132 | .then(lazyScrollTo([100, 100])) 133 | .then(function (test) { 134 | var info = test.spy.calls.argsFor(1)[0]; 135 | expect(info.direction).toEqual({ 136 | horizontal: -100, 137 | vertical: -100 138 | }); 139 | return test; 140 | }) 141 | .then(lazyScrollTo([50, 50])) 142 | .then(function (test) { 143 | var info = test.spy.calls.argsFor(2)[0]; 144 | expect(info.direction).toEqual({ 145 | horizontal: 50, 146 | vertical: 50 147 | }); 148 | }) 149 | .then(done); 150 | }); 151 | 152 | }); 153 | 154 | describe("offset options", function() { 155 | 156 | it("should consider element offset option", function(done) { 157 | makeTestForHtml( 158 | '
    ' + 159 | '
    ' 160 | ) 161 | .then(function (test) { 162 | var info = test.spy.calls.argsFor(0)[0]; 163 | expect(info.inView).toEqual(true); 164 | expect(info.parts).toEqual({ 165 | top: false, 166 | left: true, 167 | bottom: true, 168 | right: true 169 | }); 170 | return test; 171 | }) 172 | .then(done); 173 | }); 174 | 175 | it("should consider negative offsets", function(done) { 176 | makeTestForHtml( 177 | '
    ' + 178 | '
    ' 179 | ) 180 | .then(function (test) { 181 | var info = test.spy.calls.argsFor(0)[0]; 182 | expect(info.parts).toEqual({ 183 | top: true, 184 | left: true, 185 | bottom: true, 186 | right: true 187 | }); 188 | return test; 189 | }) 190 | .then(lazyScrollTo(100)) 191 | .then(function (test) { 192 | var info = test.spy.calls.argsFor(1)[0]; 193 | expect(info.parts).toEqual({ 194 | top: false, 195 | left: true, 196 | bottom: true, 197 | right: true 198 | }); 199 | return test; 200 | }) 201 | .then(lazyScrollTo(50)) 202 | .then(function (test) { 203 | var info = test.spy.calls.argsFor(2)[0]; 204 | expect(info.parts).toEqual({ 205 | top: true, 206 | left: true, 207 | bottom: true, 208 | right: true 209 | }); 210 | return test; 211 | }) 212 | .then(done); 213 | }); 214 | 215 | it("should consider percent offset option", function(done) { 216 | makeTestForHtml( 217 | '
    ' + 218 | '
    ' 219 | ) 220 | .then(function (test) { 221 | var info = test.spy.calls.argsFor(0)[0]; 222 | expect(info.inView).toEqual(true); 223 | expect(info.parts).toEqual({ 224 | top: false, 225 | left: true, 226 | bottom: true, 227 | right: true 228 | }); 229 | return test; 230 | }) 231 | .then(done); 232 | }); 233 | 234 | it("should consider viewport offset options", function(done) { 235 | makeTestForHtml( 236 | '
    ' + 237 | '
    ' 238 | ) 239 | .then(function (test) { 240 | var info = test.spy.calls.argsFor(0)[0]; 241 | expect(info.parts).toEqual({ 242 | top: true, 243 | left: true, 244 | bottom: true, 245 | right: true 246 | }); 247 | return test; 248 | }) 249 | .then(lazyScrollTo(200)) 250 | .then(function (test) { 251 | var info = test.spy.calls.argsFor(1)[0]; 252 | expect(info.parts).toEqual({ 253 | top: false, 254 | left: true, 255 | bottom: true, 256 | right: true 257 | }); 258 | return test; 259 | }) 260 | .then(done); 261 | }); 262 | 263 | }); 264 | 265 | it("should accept a `throttle` option", function(done) { 266 | makeTestForHtml( 267 | '
    ' + 268 | '
    ' 269 | ) 270 | .then(function (test) { 271 | expect(test.spy.calls.count()).toBe(1); 272 | expect(test.spy.calls.mostRecent().args[0]).toBe(true); 273 | return test; 274 | }) 275 | .then(lazyScrollTo(200)) 276 | .then(lazyWait(100)) 277 | .then(function (test) { 278 | expect(test.spy.calls.count()).toBe(1); 279 | return test; 280 | }) 281 | .then(lazyScrollTo(0)) 282 | .then(lazyWait(100)) 283 | .then(function (test) { 284 | expect(test.spy.calls.count()).toBe(1); 285 | return test; 286 | }) 287 | .then(lazyScrollTo(200)) 288 | .then(lazyWait(100)) 289 | .then(function (test) { 290 | expect(test.spy.calls.count()).toBe(2); 291 | expect(test.spy.calls.mostRecent().args[0]).toBe(false); 292 | return test; 293 | }) 294 | .then(lazyScrollTo(0)) 295 | .then(done); 296 | }); 297 | 298 | }); 299 | 300 | describe("in-view-container directive", function() { 301 | 302 | it("should trigger in-view when scrolling a container", function(done) { 303 | makeTestForHtml( 304 | '
    ' + 305 | '
    ' + 306 | '
    ' + 307 | '
    ' 308 | ) 309 | .then(function (test) { 310 | expect(test.spy.calls.count()).toBe(1); 311 | expect(test.spy).toHaveBeenCalledWith(true); 312 | return test; 313 | }) 314 | .then(lazyScrollTestElementTo(100)) 315 | .then(function (test) { 316 | expect(test.spy.calls.count()).toBe(2); 317 | expect(test.spy).toHaveBeenCalledWith(false); 318 | }) 319 | .then(done); 320 | }); 321 | 322 | }); 323 | 324 | // A test object has the properties: 325 | // 326 | // - `element`: An angular element inserted in the test page 327 | // - `scope`: a new isolated scope that can be referenced in the element 328 | // - `spy`: a conveninence jasmine spy attached to the scope as `spy` 329 | function makeTestForHtml(html) { 330 | var test = {}; 331 | // Prepare test elements 332 | window.document.body.style.height = '100%'; 333 | window.document.body.parentElement.style.height = '100%'; 334 | test.element = angular.element(html); 335 | angular.element(window.document.body).empty().append(test.element); 336 | // Prepare test scope 337 | test.scope = $rootScope.$new(true); 338 | test.spy = test.scope.spy = jasmine.createSpy('spy'); 339 | // Compile the element 340 | $compile(test.element)(test.scope); 341 | test.scope.$digest(); 342 | return scrollTo(window, [0, 0], true).then(function () { 343 | return test; 344 | }); 345 | } 346 | 347 | // Scrolls the element to the given x, y position and waits a bit before 348 | // resolving the returned promise. 349 | function scrollTo(element, position, useTimeout) { 350 | if (!angular.isDefined(position)) { 351 | position = element; 352 | element = window; 353 | } 354 | if (!angular.isArray(position)) { 355 | position = [0, position]; 356 | } 357 | // Prepare promise resolution 358 | var deferred = $q.defer(), timeout; 359 | var scrollOnceHandler = function () { 360 | var check = (element === window) ? 361 | [element.scrollX, element.scrollY] : 362 | [element.scrollLeft, element.scrollTop]; 363 | if (check[0] != position[0] || check[1] != position[1]) { 364 | return; 365 | } 366 | if (timeout) { 367 | clearTimeout(timeout); 368 | timeout = null; 369 | } 370 | angular.element(element).off('scroll', scrollOnceHandler); 371 | deferred.resolve(); 372 | $rootScope.$digest(); 373 | }; 374 | angular.element(element).on('scroll', scrollOnceHandler); 375 | // Actual scrolling 376 | if (element === window) { 377 | element.scrollTo.apply(element, position); 378 | } 379 | else { 380 | element.scrollLeft = position[0]; 381 | element.scrollTop = position[1]; 382 | } 383 | // Backup resolver 384 | if (useTimeout) timeout = setTimeout(function () { 385 | angular.element(element).off('scroll', scrollOnceHandler); 386 | var check = (element === window) ? 387 | [element.scrollX, element.scrollY] : 388 | [element.scrollLeft, element.scrollTop]; 389 | if (check[0] != position[0] || check[1] != position[1]) { 390 | deferred.reject(); 391 | } 392 | else { 393 | deferred.resolve(); 394 | } 395 | $rootScope.$digest(); 396 | }, 100); 397 | return deferred.promise; 398 | } 399 | 400 | function lazyScrollTo () { 401 | var args = arguments; 402 | return function (x) { 403 | return scrollTo.apply(null, args).then(function () { 404 | return x; 405 | }); 406 | } 407 | } 408 | 409 | function lazyScrollTestElementTo (pos) { 410 | return function (test) { 411 | return scrollTo(test.element[0], pos, true).then(function () { 412 | return test; 413 | }); 414 | } 415 | } 416 | 417 | function lazyWait (millisec) { 418 | return function (x) { 419 | return $q(function (resolve) { 420 | setTimeout(function () { 421 | resolve(x); 422 | $rootScope.$digest(); 423 | }, millisec); 424 | }); 425 | } 426 | } 427 | 428 | }); 429 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-inview", 3 | "version": "2.0.0", 4 | "main": "./angular-inview.js", 5 | "dependencies": { 6 | "angular": "~1" 7 | }, 8 | "readmeFilename": "README.md", 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/thenikso/angular-inview.git" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | angular-inview basic example 6 | 34 | 35 | 36 | 37 |
    38 |
      39 |
    • 40 |
    41 |
    42 | 43 |
      44 |
    • This is test line #{{$index}}
    • 45 |
    46 | 47 | 48 | 49 | 74 | 75 | -------------------------------------------------------------------------------- /examples/container.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | angular-inview container example 6 | 44 | 45 | 46 | 47 |
    48 |
      49 |
    • 50 |
    51 |
    52 | 53 |
    54 |
      55 |
    • This is test contained-line #{{$index}}
    • 56 |
    57 |
    58 |
      59 |
    • This is test line #{{$index}}
    • 60 |
    61 | 62 | 63 | 64 | 88 | 89 | -------------------------------------------------------------------------------- /examples/fixed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | angular-inview basic example 6 | 34 | 35 | 36 | 37 |
    38 |
      39 |
    • 40 |
    41 |
    42 | 43 |
      44 |
    • This is test line #{{$index}}
    • 45 |
    46 | 47 | 48 | 49 | 66 | 67 | -------------------------------------------------------------------------------- /examples/throttle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | angular-inview debounce option example 6 | 34 | 35 | 36 | 37 |
    38 |
      39 |
    • In-view events will be fired only after 1 second of scroll inactivity.
    • 40 |
    • 41 |
    42 |
    43 | 44 |
      45 |
    • This is test line #{{$index}}
    • 48 |
    49 | 50 | 51 | 52 | 71 | 72 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Jul 30 2014 11:18:46 GMT+0200 (CEST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'node_modules/angular/angular.js', 19 | 'node_modules/angular-mocks/angular-mocks.js', 20 | 'angular-inview.js', 21 | 'angular-inview.spec.js' 22 | ], 23 | 24 | 25 | // list of files to exclude 26 | exclude: [], 27 | 28 | 29 | // preprocess matching files before serving them to the browser 30 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 31 | preprocessors: { 32 | }, 33 | 34 | 35 | // test results reporter to use 36 | // possible values: 'dots', 'progress' 37 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 38 | reporters: ['progress'], 39 | 40 | 41 | // web server port 42 | port: 9876, 43 | 44 | 45 | // enable / disable colors in the output (reporters and logs) 46 | colors: true, 47 | 48 | 49 | // level of logging 50 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 51 | logLevel: config.LOG_INFO, 52 | 53 | 54 | // enable / disable watching file and executing tests whenever any file changes 55 | autoWatch: true, 56 | 57 | 58 | // start these browsers 59 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 60 | browsers: ['Chrome'], 61 | 62 | 63 | // Continuous Integration mode 64 | // if true, Karma captures browsers, runs the tests and exits 65 | singleRun: true 66 | }); 67 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-inview", 3 | "version": "3.1.0", 4 | "description": "AngularJS directive to check if a DOM element is in the viewport", 5 | "main": "angular-inview.js", 6 | "scripts": { 7 | "test": "karma start", 8 | "publish-docs": "docco -o docs angular-inview.js && mv docs/angular-inview.html docs/index.html && gh-pages -d docs" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/thenikso/angular-inview.git" 13 | }, 14 | "keywords": [ 15 | "angualr", 16 | "inview" 17 | ], 18 | "author": "Nicola Peduzzi (http://nikso.net)", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/thenikso/angular-inview/issues" 22 | }, 23 | "homepage": "https://github.com/thenikso/angular-inview#readme", 24 | "peerDependencies": { 25 | "angular": ">=1.0.0 < 2.0.0" 26 | }, 27 | "devDependencies": { 28 | "angular": ">=1.0.0 < 2.0.0", 29 | "angular-mocks": "1.6.8", 30 | "docco": "^0.7.0", 31 | "gh-pages": "^0.11.0", 32 | "jasmine-core": "2.9.1", 33 | "karma": "2.0.0", 34 | "karma-chrome-launcher": "2.2.0", 35 | "karma-jasmine": "1.1.1" 36 | } 37 | } 38 | --------------------------------------------------------------------------------