diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f1f9f5cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +public +node_modules +temp \ No newline at end of file diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 00000000..52f4bb0a --- /dev/null +++ b/.jshintrc @@ -0,0 +1,23 @@ +{ + "boss": true, + "browser": true, + "eqnull": true, + "expr": true, + "immed": true, + "laxbreak": true, + "loopfunc": true, + "newcap": true, + "noarg": true, + "noempty": true, + "nonew": true, + "quotmark": true, + "smarttabs": true, + "strict": true, + "sub": true, + "trailing": true, + "undef": true, + "unused": true, + "globals": { + "angular": false + } +} diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..2ccbe465 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..68653b1d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +--- +language: node_js +node_js: +- '0.10' +before_install: +- export DISPLAY=:99.0 +- sh -e /etc/init.d/xvfb start +- npm install -g npm +- npm install -g bower grunt-cli karma +- npm install +script: +- grunt travis +branches: + only: + - master diff --git a/Gruntfile.coffee b/Gruntfile.coffee new file mode 100644 index 00000000..40d43b31 --- /dev/null +++ b/Gruntfile.coffee @@ -0,0 +1,140 @@ +# Build configurations. +module.exports = (grunt) -> + + grunt.loadNpmTasks 'grunt-karma' + grunt.loadNpmTasks 'grunt-contrib-connect' + grunt.loadNpmTasks 'grunt-contrib-watch' + grunt.loadNpmTasks 'grunt-contrib-coffee' + grunt.loadNpmTasks 'grunt-contrib-jshint' + grunt.loadNpmTasks 'grunt-contrib-concat' + grunt.loadNpmTasks 'grunt-contrib-uglify' + + grunt.initConfig + connect: + app: + options: + base: './src/' + middleware: require './server/middleware' + port: 5001 + watch: + options: + livereload: false + karma: + unit: + options: + autoWatch: true + colors: true + configFile: './test/karma.conf.js' + keepalive: true + port: 8081 + runnerPort: 9100 + travis: + options: + colors: true + configFile: './test/karma.conf.js' + runnerPort: 9100 + singleRun: true + + # transpile CoffeeScript (.coffee) files to JavaScript (.js). + coffee: + build: + files: [ + cwd: './src' + src: '*.coffee' + dest: './temp/' + expand: true + ext: '.js' + ] + options: + bare: true + #sourceMap: true + + #prepend 'use strict' to the files + concat: + #usestrict: + options: + banner: '(function () {\n\'use strict\';\n' + footer: '}());' + stripBanners: true + process: (src, filepath) -> + console.log("Processing #{filepath} ...") + + strings = /("(?:(?:\\")|[^"])*")/g + singleQuotes = /'/g + + src.replace(strings, + (match) -> + console.log("match: " + match) + result = "'" + match.substring(1, match.length-1).replace(singleQuotes, "\\'") + "'" + console.log "replaced with: " + result + result + ) + + dynamic_mappings: + files: + 'dist/ui-scroll.js': ['./temp/**/ui-scroll.js'] + 'dist/ui-scroll-jqlite.js': ['./temp/**/ui-scroll-jqlite.js'] + + uglify: + common: + files: + './dist/ui-scroll.min.js': [ + './dist/ui-scroll.js' + ] + './dist/ui-scroll-jqlite.min.js': [ + './dist/ui-scroll-jqlite.js' + ] + + # run the linter + jshint: + dist: + files: + src: ['./dist/ui-scroll.js', './dist/ui-scroll-jqlite.js'] + options: jshintrc: '.jshintrc' + test: + files: + src : [ './test/*Spec.js'] + options: grunt.util._.extend({}, grunt.file.readJSON('.jshintrc'), { + node: true + globals: + angular: false + inject: false + jQuery: false + jasmine: false + afterEach: false + beforeEach: false + ddescribe: false + describe: false + expect: false + iit: false + it: false + spyOn: false + xdescribe: false + xit: false + }) + + # Starts a web server + # Enter the following command at the command line to execute this task: + # grunt server + grunt.registerTask 'server', [ + 'connect' + 'watch' + ] + + grunt.registerTask 'default', ['server'] + + grunt.registerTask 'test', [ + 'karma:unit' + ] + + grunt.registerTask 'build', [ + 'jshint:test' + 'karma:travis' + 'coffee:build' + 'concat' + 'jshint:dist' + 'uglify:common'] + + grunt.registerTask 'travis', [ + 'karma:travis' + ] \ No newline at end of file diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..90a9f068 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,22 @@ +Copyright (c) 2013 Hill30 INC + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/bower.json b/bower.json new file mode 100644 index 00000000..7e420cd5 --- /dev/null +++ b/bower.json @@ -0,0 +1,30 @@ +{ + "name": "ui-scroll", + "version": "1.2.1", + "homepage": "https://github.com/Hill30/NGScroller.git", + "description": "AngularJS infinite scrolling module", + "main": "./dist/ui-scroll.js", + "keywords": [ + "ui.scroll", + "angularjs", + "ui-utils", + "ui.utils", + "infinite", + "live", + "perpetual", + "scroll", + "scroller", + "scrolling" + ], + "license": "MIT", + "ignore": [ + "server", + "test", + "src/examples", + "src/index.html", + "**/.*", + "Gruntfile.coffee", + "package.json", + "test.dat" + ] +} diff --git a/demo/examples/adapter.coffee b/demo/examples/adapter.coffee new file mode 100644 index 00000000..bfd5810c --- /dev/null +++ b/demo/examples/adapter.coffee @@ -0,0 +1,88 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']).controller('mainController', + [ '$scope', '$log', '$timeout' + ($scope, console, $timeout)-> + + # datasource implementation + + datasource = {} + + datasource.get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count-1] + item = {} + item.id = i + item.content = "item #" + i + result.push item + success(result) + 100 + ) + + $scope.datasource = datasource + + + # 1st list adapter implementation + + $scope.firstListAdapter = remain: true + + $scope.updateList1 = -> + $scope.firstListAdapter.applyUpdates (item, scope) -> + item.content += ' *' + + $scope.removeFromList1 = -> + $scope.firstListAdapter.applyUpdates (item, scope) -> + if scope.$index % 2 == 0 + return [] + + idList1 = 1000 + + $scope.addToList1 = -> + $scope.firstListAdapter.applyUpdates (item, scope) -> + newItem = undefined + if scope.$index == 2 + newItem = + id: idList1 + content: 'a new one #' + idList1 + idList1++ + return [ + item + newItem + ] + return + + + # 2nd list adapter implementation + + $scope.updateList2 = -> + $scope.second.list.adapter.applyUpdates (item, scope) -> + item.content += ' *' + + $scope.removeFromList2 = -> + $scope.second.list.adapter.applyUpdates (item, scope) -> + if scope.$index % 2 != 0 + return [] + + idList2 = 2000 + + $scope.addToList2 = -> + $scope.second.list.adapter.applyUpdates (item, scope) -> + newItem = undefined + if scope.$index == 4 + newItem = + id: idList2 + content: 'a new one #' + idList1 + idList2++ + return [ + item + newItem + ] + return + + ]) + +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/adapter.js +### \ No newline at end of file diff --git a/demo/examples/adapter.html b/demo/examples/adapter.html new file mode 100644 index 00000000..0b0bc5d9 --- /dev/null +++ b/demo/examples/adapter.html @@ -0,0 +1,95 @@ + + + + + Scroller Demo (adapter) + + + + + + + + + + + +
+

+ Adapter demo +

+ + + + + + +
+
+ ...data loading... + 1st list is loaded +
+
+
{{item.content}}
+
+ + + +
+
+
+ ...data loading... + 2nd list is loaded +
+
+
{{item.content}}
+
+ + + +
+
+
+ +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/demo/examples/animation.coffee b/demo/examples/animation.coffee new file mode 100644 index 00000000..f87c02aa --- /dev/null +++ b/demo/examples/animation.coffee @@ -0,0 +1,60 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite', 'ngAnimate']).controller('mainController', + [ '$scope', '$log', '$timeout' + ($scope, console, $timeout)-> + + # datasource implementation + + datasource = {} + + datasource.get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count-1] + continue if i <= 0 or i > 14 + item = {} + item.id = i + item.content = "item #" + i + result.push item + success(result) + 100 + ) + + $scope.datasource = datasource + + # adapter implementation + + $scope.adapterContainer = { adapter: { remain: true } } + + $scope.updateList = -> + $scope.adapterContainer.adapter.applyUpdates (item, scope) -> + item.content += ' *' + + $scope.removeFromList = -> + $scope.adapterContainer.adapter.applyUpdates (item, scope) -> + if scope.$index % 2 is 0 + return [] + + idList = 1000 + + $scope.addToList = -> + $scope.adapterContainer.adapter.applyUpdates (item, scope) -> + newItem = undefined + if scope.$index is 2 + newItem = + id: idList + content: 'a new one #' + idList + idList++ + return [ + item + newItem + ] + return + + ]) + +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/animation.js +### \ No newline at end of file diff --git a/demo/examples/animation.html b/demo/examples/animation.html new file mode 100644 index 00000000..e4d1d6c6 --- /dev/null +++ b/demo/examples/animation.html @@ -0,0 +1,63 @@ + + + + + Scroller Demo (animation) + + + + + + + + + + + + +

+ Animation demo +

+ + + + + +
+
+
+ {{$index}}) {{item.content}} +
+
+ + + +
+ + + \ No newline at end of file diff --git a/demo/examples/applyUpdate.coffee b/demo/examples/applyUpdate.coffee new file mode 100644 index 00000000..97b09674 --- /dev/null +++ b/demo/examples/applyUpdate.coffee @@ -0,0 +1,36 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) + .factory( 'datasource', + [ '$log', '$timeout' + + (console, $timeout)-> + + get = (index, count, success)-> + + result = [] + + start = Math.max(1, index) + end = Math.min(index + count-1, 3) + + if (start > end) + success result + else + for i in [start..end] + result.push "item #{i}" + success(result) + + {get} + + ]) + .controller('main', ($scope) -> + $scope.click = -> + $scope.adapter.applyUpdates( + 2, ["item 2", "two"] + #(item, scope) -> [item + ' *' + scope.$index] + ) + ) + +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/applyUpdate.js +### \ No newline at end of file diff --git a/demo/examples/applyUpdate.html b/demo/examples/applyUpdate.html new file mode 100644 index 00000000..3a6d5423 --- /dev/null +++ b/demo/examples/applyUpdate.html @@ -0,0 +1,20 @@ + + + + + Scroller Demo (multiple lists) + + + + + + + +
+
{{$index}}: {{item}}
+
+ + + + + \ No newline at end of file diff --git a/demo/examples/bootstrapWellClass.html b/demo/examples/bootstrapWellClass.html new file mode 100644 index 00000000..25b9b771 --- /dev/null +++ b/demo/examples/bootstrapWellClass.html @@ -0,0 +1,25 @@ + + + + + Scroller Demo (bootstrap's well css class) + + + + + + + + + + + +

is loading: {{loading}};       browse sample code

+

Bootstraped viewport

+ +
+
*{{item}}*
+
+ + + \ No newline at end of file diff --git a/demo/examples/cache.coffee b/demo/examples/cache.coffee new file mode 100644 index 00000000..01eae752 --- /dev/null +++ b/demo/examples/cache.coffee @@ -0,0 +1,76 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']).controller('mainController', + [ '$scope', '$log', '$timeout' + ($scope, console, $timeout)-> + datasource = {} + + datasource.cache = { + + initialize: () -> + this.isEnabled = true + this.items = {} + this.getPure = datasource.get + datasource.get = this.getCached + + getCached: (index, count, successCallback) -> + self = datasource.cache + + if self.isEnabled + return if self.getItems index, count, successCallback + return self.getPure index, count, (result) -> + self.saveItems index, count, result + successCallback result + + return self.getPure index, count, successCallback + + toggle: () -> + this.isEnabled = not this.isEnabled + this.items = {} + + saveItems: (index, count, resultItems) -> + for item, i in resultItems + if !this.items.hasOwnProperty(index + i) + this.items[index + i] = item + + getItems: (index, count, successCallback) -> + result = [] + isCached = true + + for i in [index..index + count - 1] by 1 + if not this.items.hasOwnProperty(i) + isCached = false + return + result.push this.items[i] + + successCallback result + return true + + } + + + #this method is not changed; it is the same as in non-cache case + datasource.get = (index, count, success)-> + $timeout () -> + result = [] + for i in [index..index + count - 1] + item = {} + item.content = "item ##{i}" + item.data = { + some: false + } + result.push item + success result + , 100 + + $scope.datasource = datasource + + + #don't forget to init cache + datasource.cache.initialize() + + ]) + +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/cache.js +### \ No newline at end of file diff --git a/demo/examples/cache.html b/demo/examples/cache.html new file mode 100644 index 00000000..0250f78f --- /dev/null +++ b/demo/examples/cache.html @@ -0,0 +1,63 @@ + + + + + Scroller Demo (cache option) + + + + + + + + + + + +
+

+ Cache is {{datasource.cache.isEnabled ? 'enabled' : 'disabled'}} + [toggle] +

+ +
+
*{{item.content}}*
+
+
+ +
+

+ All you need to start working with this cache is + datasource.cache.initialize() + in your datasource controller/service code. +

+ +

+ Base data retrieving method datasource.get() has not to be changed. + We don't touch its implementation. Nothing changes. +

+ +

+ You may disable/enable cache via datasource.cache.isEnabled property. + Toggle-link demonstrates this ability. +

+
+ +
+ + + \ No newline at end of file diff --git a/demo/examples/customviewport.coffee b/demo/examples/customviewport.coffee new file mode 100644 index 00000000..9999baa3 --- /dev/null +++ b/demo/examples/customviewport.coffee @@ -0,0 +1,24 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) + .factory( 'datasource', + [ '$log', '$timeout' + + (console, $timeout)-> + + get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count-1] + result.push "item ##{i}" + success(result) + 100 + ) + + {get} + + ]) +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/customviewport.js +### \ No newline at end of file diff --git a/demo/examples/customviewport.html b/demo/examples/customviewport.html new file mode 100644 index 00000000..6ad9e636 --- /dev/null +++ b/demo/examples/customviewport.html @@ -0,0 +1,30 @@ + + + + + Scroller Demo (multiple lists) + + + + + + + +

is loading: {{loading}};       browse sample code

+

The list

+
+
*one {{item}}*
+
+

One more list

+
+
+ *two {{item}}* + +   +
+
+ + + \ No newline at end of file diff --git a/demo/examples/listScroller.coffee b/demo/examples/listScroller.coffee new file mode 100644 index 00000000..c1ab3e49 --- /dev/null +++ b/demo/examples/listScroller.coffee @@ -0,0 +1,24 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) + .factory( 'datasource', + [ '$log', '$timeout' + + (console, $timeout)-> + + get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count-1] + result.push "item ##{i}" + success(result) + 100 + ) + + {get} + + ]) +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/listScroller.js +### \ No newline at end of file diff --git a/demo/examples/listScroller.html b/demo/examples/listScroller.html new file mode 100644 index 00000000..4c512833 --- /dev/null +++ b/demo/examples/listScroller.html @@ -0,0 +1,25 @@ + + + + + Scroller Demo (list based) + + + + + + + + +

is loading: {{loading}};       browse sample code

