├── .bowerrc ├── .gitignore ├── .jscsrc ├── .jshintrc ├── .nvmrc ├── .travis.yml ├── Gemfile ├── Gruntfile.js ├── LICENSE ├── README.md ├── angular-viewport-watch.js ├── angular-viewport-watch.sublime-project ├── app ├── images │ ├── svg-font-icons │ │ └── wix-logo.svg │ ├── wixlogo.jpg │ └── yeoman.png ├── index.html ├── scripts │ ├── app.js │ ├── controllers │ │ └── main.js │ └── directives │ │ └── viewport-watch.js ├── styles │ └── main.scss └── views │ └── main.haml ├── bower.json ├── karma.conf.js ├── package.json ├── pom.xml └── test ├── .jshintrc ├── mock └── server-api.js └── spec ├── controllers └── main.spec.js ├── directives └── viewport-watch.spec.js └── services └── app.spec.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "app/bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .tmp 3 | .sass-cache 4 | app/bower_components 5 | *.iml 6 | *.sublime-workspace 7 | npm-debug.log 8 | sauce_connect.log* 9 | .DS_Store 10 | .bundle 11 | .idea 12 | .sauce-connect 13 | vendor 14 | Gemfile.lock 15 | coverage 16 | target 17 | replace.private.conf.js 18 | reference.ts 19 | .baseDir.ts 20 | .tscache 21 | dist 22 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "crockford", 3 | "validateIndentation": 2, 4 | "disallowMultipleVarDecl": null, 5 | "requireMultipleVarDecl": null, 6 | "requireCamelCaseOrUpperCaseIdentifiers": "ignoreProperties", 7 | "disallowDanglingUnderscores": null, 8 | "requireSpaceBeforeObjectValues": true, 9 | "requireVarDeclFirst": null 10 | } 11 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "camelcase": true, 4 | "curly": true, 5 | "eqeqeq": true, 6 | "immed": true, 7 | "indent": 2, 8 | "latedef": true, 9 | "newcap": true, 10 | "noarg": true, 11 | "quotmark": "single", 12 | "undef": true, 13 | "unused": true, 14 | "strict": true, 15 | "globalstrict": true, 16 | "trailing": true, 17 | "smarttabs": true, 18 | "white": true, 19 | "globals": { 20 | "_": false, 21 | "angular": false, 22 | "require": false, 23 | "module": false 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 6.9.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | 5 | install: 6 | - npm install -g npm@2 7 | - npm install 8 | - bundle install 9 | 10 | before_script: 11 | - npm install -g grunt-cli bower 12 | - bower install 13 | 14 | script: 15 | - grunt build:ci 16 | 17 | after_success: 18 | - cat ./coverage/*/lcov.info | ./node_modules/coveralls/bin/coveralls.js 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'compass' 4 | gem 'haml' 5 | gem 'scss-lint', '>=0.28.0' 6 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | // Generated on 2014-12-12 using generator-wix-angular 0.1.84 2 | 'use strict'; 3 | 4 | module.exports = function (grunt) { 5 | var unitTestFiles = []; 6 | require('./karma.conf.js')({set: function (karmaConf) { 7 | unitTestFiles = karmaConf.files.filter(function (value) { 8 | return value.indexOf('bower_component') !== -1; 9 | }); 10 | }}); 11 | require('wix-gruntfile')(grunt, { 12 | port: 9000, 13 | preloadModule: 'angularViewportWatchAppInternal', 14 | unitTestFiles: unitTestFiles, 15 | protractor: false, 16 | bowerComponent: true 17 | }); 18 | 19 | grunt.modifyTask('yeoman', { 20 | local: 'http://localhost:<%= connect.options.port %>/' 21 | }); 22 | 23 | grunt.modifyTask('karma', { 24 | teamcity: { 25 | coverageReporter: {dir: 'coverage/', type: 'lcov'} 26 | } 27 | }); 28 | 29 | grunt.modifyTask('copy', { 30 | js: { 31 | expand: true, 32 | cwd: 'dist/scripts', 33 | dest: '', 34 | src: 'angular-viewport-watch.js' 35 | } 36 | }); 37 | 38 | grunt.hookTask('build').push('copy:js'); 39 | 40 | }; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Wix.com 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Angular Viewport Watch [![Build Status](https://travis-ci.org/shahata/angular-viewport-watch.svg?branch=master)](https://travis-ci.org/shahata/angular-viewport-watch) [![Coverage Status](https://coveralls.io/repos/shahata/angular-viewport-watch/badge.png?branch=master)](https://coveralls.io/r/shahata/angular-viewport-watch?branch=master) 2 | ================ 3 | 4 | Boost performance of [Angular](http://www.angularjs.org)'s `ng-repeat` directive for long lists by disabling watchers while elements are not displayed inside viewport. 5 | 6 | Demo: http://shahata.github.io/angular-viewport-watch/ 7 | 8 | BTW, `ng-repeat` is just an example, this directive will work on anything. 9 | 10 | ## What it does 11 | 12 | Displaying long lists of items is a big pain in angular since they add many more watchers to the scope which makes the digest loop longer. Since every model change in angular triggers a digest loop, even a simple thing like typing a name inside some input field might become sluggish if a long list of some items is displayed on the page at the same time. 13 | 14 | Angular 1.3 added a bind-once mechanism which removes watchers once they receive a value, but this only helps if the list you are displaying is static. What about cases where your list contains dynamic information which might change at any moment? Bind-once will not help you in those cases. 15 | 16 | This library introduces a simple directive named `viewport-watch` which solves this issue by disabling watchers that are currently out of the viewport and makes sure they get enabled and updated with their correct value the moment they come back into the viewport. This means that at any moment, the amount of items being watched is not greater then the amount of items that fit into the user's screen. This obviously cuts down the digest loop length by orders of magnitude. 17 | 18 | ## Installation 19 | 20 | Install using bower 21 | 22 | `bower install --save angular-viewport-watch` 23 | 24 | Include script tag in your html document. 25 | 26 | ```html 27 | 28 | 29 | ``` 30 | 31 | Add a dependency to your application module. 32 | 33 | ```javascript 34 | angular.module('myApp', ['angularViewportWatch']); 35 | ``` 36 | 37 | ## Directive Usage 38 | 39 | ```html 40 |
...
41 | ``` 42 | 43 | ## Manual watcher toggling 44 | 45 | In some cases you might want to disable or enable the watchers of some scope regardless of its position relative to the view port. This can be done easily by broadcasting an event to this scope (this will effect only scopes that have the `viewport-watch` directive on them): 46 | 47 | ```js 48 | scope.$broadcast('toggleWatchers', false); //turn off watchers 49 | scope.$broadcast('toggleWatchers', true); //turn watchers back on 50 | ``` 51 | 52 | ## License 53 | 54 | The MIT License. 55 | 56 | See [LICENSE](https://github.com/shahata/angular-viewport-watch/blob/master/LICENSE) 57 | -------------------------------------------------------------------------------- /angular-viewport-watch.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (function() { 4 | viewportWatch.$inject = [ "scrollMonitor", "$timeout" ]; 5 | function viewportWatch(scrollMonitor, $timeout) { 6 | var viewportUpdateTimeout; 7 | function debouncedViewportUpdate() { 8 | $timeout.cancel(viewportUpdateTimeout); 9 | viewportUpdateTimeout = $timeout(function() { 10 | scrollMonitor.update(); 11 | }, 10); 12 | } 13 | return { 14 | restrict: "AE", 15 | link: function(scope, element, attr) { 16 | var elementWatcher = scrollMonitor.create(element, scope.$eval(attr.viewportWatch || "0")); 17 | function watchDuringDisable() { 18 | this.$$watchersBackup = this.$$watchersBackup || []; 19 | this.$$watchers = this.$$watchersBackup; 20 | var unwatch = this.constructor.prototype.$watch.apply(this, arguments); 21 | this.$$watchers = null; 22 | return unwatch; 23 | } 24 | function toggleWatchers(scope, enable) { 25 | var digest, current, next = scope; 26 | do { 27 | current = next; 28 | if (enable) { 29 | if (current.hasOwnProperty("$$watchersBackup")) { 30 | current.$$watchers = current.$$watchersBackup; 31 | delete current.$$watchersBackup; 32 | delete current.$watch; 33 | digest = !scope.$root.$$phase; 34 | } 35 | } else { 36 | if (!current.hasOwnProperty("$$watchersBackup")) { 37 | current.$$watchersBackup = current.$$watchers; 38 | current.$$watchers = null; 39 | current.$watch = watchDuringDisable; 40 | } 41 | } 42 | next = current.$$childHead; 43 | while (!next && current !== scope) { 44 | if (current.$$nextSibling) { 45 | next = current.$$nextSibling; 46 | } else { 47 | current = current.$parent; 48 | } 49 | } 50 | } while (next); 51 | if (digest) { 52 | scope.$digest(); 53 | } 54 | } 55 | function disableDigest() { 56 | toggleWatchers(scope, false); 57 | } 58 | function enableDigest() { 59 | toggleWatchers(scope, true); 60 | } 61 | if (!elementWatcher.isInViewport) { 62 | scope.$evalAsync(disableDigest); 63 | debouncedViewportUpdate(); 64 | } 65 | elementWatcher.enterViewport(enableDigest); 66 | elementWatcher.exitViewport(disableDigest); 67 | scope.$on("toggleWatchers", function(event, enable) { 68 | toggleWatchers(scope, enable); 69 | }); 70 | scope.$on("$destroy", function() { 71 | elementWatcher.destroy(); 72 | debouncedViewportUpdate(); 73 | }); 74 | } 75 | }; 76 | } 77 | angular.module("angularViewportWatch", []).directive("viewportWatch", viewportWatch).value("scrollMonitor", window.scrollMonitor); 78 | })(); -------------------------------------------------------------------------------- /angular-viewport-watch.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "path": ".", 6 | "folder_exclude_patterns": [".tmp", ".sass-cache", "dist", "maven", "coverage", "node_modules", ".bundle", "vendor"] 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /app/images/svg-font-icons/wix-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 16 | 23 | 30 | 31 | 32 | 34 | 38 | 40 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /app/images/wixlogo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wix-incubator/angular-viewport-watch/86bca6a04af34db7384aa3034935b826fb240df5/app/images/wixlogo.jpg -------------------------------------------------------------------------------- /app/images/yeoman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wix-incubator/angular-viewport-watch/86bca6a04af34db7384aa3034935b826fb240df5/app/images/yeoman.png -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | angularViewportWatch 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
20 | Watch Count:
21 | Digest Cycle Length: 22 |
23 |
24 | 25 |
26 | Hide watchers 27 | 28 |
29 |
30 |
31 |
32 | Click cells inside the table or bump button and see digest cycle length. Now toggle the "Hide watchers" checkbox to see the diff