+
+ +
+ + \ No newline at end of file diff --git a/demo/examples/persistentScroll.coffee b/demo/examples/persistentScroll.coffee new file mode 100644 index 00000000..90256c81 --- /dev/null +++ b/demo/examples/persistentScroll.coffee @@ -0,0 +1,41 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) + .factory( 'datasource', + [ '$log', '$timeout', '$rootScope', '$location' + + (console, $timeout, $rootScope, $location)-> + + offset = parseInt($location.search().offset || '0') + + get = (index, count, success)-> + $timeout( + -> + actualIndex = index + offset + result = [] + + start = Math.max(-40, actualIndex) + end = Math.min(actualIndex + count-1, 100) + + if (start > end) + success result + else + for i in [start..end] + result.push "item #{i}" + success(result) + 100 + ) + + $rootScope.$watch (-> $rootScope.topVisible), + -> + if $rootScope.topVisible + $location.search('offset', $rootScope.topVisible.$index + offset) + $location.replace() + { + get + } + + ]) +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/persistentScroll.js +### \ No newline at end of file diff --git a/demo/examples/persistentScroll.html b/demo/examples/persistentScroll.html new file mode 100644 index 00000000..c1d83091 --- /dev/null +++ b/demo/examples/persistentScroll.html @@ -0,0 +1,29 @@ + + + + + Scroller Demo (list based) + + + + + + + + +

+ is loading: {{loading}};       browse sample code +
+ top visible: {{topVisible.$index}} +

+
+ +
+ + \ No newline at end of file diff --git a/demo/examples/positionedList.coffee b/demo/examples/positionedList.coffee new file mode 100644 index 00000000..596d0e8f --- /dev/null +++ b/demo/examples/positionedList.coffee @@ -0,0 +1,54 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) + .factory( 'datasource', + [ '$log', '$timeout', '$rootScope', '$location' + + (console, $timeout, $rootScope, $location)-> + + $rootScope.key = "" + + position = 0 + + data = [] + + for letter1 in 'abcdefghijk' + for letter2 in 'abcdefghijk' + for i in [0..9] + data.push("#{letter1}#{letter2}: 0#{i}") + + get = (index, count, success)-> + $timeout( + -> + actualIndex = index + position + + start = Math.max(0 - position, actualIndex) + end = Math.min(actualIndex + count-1, data.length) + + if (start > end) + success [] + else + success data.slice(start, end+1) + 100 + ) + + current = 0 + + $rootScope.$watch ( -> $rootScope.key), + -> + position = 0 + for record in data when $rootScope.key > record + position++ + current++ + + revision = -> current + + { + get + revision + } + + ]) +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/positionedList.js +### \ No newline at end of file diff --git a/demo/examples/positionedList.html b/demo/examples/positionedList.html new file mode 100644 index 00000000..ef1dcfc9 --- /dev/null +++ b/demo/examples/positionedList.html @@ -0,0 +1,27 @@ + + + + + Scroller Demo (list based) + + + + + + + + +

+ is loading: {{loading}};       browse sample code +
+ enter key value: +

+
+
{{$index}}: {{item}}
+
+ + \ No newline at end of file diff --git a/demo/examples/scopeDatasource.coffee b/demo/examples/scopeDatasource.coffee new file mode 100644 index 00000000..52c3878a --- /dev/null +++ b/demo/examples/scopeDatasource.coffee @@ -0,0 +1,26 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']).controller('mainController', + [ '$scope', '$log', '$timeout' + + ($scope, console, $timeout)-> + + datasource = {} + + datasource.get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count-1] + result.push "item ##{i}" + success(result) + 100 + ) + + $scope.datasource = datasource + + ]) + +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/scopeDatasource.js +### \ No newline at end of file diff --git a/demo/examples/scopeDatasource.html b/demo/examples/scopeDatasource.html new file mode 100644 index 00000000..8e487a3f --- /dev/null +++ b/demo/examples/scopeDatasource.html @@ -0,0 +1,23 @@ + + + + + Scroller Demo (datasource within scope) + + + + + + + + +

is loading: {{loading}};       browse sample code

+ +
+ +
+ + + \ No newline at end of file diff --git a/demo/examples/scrollBubblingPrevent.coffee b/demo/examples/scrollBubblingPrevent.coffee new file mode 100644 index 00000000..8bfabca7 --- /dev/null +++ b/demo/examples/scrollBubblingPrevent.coffee @@ -0,0 +1,29 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) + .factory( 'datasource', + [ '$log', '$timeout' + + (console, $timeout)-> + + min = -50 + max = 50 + + get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count-1] + if i < min or i > max + break + result.push "item ##{i}" + success(result) + 50 + ) + + {get} + + ]) +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/scrollBubblingPrevent.js +### \ No newline at end of file diff --git a/demo/examples/scrollBubblingPrevent.html b/demo/examples/scrollBubblingPrevent.html new file mode 100644 index 00000000..f1c497eb --- /dev/null +++ b/demo/examples/scrollBubblingPrevent.html @@ -0,0 +1,38 @@ + + + + + + Scroller Demo (scroll bubbles up only on eof/bof) + + + + + + + +

Scroll bubbles up only on eof/bof

+There are 100 elements in datasource indexes -50 through +50. Scrolling of the document begins only after bof/eof is hit. + +
+ + padding
padding
padding
padding
padding
padding
padding
padding
padding
+ padding
padding
padding
padding
padding
padding
padding
padding
padding
+ padding
padding
padding
padding
padding
padding
padding
padding
padding
+ padding
padding
padding
padding
padding
padding
padding
padding
padding
+ +
+ +
+ + padding
padding
padding
padding
padding
padding
padding
padding
padding
+ padding
padding
padding
padding
padding
padding
padding
padding
padding
+ padding
padding
padding
padding
padding
padding
padding
padding
padding
+ padding
padding
padding
padding
padding
padding
padding
padding
padding
+ +
+ + + diff --git a/demo/examples/tableScroller.coffee b/demo/examples/tableScroller.coffee new file mode 100644 index 00000000..2dcab448 --- /dev/null +++ b/demo/examples/tableScroller.coffee @@ -0,0 +1,24 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) + .factory( 'datasource', + [ '$log', '$timeout' + + (console, $timeout)-> + + get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count-1] + result.push "item ##{i}" + success(result) + 100 + ) + + {get} + + ]) +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/tableScroller.js +### \ No newline at end of file diff --git a/demo/examples/tableScroller.html b/demo/examples/tableScroller.html new file mode 100644 index 00000000..49a83db1 --- /dev/null +++ b/demo/examples/tableScroller.html @@ -0,0 +1,33 @@ + + + + + Scroller Demo (table based) + + + + + + + + +

is loading: {{loading}};       browse sample code

+ + + + + + + +
+ {{$index}} + + {{item}} +
+ + + \ No newline at end of file diff --git a/demo/examples/windowviewport.coffee b/demo/examples/windowviewport.coffee new file mode 100644 index 00000000..e728d079 --- /dev/null +++ b/demo/examples/windowviewport.coffee @@ -0,0 +1,24 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) + .factory( 'datasource', + [ '$log', '$timeout' + + (console, $timeout)-> + + get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count-1] + result.push "item ##{i}" + success(result) + 100 + ) + + {get} + + ]) +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/windowviewport.js +### \ No newline at end of file diff --git a/demo/examples/windowviewport.html b/demo/examples/windowviewport.html new file mode 100644 index 00000000..1e046975 --- /dev/null +++ b/demo/examples/windowviewport.html @@ -0,0 +1,25 @@ + + + + + Scroller Demo (entire window) + + + + + + + + +

is loading: {{loading}};       browse sample code

+

top visible: {{topItem}}

+
*{{item}}*
+ + \ No newline at end of file diff --git a/demo/examples/windowviewportInline.coffee b/demo/examples/windowviewportInline.coffee new file mode 100644 index 00000000..8610fcfa --- /dev/null +++ b/demo/examples/windowviewportInline.coffee @@ -0,0 +1,29 @@ +angular.module('application', ['ui.scroll', 'ui.scroll.jqlite']) +.factory('datasource', + [ '$log', '$timeout' + + (console, $timeout)-> + get = (index, count, success)-> + $timeout( + -> + result = [] + for i in [index..index + count - 1] + item = {} + if inlineDemo + item.width = inlineDemo.getWidth(i) + item.height = inlineDemo.getHeight(i) + item.color = inlineDemo.getColor(i) + item.content = "item ##{i}" + result.push item + success(result) + 100 + ) + + {get} + + ]) +angular.bootstrap(document, ["application"]) + +### +//# sourceURL=src/windowviewportInline.js +### \ No newline at end of file diff --git a/demo/examples/windowviewportInline.html b/demo/examples/windowviewportInline.html new file mode 100644 index 00000000..acad2ca2 --- /dev/null +++ b/demo/examples/windowviewportInline.html @@ -0,0 +1,92 @@ + + + + + Scroller Demo (entire window) + + + + + + + + + + + + + +

is loading: {{loading}};       browse sample code

+ +

top visible: {{topItem}}

+ + + + *{{item.content}}* + + + + \ No newline at end of file diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 00000000..3422ad41 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,26 @@ + + + + + Scroller Demo + + +

Scroller Examples

+ +read more... + + \ No newline at end of file diff --git a/dist/ui-scroll-jqlite.js b/dist/ui-scroll-jqlite.js new file mode 100644 index 00000000..fe5b9002 --- /dev/null +++ b/dist/ui-scroll-jqlite.js @@ -0,0 +1,230 @@ +(function () { +'use strict'; +angular.module('ui.scroll.jqlite', ['ui.scroll']).service('jqLiteExtras', [ + '$log', '$window', function(console, window) { + return { + registerFor: function(element) { + var convertToPx, css, getMeasurements, getStyle, getWidthHeight, isWindow, scrollTo; + css = angular.element.prototype.css; + element.prototype.css = function(name, value) { + var elem, self; + self = this; + elem = self[0]; + if (!(!elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style)) { + return css.call(self, name, value); + } + }; + isWindow = function(obj) { + return obj && obj.document && obj.location && obj.alert && obj.setInterval; + }; + scrollTo = function(self, direction, value) { + var elem, method, preserve, prop, _ref; + elem = self[0]; + _ref = { + top: ['scrollTop', 'pageYOffset', 'scrollLeft'], + left: ['scrollLeft', 'pageXOffset', 'scrollTop'] + }[direction], method = _ref[0], prop = _ref[1], preserve = _ref[2]; + if (isWindow(elem)) { + if (angular.isDefined(value)) { + return elem.scrollTo(self[preserve].call(self), value); + } else { + if (prop in elem) { + return elem[prop]; + } else { + return elem.document.documentElement[method]; + } + } + } else { + if (angular.isDefined(value)) { + return elem[method] = value; + } else { + return elem[method]; + } + } + }; + if (window.getComputedStyle) { + getStyle = function(elem) { + return window.getComputedStyle(elem, null); + }; + convertToPx = function(elem, value) { + return parseFloat(value); + }; + } else { + getStyle = function(elem) { + return elem.currentStyle; + }; + convertToPx = function(elem, value) { + var core_pnum, left, result, rnumnonpx, rs, rsLeft, style; + core_pnum = /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source; + rnumnonpx = new RegExp('^(' + core_pnum + ')(?!px)[a-z%]+$', 'i'); + if (!rnumnonpx.test(value)) { + return parseFloat(value); + } else { + style = elem.style; + left = style.left; + rs = elem.runtimeStyle; + rsLeft = rs && rs.left; + if (rs) { + rs.left = style.left; + } + style.left = value; + result = style.pixelLeft; + style.left = left; + if (rsLeft) { + rs.left = rsLeft; + } + return result; + } + }; + } + getMeasurements = function(elem, measure) { + var base, borderA, borderB, computedMarginA, computedMarginB, computedStyle, dirA, dirB, marginA, marginB, paddingA, paddingB, _ref; + if (isWindow(elem)) { + base = document.documentElement[{ + height: 'clientHeight', + width: 'clientWidth' + }[measure]]; + return { + base: base, + padding: 0, + border: 0, + margin: 0 + }; + } + _ref = { + width: [elem.offsetWidth, 'Left', 'Right'], + height: [elem.offsetHeight, 'Top', 'Bottom'] + }[measure], base = _ref[0], dirA = _ref[1], dirB = _ref[2]; + computedStyle = getStyle(elem); + paddingA = convertToPx(elem, computedStyle['padding' + dirA]) || 0; + paddingB = convertToPx(elem, computedStyle['padding' + dirB]) || 0; + borderA = convertToPx(elem, computedStyle['border' + dirA + 'Width']) || 0; + borderB = convertToPx(elem, computedStyle['border' + dirB + 'Width']) || 0; + computedMarginA = computedStyle['margin' + dirA]; + computedMarginB = computedStyle['margin' + dirB]; + marginA = convertToPx(elem, computedMarginA) || 0; + marginB = convertToPx(elem, computedMarginB) || 0; + return { + base: base, + padding: paddingA + paddingB, + border: borderA + borderB, + margin: marginA + marginB + }; + }; + getWidthHeight = function(elem, direction, measure) { + var computedStyle, measurements, result; + measurements = getMeasurements(elem, direction); + if (measurements.base > 0) { + return { + base: measurements.base - measurements.padding - measurements.border, + outer: measurements.base, + outerfull: measurements.base + measurements.margin + }[measure]; + } else { + computedStyle = getStyle(elem); + result = computedStyle[direction]; + if (result < 0 || result === null) { + result = elem.style[direction] || 0; + } + result = parseFloat(result) || 0; + return { + base: result - measurements.padding - measurements.border, + outer: result, + outerfull: result + measurements.padding + measurements.border + measurements.margin + }[measure]; + } + }; + return angular.forEach({ + before: function(newElem) { + var children, elem, i, parent, self, _i, _ref; + self = this; + elem = self[0]; + parent = self.parent(); + children = parent.contents(); + if (children[0] === elem) { + return parent.prepend(newElem); + } else { + for (i = _i = 1, _ref = children.length - 1; 1 <= _ref ? _i <= _ref : _i >= _ref; i = 1 <= _ref ? ++_i : --_i) { + if (children[i] === elem) { + angular.element(children[i - 1]).after(newElem); + return; + } + } + throw new Error('invalid DOM structure ' + elem.outerHTML); + } + }, + height: function(value) { + var self; + self = this; + if (angular.isDefined(value)) { + if (angular.isNumber(value)) { + value = value + 'px'; + } + return css.call(self, 'height', value); + } else { + return getWidthHeight(this[0], 'height', 'base'); + } + }, + outerHeight: function(option) { + return getWidthHeight(this[0], 'height', option ? 'outerfull' : 'outer'); + }, + /* + The offset setter method is not implemented + */ + + offset: function(value) { + var box, doc, docElem, elem, self, win; + self = this; + if (arguments.length) { + if (value === void 0) { + return self; + } else { + throw new Error('offset setter method is not implemented'); + } + } + box = { + top: 0, + left: 0 + }; + elem = self[0]; + doc = elem && elem.ownerDocument; + if (!doc) { + return; + } + docElem = doc.documentElement; + if (elem.getBoundingClientRect != null) { + box = elem.getBoundingClientRect(); + } + win = doc.defaultView || doc.parentWindow; + return { + top: box.top + (win.pageYOffset || docElem.scrollTop) - (docElem.clientTop || 0), + left: box.left + (win.pageXOffset || docElem.scrollLeft) - (docElem.clientLeft || 0) + }; + }, + scrollTop: function(value) { + return scrollTo(this, 'top', value); + }, + scrollLeft: function(value) { + return scrollTo(this, 'left', value); + } + }, function(value, key) { + if (!element.prototype[key]) { + return element.prototype[key] = value; + } + }); + } + }; + } +]).run([ + '$log', '$window', 'jqLiteExtras', function(console, window, jqLiteExtras) { + if (!window.jQuery) { + return jqLiteExtras.registerFor(angular.element); + } + } +]); + +/* +//# sourceURL=src/ui-scroll-jqlite.js +*/ + +}()); \ No newline at end of file diff --git a/dist/ui-scroll-jqlite.min.js b/dist/ui-scroll-jqlite.min.js new file mode 100644 index 00000000..6d007b28 --- /dev/null +++ b/dist/ui-scroll-jqlite.min.js @@ -0,0 +1 @@ +!function(){"use strict";angular.module("ui.scroll.jqlite",["ui.scroll"]).service("jqLiteExtras",["$log","$window",function(a,b){return{registerFor:function(a){var c,d,e,f,g,h,i;return d=angular.element.prototype.css,a.prototype.css=function(a,b){var c,e;return e=this,c=e[0],c&&3!==c.nodeType&&8!==c.nodeType&&c.style?d.call(e,a,b):void 0},h=function(a){return a&&a.document&&a.location&&a.alert&&a.setInterval},i=function(a,b,c){var d,e,f,g,i;return d=a[0],i={top:["scrollTop","pageYOffset","scrollLeft"],left:["scrollLeft","pageXOffset","scrollTop"]}[b],e=i[0],g=i[1],f=i[2],h(d)?angular.isDefined(c)?d.scrollTo(a[f].call(a),c):g in d?d[g]:d.document.documentElement[e]:angular.isDefined(c)?d[e]=c:d[e]},b.getComputedStyle?(f=function(a){return b.getComputedStyle(a,null)},c=function(a,b){return parseFloat(b)}):(f=function(a){return a.currentStyle},c=function(a,b){var c,d,e,f,g,h,i;return c=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,f=new RegExp("^("+c+")(?!px)[a-z%]+$","i"),f.test(b)?(i=a.style,d=i.left,g=a.runtimeStyle,h=g&&g.left,g&&(g.left=i.left),i.left=b,e=i.pixelLeft,i.left=d,h&&(g.left=h),e):parseFloat(b)}),e=function(a,b){var d,e,g,i,j,k,l,m,n,o,p,q,r;return h(a)?(d=document.documentElement[{height:"clientHeight",width:"clientWidth"}[b]],{base:d,padding:0,border:0,margin:0}):(r={width:[a.offsetWidth,"Left","Right"],height:[a.offsetHeight,"Top","Bottom"]}[b],d=r[0],l=r[1],m=r[2],k=f(a),p=c(a,k["padding"+l])||0,q=c(a,k["padding"+m])||0,e=c(a,k["border"+l+"Width"])||0,g=c(a,k["border"+m+"Width"])||0,i=k["margin"+l],j=k["margin"+m],n=c(a,i)||0,o=c(a,j)||0,{base:d,padding:p+q,border:e+g,margin:n+o})},g=function(a,b,c){var d,g,h;return g=e(a,b),g.base>0?{base:g.base-g.padding-g.border,outer:g.base,outerfull:g.base+g.margin}[c]:(d=f(a),h=d[b],(0>h||null===h)&&(h=a.style[b]||0),h=parseFloat(h)||0,{base:h-g.padding-g.border,outer:h,outerfull:h+g.padding+g.border+g.margin}[c])},angular.forEach({before:function(a){var b,c,d,e,f,g,h;if(f=this,c=f[0],e=f.parent(),b=e.contents(),b[0]===c)return e.prepend(a);for(d=g=1,h=b.length-1;h>=1?h>=g:g>=h;d=h>=1?++g:--g)if(b[d]===c)return void angular.element(b[d-1]).after(a);throw new Error("invalid DOM structure "+c.outerHTML)},height:function(a){var b;return b=this,angular.isDefined(a)?(angular.isNumber(a)&&(a+="px"),d.call(b,"height",a)):g(this[0],"height","base")},outerHeight:function(a){return g(this[0],"height",a?"outerfull":"outer")},offset:function(a){var b,c,d,e,f,g;if(f=this,arguments.length){if(void 0===a)return f;throw new Error("offset setter method is not implemented")}return b={top:0,left:0},e=f[0],(c=e&&e.ownerDocument)?(d=c.documentElement,null!=e.getBoundingClientRect&&(b=e.getBoundingClientRect()),g=c.defaultView||c.parentWindow,{top:b.top+(g.pageYOffset||d.scrollTop)-(d.clientTop||0),left:b.left+(g.pageXOffset||d.scrollLeft)-(d.clientLeft||0)}):void 0},scrollTop:function(a){return i(this,"top",a)},scrollLeft:function(a){return i(this,"left",a)}},function(b,c){return a.prototype[c]?void 0:a.prototype[c]=b})}}}]).run(["$log","$window","jqLiteExtras",function(a,b,c){return b.jQuery?void 0:c.registerFor(angular.element)}])}(); \ No newline at end of file diff --git a/dist/ui-scroll.js b/dist/ui-scroll.js new file mode 100644 index 00000000..19391d89 --- /dev/null +++ b/dist/ui-scroll.js @@ -0,0 +1,579 @@ +(function () { +'use strict'; +/*! +globals: angular, window + + List of used element methods available in JQuery but not in JQuery Lite + + element.before(elem) + element.height() + element.outerHeight(true) + element.height(value) = only for Top/Bottom padding elements + element.scrollTop() + element.scrollTop(value) +*/ + +angular.module('ui.scroll', []).directive('uiScrollViewport', function() { + return { + controller: [ + '$scope', '$element', function(scope, element) { + this.viewport = element; + return this; + } + ] + }; +}).directive('uiScroll', [ + '$log', '$injector', '$rootScope', '$timeout', '$q', '$parse', function(console, $injector, $rootScope, $timeout, $q, $parse) { + var $animate; + if ($injector.has && $injector.has('$animate')) { + $animate = $injector.get('$animate'); + } + return { + require: ['?^uiScrollViewport'], + transclude: 'element', + priority: 1000, + terminal: true, + compile: function(elementTemplate, attr, linker) { + return function($scope, element, $attr, controllers) { + var adapter, adapterOnScope, adjustBuffer, applyUpdate, bof, bottomVisiblePos, buffer, bufferPadding, bufferSize, builder, clipBottom, clipTop, datasource, datasourceName, dismissPendingRequests, enqueueFetch, eof, eventListener, fetch, finalize, first, insertElement, insertElementAnimated, insertItem, isDatasourceValid, itemName, loading, log, match, next, pending, reload, removeFromBuffer, removeItem, resizeAndScrollHandler, ridActual, scrollHeight, shouldLoadBottom, shouldLoadTop, topVisible, topVisiblePos, unsupportedMethod, viewport, viewportScope, wheelHandler; + log = console.debug || console.log; + if (!(match = $attr.uiScroll.match(/^\s*(\w+)\s+in\s+([\w\.]+)\s*$/))) { + throw new Error('Expected uiScroll in form of \'_item_ in _datasource_\' but got \' + $attr.uiScroll + \''); + } + itemName = match[1]; + datasourceName = match[2]; + datasource = $parse(datasourceName)($scope); + isDatasourceValid = function() { + return angular.isObject(datasource) && angular.isFunction(datasource.get); + }; + if (!isDatasourceValid()) { + datasource = $injector.get(datasourceName); + if (!isDatasourceValid()) { + throw new Error(datasourceName + ' is not a valid datasource'); + } + } + bufferSize = Math.max(3, +$attr.bufferSize || 10); + bufferPadding = function() { + return viewport.outerHeight() * Math.max(0.1, +$attr.padding || 0.1); + }; + scrollHeight = function(elem) { + var _ref; + return (_ref = elem[0].scrollHeight) != null ? _ref : elem[0].document.documentElement.scrollHeight; + }; + builder = null; + ridActual = 0; + first = 1; + next = 1; + buffer = []; + pending = []; + eof = false; + bof = false; + removeItem = $animate ? angular.version.minor === 2 ? function(wrapper) { + var deferred; + buffer.splice(buffer.indexOf(wrapper), 1); + deferred = $q.defer(); + $animate.leave(wrapper.element, function() { + wrapper.scope.$destroy(); + return deferred.resolve(); + }); + return [deferred.promise]; + } : function(wrapper) { + buffer.splice(buffer.indexOf(wrapper), 1); + return [ + ($animate.leave(wrapper.element)).then(function() { + return wrapper.scope.$destroy(); + }) + ]; + } : function(wrapper) { + buffer.splice(buffer.indexOf(wrapper), 1); + wrapper.element.remove(); + wrapper.scope.$destroy(); + return []; + }; + insertElement = function(newElement, previousElement) { + element.after.apply(previousElement, newElement); + return []; + }; + insertElementAnimated = $animate ? angular.version.minor === 2 ? function(newElement, previousElement) { + var deferred; + deferred = $q.defer(); + $animate.enter(newElement, element, previousElement, function() { + return deferred.resolve(); + }); + return [deferred.promise]; + } : function(newElement, previousElement) { + return [$animate.enter(newElement, element, previousElement)]; + } : insertElement; + linker($scope.$new(), function(template) { + var bottomPadding, padding, repeaterType, topPadding, viewport; + repeaterType = template[0].localName; + if (repeaterType === 'dl') { + throw new Error('ui-scroll directive does not support <' + template[0].localName + '> as a repeating tag: ' + template[0].outerHTML); + } + if (repeaterType !== 'li' && repeaterType !== 'tr') { + repeaterType = 'div'; + } + viewport = controllers[0] && controllers[0].viewport ? controllers[0].viewport : angular.element(window); + viewport.css({ + 'overflow-y': 'auto', + 'display': 'block' + }); + padding = function(repeaterType) { + var div, result, table; + switch (repeaterType) { + case 'tr': + table = angular.element('
'); + div = table.find('div'); + result = table.find('tr'); + result.paddingHeight = function() { + return div.height.apply(div, arguments); + }; + break; + default: + result = angular.element('<' + repeaterType + '>'); + result.paddingHeight = result.height; + } + return result; + }; + topPadding = padding(repeaterType); + element.before(topPadding); + bottomPadding = padding(repeaterType); + element.after(bottomPadding); + $scope.$on('$destroy', function() { + return template.remove(); + }); + return builder = { + viewport: viewport, + topPadding: function() { + return topPadding.paddingHeight.apply(topPadding, arguments); + }, + bottomPadding: function() { + return bottomPadding.paddingHeight.apply(bottomPadding, arguments); + }, + insertElement: function(e, sibling) { + return insertElement(e, sibling || topPadding); + }, + insertElementAnimated: function(e, sibling) { + return insertElementAnimated(e, sibling || topPadding); + }, + bottomDataPos: function() { + return scrollHeight(viewport) - bottomPadding.paddingHeight(); + }, + topDataPos: function() { + return topPadding.paddingHeight(); + } + }; + }); + viewport = builder.viewport; + viewportScope = viewport.scope() || $rootScope; + topVisible = function(item) { + adapter.topVisible = item.scope[itemName]; + adapter.topVisibleElement = item.element; + adapter.topVisibleScope = item.scope; + if ($attr.topVisible) { + $parse($attr.topVisible).assign(viewportScope, adapter.topVisible); + } + if ($attr.topVisibleElement) { + $parse($attr.topVisibleElement).assign(viewportScope, adapter.topVisibleElement); + } + if ($attr.topVisibleScope) { + $parse($attr.topVisibleScope).assign(viewportScope, adapter.topVisibleScope); + } + if (angular.isFunction(datasource.topVisible)) { + return datasource.topVisible(item); + } + }; + loading = function(value) { + adapter.isLoading = value; + if ($attr.isLoading) { + $parse($attr.isLoading).assign($scope, value); + } + if (angular.isFunction(datasource.loading)) { + return datasource.loading(value); + } + }; + removeFromBuffer = function(start, stop) { + var i, _i; + for (i = _i = start; start <= stop ? _i < stop : _i > stop; i = start <= stop ? ++_i : --_i) { + buffer[i].scope.$destroy(); + buffer[i].element.remove(); + } + return buffer.splice(start, stop - start); + }; + dismissPendingRequests = function() { + ridActual++; + return pending = []; + }; + reload = function() { + dismissPendingRequests(); + first = 1; + next = 1; + removeFromBuffer(0, buffer.length); + builder.topPadding(0); + builder.bottomPadding(0); + eof = false; + bof = false; + return adjustBuffer(ridActual); + }; + bottomVisiblePos = function() { + return viewport.scrollTop() + viewport.outerHeight(); + }; + topVisiblePos = function() { + return viewport.scrollTop(); + }; + shouldLoadBottom = function() { + return !eof && builder.bottomDataPos() < bottomVisiblePos() + bufferPadding(); + }; + clipBottom = function() { + var bottomHeight, i, item, itemHeight, itemTop, newRow, overage, rowTop, _i, _ref; + bottomHeight = 0; + overage = 0; + for (i = _i = _ref = buffer.length - 1; _ref <= 0 ? _i <= 0 : _i >= 0; i = _ref <= 0 ? ++_i : --_i) { + item = buffer[i]; + itemTop = item.element.offset().top; + newRow = rowTop !== itemTop; + rowTop = itemTop; + if (newRow) { + itemHeight = item.element.outerHeight(true); + } + if (builder.bottomDataPos() - bottomHeight - itemHeight > bottomVisiblePos() + bufferPadding()) { + if (newRow) { + bottomHeight += itemHeight; + } + overage++; + eof = false; + } else { + if (newRow) { + break; + } + overage++; + } + } + if (overage > 0) { + builder.bottomPadding(builder.bottomPadding() + bottomHeight); + removeFromBuffer(buffer.length - overage, buffer.length); + return next -= overage; + } + }; + shouldLoadTop = function() { + return !bof && (builder.topDataPos() > topVisiblePos() - bufferPadding()); + }; + clipTop = function() { + var item, itemHeight, itemTop, newRow, overage, rowTop, topHeight, _i, _len; + topHeight = 0; + overage = 0; + for (_i = 0, _len = buffer.length; _i < _len; _i++) { + item = buffer[_i]; + itemTop = item.element.offset().top; + newRow = rowTop !== itemTop; + rowTop = itemTop; + if (newRow) { + itemHeight = item.element.outerHeight(true); + } + if (builder.topDataPos() + topHeight + itemHeight < topVisiblePos() - bufferPadding()) { + if (newRow) { + topHeight += itemHeight; + } + overage++; + bof = false; + } else { + if (newRow) { + break; + } + overage++; + } + } + if (overage > 0) { + builder.topPadding(builder.topPadding() + topHeight); + removeFromBuffer(0, overage); + return first += overage; + } + }; + enqueueFetch = function(rid, direction) { + if (!adapter.isLoading) { + loading(true); + } + if (pending.push(direction) === 1) { + return fetch(rid); + } + }; + insertItem = function(operation, item) { + var itemScope, wrapper; + itemScope = $scope.$new(); + itemScope[itemName] = item; + wrapper = { + scope: itemScope + }; + linker(itemScope, function(clone) { + return wrapper.element = clone; + }); + if (operation % 1 === 0) { + wrapper.op = 'insert'; + return buffer.splice(operation, 0, wrapper); + } else { + wrapper.op = operation; + switch (operation) { + case 'append': + return buffer.push(wrapper); + case 'prepend': + return buffer.unshift(wrapper); + } + } + }; + adjustBuffer = function(rid, finalize) { + var i, promises, toBePrepended, toBeRemoved, wrapper, _i, _len; + promises = []; + toBePrepended = []; + toBeRemoved = []; + for (i = _i = 0, _len = buffer.length; _i < _len; i = ++_i) { + wrapper = buffer[i]; + switch (wrapper.op) { + case 'prepend': + toBePrepended.unshift(wrapper); + break; + case 'append': + if (i === 0) { + builder.insertElement(wrapper.element); + } else { + builder.insertElement(wrapper.element, buffer[i - 1].element); + } + builder.bottomPadding(Math.max(0, builder.bottomPadding() - wrapper.element.outerHeight(true))); + wrapper.op = 'none'; + break; + case 'insert': + if (i === 0) { + promises = promises.concat(builder.insertElementAnimated(wrapper.element)); + } else { + promises = promises.concat(builder.insertElementAnimated(wrapper.element, buffer[i - 1].element)); + } + builder.bottomPadding(Math.max(0, builder.bottomPadding() - wrapper.element.outerHeight(true))); + wrapper.op = 'none'; + break; + case 'remove': + toBeRemoved.push(wrapper); + } + } + return $timeout(function() { + var item, itemHeight, itemTop, newHeight, newRow, rowTop, topHeight, _j, _k, _l, _len1, _len2, _len3, _len4, _m; + for (_j = 0, _len1 = toBePrepended.length; _j < _len1; _j++) { + wrapper = toBePrepended[_j]; + builder.insertElement(wrapper.element); + newHeight = builder.topPadding() - wrapper.element.outerHeight(true); + if (newHeight >= 0) { + builder.topPadding(newHeight); + } else { + viewport.scrollTop(viewport.scrollTop() + wrapper.element.outerHeight(true)); + } + wrapper.op = 'none'; + } + for (_k = 0, _len2 = toBeRemoved.length; _k < _len2; _k++) { + wrapper = toBeRemoved[_k]; + promises = promises.concat(removeItem(wrapper)); + } + for (i = _l = 0, _len3 = buffer.length; _l < _len3; i = ++_l) { + item = buffer[i]; + item.scope.$index = first + i; + } + if (shouldLoadBottom()) { + enqueueFetch(rid, true); + } else { + if (shouldLoadTop()) { + enqueueFetch(rid, false); + } + } + if (finalize) { + finalize(rid); + } + if (pending.length === 0) { + topHeight = 0; + for (_m = 0, _len4 = buffer.length; _m < _len4; _m++) { + item = buffer[_m]; + itemTop = item.element.offset().top; + newRow = rowTop !== itemTop; + rowTop = itemTop; + if (newRow) { + itemHeight = item.element.outerHeight(true); + } + if (newRow && (builder.topDataPos() + topHeight + itemHeight < topVisiblePos())) { + topHeight += itemHeight; + } else { + if (newRow) { + topVisible(item); + } + break; + } + } + } + if (promises.length) { + return $q.all(promises).then(function() { + return adjustBuffer(rid); + }); + } + }); + }; + finalize = function(rid) { + return adjustBuffer(rid, function() { + pending.shift(); + if (pending.length === 0) { + return loading(false); + } else { + return fetch(rid); + } + }); + }; + fetch = function(rid) { + if (pending[0]) { + if (buffer.length && !shouldLoadBottom()) { + return finalize(rid); + } else { + return datasource.get(next, bufferSize, function(result) { + var item, _i, _len; + if ((rid && rid !== ridActual) || $scope.$$destroyed) { + return; + } + if (result.length < bufferSize) { + eof = true; + builder.bottomPadding(0); + } + if (result.length > 0) { + clipTop(); + for (_i = 0, _len = result.length; _i < _len; _i++) { + item = result[_i]; + ++next; + insertItem('append', item); + } + } + return finalize(rid); + }); + } + } else { + if (buffer.length && !shouldLoadTop()) { + return finalize(rid); + } else { + return datasource.get(first - bufferSize, bufferSize, function(result) { + var i, _i, _ref; + if ((rid && rid !== ridActual) || $scope.$$destroyed) { + return; + } + if (result.length < bufferSize) { + bof = true; + builder.topPadding(0); + } + if (result.length > 0) { + if (buffer.length) { + clipBottom(); + } + for (i = _i = _ref = result.length - 1; _ref <= 0 ? _i <= 0 : _i >= 0; i = _ref <= 0 ? ++_i : --_i) { + --first; + insertItem('prepend', result[i]); + } + } + return finalize(rid); + }); + } + } + }; + resizeAndScrollHandler = function() { + if (!$rootScope.$$phase && !adapter.isLoading) { + adjustBuffer(); + return $scope.$apply(); + } + }; + wheelHandler = function(event) { + var scrollTop, yMax; + scrollTop = viewport[0].scrollTop; + yMax = viewport[0].scrollHeight - viewport[0].clientHeight; + if ((scrollTop === 0 && !bof) || (scrollTop === yMax && !eof)) { + return event.preventDefault(); + } + }; + viewport.bind('resize', resizeAndScrollHandler); + viewport.bind('scroll', resizeAndScrollHandler); + viewport.bind('mousewheel', wheelHandler); + $scope.$watch(datasource.revision, reload); + $scope.$on('$destroy', function() { + var item, _i, _len; + for (_i = 0, _len = buffer.length; _i < _len; _i++) { + item = buffer[_i]; + item.scope.$destroy(); + item.element.remove(); + } + viewport.unbind('resize', resizeAndScrollHandler); + viewport.unbind('scroll', resizeAndScrollHandler); + return viewport.unbind('mousewheel', wheelHandler); + }); + adapter = {}; + adapter.isLoading = false; + adapter.reload = reload; + applyUpdate = function(wrapper, newItems) { + var i, keepIt, newItem, pos, _i, _len, _ref; + if (angular.isArray(newItems)) { + pos = (buffer.indexOf(wrapper)) + 1; + _ref = newItems.reverse(); + for (i = _i = 0, _len = _ref.length; _i < _len; i = ++_i) { + newItem = _ref[i]; + if (newItem === wrapper.scope[itemName]) { + keepIt = true; + pos--; + } else { + insertItem(pos, newItem); + } + } + if (!keepIt) { + return wrapper.op = 'remove'; + } + } + }; + adapter.applyUpdates = function(arg1, arg2) { + var bufferClone, i, wrapper, _i, _len, _ref; + dismissPendingRequests(); + if (angular.isFunction(arg1)) { + bufferClone = buffer.slice(0); + for (i = _i = 0, _len = bufferClone.length; _i < _len; i = ++_i) { + wrapper = bufferClone[i]; + applyUpdate(wrapper, arg1(wrapper.scope[itemName], wrapper.scope, wrapper.element)); + } + } else { + if (arg1 % 1 === 0) { + if ((0 <= (_ref = arg1 - first) && _ref < buffer.length)) { + applyUpdate(buffer[arg1 - first], arg2); + } + } else { + throw new Error('applyUpdates - ' + arg1 + ' is not a valid index'); + } + } + return adjustBuffer(ridActual); + }; + if ($attr.adapter) { + adapterOnScope = $parse($attr.adapter)($scope); + if (!adapterOnScope) { + $parse($attr.adapter).assign($scope, {}); + adapterOnScope = $parse($attr.adapter)($scope); + } + angular.extend(adapterOnScope, adapter); + adapter = adapterOnScope; + } + unsupportedMethod = function(token) { + throw new Error(token + ' event is no longer supported - use applyUpdates instead'); + }; + eventListener = datasource.scope ? datasource.scope.$new() : $scope.$new(); + eventListener.$on('insert.item', function() { + return unsupportedMethod('insert'); + }); + eventListener.$on('update.items', function() { + return unsupportedMethod('update'); + }); + return eventListener.$on('delete.items', function() { + return unsupportedMethod('delete'); + }); + }; + } + }; + } +]); + +/* +//# sourceURL=src/ui-scroll.js +*/ + +}()); \ No newline at end of file diff --git a/dist/ui-scroll.min.js b/dist/ui-scroll.min.js new file mode 100644 index 00000000..14414420 --- /dev/null +++ b/dist/ui-scroll.min.js @@ -0,0 +1 @@ +!function(){"use strict";angular.module("ui.scroll",[]).directive("uiScrollViewport",function(){return{controller:["$scope","$element",function(a,b){return this.viewport=b,this}]}}).directive("uiScroll",["$log","$injector","$rootScope","$timeout","$q","$parse",function(a,b,c,d,e,f){var g;return b.has&&b.has("$animate")&&(g=b.get("$animate")),{require:["?^uiScrollViewport"],transclude:"element",priority:1e3,terminal:!0,compile:function(h,i,j){return function(h,i,k,l){var m,n,o,p,q,r,s,t,u,v,w,x,y,z,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,$,_,aa,ba,ca;if(N=a.debug||a.log,!(O=k.uiScroll.match(/^\s*(\w+)\s+in\s+([\w\.]+)\s*$/)))throw new Error("Expected uiScroll in form of '_item_ in _datasource_' but got ' + $attr.uiScroll + '");if(L=O[1],z=O[2],y=f(z)(h),K=function(){return angular.isObject(y)&&angular.isFunction(y.get)},!K()&&(y=b.get(z),!K()))throw new Error(z+" is not a valid datasource");return u=Math.max(3,+k.bufferSize||10),t=function(){return aa.outerHeight()*Math.max(.1,+k.padding||.1)},W=function(a){var b;return null!=(b=a[0].scrollHeight)?b:a[0].document.documentElement.scrollHeight},v=null,V=0,G=1,P=1,s=[],Q=[],C=!1,q=!1,T=g?2===angular.version.minor?function(a){var b;return s.splice(s.indexOf(a),1),b=e.defer(),g.leave(a.element,function(){return a.scope.$destroy(),b.resolve()}),[b.promise]}:function(a){return s.splice(s.indexOf(a),1),[g.leave(a.element).then(function(){return a.scope.$destroy()})]}:function(a){return s.splice(s.indexOf(a),1),a.element.remove(),a.scope.$destroy(),[]},H=function(a,b){return i.after.apply(b,a),[]},I=g?2===angular.version.minor?function(a,b){var c;return c=e.defer(),g.enter(a,i,b,function(){return c.resolve()}),[c.promise]}:function(a,b){return[g.enter(a,i,b)]}:H,j(h.$new(),function(a){var b,c,d,e,f;if(d=a[0].localName,"dl"===d)throw new Error("ui-scroll directive does not support <"+a[0].localName+"> as a repeating tag: "+a[0].outerHTML);return"li"!==d&&"tr"!==d&&(d="div"),f=l[0]&&l[0].viewport?l[0].viewport:angular.element(window),f.css({"overflow-y":"auto",display:"block"}),c=function(a){var b,c,d;switch(a){case"tr":d=angular.element("
"),b=d.find("div"),c=d.find("tr"),c.paddingHeight=function(){return b.height.apply(b,arguments)};break;default:c=angular.element("<"+a+">"),c.paddingHeight=c.height}return c},e=c(d),i.before(e),b=c(d),i.after(b),h.$on("$destroy",function(){return a.remove()}),v={viewport:f,topPadding:function(){return e.paddingHeight.apply(e,arguments)},bottomPadding:function(){return b.paddingHeight.apply(b,arguments)},insertElement:function(a,b){return H(a,b||e)},insertElementAnimated:function(a,b){return I(a,b||e)},bottomDataPos:function(){return W(f)-b.paddingHeight()},topDataPos:function(){return e.paddingHeight()}}}),aa=v.viewport,ba=aa.scope()||c,Z=function(a){return m.topVisible=a.scope[L],m.topVisibleElement=a.element,m.topVisibleScope=a.scope,k.topVisible&&f(k.topVisible).assign(ba,m.topVisible),k.topVisibleElement&&f(k.topVisibleElement).assign(ba,m.topVisibleElement),k.topVisibleScope&&f(k.topVisibleScope).assign(ba,m.topVisibleScope),angular.isFunction(y.topVisible)?y.topVisible(a):void 0},M=function(a){return m.isLoading=a,k.isLoading&&f(k.isLoading).assign(h,a),angular.isFunction(y.loading)?y.loading(a):void 0},S=function(a,b){var c,d;for(c=d=a;b>=a?b>d:d>b;c=b>=a?++d:--d)s[c].scope.$destroy(),s[c].element.remove();return s.splice(a,b-a)},A=function(){return V++,Q=[]},R=function(){return A(),G=1,P=1,S(0,s.length),v.topPadding(0),v.bottomPadding(0),C=!1,q=!1,o(V)},r=function(){return aa.scrollTop()+aa.outerHeight()},$=function(){return aa.scrollTop()},X=function(){return!C&&v.bottomDataPos()=j?0>=i:i>=0;b=0>=j?++i:--i)if(c=s[b],e=c.element.offset().top,f=h!==e,h=e,f&&(d=c.element.outerHeight(!0)),v.bottomDataPos()-a-d>r()+t())f&&(a+=d),g++,C=!1;else{if(f)break;g++}return g>0?(v.bottomPadding(v.bottomPadding()+a),S(s.length-g,s.length),P-=g):void 0},Y=function(){return!q&&v.topDataPos()>$()-t()},x=function(){var a,b,c,d,e,f,g,h,i;for(g=0,e=0,h=0,i=s.length;i>h;h++)if(a=s[h],c=a.element.offset().top,d=f!==c,f=c,d&&(b=a.element.outerHeight(!0)),v.topDataPos()+g+b<$()-t())d&&(g+=b),e++,q=!1;else{if(d)break;e++}return e>0?(v.topPadding(v.topPadding()+g),S(0,e),G+=e):void 0},B=function(a,b){return m.isLoading||M(!0),1===Q.push(b)?E(a):void 0},J=function(a,b){var c,d;if(c=h.$new(),c[L]=b,d={scope:c},j(c,function(a){return d.element=a}),a%1===0)return d.op="insert",s.splice(a,0,d);switch(d.op=a,a){case"append":return s.push(d);case"prepend":return s.unshift(d)}},o=function(a,b){var c,f,g,h,i,j,k;for(f=[],g=[],h=[],c=j=0,k=s.length;k>j;c=++j)switch(i=s[c],i.op){case"prepend":g.unshift(i);break;case"append":0===c?v.insertElement(i.element):v.insertElement(i.element,s[c-1].element),v.bottomPadding(Math.max(0,v.bottomPadding()-i.element.outerHeight(!0))),i.op="none";break;case"insert":f=0===c?f.concat(v.insertElementAnimated(i.element)):f.concat(v.insertElementAnimated(i.element,s[c-1].element)),v.bottomPadding(Math.max(0,v.bottomPadding()-i.element.outerHeight(!0))),i.op="none";break;case"remove":h.push(i)}return d(function(){var d,j,k,l,m,n,p,q,r,t,u,w,x,y,z;for(q=0,u=g.length;u>q;q++)i=g[q],v.insertElement(i.element),l=v.topPadding()-i.element.outerHeight(!0),l>=0?v.topPadding(l):aa.scrollTop(aa.scrollTop()+i.element.outerHeight(!0)),i.op="none";for(r=0,w=h.length;w>r;r++)i=h[r],f=f.concat(T(i));for(c=t=0,x=s.length;x>t;c=++t)d=s[c],d.scope.$index=G+c;if(X()?B(a,!0):Y()&&B(a,!1),b&&b(a),0===Q.length)for(p=0,z=0,y=s.length;y>z;z++){if(d=s[z],k=d.element.offset().top,m=n!==k,n=k,m&&(j=d.element.outerHeight(!0)),!(m&&v.topDataPos()+p+j<$())){m&&Z(d);break}p+=j}return f.length?e.all(f).then(function(){return o(a)}):void 0})},F=function(a){return o(a,function(){return Q.shift(),0===Q.length?M(!1):E(a)})},E=function(a){return Q[0]?s.length&&!X()?F(a):y.get(P,u,function(b){var c,d,e;if(!(a&&a!==V||h.$$destroyed)){if(b.length0)for(x(),d=0,e=b.length;e>d;d++)c=b[d],++P,J("append",c);return F(a)}}):s.length&&!Y()?F(a):y.get(G-u,u,function(b){var c,d,e;if(!(a&&a!==V||h.$$destroyed)){if(b.length0)for(s.length&&w(),c=d=e=b.length-1;0>=e?0>=d:d>=0;c=0>=e?++d:--d)--G,J("prepend",b[c]);return F(a)}})},U=function(){return c.$$phase||m.isLoading?void 0:(o(),h.$apply())},ca=function(a){var b,c;return b=aa[0].scrollTop,c=aa[0].scrollHeight-aa[0].clientHeight,0===b&&!q||b===c&&!C?a.preventDefault():void 0},aa.bind("resize",U),aa.bind("scroll",U),aa.bind("mousewheel",ca),h.$watch(y.revision,R),h.$on("$destroy",function(){var a,b,c;for(b=0,c=s.length;c>b;b++)a=s[b],a.scope.$destroy(),a.element.remove();return aa.unbind("resize",U),aa.unbind("scroll",U),aa.unbind("mousewheel",ca)}),m={},m.isLoading=!1,m.reload=R,p=function(a,b){var c,d,e,f,g,h,i;if(angular.isArray(b)){for(f=s.indexOf(a)+1,i=b.reverse(),c=g=0,h=i.length;h>g;c=++g)e=i[c],e===a.scope[L]?(d=!0,f--):J(f,e);if(!d)return a.op="remove"}},m.applyUpdates=function(a,b){var c,d,e,f,g,h;if(A(),angular.isFunction(a))for(c=s.slice(0),d=f=0,g=c.length;g>f;d=++f)e=c[d],p(e,a(e.scope[L],e.scope,e.element));else{if(a%1!==0)throw new Error("applyUpdates - "+a+" is not a valid index");0<=(h=a-G)&&h + express = require 'express' + routes = require './server' + app = express() + + app.configure -> + app.use express.logger 'dev' + app.use express.bodyParser() + app.use express.methodOverride() + app.use express.errorHandler() + app.use express.static options.base + app.use app.router + routes app, options + [connect(app)] \ No newline at end of file diff --git a/server/server.coffee b/server/server.coffee new file mode 100644 index 00000000..692bbba6 --- /dev/null +++ b/server/server.coffee @@ -0,0 +1,4 @@ +module.exports = (app, dir) -> + + app.get '/', (req, res) -> + res.render "#{dir}/index.html" diff --git a/src/ui-scroll-jqlite.coffee b/src/ui-scroll-jqlite.coffee new file mode 100644 index 00000000..948a0358 --- /dev/null +++ b/src/ui-scroll-jqlite.coffee @@ -0,0 +1,205 @@ +angular.module('ui.scroll.jqlite', ['ui.scroll']) + +.service('jqLiteExtras', [ + '$log', '$window' + (console, window) -> + registerFor : (element) -> + + # angular implementation blows up if elem is the window + css = angular.element.prototype.css + element.prototype.css = (name, value) -> + self = this + elem = self[0] + css.call(self, name, value) unless !elem || elem.nodeType == 3 || elem.nodeType == 8 || !elem.style + + # as defined in angularjs v1.0.5 + isWindow = (obj) -> + obj && obj.document && obj.location && obj.alert && obj.setInterval + + scrollTo = (self, direction, value) -> + elem = self[0] + [method, prop, preserve] = { + top: ['scrollTop', 'pageYOffset', 'scrollLeft'] + left: ['scrollLeft', 'pageXOffset', 'scrollTop'] + }[direction] + if isWindow elem + if angular.isDefined value + elem.scrollTo self[preserve].call(self), value + else + if (prop of elem) + elem[ prop ] + else + elem.document.documentElement[ method ] + else + if angular.isDefined value + elem[method] = value + else + elem[method] + + + if window.getComputedStyle + getStyle = (elem) -> window.getComputedStyle(elem, null) + convertToPx = (elem, value) -> parseFloat value + else + getStyle = (elem) -> elem.currentStyle + convertToPx = (elem, value) -> + core_pnum = /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source + rnumnonpx = new RegExp( '^(' + core_pnum + ')(?!px)[a-z%]+$', 'i' ) + if !rnumnonpx.test(value) + parseFloat value + else + # ported from JQuery + style = elem.style + left = style.left + rs = elem.runtimeStyle + rsLeft = rs && rs.left + # put in the new values to get a computed style out + rs.left = style.left if rs + style.left = value + result = style.pixelLeft + style.left = left + rs.left = rsLeft if rsLeft + result + + + getMeasurements = (elem, measure) -> + if isWindow elem + base = document.documentElement[{height: 'clientHeight', width: 'clientWidth'}[measure]] + return { + base: base + padding: 0 + border: 0 + margin: 0 + } + + # Start with offset property + [base, dirA, dirB] = { + width: [elem.offsetWidth, 'Left', 'Right'] + height: [elem.offsetHeight, 'Top', 'Bottom'] + }[measure] + + computedStyle = getStyle( elem ) + paddingA = convertToPx(elem, computedStyle[ 'padding' + dirA ] ) || 0 + paddingB = convertToPx(elem, computedStyle[ 'padding' + dirB ] ) || 0 + borderA = convertToPx(elem, computedStyle[ 'border' + dirA + 'Width' ] ) || 0 + borderB = convertToPx(elem, computedStyle[ 'border' + dirB + 'Width' ] ) || 0 + computedMarginA = computedStyle[ 'margin' + dirA ] + computedMarginB = computedStyle[ 'margin' + dirB ] + + # I do not care for width for now, so this hack is irrelevant + #if ( !supportsPercentMargin ) + #computedMarginA = hackPercentMargin( elem, computedStyle, computedMarginA ) + #computedMarginB = hackPercentMargin( elem, computedStyle, computedMarginB ) + + marginA = convertToPx(elem, computedMarginA ) || 0 + marginB = convertToPx(elem, computedMarginB ) || 0 + + base: base + padding: paddingA + paddingB + border: borderA + borderB + margin: marginA + marginB + + + getWidthHeight = ( elem, direction, measure ) -> + measurements = getMeasurements(elem, direction) + if measurements.base > 0 + { + base: measurements.base - measurements.padding - measurements.border + outer: measurements.base + outerfull: measurements.base + measurements.margin + }[measure] + else + + #// Fall back to computed then uncomputed css if necessary + computedStyle = getStyle( elem ) + result = computedStyle[ direction ] + if ( result < 0 || result == null ) + result = elem.style[ direction ] || 0 + + #// Normalize "", auto, and prepare for extra + result = parseFloat( result ) || 0 + + { + base: result - measurements.padding - measurements.border + outer: result + outerfull: result + measurements.padding + measurements.border + measurements.margin + }[measure] + + # define missing methods + angular.forEach { + + before: (newElem) -> + self = this + elem = self[0] + parent = self.parent() + children = parent.contents() + if children[0] == elem + parent.prepend newElem + else + for i in [1..children.length-1] + if children[i] == elem + angular.element(children[i-1]).after newElem + return + throw new Error 'invalid DOM structure ' + elem.outerHTML + + height: (value) -> + self = this + if angular.isDefined value + if angular.isNumber value + value = value + 'px' + css.call(self, 'height', value) + + else + getWidthHeight(this[0], 'height', 'base') + + outerHeight: (option) -> + getWidthHeight(this[0], 'height', if option then 'outerfull' else 'outer') + + ### +The offset setter method is not implemented + ### + + offset: (value)-> + self = this + if arguments.length + return if value == undefined + self + else + # TODO: implement setter + throw new Error 'offset setter method is not implemented' + + box = {top:0, left:0} + elem = self[0] + doc = elem && elem.ownerDocument + if !doc + return + + docElem = doc.documentElement + + # TODO: Make sure it's not a disconnected DOM node + + box = elem.getBoundingClientRect() if elem.getBoundingClientRect? + win = doc.defaultView || doc.parentWindow + top: box.top + ( win.pageYOffset || docElem.scrollTop ) - ( docElem.clientTop || 0 ), + left: box.left + ( win.pageXOffset || docElem.scrollLeft ) - ( docElem.clientLeft || 0 ) + + scrollTop: (value) -> + scrollTo this, 'top', value + + scrollLeft: (value) -> + scrollTo this, 'left', value + + }, (value, key) -> + element.prototype[key] = value unless element.prototype[key] + +]) + +.run [ + '$log', '$window', 'jqLiteExtras' + (console, window, jqLiteExtras) -> + jqLiteExtras.registerFor angular.element unless window.jQuery +] + +### +//# sourceURL=src/ui-scroll-jqlite.js +### \ No newline at end of file diff --git a/src/ui-scroll.coffee b/src/ui-scroll.coffee new file mode 100644 index 00000000..09637706 --- /dev/null +++ b/src/ui-scroll.coffee @@ -0,0 +1,490 @@ +###! +globals: angular, window + + List of used element methods available in JQuery but not in JQuery Lite + + element.before(elem) + element.height() + element.outerHeight(true) + element.height(value) = only for Top/Bottom padding elements + element.scrollTop() + element.scrollTop(value) + +### + +angular.module('ui.scroll', []) + +.directive( 'uiScrollViewport', -> + controller: [ + '$scope', '$element' + (scope, element) -> + this.viewport = element + this + ] +) + +.directive( 'uiScroll', [ + '$log', '$injector', '$rootScope', '$timeout', '$q', '$parse' + (console, $injector, $rootScope, $timeout, $q, $parse) -> + + $animate = $injector.get('$animate') if $injector.has && $injector.has('$animate') + + require: ['?^uiScrollViewport'] + transclude: 'element' + priority: 1000 + terminal: true + + compile: (elementTemplate, attr, linker) -> + ($scope, element, $attr, controllers) -> + + log = console.debug || console.log + + unless match = $attr.uiScroll.match(/^\s*(\w+)\s+in\s+([\w\.]+)\s*$/) + throw new Error 'Expected uiScroll in form of \'_item_ in _datasource_\' but got \' + $attr.uiScroll + \'' + itemName = match[1] + datasourceName = match[2] + + datasource = $parse(datasourceName)($scope) + isDatasourceValid = () -> angular.isObject(datasource) and angular.isFunction(datasource.get) + if !isDatasourceValid() # then try to inject datasource as service + datasource = $injector.get(datasourceName) + if !isDatasourceValid() + throw new Error datasourceName + ' is not a valid datasource' + + bufferSize = Math.max(3, +$attr.bufferSize || 10) + bufferPadding = -> viewport.outerHeight() * Math.max(0.1, +$attr.padding || 0.1) # some extra space to initiate preload + + scrollHeight = (elem)-> + elem[0].scrollHeight ? elem[0].document.documentElement.scrollHeight + + # initial settings + + builder = null + ridActual = 0 # current data revision id + first = 1 + next = 1 + buffer = [] + pending = [] + eof = false + bof = false + + # Element manipulation routines + + removeItem = + if $animate + if angular.version.minor == 2 + (wrapper) -> + buffer.splice buffer.indexOf(wrapper), 1 + deferred = $q.defer() + $animate.leave wrapper.element, -> + wrapper.scope.$destroy() + deferred.resolve() + [deferred.promise] + else + (wrapper) -> + buffer.splice buffer.indexOf(wrapper), 1 + [($animate.leave wrapper.element).then -> + wrapper.scope.$destroy() + ] + else + (wrapper) -> + buffer.splice buffer.indexOf(wrapper), 1 + wrapper.element.remove() + wrapper.scope.$destroy() + [] + + insertElement = + (newElement, previousElement) -> + element.after.apply(previousElement, newElement) + [] + + insertElementAnimated = + if $animate + if angular.version.minor == 2 + (newElement, previousElement) -> + deferred = $q.defer() + $animate.enter newElement, element, previousElement, -> deferred.resolve() + [deferred.promise] + else + (newElement, previousElement) -> + [$animate.enter newElement, element, previousElement] + + else insertElement + + # Element builder + # + # Calling linker is the only way I found to get access to the tag name of the template + # to prevent the directive scope from pollution a new scope is created and destroyed + # right after the repeaterHandler creation is completed + linker $scope.$new(), (template) -> + + repeaterType = template[0].localName + if repeaterType in ['dl'] + throw new Error 'ui-scroll directive does not support <' + template[0].localName + '> as a repeating tag: ' + template[0].outerHTML + repeaterType = 'div' if repeaterType not in ['li', 'tr'] + + viewport = if controllers[0] and controllers[0].viewport then controllers[0].viewport else angular.element(window) + viewport.css({'overflow-y': 'auto', 'display': 'block'}) + + padding = (repeaterType)-> + switch repeaterType + when 'tr' + table = angular.element('
') + div = table.find('div') + result = table.find('tr') + result.paddingHeight = -> div.height.apply(div, arguments) + else + result = angular.element('<' + repeaterType + '>') + result.paddingHeight = result.height + result + + topPadding = padding(repeaterType) + element.before topPadding + + bottomPadding = padding(repeaterType) + element.after bottomPadding + + $scope.$on '$destroy', -> template.remove() + + builder = + viewport: viewport + topPadding: -> topPadding.paddingHeight.apply(topPadding, arguments) + bottomPadding: -> bottomPadding.paddingHeight.apply(bottomPadding, arguments) + insertElement: (e, sibling) -> insertElement(e, sibling || topPadding) + insertElementAnimated: (e, sibling) -> insertElementAnimated(e, sibling || topPadding) + bottomDataPos: -> + scrollHeight(viewport) - bottomPadding.paddingHeight() + topDataPos: -> + topPadding.paddingHeight() + + viewport = builder.viewport + + viewportScope = viewport.scope() || $rootScope + + topVisible = (item) -> + adapter.topVisible = item.scope[itemName] + adapter.topVisibleElement = item.element + adapter.topVisibleScope = item.scope + $parse($attr.topVisible).assign(viewportScope, adapter.topVisible) if $attr.topVisible + $parse($attr.topVisibleElement).assign(viewportScope, adapter.topVisibleElement) if $attr.topVisibleElement + $parse($attr.topVisibleScope).assign(viewportScope, adapter.topVisibleScope) if $attr.topVisibleScope + datasource.topVisible(item) if angular.isFunction(datasource.topVisible) + + loading = (value) -> + adapter.isLoading = value + $parse($attr.isLoading).assign($scope, value) if $attr.isLoading + datasource.loading(value) if angular.isFunction(datasource.loading) + + #removes items from start (including) through stop (excluding) + removeFromBuffer = (start, stop)-> + for i in [start...stop] + buffer[i].scope.$destroy() + buffer[i].element.remove() + buffer.splice start, stop - start + + dismissPendingRequests = () -> + ridActual++ + pending = [] + + reload = -> + dismissPendingRequests() + first = 1 + next = 1 + removeFromBuffer(0, buffer.length) + builder.topPadding(0) + builder.bottomPadding(0) + eof = false + bof = false + adjustBuffer ridActual + + bottomVisiblePos = -> + viewport.scrollTop() + viewport.outerHeight() + + topVisiblePos = -> + viewport.scrollTop() + + shouldLoadBottom = -> + !eof && builder.bottomDataPos() < bottomVisiblePos() + bufferPadding() + + clipBottom = -> + # clip the invisible items off the bottom + bottomHeight = 0 #builder.bottomPadding() + overage = 0 + + for i in [buffer.length-1..0] + item = buffer[i] + itemTop = item.element.offset().top + newRow = rowTop isnt itemTop + rowTop = itemTop + itemHeight = item.element.outerHeight(true) if newRow + if (builder.bottomDataPos() - bottomHeight - itemHeight > bottomVisiblePos() + bufferPadding()) + bottomHeight += itemHeight if newRow + overage++ + eof = false + else + break if newRow + overage++ + + if overage > 0 + builder.bottomPadding(builder.bottomPadding() + bottomHeight) + removeFromBuffer(buffer.length - overage, buffer.length) + next -= overage + #log 'clipped off bottom ' + overage + ' bottom padding ' + builder.bottomPadding() + + shouldLoadTop = -> + !bof && (builder.topDataPos() > topVisiblePos() - bufferPadding()) + + clipTop = -> + # clip the invisible items off the top + topHeight = 0 + overage = 0 + for item in buffer + itemTop = item.element.offset().top + newRow = rowTop isnt itemTop + rowTop = itemTop + itemHeight = item.element.outerHeight(true) if newRow + if (builder.topDataPos() + topHeight + itemHeight < topVisiblePos() - bufferPadding()) + topHeight += itemHeight if newRow + overage++ + bof = false + else + break if newRow + overage++ + if overage > 0 + builder.topPadding(builder.topPadding() + topHeight) + removeFromBuffer(0, overage) + first += overage + #log 'clipped off top ' + overage + ' top padding ' + builder.topPadding() + + enqueueFetch = (rid, direction)-> + if (!adapter.isLoading) + loading(true) + if pending.push(direction) == 1 + fetch(rid) + + insertItem = (operation, item) -> + itemScope = $scope.$new() + itemScope[itemName] = item + wrapper = + scope: itemScope + + linker itemScope, (clone) -> + wrapper.element = clone + + # operations: 'append', 'prepend', 'insert', 'remove', 'update', 'none' + if (operation%1 == 0) # it is an insert + wrapper.op = 'insert' + buffer.splice operation, 0, wrapper + else + wrapper.op = operation + switch operation + when 'append' then buffer.push wrapper + when 'prepend' then buffer.unshift wrapper + + adjustBuffer = (rid, finalize) -> + + promises = [] + toBePrepended = [] + toBeRemoved = [] + + for wrapper, i in buffer + switch wrapper.op + when 'prepend' then toBePrepended.unshift wrapper + when 'append' + if (i == 0) + builder.insertElement wrapper.element + else + builder.insertElement wrapper.element, buffer[i-1].element + builder.bottomPadding(Math.max(0,builder.bottomPadding() - wrapper.element.outerHeight(true))) + wrapper.op = 'none' + when 'insert' + if (i == 0) + promises = promises.concat (builder.insertElementAnimated wrapper.element) + else + promises = promises.concat (builder.insertElementAnimated wrapper.element, buffer[i-1].element) + builder.bottomPadding(Math.max(0,builder.bottomPadding() - wrapper.element.outerHeight(true))) + wrapper.op = 'none' + when 'remove' then toBeRemoved.push wrapper + + # We need the item bindings to be processed before we can do adjustment + $timeout -> + for wrapper in toBePrepended + builder.insertElement wrapper.element + # an element is inserted at the top + newHeight = builder.topPadding() - wrapper.element.outerHeight(true) + # adjust padding to prevent it from visually pushing everything down + if newHeight >= 0 + # if possible, reduce topPadding + builder.topPadding(newHeight) + else + # if not, increment scrollTop + viewport.scrollTop(viewport.scrollTop() + wrapper.element.outerHeight(true)) + wrapper.op = 'none' + + for wrapper in toBeRemoved + promises = promises.concat (removeItem wrapper) + + # re-index the buffer + item.scope.$index = first + i for item,i in buffer + + #log "top {actual=#{builder.topDataPos()} visible from=#{topVisiblePos()} bottom {visible through=#{bottomVisiblePos()} actual=#{builder.bottomDataPos()}}" + if shouldLoadBottom() + enqueueFetch(rid, true) + else + enqueueFetch(rid, false) if shouldLoadTop() + finalize(rid) if finalize + if pending.length == 0 + topHeight = 0 + for item in buffer + itemTop = item.element.offset().top + newRow = rowTop isnt itemTop + rowTop = itemTop + itemHeight = item.element.outerHeight(true) if newRow + if newRow and (builder.topDataPos() + topHeight + itemHeight < topVisiblePos()) + topHeight += itemHeight + else + topVisible(item) if newRow + break + # the promise from the timeout should be added to promises array + # I just could not make promises work with the jasmine tests + if (promises.length) + $q.all(promises).then -> + #log "Animation completed rid #{rid}" + adjustBuffer rid + + finalize = (rid) -> + adjustBuffer rid, -> + pending.shift() + if pending.length == 0 + loading(false) + else + fetch(rid) + + fetch = (rid) -> + #log "Running fetch... #{{true:'bottom', false: 'top'}[direction]} pending #{pending.length}" + if pending[0] # scrolling down + if buffer.length && !shouldLoadBottom() + finalize rid + else + #log "appending... requested #{bufferSize} records starting from #{next}" + datasource.get next, bufferSize, + (result) -> + return if (rid and rid isnt ridActual) or $scope.$$destroyed + if result.length < bufferSize + eof = true + builder.bottomPadding(0) + #log 'eof is reached' + if result.length > 0 + clipTop() + for item in result + ++next + insertItem 'append', item + #log 'appended: requested ' + bufferSize + ' received ' + result.length + ' buffer size ' + buffer.length + ' first ' + first + ' next ' + next + finalize rid + else + if buffer.length && !shouldLoadTop() + finalize rid + else + #log "prepending... requested #{size} records starting from #{start}" + datasource.get first-bufferSize, bufferSize, + (result) -> + return if (rid and rid isnt ridActual) or $scope.$$destroyed + if result.length < bufferSize + bof = true + builder.topPadding(0) + #log 'bof is reached' + if result.length > 0 + clipBottom() if buffer.length + for i in [result.length-1..0] + --first + insertItem 'prepend', result[i] + #log 'prepended: requested ' + bufferSize + ' received ' + result.length + ' buffer size ' + buffer.length + ' first ' + first + ' next ' + next + finalize rid + + + # events and bindings + + resizeAndScrollHandler = -> + if !$rootScope.$$phase && !adapter.isLoading + adjustBuffer() + $scope.$apply() + + wheelHandler = (event) -> + scrollTop = viewport[0].scrollTop + yMax = viewport[0].scrollHeight - viewport[0].clientHeight + if (scrollTop is 0 and not bof) or (scrollTop is yMax and not eof) + event.preventDefault() + + viewport.bind 'resize', resizeAndScrollHandler + viewport.bind 'scroll', resizeAndScrollHandler + viewport.bind 'mousewheel', wheelHandler + + $scope.$watch datasource.revision, reload + + $scope.$on '$destroy', -> + for item in buffer + item.scope.$destroy() + item.element.remove() + viewport.unbind 'resize', resizeAndScrollHandler + viewport.unbind 'scroll', resizeAndScrollHandler + viewport.unbind 'mousewheel', wheelHandler + + + # adapter setup + + adapter = {} + adapter.isLoading = false + adapter.reload = reload + + applyUpdate = (wrapper, newItems) -> + if angular.isArray newItems + pos = (buffer.indexOf wrapper) + 1 + for newItem,i in newItems.reverse() + if newItem == wrapper.scope[itemName] + keepIt = true; + pos-- + else + insertItem pos, newItem + unless keepIt + wrapper.op = 'remove' + + adapter.applyUpdates = (arg1, arg2) -> + dismissPendingRequests() + if angular.isFunction arg1 + # arg1 is the updater function, arg2 is ignored + bufferClone = buffer.slice(0) + for wrapper,i in bufferClone # we need to do it on the buffer clone, because buffer content + # may change as we iterate through + applyUpdate wrapper, arg1(wrapper.scope[itemName], wrapper.scope, wrapper.element) + else + # arg1 is item index, arg2 is the newItems array + if arg1%1 == 0 # checking if it is an integer + if 0 <= arg1-first < buffer.length + applyUpdate buffer[arg1 - first], arg2 + else + throw new Error 'applyUpdates - ' + arg1 + ' is not a valid index' + adjustBuffer ridActual + + if $attr.adapter # so we have an adapter on $scope + adapterOnScope = $parse($attr.adapter)($scope) + if not adapterOnScope + $parse($attr.adapter).assign($scope, {}) + adapterOnScope = $parse($attr.adapter)($scope) + angular.extend(adapterOnScope, adapter) + adapter = adapterOnScope + + + # update events (deprecated since v1.1.0, unsupported since 1.2.0) + + unsupportedMethod = (token) -> + throw new Error token + ' event is no longer supported - use applyUpdates instead' + eventListener = if datasource.scope then datasource.scope.$new() else $scope.$new() + eventListener.$on 'insert.item', -> unsupportedMethod('insert') + eventListener.$on 'update.items', -> unsupportedMethod('update') + eventListener.$on 'delete.items', -> unsupportedMethod('delete') + +]) + +### +//# sourceURL=src/ui-scroll.js +### \ No newline at end of file diff --git a/test.dat b/test.dat new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/test.dat @@ -0,0 +1 @@ +test diff --git a/test/ScrollerSpec.js b/test/ScrollerSpec.js new file mode 100644 index 00000000..7c3bfaf7 --- /dev/null +++ b/test/ScrollerSpec.js @@ -0,0 +1,1672 @@ +/*global describe, beforeEach, module, inject, it, spyOn, expect, $ */ +describe('uiScroll', function () { + 'use strict'; + + angular.module('ui.scroll.test', []) + .factory('myEmptyDatasource', [ + '$log', '$timeout', '$rootScope', function () { + return { + get: function (index, count, success) { + success([]); + } + }; + } + ]) + + .factory('myOnePageDatasource', [ + '$log', '$timeout', '$rootScope', function () { + return { + get: function (index, count, success) { + if (index === 1) { + success(['one', 'two', 'three']); + } else { + success([]); + } + } + }; + } + ]) + + .factory('myObjectDatasource', [ + '$log', '$timeout', '$rootScope', function () { + return { + get: function (index, count, success) { + if (index === 1) { + success([{text: 'one'}, {text: 'two'}, {text: 'three'}]); + } else { + success([]); + } + } + }; + } + ]) + + .factory('myMultipageDatasource', [ + '$log', '$timeout', '$rootScope', function () { + return { + get: function (index, count, success) { + var result = []; + for (var i = index; i < index + count; i++) { + if (i > 0 && i <= 20) + result.push('item' + i); + } + success(result); + } + }; + } + ]) + + .factory('anotherDatasource', [ + '$log', '$timeout', '$rootScope', function () { + return { + get: function (index, count, success) { + var result = []; + for (var i = index; i < index + count; i++) { + if (i > -3 && i < 1) + result.push('item' + i); + } + success(result); + } + }; + } + ]) + + .factory('myEdgeDatasource', [ + '$log', '$timeout', '$rootScope', function () { + return { + get: function (index, count, success) { + var result = []; + for (var i = index; i < index + count; i++) { + if (i > -6 && i <= 6) + result.push('item' + i); + } + success(result); + } + }; + } + ]) + + .factory('myDatasourceToPreventScrollBubbling', [ + '$log', '$timeout', '$rootScope', function () { + return { + get: function (index, count, success) { + var result = []; + for (var i = index; i < index + count; i++) { + if (i < -6 || i > 20) { + break; + } + result.push('item' + i); + } + success(result); + } + }; + } + ]); + + beforeEach(module('ui.scroll')); + beforeEach(module('ui.scroll.test')); + + var createHtml = function (settings) { + var viewportStyle = ' style="height:' + (settings.viewportHeight || 200) + 'px"'; + var itemStyle = settings.itemHeight ? ' style="height:' + settings.itemHeight + 'px"' : ''; + var bufferSize = settings.bufferSize ? ' buffer-size="' + settings.bufferSize + '"' : ''; + var isLoading = settings.isLoading ? ' is-loading="' + settings.isLoading + '"' : ''; + var adapter = settings.adapter ? ' adapter="' + settings.adapter + '"' : ''; + var template = settings.template ? settings.template : '{{$index}}: {{item}}'; + return '
' + + '
' + + template + + '
' + + '
'; + }; + + var runTest = function (scrollSettings, run, options) { + inject(function ($rootScope, $compile, $window, $timeout) { + var scroller = angular.element(createHtml(scrollSettings)); + var scope = $rootScope.$new(); + angular.element(document).find('body').append(scroller); + + $compile(scroller)(scope); + + scope.$apply(); + $timeout.flush(); + + run(scroller, scope, $timeout); + + scroller.remove(); + + if (options && typeof options.cleanupTest === 'function') { + options.cleanupTest(scroller, scope, $timeout); + } + } + ); + }; + + + describe('basic setup', function () { + var scrollSettings = {datasource: 'myEmptyDatasource'}; + + it('should bind to window scroll and resize events and unbind them after the scope is destroyed', function () { + spyOn($.fn, 'bind').andCallThrough(); + spyOn($.fn, 'unbind').andCallThrough(); + runTest(scrollSettings, + function (viewport) { + expect($.fn.bind.calls.length).toBe(3); + expect($.fn.bind.calls[0].args[0]).toBe('resize'); + expect($.fn.bind.calls[0].object[0]).toBe(viewport[0]); + expect($.fn.bind.calls[1].args[0]).toBe('scroll'); + expect($.fn.bind.calls[1].object[0]).toBe(viewport[0]); + expect($.fn.bind.calls[2].args[0]).toBe('mousewheel'); + expect($.fn.bind.calls[2].object[0]).toBe(viewport[0]); + }, { + cleanupTest: function (viewport, scope, $timeout) { + $timeout(function () { + expect($.fn.unbind.calls.length).toBe(3); + expect($.fn.unbind.calls[0].args[0]).toBe('resize'); + expect($.fn.unbind.calls[0].object[0]).toBe(viewport[0]); + expect($.fn.unbind.calls[1].args[0]).toBe('scroll'); + expect($.fn.unbind.calls[1].object[0]).toBe(viewport[0]); + expect($.fn.unbind.calls[2].args[0]).toBe('mousewheel'); + expect($.fn.unbind.calls[2].object[0]).toBe(viewport[0]); + }); + } + } + ); + }); + + it('should create 2 divs of 0 height', function () { + runTest(scrollSettings, + function (viewport) { + expect(viewport.children().length).toBe(2); + + var topPadding = viewport.children()[0]; + expect(topPadding.tagName.toLowerCase()).toBe('div'); + expect(angular.element(topPadding).css('height')).toBe('0px'); + + var bottomPadding = viewport.children()[1]; + expect(bottomPadding.tagName.toLowerCase()).toBe('div'); + expect(angular.element(bottomPadding).css('height')).toBe('0px'); + } + ); + }); + + it('should call get on the datasource 1 time ', function () { + var spy; + inject(function (myEmptyDatasource) { + spy = spyOn(myEmptyDatasource, 'get').andCallThrough(); + }); + runTest(scrollSettings, + function () { + expect(spy.calls.length).toBe(2); + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(-9); + } + ); + }); + } + ); + + + describe('datasource with only 3 elements', function () { + var scrollSettings = {datasource: 'myOnePageDatasource'}; + + it('should create 3 divs with data (+ 2 padding divs)', function () { + runTest(scrollSettings, + function (viewport) { + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three'); + } + ); + }); + + it('should call get on the datasource 2 times ', function () { + var spy; + inject(function (myOnePageDatasource) { + spy = spyOn(myOnePageDatasource, 'get').andCallThrough(); + runTest(scrollSettings, + function () { + expect(spy.calls.length).toBe(2); + expect(spy.calls[0].args[0]).toBe(1); // gets 3 rows (with eof) + expect(spy.calls[1].args[0]).toBe(-9); // gets 0 rows (and bof) + }); + }); + }); + }); + + + describe('applyUpdates tests', function () { + var scrollSettings = {datasource: 'myOnePageDatasource', adapter: 'adapter'}; + + it('should create adapter object', function () { + runTest(scrollSettings, + function (viewport, scope) { + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should update rows in place', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item, scope) { + return [item + ' *' + scope.$index]; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one *1'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two *2'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three *3'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one *1'); + } + ); + }); + + it('should update selected (first) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item, scope) { + if (item === 'one') + return [item + ' *' + scope.$index]; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one *1'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one *1'); + } + ); + }); + + it('should update selected (middle) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item, scope) { + if (item === 'two') + return [item + ' *' + scope.$index]; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two *2'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should update selected (last) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item, scope) { + if (item === 'three') + return [item + ' *' + scope.$index]; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three *3'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should delete selected (first) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item) { + if (item === 'one') + return []; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(4); + + var row2 = viewport.children()[1]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('1: two'); + + var row3 = viewport.children()[2]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('2: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: two'); + } + ); + }); + + it('should delete selected (middle) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item) { + if (item === 'two') + return []; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(4); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should delete selected (last) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item) { + if (item === 'three') + return []; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(4); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should insert a new element before selected (first) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item) { + if (item === 'one') + return ['before one', item]; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(6); + + var row0 = viewport.children()[1]; + expect(row0.tagName.toLowerCase()).toBe('div'); + expect(row0.innerHTML).toBe('1: before one'); + + var row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: one'); + + var row2 = viewport.children()[3]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('3: two'); + + var row3 = viewport.children()[4]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('4: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: before one'); + } + ); + }); + + it('should insert a new element after selected (first) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item) { + if (item === 'one') + return [item, 'after one']; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(6); + + var row0 = viewport.children()[1]; + expect(row0.tagName.toLowerCase()).toBe('div'); + expect(row0.innerHTML).toBe('1: one'); + + var row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: after one'); + + var row2 = viewport.children()[3]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('3: two'); + + var row3 = viewport.children()[4]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('4: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should insert a new element before selected (middle) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item) { + if (item === 'two') + return ['before two', item]; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(6); + + var row0 = viewport.children()[1]; + expect(row0.tagName.toLowerCase()).toBe('div'); + expect(row0.innerHTML).toBe('1: one'); + + var row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: before two'); + + var row2 = viewport.children()[3]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('3: two'); + + var row3 = viewport.children()[4]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('4: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should insert a new element after selected (last) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item) { + if (item === 'three') + return [item, 'after three']; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(6); + + var row0 = viewport.children()[1]; + expect(row0.tagName.toLowerCase()).toBe('div'); + expect(row0.innerHTML).toBe('1: one'); + + var row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: two'); + + var row2 = viewport.children()[3]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('3: three'); + + var row3 = viewport.children()[4]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('4: after three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + }); + + + describe('applyUpdates tests (index based)', function () { + var scrollSettings = {datasource: 'myOnePageDatasource', adapter: 'adapter'}; + + it('should update selected (first) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(1, ['one *1']); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one *1'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one *1'); + } + ); + }); + + it('should ignore out of bound indexes', function () { + runTest(scrollSettings, + function (viewport, scope) { + + scope.adapter.applyUpdates(0, ['invalid']); + + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should ignore out of bound indexes 2', function () { + runTest(scrollSettings, + function (viewport, scope) { + + scope.adapter.applyUpdates(4, ['invalid']); + + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should update selected (middle) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(2, ['two *2']); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two *2'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should update selected (last) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(3, ['three *3']); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three *3'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should delete selected (first) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(1, []); + + $timeout.flush(); + + expect(viewport.children().length).toBe(4); + + var row2 = viewport.children()[1]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('1: two'); + + var row3 = viewport.children()[2]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('2: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: two'); + } + ); + }); + + it('should delete selected (middle) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(2, []); + + $timeout.flush(); + + expect(viewport.children().length).toBe(4); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should delete selected (last) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(3, []); + + $timeout.flush(); + + expect(viewport.children().length).toBe(4); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should insert a new element before selected (first) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(1, ['before one', 'one']); + + $timeout.flush(); + + expect(viewport.children().length).toBe(6); + + var row0 = viewport.children()[1]; + expect(row0.tagName.toLowerCase()).toBe('div'); + expect(row0.innerHTML).toBe('1: before one'); + + var row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: one'); + + var row2 = viewport.children()[3]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('3: two'); + + var row3 = viewport.children()[4]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('4: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: before one'); + } + ); + }); + + it('should insert a new element after selected (first) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(1, ['one', 'after one']); + + $timeout.flush(); + + expect(viewport.children().length).toBe(6); + + var row0 = viewport.children()[1]; + expect(row0.tagName.toLowerCase()).toBe('div'); + expect(row0.innerHTML).toBe('1: one'); + + var row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: after one'); + + var row2 = viewport.children()[3]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('3: two'); + + var row3 = viewport.children()[4]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('4: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should insert a new element before selected (middle) row', function () { + + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(2, ['before two', 'two']); + + $timeout.flush(); + + expect(viewport.children().length).toBe(6); + + var row0 = viewport.children()[1]; + expect(row0.tagName.toLowerCase()).toBe('div'); + expect(row0.innerHTML).toBe('1: one'); + + var row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: before two'); + + var row2 = viewport.children()[3]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('3: two'); + + var row3 = viewport.children()[4]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('4: three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + + it('should insert a new element after selected (last) row', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates(3, ['three', 'after three']); + + $timeout.flush(); + + expect(viewport.children().length).toBe(6); + + var row0 = viewport.children()[1]; + expect(row0.tagName.toLowerCase()).toBe('div'); + expect(row0.innerHTML).toBe('1: one'); + + var row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: two'); + + var row2 = viewport.children()[3]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('3: three'); + + var row3 = viewport.children()[4]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('4: after three'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one'); + } + ); + }); + }); + + + describe('applyUpdates tests with object items', function () { + var scrollSettings = { + datasource: 'myObjectDatasource', + adapter: 'adapter', + template: '{{$index}}: {{item.text}}' + }; + + it('should update existing item inplace', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item, scope) { + item.text += ' *' + scope.$index; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one *1'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two *2'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three *3'); + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one *1'); + } + ); + }); + + it('should replace existing item with an updated one', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item, scope) { + return [ + { + text: item.text + ' *' + scope.$index + } + ]; + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(5); + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one *1'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('2: two *2'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('3: three *3'); + + + expect(scope.adapter).toBeTruthy(); + expect(scope.adapter.topVisibleElement[0].innerHTML).toBe('1: one *1'); + } + ); + }); + + it('should preserve the order of inserted items', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item, scope) { + if (scope.$index == 1) { + item.text += ' *' + scope.$index; + return [ + {text: item.text + ' before 1'}, + {text: item.text + ' before 2'}, + item, + {text: item.text + ' after 1'}, + {text: item.text + ' after 2'} + ]; + } + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(9); + var row1, row2, row3; + + row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one *1 before 1'); + + row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: one *1 before 2'); + + row1 = viewport.children()[3]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('3: one *1'); + + row1 = viewport.children()[4]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('4: one *1 after 1'); + + row1 = viewport.children()[5]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('5: one *1 after 2'); + + row2 = viewport.children()[6]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('6: two'); + + row3 = viewport.children()[7]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('7: three'); + + + expect(scope.adapter).toBeTruthy(); + } + ); + }); + + it('should preserve the order of inserted items 2', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + scope.adapter.applyUpdates( + function (item, scope) { + if (scope.$index == 2) { + item.text += ' *' + scope.$index; + return [ + {text: item.text + ' before 1'}, + {text: item.text + ' before 2'}, + item, + {text: item.text + ' after 1'}, + {text: item.text + ' after 2'} + ]; + } + } + ); + + $timeout.flush(); + + expect(viewport.children().length).toBe(9); + var row1, row2, row3; + + row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('1: one'); + + row1 = viewport.children()[2]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('2: two *2 before 1'); + + row1 = viewport.children()[3]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('3: two *2 before 2'); + + row2 = viewport.children()[4]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('4: two *2'); + + row1 = viewport.children()[5]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('5: two *2 after 1'); + + row1 = viewport.children()[6]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('6: two *2 after 2'); + + row3 = viewport.children()[7]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('7: three'); + + expect(scope.adapter).toBeTruthy(); + } + ); + }); + }); + + + describe('datasource with only 3 elements (negative index)', function () { + var scrollSettings = { datasource: 'anotherDatasource' }; + it('should create 3 divs with data (+ 2 padding divs)', function () { + runTest(scrollSettings, + function (viewport) { + expect(viewport.children().length).toBe(5); + + var row1 = viewport.children()[1]; + expect(row1.tagName.toLowerCase()).toBe('div'); + expect(row1.innerHTML).toBe('-2: item-2'); + + var row2 = viewport.children()[2]; + expect(row2.tagName.toLowerCase()).toBe('div'); + expect(row2.innerHTML).toBe('-1: item-1'); + + var row3 = viewport.children()[3]; + expect(row3.tagName.toLowerCase()).toBe('div'); + expect(row3.innerHTML).toBe('0: item0'); + } + ); + }); + + it('should call get on the datasource 2 times ', function () { + var spy; + inject(function (anotherDatasource) { + spy = spyOn(anotherDatasource, 'get').andCallThrough(); + runTest(scrollSettings, + function () { + expect(spy.calls.length).toBe(2); + + expect(spy.calls[0].args[0]).toBe(1); // gets 0 rows (and eof) + expect(spy.calls[1].args[0]).toBe(-9); // gets 3 rows (and bof) + }); + }); + }); + }); + + + describe('datasource with 20 elements and buffer size 3 - constrained viewport', function () { + var scrollSettings = { datasource: 'myMultipageDatasource', itemHeight: 40, bufferSize: 3 }; + + it('should create 6 divs with data (+ 2 padding divs)', function () { + runTest(scrollSettings, + function (viewport) { + expect(viewport.children().length).toBe(8); + expect(viewport.scrollTop()).toBe(0); + expect(viewport.children().css('height')).toBe('0px'); + expect(angular.element(viewport.children()[7]).css('height')).toBe('0px'); + + for (var i = 1; i < 7; i++) { + var row = viewport.children()[i]; + expect(row.tagName.toLowerCase()).toBe('div'); + expect(row.innerHTML).toBe(i + ': item' + i); + } + } + ); + }); + + it('should call get on the datasource 3 times ', function () { + var spy; + inject(function (myMultipageDatasource) { + spy = spyOn(myMultipageDatasource, 'get').andCallThrough(); + }); + runTest(scrollSettings, + function () { + expect(spy.calls.length).toBe(3); + + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(4); + expect(spy.calls[2].args[0]).toBe(-2); + } + ); + }); + + it('should create 3 more divs (9 divs total) with data (+ 2 padding divs)', function () { + runTest(scrollSettings, + function (viewport) { + viewport.scrollTop(100); + viewport.trigger('scroll'); + inject(function ($timeout) { + $timeout.flush(); + expect(viewport.children().length).toBe(11); + expect(viewport.scrollTop()).toBe(40); + expect(viewport.children().css('height')).toBe('0px'); + expect(angular.element(viewport.children()[10]).css('height')).toBe('0px'); + + for (var i = 1; i < 10; i++) { + var row = viewport.children()[i]; + expect(row.tagName.toLowerCase()).toBe('div'); + expect(row.innerHTML).toBe(i + ': item' + i); + } + }); + } + ); + }); + + it('should call get on the datasource 1 extra time (4 total) ', function () { + var spy; + inject(function (myMultipageDatasource) { + spy = spyOn(myMultipageDatasource, 'get').andCallThrough(); + }); + runTest(scrollSettings, + function (viewport, scope, $timeout) { + viewport.scrollTop(100); + viewport.trigger('scroll'); + $timeout.flush(); + + expect(spy.calls.length).toBe(4); + + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(4); + expect(spy.calls[2].args[0]).toBe(-2); + expect(spy.calls[3].args[0]).toBe(7); + } + ); + }); + + it('should clip 3 divs from the top and add 3 more divs to the bottom (9 divs total) (+ 2 padding divs)', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + viewport.scrollTop(100); + viewport.trigger('scroll'); + $timeout.flush(); + + viewport.scrollTop(400); + viewport.trigger('scroll'); + $timeout.flush(); + + expect(viewport.children().length).toBe(11); + expect(viewport.scrollTop()).toBe(160); + expect(viewport.children().css('height')).toBe('120px'); + expect(angular.element(viewport.children()[10]).css('height')).toBe('0px'); + + for (var i = 1; i < 10; i++) { + var row = viewport.children()[i]; + expect(row.tagName.toLowerCase()).toBe('div'); + expect(row.innerHTML).toBe((i + 3) + ': item' + (i + 3)); + } + } + ); + }); + + it('should call get on the datasource 1 more time (4 total) ', function () { + var spy; + inject(function (myMultipageDatasource) { + spy = spyOn(myMultipageDatasource, 'get').andCallThrough(); + }); + runTest(scrollSettings, + function (viewport, scope, $timeout) { + + viewport.scrollTop(100); + viewport.trigger('scroll'); + $timeout.flush(); + + viewport.scrollTop(400); + viewport.trigger('scroll'); + $timeout.flush(); + + expect(spy.calls.length).toBe(5); + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(4); + expect(spy.calls[2].args[0]).toBe(-2); + expect(spy.calls[3].args[0]).toBe(7); + expect(spy.calls[4].args[0]).toBe(10); + } + ); + }); + + it('should re-add 3 divs at the top and clip 3 divs from the bottom (9 divs total) (+ 2 padding divs)', function () { + runTest(scrollSettings, + function (viewport, scope, $timeout) { + var flush = $timeout.flush; + + viewport.scrollTop(100); + viewport.trigger('scroll'); + flush(); + + viewport.scrollTop(400); + viewport.trigger('scroll'); + flush(); + + viewport.scrollTop(0); + viewport.trigger('scroll'); + flush(); + + expect(viewport.children().length).toBe(8); + expect(viewport.scrollTop()).toBe(0); + expect(viewport.children().css('height')).toBe('0px'); + expect(angular.element(viewport.children()[7]).css('height')).toBe('240px'); + + for (var i = 1; i < 7; i++) { + var row = viewport.children()[i]; + expect(row.tagName.toLowerCase()).toBe('div'); + expect(row.innerHTML).toBe((i) + ': item' + (i)); + } + } + ); + }); + + it('should call get on the datasource 1 more time (4 total) ', function () { + var spy; + inject(function (myMultipageDatasource) { + spy = spyOn(myMultipageDatasource, 'get').andCallThrough(); + }); + runTest(scrollSettings, + function (viewport, scope, $timeout) { + var flush = $timeout.flush; + + viewport.scrollTop(100); + viewport.trigger('scroll'); + flush(); + + viewport.scrollTop(400); + viewport.trigger('scroll'); + flush(); + + viewport.scrollTop(0); + viewport.trigger('scroll'); + flush(); + + expect(spy.calls.length).toBe(7); + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(4); + expect(spy.calls[2].args[0]).toBe(-2); + expect(spy.calls[3].args[0]).toBe(7); + expect(spy.calls[4].args[0]).toBe(10); + expect(spy.calls[5].args[0]).toBe(1); + expect(spy.calls[6].args[0]).toBe(-2); + + } + ); + }); + }); + + describe('datasource with 12 elements and buffer size 3 (fold/edge cases)', function () { + var itemsCount = 12, buffer = 3, itemHeight = 20; + + it('[full frame] should call get on the datasource 4 (12/3) times + 2 additional times (with empty result)', function () { + var spy; + var viewportHeight = itemsCount * itemHeight; + + inject(function (myEdgeDatasource) { + spy = spyOn(myEdgeDatasource, 'get').andCallThrough(); + }); + + runTest( + { + datasource: 'myEdgeDatasource', + bufferSize: buffer, + viewportHeight: viewportHeight, + itemHeight: itemHeight + }, + function () { + expect(spy.calls.length).toBe(parseInt(itemsCount / buffer, 10) + 2); + + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(4); + expect(spy.calls[2].args[0]).toBe(7); + expect(spy.calls[3].args[0]).toBe(-2); + expect(spy.calls[4].args[0]).toBe(-5); + expect(spy.calls[5].args[0]).toBe(-8); + } + ); + }); + + it('[fold frame] should call get on the datasource 3 times', function () { + var spy; + var viewportHeight = buffer * itemHeight; + + inject(function (myEdgeDatasource) { + spy = spyOn(myEdgeDatasource, 'get').andCallThrough(); + }); + + runTest( + { + datasource: 'myEdgeDatasource', + bufferSize: buffer, + viewportHeight: viewportHeight, + itemHeight: itemHeight + }, + function () { + expect(spy.calls.length).toBe(3); + + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(4); + expect(spy.calls[2].args[0]).toBe(-2); + } + ); + }); + + it('[fold frame, scroll down] should call get on the datasource 1 extra time', function () { + var spy; + var viewportHeight = buffer * itemHeight; + + inject(function (myEdgeDatasource) { + spy = spyOn(myEdgeDatasource, 'get').andCallThrough(); + }); + + runTest( + { + datasource: 'myEdgeDatasource', + bufferSize: buffer, + viewportHeight: viewportHeight, + itemHeight: itemHeight + }, + function (viewport, scope, $timeout) { + var flush = $timeout.flush; + viewport.scrollTop(viewportHeight + itemHeight); + viewport.trigger('scroll'); + flush(); + viewport.scrollTop(viewportHeight + itemHeight * 2); + viewport.trigger('scroll'); + flush(); + expect(flush).toThrow(); + + expect(spy.calls.length).toBe(4); + + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(4); //last full + expect(spy.calls[2].args[0]).toBe(-2); + expect(spy.calls[3].args[0]).toBe(5); //empty + + } + ); + }); + + it('[fold frame, scroll up] should call get on the datasource 2 extra times', function () { + var spy; + var viewportHeight = buffer * itemHeight; + + inject(function (myEdgeDatasource) { + spy = spyOn(myEdgeDatasource, 'get').andCallThrough(); + }); + + runTest( + { + datasource: 'myEdgeDatasource', + bufferSize: buffer, + viewportHeight: viewportHeight, + itemHeight: itemHeight + }, + function (viewport, scope, $timeout) { + var flush = $timeout.flush; + + viewport.scrollTop(0); //first full, scroll to -2 + viewport.trigger('scroll'); + flush(); + + viewport.scrollTop(0); //last full, scroll to -5, bof is reached + viewport.trigger('scroll'); + flush(); + + expect(flush).toThrow(); + viewport.scrollTop(0); //empty, no scroll occurred (-8) + viewport.trigger('scroll'); + flush(); + + expect(flush).toThrow(); + + expect(spy.calls.length).toBe(5); + expect(spy.calls[0].args[0]).toBe(1); + expect(spy.calls[1].args[0]).toBe(4); + expect(spy.calls[2].args[0]).toBe(-2); //first full + expect(spy.calls[3].args[0]).toBe(-5); //last full + expect(spy.calls[4].args[0]).toBe(-8); //empty + } + ); + }); + }); + + + describe('prevent unwanted scroll bubbling', function () { + var scrollSettings = { datasource: 'myDatasourceToPreventScrollBubbling', bufferSize: 3, viewportHeight: 300 }; + var documentScrollBubblingCount = 0; + + var incrementDocumentScrollCount = function (event) { + event = event.originalEvent || event; + if (!event.defaultPrevented) { + documentScrollBubblingCount++; + } + }; + var getNewWheelEvent = function () { + var event = document.createEvent('MouseEvents'); + event.initEvent('mousewheel', true, true); + event.wheelDelta = 120; + return event; + }; + + it('should prevent wheel-event bubbling until bof is reached', function () { + var spy; + + inject(function (myDatasourceToPreventScrollBubbling) { + spy = spyOn(myDatasourceToPreventScrollBubbling, 'get').andCallThrough(); + }); + + runTest(scrollSettings, + function (viewport, scope, $timeout) { + var wheelEventElement = viewport[0]; + var flush = $timeout.flush; + + angular.element(document.body).bind('mousewheel', incrementDocumentScrollCount); //spy for wheel-events bubbling + + //simulate multiple wheel-scroll events within viewport + + wheelEventElement.dispatchEvent(getNewWheelEvent()); //preventDefault will not occurred but the document will not scroll because of viewport will be scrolled + expect(documentScrollBubblingCount).toBe(1); + + viewport.scrollTop(0); + viewport.trigger('scroll'); + + wheelEventElement.dispatchEvent(getNewWheelEvent()); //now we are at the top but preventDefault is occurred because of bof will be reached only after next scroll trigger + expect(documentScrollBubblingCount).toBe(1); //here! the only one prevented wheel-event + + flush(); + + wheelEventElement.dispatchEvent(getNewWheelEvent()); //preventDefault will not occurred but document will not scroll because of viewport will be scrolled + expect(documentScrollBubblingCount).toBe(2); + + viewport.scrollTop(0); + viewport.trigger('scroll'); //bof will be reached right after that + + flush(); + + wheelEventElement.dispatchEvent(getNewWheelEvent()); //preventDefault will not occurred because of we are at the top and bof is reached + expect(documentScrollBubblingCount).toBe(3); + + expect(flush).toThrow(); //there is no new data, bof is reached + + wheelEventElement.dispatchEvent(getNewWheelEvent()); //preventDefault will not occurred because of we are at the top and bof is reached + expect(documentScrollBubblingCount).toBe(4); + + }, { + cleanupTest: function () { + angular.element(document.body).unbind('mousewheel', incrementDocumentScrollCount); + } + } + ); + }); + }); + + + describe('isLoading property: deep access and sync', function () { + + it('should get isLoading as an adapter property', function () { + runTest({datasource: 'myOnePageDatasource', adapter: 'container.sub.adapter'}, + function (viewport, scope) { + expect(!!scope.container && !!scope.container.sub && !!scope.container.sub.adapter).toBe(true); + expect(typeof scope.container.sub.adapter.isLoading).toBe('boolean'); + } + ); + }); + + it('should get isLoading as a scope property', function () { + runTest({datasource: 'myOnePageDatasource', isLoading: 'container.sub.isLoading'}, + function (viewport, scope) { + expect(!!scope.container && !!scope.container.sub).toBe(true); + expect(typeof scope.container.sub.isLoading).toBe('boolean'); + } + ); + }); + + it('should sync scope-isLoading with adapter-isLoading', function () { + runTest({ + datasource: 'myMultipageDatasource', + itemHeight: 40, + bufferSize: 3, + adapter: 'container1.adapter', + isLoading: 'container2.isLoading' + }, + function (viewport, scope, $timeout) { + var isLoadingChangeCount = 0; + + expect(!!scope.container1 && !!scope.container1.adapter && !!scope.container2).toBe(true); + + scope.$watch('container2.isLoading', function(newValue, oldValue) { + switch(++isLoadingChangeCount) { + case 1: expect(newValue).toBe(false); expect(oldValue).toBe(false); break; + case 2: expect(newValue).toBe(true); expect(oldValue).toBe(false); break; + case 3: expect(newValue).toBe(false); expect(oldValue).toBe(true); break; + } + expect(scope.container1.adapter.isLoading).toBe(newValue); + }); + + viewport.scrollTop(100); + viewport.trigger('scroll'); + $timeout.flush(); + + expect(isLoadingChangeCount).toBe(3); + } + ); + }); + + }); + + +}); \ No newline at end of file diff --git a/test/jqliteExtrasSpec.js b/test/jqliteExtrasSpec.js new file mode 100644 index 00000000..b49798c4 --- /dev/null +++ b/test/jqliteExtrasSpec.js @@ -0,0 +1,185 @@ +describe('\njqLite: testing against jQuery\n', function () { + 'use strict'; + + var sandbox = angular.element('
'); + + var extras; + + beforeEach(module('ui.scroll.jqlite')); + beforeEach(function(){ + angular.element(document).find('body').append(sandbox = angular.element('
')); + inject(function(jqLiteExtras) { + extras = function(){}; + jqLiteExtras.registerFor(extras); + }); + }); + + afterEach(function() {sandbox.remove();}); + + describe('height() getter for window\n', function() { + it('should work for window element', function() { + var element = angular.element(window); + expect(extras.prototype.height.call(element)).toBe(element.height()); + }); + }); + + describe('getters height() and outerHeight()\n', function () { + + function createElement(element) { + var result = angular.element(element); + sandbox.append(result); + return result; + } + + angular.forEach( + [ + '
some text
', + '
some text (height in em)
', + '
some text height in px
', + '
some text w border
', + '
some text w border
', + '
some text w padding
', + '
some text w padding
', + '
some text w margin
', + '
some text w margin
' + ], function(element) { + + it('should be the same as jQuery height() for ' + element, function() { + (function(element) { + expect(extras.prototype.height.call(element)).toBe(element.height()); + })(createElement(element)); + } + ); + + it ('should be the same as jQuery outerHeight() for ' + element, function() { + (function(element) { + expect(extras.prototype.outerHeight.call(element)).toBe(element.outerHeight()); + })(createElement(element)); + } + ); + + it ('should be the same as jQuery outerHeight(true) for ' + element, function() { + (function(element) { + expect(extras.prototype.outerHeight.call(element, true)).toBe(element.outerHeight(true)); + })(createElement(element)); + } + ); + + } + + ); + }); + + describe('height(value) setter\n', function () { + + function createElement(element) { + var result = angular.element(element); + sandbox.append(result); + return result; + } + + angular.forEach( + [ + '
some text
', + '
some text (height in em)
', + '
some text height in px
', + '
some text w border
', + '
some text w border
', + '
some text w padding
', + '
some text w padding
', + '
some text w margin
', + '
some text w margin
', + '
some text w margin
', + '
some text w line height
' + ], function(element) { + + /*function validateHeight(element) { + expect(extras.prototype.height.call(element)).toBe(element.height()); + var h = element.height(); + extras.prototype.height.call(element, h*2); + expect(extras.prototype.height.call(element)).toBe(h*2); + }*/ + + it('height(value) for ' + element, function() { + (function (element) { + expect(extras.prototype.height.call(element)).toBe(element.height()); + var h = element.height(); + extras.prototype.height.call(element, h*2); + expect(extras.prototype.height.call(element)).toBe(h*2); + })(createElement(element)); + } + ); + + } + + ); + }); + + describe('offset() getter\n', function () { + + function createElement(element) { + var result = angular.element(element); + sandbox.append(result); + return result; + } + + angular.forEach( + [ + '
some text
', + '
some text (height in em)
', +// '
some text height in px
', +// '
some text w border
', +// '
some text w border
', +// '
some text w padding
', +// '
some text w padding
', +// '
some text w margin
', + '

some text w margin

' + ], function(element) { + + it('should be the same as jQuery offset() for ' + element, function() { + (function (element) { + var target = jQuery(element.contents()[0]); + expect(extras.prototype.offset.call(target)).toEqual(element.offset()); + })(createElement(element)); + } + ); + + } + + ); + }); + + describe('scrollTop()\n', function() { + + function createElement(element) { + var result = angular.element(element); + sandbox.append(result); + return result; + } + + it('should be the same as jQuery scrollTop() for window', function() { + + createElement('
'); + var element = jQuery(window); + expect(extras.prototype.scrollTop.call(element)).toBe(element.scrollTop()); + element.scrollTop(100); + expect(extras.prototype.scrollTop.call(element)).toBe(element.scrollTop()); + extras.prototype.scrollTop.call(element, 200); + expect(extras.prototype.scrollTop.call(element)).toBe(element.scrollTop()); + } + ); + + it('should be the same as jQuery scrollTop() for window', function() { + + var element = createElement('
'); + expect(extras.prototype.scrollTop.call(element)).toBe(element.scrollTop()); + element.scrollTop(100); + expect(extras.prototype.scrollTop.call(element)).toBe(element.scrollTop()); + extras.prototype.scrollTop.call(element, 200); + expect(extras.prototype.scrollTop.call(element)).toBe(element.scrollTop()); + } + ); + + }); + +}); \ No newline at end of file diff --git a/test/karma.conf.js b/test/karma.conf.js new file mode 100644 index 00000000..98c624c8 --- /dev/null +++ b/test/karma.conf.js @@ -0,0 +1,92 @@ +// Karma configuration +// Generated on Sat Aug 10 2013 19:47:03 GMT-0500 (Central Daylight Time) + +module.exports = function(config) { + config.set({ + + // base path, that will be used to resolve files and exclude + basePath: '', + + + // frameworks to use + frameworks: ['jasmine'], + + + // list of files / patterns to load in the browser + files: [ + 'http://code.jquery.com/jquery-1.9.1.js', + 'https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular.js', + 'https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular-mocks.js', + '../src/ui-scroll*.coffee', + '**/*Spec.js' + ], + + preprocessors: { + '../src/ui-scroll*.coffee': ['coffee'] + }, + + coffeePreprocessor: { + options: { + bare: true, + sourceMap: false + }, + transformPath: function(path) { + return path.replace(/\.js$/, '.coffee'); + } + }, + + // list of files to exclude + exclude: [ + + ], + + + // test results reporter to use + // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' + reporters: ['dots'], + + + // web server port + port: 9876, + + + // enable / disable colors in the output (reporters and logs) + colors: true, + + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: + process.env.TRAVIS ? + ['Firefox']: + //['Chrome', 'IE', 'Firefox'], + ['Chrome'], + //browsers: ['Firefox'], + //browsers = ['Chrome']; + //browsers = ['IE']; + + + // If browser does not capture in given timeout [ms], kill it + captureTimeout: 60000, + + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false + }); +};