33 | The "hide watchers" directive disables watchers while they 34 | are out of viewport, which shrinks the digest cycle length. 35 | This can be extremely useful when displaying repeaters with 36 | dynamic data, where bind-once cannot help.
37 |
38 | 39 | 42 | 47 | 48 |
{{cell + main.bump}}
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/scripts/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //add services, directives, controllers, filters, etc. in this module 4 | //avoid adding module dependencies for this module 5 | angular 6 | .module('angularViewportWatchAppInternal', ['angularViewportWatch']); 7 | 8 | //add module dependencies & config and run blocks in this module 9 | //load only the internal module in tests and mock any module dependency 10 | //the only exception to load this module in tests in to test the config & run blocks 11 | angular 12 | .module('angularViewportWatchApp', ['angularViewportWatchAppInternal', 'angularStats']) 13 | .config(function ($provide) { 14 | $provide.decorator('viewportWatchDirective', function ($delegate) { 15 | var hookedLink = $delegate[0].link; 16 | 17 | $delegate[0].compile = function () { 18 | return function (scope) { 19 | if (scope.perf) { 20 | return hookedLink.apply(this, arguments); 21 | } 22 | }; 23 | }; 24 | return $delegate; 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /app/scripts/controllers/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function () { 4 | 5 | /* @ngInject */ 6 | function MainController($scope, $timeout, scrollMonitor) { 7 | var vm = this; 8 | $scope.perf = true; 9 | vm.size = 500; 10 | vm.selected = [5, 5]; 11 | vm.generate = function () { 12 | vm.bump = 0; 13 | vm.items = []; 14 | $timeout(function () { 15 | scrollMonitor.update(); //hack to workaround some bug 16 | for (var i = 0; i < vm.size; i++) { 17 | vm.items.push('item #' + i); 18 | } 19 | }, 10); 20 | }; 21 | $scope.$watch('perf', function () { 22 | vm.generate(); 23 | }); 24 | } 25 | 26 | angular 27 | .module('angularViewportWatchAppInternal') 28 | .controller('MainController', MainController); 29 | 30 | })(); 31 | -------------------------------------------------------------------------------- /app/scripts/directives/viewport-watch.js: -------------------------------------------------------------------------------- 1 | /* global window */ 2 | 'use strict'; 3 | 4 | (function () { 5 | 6 | /* @ngInject */ 7 | function viewportWatch(scrollMonitor, $timeout) { 8 | var viewportUpdateTimeout; 9 | 10 | function debouncedViewportUpdate() { 11 | $timeout.cancel(viewportUpdateTimeout); 12 | viewportUpdateTimeout = $timeout(function () { 13 | scrollMonitor.update(); 14 | }, 10); 15 | } 16 | 17 | return { 18 | restrict: 'AE', 19 | link: function (scope, element, attr) { 20 | var elementWatcher = scrollMonitor.create(element, scope.$eval(attr.viewportWatch || '0')); 21 | 22 | function watchDuringDisable() { 23 | /*jshint validthis:true */ 24 | this.$$watchersBackup = this.$$watchersBackup || []; 25 | this.$$watchers = this.$$watchersBackup; 26 | var unwatch = this.constructor.prototype.$watch.apply(this, arguments); 27 | this.$$watchers = null; 28 | return unwatch; 29 | } 30 | 31 | function toggleWatchers(scope, enable) { 32 | var digest, current, next = scope; 33 | 34 | do { 35 | current = next; 36 | 37 | if (enable) { 38 | if (current.hasOwnProperty('$$watchersBackup')) { 39 | current.$$watchers = current.$$watchersBackup; 40 | delete current.$$watchersBackup; 41 | delete current.$watch; 42 | digest = !scope.$root.$$phase; 43 | } 44 | } else { 45 | if (!current.hasOwnProperty('$$watchersBackup')) { 46 | current.$$watchersBackup = current.$$watchers; 47 | current.$$watchers = null; 48 | current.$watch = watchDuringDisable; 49 | } 50 | } 51 | 52 | //DFS 53 | next = current.$$childHead; 54 | while (!next && current !== scope) { 55 | if (current.$$nextSibling) { 56 | next = current.$$nextSibling; 57 | } else { 58 | current = current.$parent; 59 | } 60 | } 61 | } while (next); 62 | 63 | if (digest) { 64 | //local digest only for this scope subtree 65 | scope.$digest(); 66 | } 67 | } 68 | 69 | function disableDigest() { 70 | toggleWatchers(scope, false); 71 | } 72 | 73 | function enableDigest() { 74 | toggleWatchers(scope, true); 75 | } 76 | 77 | if (!elementWatcher.isInViewport) { 78 | scope.$evalAsync(disableDigest); 79 | debouncedViewportUpdate(); 80 | } 81 | 82 | elementWatcher.enterViewport(enableDigest); 83 | elementWatcher.exitViewport(disableDigest); 84 | scope.$on('toggleWatchers', function (event, enable) { 85 | toggleWatchers(scope, enable); 86 | }); 87 | 88 | scope.$on('$destroy', function () { 89 | elementWatcher.destroy(); 90 | debouncedViewportUpdate(); 91 | }); 92 | } 93 | }; 94 | } 95 | 96 | angular 97 | .module('angularViewportWatch', []) 98 | .directive('viewportWatch', viewportWatch) 99 | .value('scrollMonitor', window.scrollMonitor); 100 | 101 | })(); 102 | -------------------------------------------------------------------------------- /app/styles/main.scss: -------------------------------------------------------------------------------- 1 | .selected { 2 | background-color: #ff0000; 3 | } 4 | 5 | table tr td { 6 | user-select: none; 7 | cursor: pointer; 8 | } 9 | 10 | td { 11 | border: 1px solid #000000; 12 | padding: 20px; 13 | } 14 | -------------------------------------------------------------------------------- /app/views/main.haml: -------------------------------------------------------------------------------- 1 | .hero-unit{:"ng-controller" => "MainController as main"} 2 | %h1 3 | {{'general.YO' | translate}} 4 | %i.logo-on-header.angular-viewport-watch-svg-font-icons-wix-logo 5 | %p You now have 6 | %ul 7 | %li{:"ng-repeat" => "thing in main.awesomeThings"} {{thing}} 8 | %p installed. 9 | %h3 Enjoy coding! - Yeoman 10 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-viewport-watch", 3 | "version": "0.1.4", 4 | "main": "angular-viewport-watch.js", 5 | "ignore": [ 6 | ".*", 7 | "Gemfile*", 8 | "Gruntfile.js", 9 | "pom.xml", 10 | "*.json", 11 | "*.conf.js", 12 | "app", 13 | "test", 14 | "maven", 15 | "*.sublime*", 16 | "protractor-conf.js" 17 | ], 18 | "dependencies": { 19 | "angular": "^1.2.0", 20 | "scrollMonitor": "git@github.com:stutrek/scrollMonitor.git#~1.0.12" 21 | }, 22 | "devDependencies": { 23 | "angular-mocks": "^1.2.0", 24 | "es5-shim": "~4.0.5", 25 | "ng-stats": "~2.0.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // http://karma-runner.github.io/0.10/config/configuration-file.html 3 | 'use strict'; 4 | 5 | module.exports = function (config) { 6 | config.set({ 7 | plugins: ['karma-jasmine', 'karma-coverage', 'karma-phantomjs-launcher', 'karma-ng-html2js-preprocessor'], 8 | 9 | preprocessors: { 10 | '{.tmp,app}/scripts/{,!(lib)/**/}*.js': 'coverage', 11 | '{app,.tmp}/views/**/*.html': 'ng-html2js' 12 | }, 13 | 14 | ngHtml2JsPreprocessor: { 15 | stripPrefix: '(app|.tmp)/', 16 | moduleName: 'angularViewportWatchAppInternal' 17 | }, 18 | 19 | // base path, that will be used to resolve files and exclude 20 | basePath: '', 21 | 22 | // testing framework to use (jasmine/mocha/qunit/...) 23 | frameworks: ['jasmine'], 24 | 25 | // list of files / patterns to load in the browser 26 | files: [ 27 | 'app/bower_components/jquery/jquery.js', 28 | 'app/bower_components/angular/angular.js', 29 | 'app/bower_components/angular-mocks/angular-mocks.js', 30 | 'app/bower_components/angular-translate/angular-translate.js', 31 | 'app/bower_components/wix-angular/dist/wix-angular.js', 32 | 'app/bower_components/es5-shim/es5-shim.js', 33 | '{app,.tmp}/*.js', 34 | '{app,.tmp}/scripts/*.js', 35 | '{app,.tmp}/scripts/*/**/*.js', 36 | '{,.tmp/}test/**/*.js', 37 | '{app,.tmp}/views/**/*.html' 38 | ], 39 | 40 | // list of files / patterns to exclude 41 | exclude: [ 42 | '{,.tmp/}test/e2e/**/*.js', 43 | '{app,.tmp}/scripts/locale/*_!(en).js' 44 | ], 45 | 46 | // test results reporter to use 47 | // possible values: dots || progress || growl 48 | reporters: ['progress', 'coverage'], 49 | 50 | // web server port 51 | port: 8880, 52 | 53 | // level of logging 54 | // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG 55 | logLevel: config.LOG_INFO, 56 | 57 | // enable / disable watching file and executing tests whenever any file changes 58 | autoWatch: false, 59 | 60 | // Start these browsers, currently available: 61 | // - Chrome 62 | // - ChromeCanary 63 | // - Firefox 64 | // - Opera 65 | // - Safari (only Mac) 66 | // - PhantomJS 67 | // - IE (only Windows) 68 | browsers: ['PhantomJS'], 69 | 70 | // Continuous Integration mode 71 | // if true, it capture browsers, run tests and exit 72 | singleRun: true 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-viewport-watch", 3 | "version": "0.1.4", 4 | "dependencies": {}, 5 | "devDependencies": { 6 | "coveralls": "^2.10.0", 7 | "wix-gruntfile": "~0.1.2", 8 | "wix-statics-parent": "*" 9 | }, 10 | "engines": { 11 | "node": ">=0.8.0" 12 | }, 13 | "scripts": { 14 | "build": "node_modules/wix-gruntfile/scripts/build.sh", 15 | "release": "node_modules/wix-gruntfile/scripts/release.sh", 16 | "test": "#tbd", 17 | "start": "grunt serve" 18 | }, 19 | "private": false, 20 | "publishConfig": { 21 | "registry": "http://repo.dev.wix/artifactory/api/npm/npm-local/" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | com.wixpress 5 | angular-viewport-watch 6 | jar 7 | angular-viewport-watch 8 | 1.1277.0-SNAPSHOT 9 | angular viewport watch 10 | 11 | 12 | Shahar Talmi 13 | shahar@wix.com 14 | 15 | owner 16 | 17 | 18 | 19 | 20 | com.wixpress 21 | wix-statics-parent 22 | 1.0.0-SNAPSHOT 23 | 24 | 25 | 26 | 27 | org.apache.maven.plugins 28 | maven-assembly-plugin 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "camelcase": true, 4 | "curly": true, 5 | "eqeqeq": true, 6 | "immed": true, 7 | "indent": 2, 8 | "latedef": true, 9 | "newcap": true, 10 | "noarg": true, 11 | "quotmark": "single", 12 | "undef": true, 13 | "unused": true, 14 | "strict": true, 15 | "globalstrict": true, 16 | "trailing": true, 17 | "smarttabs": true, 18 | "white": true, 19 | "node": true, 20 | "globals": { 21 | "_": false, 22 | "$": false, 23 | "$$": false, 24 | "angular": false, 25 | "jasmine": false, 26 | "module": false, 27 | "inject": false, 28 | 29 | "describe": false, 30 | "it": false, 31 | "beforeEach": false, 32 | "afterEach": false, 33 | "expect": false, 34 | "spyOn": false, 35 | "runs": false, 36 | "waitsFor": false, 37 | 38 | "browser": false, 39 | "protractor": false, 40 | "by": false, 41 | "element": false, 42 | "input": false, 43 | "select": false, 44 | "binding": false, 45 | "repeater": false, 46 | "using": false, 47 | "pause": false, 48 | "resume": false, 49 | "sleep": false 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/mock/server-api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('angularViewportWatchAppMocks', ['ngMockE2E']) 4 | .run(function ($httpBackend) { 5 | $httpBackend.whenGET(/.*/).passThrough(); 6 | $httpBackend.whenPOST(/.*/).passThrough(); 7 | $httpBackend.whenPUT(/.*/).passThrough(); 8 | $httpBackend.whenDELETE(/.*/).passThrough(); 9 | }); 10 | -------------------------------------------------------------------------------- /test/spec/controllers/main.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Controller: MainController', function () { 4 | 5 | // load the controller's module 6 | beforeEach(function () { 7 | module('angularViewportWatchAppInternal'); 8 | module({scrollMonitor: {update: angular.noop}}); 9 | }); 10 | 11 | var MainController, scope; 12 | 13 | // Initialize the controller and a mock scope 14 | beforeEach(inject(function ($controller, $rootScope) { 15 | scope = $rootScope.$new(); 16 | MainController = $controller('MainController as main', { 17 | $scope: scope 18 | }); 19 | })); 20 | 21 | it('should add 500 items on startup', inject(function ($timeout) { 22 | scope.$digest(); 23 | expect(MainController.items.length).toBe(0); 24 | $timeout.flush(); 25 | expect(MainController.items.length).toBe(500); 26 | })); 27 | }); 28 | -------------------------------------------------------------------------------- /test/spec/directives/viewport-watch.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Directive: viewportWatch', function () { 4 | var enterViewport, exitViewport, isInViewport; 5 | var scope, element, destroySpy; 6 | 7 | beforeEach(function () { 8 | enterViewport = undefined; 9 | exitViewport = undefined; 10 | isInViewport = true; 11 | scope = undefined; 12 | element = undefined; 13 | destroySpy = jasmine.createSpy('destroySpy'); 14 | 15 | module('angularViewportWatch'); 16 | 17 | module({ 18 | scrollMonitor: { 19 | update: jasmine.createSpy('update'), 20 | create: jasmine.createSpy('create').and.callFake(function () { 21 | return { 22 | enterViewport: function (fn) { 23 | enterViewport = fn; 24 | }, 25 | exitViewport: function (fn) { 26 | exitViewport = fn; 27 | }, 28 | isInViewport: isInViewport, 29 | destroy: destroySpy 30 | }; 31 | }) 32 | } 33 | }); 34 | }); 35 | 36 | function compile(html, threshold) { 37 | inject(function ($rootScope, $compile) { 38 | scope = $rootScope.$new(); 39 | threshold = threshold === undefined ? '' : '="' + threshold + '"'; 40 | element = $compile('
' + html + '
')(scope); 41 | scope.$digest(); 42 | }); 43 | return scope; 44 | } 45 | 46 | function dup(str) { 47 | return str + str; 48 | } 49 | 50 | describe('single scope', function () { 51 | 52 | beforeEach(function () { 53 | compile('{{a}}'); 54 | }); 55 | 56 | it('should perform digest normally', function () { 57 | scope.$apply('a = 5'); 58 | expect(element.text()).toBe('5'); 59 | }); 60 | 61 | it('should not perform digest if out of viewport', function () { 62 | exitViewport(); 63 | scope.$apply('a = 5'); 64 | expect(element.text()).toBe(''); 65 | }); 66 | 67 | it('should perform digest when back in viewport', function () { 68 | exitViewport(); 69 | scope.$apply('a = 5'); 70 | expect(element.text()).toBe(''); 71 | enterViewport(); 72 | expect(element.text()).toBe('5'); 73 | }); 74 | 75 | it('should keep digesting normally after coming back', function () { 76 | exitViewport(); 77 | enterViewport(); 78 | scope.$apply('a = 5'); 79 | expect(element.text()).toBe('5'); 80 | }); 81 | 82 | it('should handle double exitViewport event', function () { 83 | exitViewport(); 84 | exitViewport(); 85 | enterViewport(); 86 | scope.$apply('a = 5'); 87 | expect(element.text()).toBe('5'); 88 | }); 89 | 90 | it('should handle double enterViewport event', function () { 91 | exitViewport(); 92 | enterViewport(); 93 | enterViewport(); 94 | scope.$apply('a = 5'); 95 | expect(element.text()).toBe('5'); 96 | }); 97 | 98 | it('should perform local digest when coming back', inject(function ($rootScope) { 99 | var globalWatchSpy = jasmine.createSpy('globalWatchSpy'); 100 | var localWatchSpy = jasmine.createSpy('localWatchSpy'); 101 | $rootScope.$watch(globalWatchSpy); 102 | scope.$watch(localWatchSpy); 103 | 104 | exitViewport(); 105 | enterViewport(); 106 | 107 | expect(globalWatchSpy).not.toHaveBeenCalled(); 108 | expect(localWatchSpy).toHaveBeenCalled(); 109 | })); 110 | 111 | it('should destroy scroll watcher when scope is destroyed', function () { 112 | scope.$destroy(); 113 | expect(destroySpy).toHaveBeenCalled(); 114 | }); 115 | }); 116 | 117 | describe('scope tree traversal', function () { 118 | it('should disable watchers in child scope', function () { 119 | compile('{{a}}
{{a}}
'); 120 | exitViewport(); 121 | scope.$apply('a = 5'); 122 | expect(element.text()).toBe(''); 123 | enterViewport(); 124 | expect(element.text()).toBe('55'); 125 | }); 126 | 127 | it('should traverse tree with multiple children', function () { 128 | compile(dup('{{a}}
{{a}}
')); 129 | exitViewport(); 130 | scope.$apply('a = 5'); 131 | expect(element.text()).toBe(''); 132 | enterViewport(); 133 | expect(element.text()).toBe('5555'); 134 | }); 135 | 136 | it('should traverse tree recursively', function () { 137 | compile(dup('{{a}}
{{a}}
{{a}}
')); 138 | exitViewport(); 139 | scope.$apply('a = 5'); 140 | expect(element.text()).toBe(''); 141 | enterViewport(); 142 | expect(element.text()).toBe('555555'); 143 | }); 144 | 145 | it('should digest only once when coming back to view port', function () { 146 | var localWatchSpy = jasmine.createSpy('localWatchSpy'); 147 | compile(dup('{{a}}
{{a}}
{{a}}
')); 148 | 149 | scope.$watch(localWatchSpy); 150 | scope.$digest(); 151 | localWatchSpy.calls.reset(); 152 | 153 | exitViewport(); 154 | enterViewport(); 155 | expect(localWatchSpy.calls.count()).toBe(1); 156 | }); 157 | 158 | it('should not perform digest if initially out of viewport', function () { 159 | isInViewport = false; 160 | compile('{{a}}
{{a}}
'); 161 | scope.$apply('a = 5'); 162 | expect(element.text()).toBe('{{a}}'); 163 | enterViewport(); 164 | expect(element.text()).toBe('55'); 165 | }); 166 | 167 | it('should perform initial ng-repeat digest even if out of viewport', 168 | inject(function ($compile, $rootScope) { 169 | isInViewport = false; 170 | scope = $rootScope.$new(); 171 | scope.a = 5; 172 | element = $compile('
{{item}}
')(scope); 174 | scope.$digest(); 175 | expect(element.text()).toBe('5'); 176 | scope.$apply('a = 6'); 177 | expect(element.text()).toBe('5'); 178 | enterViewport(); 179 | expect(element.text()).toBe('6'); 180 | }) 181 | ); 182 | }); 183 | 184 | describe('update scroll watcher', function () { 185 | afterEach(inject(function ($timeout) { 186 | $timeout.verifyNoPendingTasks(); 187 | })); 188 | 189 | it('should trigger on destroy', inject(function ($timeout, scrollMonitor) { 190 | compile(); 191 | scope.$destroy(); 192 | expect(scrollMonitor.update).not.toHaveBeenCalled(); 193 | $timeout.flush(); 194 | expect(scrollMonitor.update.calls.count()).toBe(1); 195 | })); 196 | 197 | it('should not trigger on create in viewport', function () { 198 | compile(); 199 | }); 200 | 201 | it('should trigger on create outside viewport', inject(function ($timeout, scrollMonitor) { 202 | isInViewport = false; 203 | compile(); 204 | expect(scrollMonitor.update).not.toHaveBeenCalled(); 205 | $timeout.flush(); 206 | expect(scrollMonitor.update.calls.count()).toBe(1); 207 | })); 208 | 209 | it('should debounce destroy trigger', inject(function ($timeout, scrollMonitor) { 210 | isInViewport = false; 211 | compile(); 212 | scope.$destroy(); 213 | compile(); 214 | scope.$destroy(); 215 | expect(scrollMonitor.update).not.toHaveBeenCalled(); 216 | $timeout.flush(); 217 | expect(scrollMonitor.update.calls.count()).toBe(1); 218 | })); 219 | }); 220 | 221 | describe('threshold parameter', function () { 222 | it('should pass threshold 0 by default', inject(function (scrollMonitor) { 223 | compile(); 224 | expect(scrollMonitor.create).toHaveBeenCalledWith(jasmine.any(Object), 0); 225 | })); 226 | 227 | it('should pass threshold parameter', inject(function (scrollMonitor) { 228 | compile('', 200); 229 | expect(scrollMonitor.create).toHaveBeenCalledWith(jasmine.any(Object), 200); 230 | })); 231 | 232 | it('should pass threshold variable', inject(function ($rootScope, scrollMonitor) { 233 | $rootScope.threshold = 500; 234 | compile('', 'threshold'); 235 | expect(scrollMonitor.create).toHaveBeenCalledWith(jasmine.any(Object), 500); 236 | })); 237 | }); 238 | 239 | describe('toggleWatchers scope event', function () { 240 | beforeEach(function () { 241 | compile('{{a}}'); 242 | }); 243 | 244 | it('should not perform digest if toggleWatchers off was sent', function () { 245 | scope.$broadcast('toggleWatchers', false); 246 | scope.$apply('a = 5'); 247 | expect(element.text()).toBe(''); 248 | }); 249 | 250 | it('should perform digest when toggleWatchers back on was sent', function () { 251 | scope.$broadcast('toggleWatchers', false); 252 | scope.$apply('a = 5'); 253 | expect(element.text()).toBe(''); 254 | scope.$broadcast('toggleWatchers', true); 255 | expect(element.text()).toBe('5'); 256 | }); 257 | 258 | it('should be okay to broadcast toggleWatchers during scope phase', function () { 259 | scope.$apply(function () { 260 | scope.$broadcast('toggleWatchers', false); 261 | }); 262 | scope.$apply('a = 5'); 263 | expect(element.text()).toBe(''); 264 | scope.$apply(function () { 265 | scope.$broadcast('toggleWatchers', true); 266 | }); 267 | expect(element.text()).toBe('5'); 268 | }); 269 | }); 270 | 271 | describe('scope.$watch edge cases', function () { 272 | beforeEach(function () { 273 | compile('{{a}}'); 274 | }); 275 | 276 | it('should be able to remove watcher while watchers are disabled', function () { 277 | var watchSpy = jasmine.createSpy('watchSpy'); 278 | var unwatch = scope.$watch(watchSpy); 279 | scope.$broadcast('toggleWatchers', false); 280 | unwatch(); 281 | scope.$broadcast('toggleWatchers', true); 282 | scope.$digest(); 283 | expect(watchSpy).not.toHaveBeenCalled(); 284 | }); 285 | 286 | it('should be able to add watcher while watchers are disabled', function () { 287 | var watchSpy = jasmine.createSpy('watchSpy'); 288 | scope.$broadcast('toggleWatchers', false); 289 | scope.$watch(watchSpy); 290 | scope.$broadcast('toggleWatchers', true); 291 | scope.$digest(); 292 | expect(watchSpy).toHaveBeenCalled(); 293 | }); 294 | 295 | it('should be able to add watcher while watchers are disabled', function () { 296 | var watchSpy = jasmine.createSpy('watchSpy'); 297 | compile(); 298 | scope.$broadcast('toggleWatchers', false); 299 | scope.$watch(watchSpy); 300 | scope.$broadcast('toggleWatchers', true); 301 | scope.$digest(); 302 | expect(watchSpy).toHaveBeenCalled(); 303 | }); 304 | 305 | it('should be able to add watcher after watchers are enabled', function () { 306 | var watchSpy = jasmine.createSpy('watchSpy'); 307 | scope.$broadcast('toggleWatchers', false); 308 | scope.$broadcast('toggleWatchers', true); 309 | scope.$watch(watchSpy); 310 | scope.$digest(); 311 | expect(watchSpy).toHaveBeenCalled(); 312 | }); 313 | }); 314 | 315 | }); 316 | -------------------------------------------------------------------------------- /test/spec/services/app.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('angularStats', []); 4 | 5 | describe('viewportWatch decorator', function () { 6 | beforeEach(module('angularViewportWatchApp')); 7 | 8 | it('should block usage of directive if no perf flag', inject(function ($rootScope, $compile) { 9 | expect(function () { 10 | $compile('
')($rootScope); 11 | }).not.toThrow(); 12 | })); 13 | 14 | it('should not block usage of directive if perf flag', inject(function ($rootScope, $compile) { 15 | $rootScope.perf = true; 16 | expect(function () { 17 | $compile('
')($rootScope); 18 | }).toThrow(); 19 | })); 20 | }); 21 | --------------------------------------------------------------------------------