├── .editorconfig ├── .eslintignore ├── .eslintrc.yaml ├── .gitignore ├── .travis.yml ├── README.md ├── SECURITY.md ├── demos ├── element-container │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ └── spec │ │ └── index.js ├── internal-container │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ ├── list.jade │ └── spec │ │ └── index.js ├── non-virtualized │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ └── spec │ │ └── index.js ├── variant-height │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ └── spec │ │ └── index.js ├── viewport │ ├── demo.config.json │ ├── index.jade │ ├── index.js │ ├── index.less │ ├── mark.jade │ ├── mark.js │ ├── mark.less │ └── spec │ │ └── index.js └── window-container │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ └── spec │ └── index.js ├── gulpfile.js ├── js ├── default-item.jade ├── default-list.jade ├── index.js └── viewport.js ├── jsdoc.json ├── karma.conf.js ├── package.json ├── spec ├── .eslintrc.yaml ├── alternative-list.jade ├── index.js ├── initial-list.jade ├── internal-viewport-list.jade ├── test-container.jade ├── test-util.js ├── viewport-detection-element.jade ├── viewport-detection-window.jade ├── viewport-detection.js └── viewport.js ├── speclist.js ├── wdio.conf.js ├── webpack.alias.js ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # npm packages 2 | node_modules 3 | 4 | # code coverage 5 | coverage 6 | 7 | # output folder 8 | dist 9 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | "parser": "babel-eslint" 3 | "extends": "xo-space" 4 | "env": 5 | "mocha": true 6 | "amd": true 7 | "browser": true 8 | "rules": 9 | "comma-dangle": 10 | - 2 11 | - "always-multiline" 12 | "object-curly-spacing": 13 | - 2 14 | - "always" 15 | "linebreak-style": 0 16 | "curly": 17 | - 2 18 | - "multi-line" 19 | "max-nested-callbacks": 20 | - 2 21 | - 10 22 | "arrow-parens": 0 23 | "babel/arrow-parens": 24 | - 2 25 | - "as-needed" 26 | "generator-star-spacing": 0 27 | "babel/generator-star-spacing": 2 28 | "plugins": 29 | - "babel" 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm packages 2 | node_modules 3 | 4 | # code coverage 5 | coverage 6 | 7 | # test output 8 | errorShots 9 | test-results 10 | 11 | # output folder 12 | dist 13 | docs 14 | 15 | # npm logs 16 | node-debug.log 17 | npm-debug.log 18 | 19 | # npm config 20 | .npmrc 21 | 22 | # generated code for example pages 23 | examples/requirejs/require.config.js 24 | examples/webpack/dist 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - v5 4 | - v4 5 | - '0.12' 6 | before_script: 7 | - export DISPLAY=:99.0 8 | - sh -e /etc/init.d/xvfb start 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [backbone-virtualized-listview][git-repo] 2 | [![NPM version][npm-image]][npm-url] 3 | [![Build Status][travis-image]][travis-url] 4 | [![Dependency Status][daviddm-image]][daviddm-url] 5 | [![Coverage percentage][coveralls-image]][coveralls-url] 6 | > Backbone list view with virtualization support 7 | 8 | UI virtualization is essential to your Web UI performance in case you have 9 | thousands of data item to render. *The idea is to skip rendering the off screen 10 | items and replace them with filler blocks. You need to handle the scroll and 11 | resize events to adjust the DOM content.* 12 | 13 | The principle is straight forward, but the implementation is fussy. This 14 | [Backbone][backbonejs] based implementation is aiming to create a general 15 | purposed virtualized view with high quality and performance, so that people can 16 | focus more on the user experience instead of the complexity of virtualization. 17 | 18 | ## Features 19 | 20 | ### Customization 21 | The `ListView` is named as "list view", but it's not necessarily to render a 22 | list. You can customize it into a `TABLE`, or a sequence of `DIV`s with the 23 | `listTemplate` and the `itemTemplate` options. 24 | 25 | Refer to the [document][docs-list-view] for detail 26 | 27 | ### Scroll to item 28 | You can scroll a certain item into the viewport, method `scrollToItem` is 29 | the helper. 30 | 31 | Refer to the [document][docs-scroll-to-item] for detail. 32 | 33 | ### Handling data change 34 | When data is changed, you can update the view with the `set` method. 35 | 36 | Refer to the [document][docs-set] for detail. 37 | 38 | ## Installation 39 | ```bash 40 | # install the module 41 | npm install --save backbone-virtualized-listview 42 | # install the peer dependencies 43 | npm install --save jquery underscore backbone fast-binary-indexed-tree 44 | ``` 45 | 46 | ## Usage 47 | 48 | Refer to the [document][docs] for details. 49 | 50 | ```javascript 51 | import _ from 'underscore'; 52 | import ListView from 'backbone-virtualized-listview'; 53 | import listTemplate from 'my-list-template.jade'; 54 | import itemTemplate from 'my-item-template.jade'; 55 | 56 | const listView = new ListView({ 57 | el: '.container', 58 | }).set({ 59 | items: _.map(_.range(2000), i => { text: i }), 60 | listTemplate, 61 | itemTemplate, 62 | }); 63 | listView.render(); 64 | 65 | // Scroll to item 66 | listView.scrollToItem(100); 67 | ``` 68 | 69 | ## License 70 | 71 | MIT 72 | 73 | This project has adopted the [Microsoft Open Source Code of Conduct][ms-code-of-conduct]. 74 | For more information see the [Code of Conduct FAQ][ms-code-of-conduct-faq] 75 | or contact [opencode@microsoft.com][ms-mailto] with any additional questions or comments. 76 | 77 | [backbonejs]: http://backbonejs.org/ 78 | [docs]: https://microsoft.github.io/backbone-virtualized-listview/ 79 | [docs-list-view]: https://microsoft.github.io/backbone-virtualized-listview/ListView.html 80 | [docs-scroll-to-item]: https://microsoft.github.io/backbone-virtualized-listview/ListView.html#scrollToItem__anchor 81 | [docs-set]: https://microsoft.github.io/backbone-virtualized-listview/ListView.html#set__anchor 82 | 83 | [ms-code-of-conduct]: https://opensource.microsoft.com/codeofconduct/ 84 | [ms-code-of-conduct-faq]: https://opensource.microsoft.com/codeofconduct/faq/ 85 | [ms-mailto]: mailto:opencode@microsoft.com 86 | 87 | [git-repo]: https://github.com/Microsoft/backbone-virtualized-listview 88 | [npm-image]: https://badge.fury.io/js/backbone-virtualized-listview.svg 89 | [npm-url]: https://npmjs.org/package/backbone-virtualized-listview 90 | [travis-image]: https://travis-ci.org/Microsoft/backbone-virtualized-listview.svg?branch=master 91 | [travis-url]: https://travis-ci.org/Microsoft/backbone-virtualized-listview 92 | [daviddm-image]: https://david-dm.org/Microsoft/backbone-virtualized-listview.svg?theme=shields.io 93 | [daviddm-url]: https://david-dm.org/Microsoft/backbone-virtualized-listview 94 | [coveralls-image]: https://coveralls.io/repos/Microsoft/backbone-virtualized-listview/badge.svg 95 | [coveralls-url]: https://coveralls.io/r/Microsoft/backbone-virtualized-listview 96 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /demos/element-container/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/element-container/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | height: 500px; 4 | overflow: scroll; 5 | background: cyan; 6 | } 7 | -------------------------------------------------------------------------------- /demos/element-container/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container(tabIndex="0") 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/element-container/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import ListView from '../../js/index'; 3 | import 'style!css!./index.css'; 4 | 5 | window.listView = new ListView({ 6 | el: '.container', 7 | }).set({ 8 | items: _.map(_.range(200000), i => ({ text: i })), 9 | defaultItemHeight: 40, 10 | }).render(); 11 | -------------------------------------------------------------------------------- /demos/element-container/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demos/internal-container/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/internal-container/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | height: 500px; 4 | overflow: scroll; 5 | background: cyan; 6 | } 7 | -------------------------------------------------------------------------------- /demos/internal-container/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container(tabIndex="0") 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/internal-container/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import listTemplate from './list.jade'; 3 | import ListView from '../../js/index'; 4 | import 'style!css!./index.css'; 5 | 6 | window.listView = new ListView({ 7 | el: '.container', 8 | viewport: '.viewport', 9 | }).set({ 10 | model: { title: 'Internal Viewport' }, 11 | items: _.map(_.range(200000), i => ({ text: i })), 12 | defaultItemHeight: 40, 13 | listTemplate, 14 | }).render(); 15 | 16 | -------------------------------------------------------------------------------- /demos/internal-container/list.jade: -------------------------------------------------------------------------------- 1 | h2=title 2 | .viewport(style={ height: '600px' }) 3 | ul 4 | .top-filler 5 | .bottom-filler 6 | 7 | -------------------------------------------------------------------------------- /demos/internal-container/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demos/non-virtualized/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/non-virtualized/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: cyan; 3 | } 4 | -------------------------------------------------------------------------------- /demos/non-virtualized/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/non-virtualized/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'underscore'; 3 | import ListView from '../../js/index'; 4 | import 'style!css!./index.css'; 5 | 6 | const listView = window.listView = new ListView({ 7 | virtualized: false, 8 | }).set({ 9 | items: _.map(_.range(2000), i => ({ text: i })), 10 | }).render(); 11 | $('.container').append(listView.$el); 12 | -------------------------------------------------------------------------------- /demos/non-virtualized/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demos/variant-height/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/variant-height/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | height: 500px; 4 | width: 200px; 5 | overflow: scroll; 6 | background: cyan; 7 | } 8 | 9 | li { 10 | word-wrap: break-word; 11 | } 12 | -------------------------------------------------------------------------------- /demos/variant-height/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container(tabIndex="0") 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/variant-height/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import ListView from '../../js/index'; 3 | import 'style!css!./index.css'; 4 | 5 | window.listView = new ListView({ el: '.container' }).set({ 6 | items: _.map(_.range(20000), i => ({ 7 | text: `${i}: ${_.map(_.range(_.random(50)), () => _.random(9)).join('')}`, 8 | })), 9 | }).render(); 10 | 11 | -------------------------------------------------------------------------------- /demos/variant-height/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demos/viewport/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/viewport/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | #window-position 6 | #content 7 | #outer 8 | #inner 9 | | Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod 10 | | tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At 11 | | vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, 12 | | no sea takimata sanctus est Lorem ipsum dolor sit amet. 13 | script(src=bundle) 14 | -------------------------------------------------------------------------------- /demos/viewport/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'underscore'; 3 | import { WindowViewport, ElementViewport } from '../../js/viewport.js'; 4 | import { Mark } from './mark.js'; 5 | import './index.less'; 6 | 7 | const vpWindow = new WindowViewport(); 8 | const vpElement = new ElementViewport(document.getElementById('outer')); 9 | const marks = _.map(_.range(4), () => new Mark().render()); 10 | 11 | _.each(marks, mark => document.body.appendChild(mark.el)); 12 | 13 | function update() { 14 | const innerEl = vpElement.getMetrics().inner; 15 | const windowWidth = window.innerWidth; 16 | const windowHeight = window.innerHeight; 17 | 18 | marks[0].model.set({ x: innerEl.left, y: innerEl.top, windowWidth, windowHeight }); 19 | marks[1].model.set({ x: innerEl.left, y: innerEl.bottom, windowWidth, windowHeight }); 20 | marks[2].model.set({ x: innerEl.right, y: innerEl.top, windowWidth, windowHeight }); 21 | marks[3].model.set({ x: innerEl.right, y: innerEl.bottom, windowWidth, windowHeight }); 22 | 23 | const innerWin = vpWindow.getMetrics().inner; 24 | const posWin = _.chain([ 25 | 'left', 26 | 'top', 27 | 'right', 28 | 'bottom', 29 | 'width', 30 | 'height', 31 | ]).map(key => `${key}: ${innerWin[key]}`).join('; ').value(); 32 | $('#window-position').text(posWin); 33 | } 34 | 35 | update(); 36 | 37 | vpWindow.on('change', update); 38 | vpElement.on('change', update); 39 | -------------------------------------------------------------------------------- /demos/viewport/index.less: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | #window-position { 7 | position: fixed; 8 | height: 20px; 9 | width: 100%; 10 | background: LightBlue; 11 | padding-left: 20px; 12 | } 13 | 14 | #content { 15 | width: 1000px; 16 | height: 600px; 17 | 18 | #outer { 19 | width: 100px; 20 | height: 100px; 21 | 22 | #inner { 23 | width: 200px; 24 | height: 200px; 25 | } 26 | } 27 | } 28 | 29 | #outer { 30 | position: relative; 31 | left: 200px; 32 | top: 200px; 33 | } 34 | -------------------------------------------------------------------------------- /demos/viewport/mark.jade: -------------------------------------------------------------------------------- 1 | .mark 2 | .row.top 3 | .cell.left 4 | span.text 5 | = textTopLeft 6 | .cell.right 7 | span.text 8 | = textTopRight 9 | .row.bottom 10 | .cell.left 11 | span.text 12 | = textBottomLeft 13 | .cell.right 14 | span.text 15 | = textBottomRight 16 | 17 | -------------------------------------------------------------------------------- /demos/viewport/mark.js: -------------------------------------------------------------------------------- 1 | import Backbone from 'backbone'; 2 | import template from './mark.jade'; 3 | import './mark.less'; 4 | 5 | export class Mark extends Backbone.View { 6 | initialize({ 7 | model = new Backbone.Model({ 8 | x: 10, 9 | y: 10, 10 | windowHeight: window.innerHeight, 11 | windowWidth: window.innerWidth, 12 | }), 13 | } = {}) { 14 | this.model = model; 15 | this.model.on('change', () => this.render()); 16 | } 17 | 18 | render() { 19 | const { x, y, windowHeight, windowWidth } = this.model.toJSON(); 20 | const center = { 21 | x: windowWidth / 2, 22 | y: windowHeight / 2, 23 | }; 24 | const pos = `(${x}, ${y})`; 25 | const options = {}; 26 | 27 | if (x > center.x) { 28 | if (y > center.y) { 29 | options.textTopLeft = pos; 30 | } else { 31 | options.textBottomLeft = pos; 32 | } 33 | } else if (y > center.y) { 34 | options.textTopRight = pos; 35 | } else { 36 | options.textBottomRight = pos; 37 | } 38 | 39 | this.$el.html(template(options)); 40 | this.$('.mark').css({ 41 | left: x - 10, 42 | top: y - 10, 43 | }); 44 | return this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /demos/viewport/mark.less: -------------------------------------------------------------------------------- 1 | @text-distance: 3px; 2 | 3 | .mark { 4 | position: fixed; 5 | 6 | .text { 7 | position: absolute; 8 | } 9 | 10 | .top { 11 | border-bottom-style: solid; 12 | .cell { 13 | vertical-align: bottom; 14 | .text { 15 | bottom: @text-distance; 16 | } 17 | } 18 | } 19 | .bottom { 20 | // border-top-style: solid; 21 | .cell { 22 | vertical-align: top; 23 | .text { 24 | top: @text-distance; 25 | } 26 | } 27 | } 28 | .left { 29 | border-right-style: solid; 30 | .text { 31 | right: @text-distance; 32 | } 33 | } 34 | .right { 35 | // border-left-style: solid; 36 | .text { 37 | left: @text-distance; 38 | } 39 | } 40 | 41 | .cell { 42 | border-width: 1px; 43 | display: inline-block; 44 | width: 10px; 45 | height: 10px; 46 | margin: 0; 47 | padding: 0; 48 | white-space: nowrap; 49 | position: relative; 50 | } 51 | 52 | .row { 53 | border-width: 1px; 54 | margin: 0; 55 | padding: 0; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /demos/viewport/spec/index.js: -------------------------------------------------------------------------------- 1 | describe('viewport', function () { 2 | }); 3 | -------------------------------------------------------------------------------- /demos/window-container/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/window-container/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: cyan; 3 | } 4 | -------------------------------------------------------------------------------- /demos/window-container/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/window-container/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'underscore'; 3 | import ListView from '../../js/index'; 4 | import 'style!css!./index.css'; 5 | 6 | const listView = window.listView = new ListView().set({ 7 | items: _.map(_.range(20000), i => ({ text: i })), 8 | }).render(); 9 | $('.container').append(listView.$el); 10 | -------------------------------------------------------------------------------- /demos/window-container/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var os = require('os'); 3 | var http = require('http'); 4 | var path = require('path'); 5 | var childProcess = require('child_process'); 6 | var resolve = require('resolve'); 7 | var gulp = require('gulp'); 8 | var gutil = require('gulp-util'); 9 | var eslint = require('gulp-eslint'); 10 | var democase = require('gulp-democase'); 11 | var excludeGitignore = require('gulp-exclude-gitignore'); 12 | var webpack = require('webpack'); 13 | var del = require('del'); 14 | // coveralls 15 | var coveralls = require('gulp-coveralls'); 16 | // coveralls-end 17 | var jsdoc = require('gulp-jsdoc3'); 18 | 19 | function webpackBuild(configFilePath) { 20 | return function (cb) { 21 | webpack(require(configFilePath), function (err, stats) { 22 | gutil.log(stats.toString({ colors: true })); 23 | cb(err || stats.hasErrors() && new Error('webpack compile error')); 24 | }); 25 | }; 26 | } 27 | 28 | function getSeleniumFilePath() { 29 | var SELENIUM_NAME = 'selenium-server-standalone-2.53.0.jar'; 30 | return path.resolve(os.tmpdir(), SELENIUM_NAME); 31 | } 32 | 33 | gulp.task('download-selenium', function (cb) { 34 | var filePath = getSeleniumFilePath(); 35 | fs.stat(filePath, function (err) { 36 | if (!err) { 37 | return cb(null); 38 | } 39 | var file = fs.createWriteStream(filePath); 40 | var URL = 'http://selenium-release.storage.googleapis.com/2.53/selenium-server-standalone-2.53.0.jar'; 41 | http.get(URL, function (response) { 42 | response.pipe(file); 43 | }); 44 | file.on('error', function (err) { 45 | fs.unlinkSync(filePath); 46 | cb(err); 47 | }); 48 | file.on('finish', cb); 49 | }); 50 | }); 51 | 52 | function startSeleniumServer() { 53 | var filePath = getSeleniumFilePath(); 54 | return childProcess.spawn('java', ['-jar', filePath], { 55 | stdio: 'inherit', 56 | env: { path: path.join(__dirname, 'node_modules', '.bin') }, 57 | }); 58 | } 59 | 60 | function testWithKarmaCmd(handler) { 61 | var karmaCmd = path.resolve('./node_modules/.bin/karma'); 62 | 63 | if (process.platform === 'win32') { 64 | karmaCmd += '.cmd'; 65 | } 66 | 67 | childProcess.spawn(karmaCmd, [ 68 | 'start', 69 | '--single-run', 70 | ], { stdio: 'inherit' }).on('close', handler); 71 | } 72 | 73 | /* 74 | function testWithKarmaAPI(handler) { 75 | var Server = require('karma').Server; 76 | new Server({ 77 | configFile: path.join(__dirname, 'karma.conf.js'), 78 | singleRun: true, 79 | }, handler).start(); 80 | } 81 | */ 82 | 83 | gulp.task('test:unit', function (cb) { 84 | var handler = function (code) { 85 | if (code) { 86 | cb(new Error('test failure')); 87 | } else { 88 | cb(); 89 | } 90 | }; 91 | testWithKarmaCmd(handler); 92 | }); 93 | 94 | // coveralls 95 | gulp.task('coveralls', ['test'], function () { 96 | if (!process.env.CI) { 97 | return; 98 | } 99 | 100 | return gulp.src(path.join(__dirname, 'coverage/report-lcov/lcov.info')) 101 | .pipe(coveralls()); 102 | }); 103 | // coveralls-end 104 | 105 | gulp.task('static', function () { 106 | return gulp.src(['js/**/*.js', 'demos/**/*.js', 'spec/**/*.js']) 107 | .pipe(excludeGitignore()) 108 | .pipe(eslint()) 109 | .pipe(eslint.format()) 110 | .pipe(eslint.failAfterError()); 111 | }); 112 | 113 | gulp.task('jsdoc', function (cb) { 114 | gulp.src(['README.md', './src/**/*.js'], { read: false }) 115 | .pipe(jsdoc(require('./jsdoc.json'), cb)); 116 | }); 117 | 118 | gulp.task('webpack', webpackBuild('./webpack.config')); 119 | 120 | gulp.task('demos', function () { 121 | return gulp.src('./demos').pipe(democase()); 122 | }); 123 | 124 | gulp.task('test:demos', ['download-selenium'], function (done) { 125 | var pathCli = path.resolve(path.dirname(resolve.sync('webdriverio', { 126 | basedir: '.', 127 | })), 'lib/cli'); 128 | var cpSelenium = null; 129 | var cpWdio = null; 130 | 131 | cpSelenium = startSeleniumServer().on('error', function () { 132 | if (cpWdio) { 133 | cpWdio.kill(); 134 | } 135 | done(new Error('Failed to launch the selenium standalone server. Make sure you have JRE available')); 136 | }); 137 | 138 | cpWdio = childProcess.fork(pathCli, [path.join(__dirname, 'wdio.conf.js')], { 139 | env: { DEMOCASE_HTTP_PORT: 8081 }, 140 | }).on('close', function (code) { 141 | cpSelenium.kill(); 142 | if (code) { 143 | done(new Error('selenium test failue')); 144 | } 145 | done(); 146 | }); 147 | }); 148 | 149 | gulp.task('test', ['test:unit']); 150 | 151 | gulp.task('prepublish', ['webpack']); 152 | 153 | gulp.task('clean:test', function () { 154 | return del(['coverage']); 155 | }); 156 | 157 | gulp.task('clean:build', function () { 158 | return del(['dist']); 159 | }); 160 | 161 | gulp.task('clean', ['clean:build', 'clean:test']); 162 | 163 | gulp.task('default', [ 164 | 'static', 165 | 'webpack', 166 | // coveralls 167 | 'coveralls', 168 | // coveralls-end 169 | ]); 170 | -------------------------------------------------------------------------------- /js/default-item.jade: -------------------------------------------------------------------------------- 1 | li=text 2 | -------------------------------------------------------------------------------- /js/default-list.jade: -------------------------------------------------------------------------------- 1 | ul.list-container 2 | .top-filler 3 | .bottom-filler 4 | -------------------------------------------------------------------------------- /js/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import $ from 'jquery'; 3 | import Backbone from 'backbone'; 4 | import BinaryIndexedTree from 'fast-binary-indexed-tree'; 5 | 6 | import defaultListTemplate from './default-list.jade'; 7 | import defaultItemTemplate from './default-item.jade'; 8 | import { ElementViewport, WindowViewport } from './viewport.js'; 9 | 10 | // Helper function to created a scoped while loop 11 | const whileTrue = func => { 12 | while (func()); 13 | }; 14 | 15 | const INVALIDATION_NONE = 0; 16 | const INVALIDATION_ITEMS = 0x1; 17 | const INVALIDATION_EVENTS = 0x2; 18 | const INVALIDATION_LIST = 0x4; 19 | const INVALIDATION_ALL = 0x7; 20 | 21 | const LIST_VIEW_EVENTS = ['willRedraw', 'didRedraw']; 22 | 23 | /** 24 | * The virtualized list view class. 25 | * 26 | * In addition to ordinary Backbone View options, the constructor also takes 27 | * 28 | * __virtualized__: whether or not the virtualization is enabled. 29 | * 30 | * __viewport__: the option locate the scrollable viewport. It can be 31 | * 32 | * * Omitted, auto detect the closest ancestor of the `$el` with 'overflowY' 33 | * style being 'auto' or 'scroll'. Use the window viewport if found none. 34 | * * A `string`, use it as a selector to select an __internal__ element as 35 | * the viewport. 36 | * * An `HTMLElement` or `jQuery`, use it as the viewport element. 37 | * * The `window`, use the window viewport. 38 | * 39 | * @param {Object} options The constructor options. 40 | * @param {boolean} [options.virtualized=true] 41 | * @param {string | HTMLElement | jQuery | window} [options.viewport] 42 | * 43 | */ 44 | 45 | class ListView extends Backbone.View { 46 | 47 | /** 48 | * Backbone view initializer 49 | * @see ListView 50 | */ 51 | initialize({ 52 | virtualized = true, 53 | viewport = null, 54 | } = {}) { 55 | this._props = { virtualized, viewport }; 56 | this.options = { 57 | model: {}, 58 | listTemplate: defaultListTemplate, 59 | events: {}, 60 | items: [], 61 | itemTemplate: defaultItemTemplate, 62 | defaultItemHeight: 20, 63 | }; 64 | 65 | // States 66 | this._state = { 67 | indexFirst: 0, 68 | indexLast: 0, 69 | anchor: null, 70 | invalidation: INVALIDATION_NONE, 71 | removed: false, 72 | eventsListView: {}, 73 | }; 74 | 75 | this._scheduleRedraw = _.noop; 76 | } 77 | 78 | _initViewport() { 79 | const viewport = this._props.viewport; 80 | 81 | if (_.isString(viewport)) { 82 | return new ElementViewport(this.$(viewport)); 83 | } else if (viewport instanceof $) { 84 | if (viewport.get(0) === window) { 85 | return new WindowViewport(); 86 | } 87 | return new ElementViewport(viewport); 88 | } else if (viewport instanceof HTMLElement) { 89 | return new ElementViewport(viewport); 90 | } else if (viewport === window) { 91 | return new WindowViewport(); 92 | } 93 | 94 | let $el = this.$el; 95 | while ($el.length > 0 && !$el.is(document)) { 96 | if (_.contains(['auto', 'scroll'], $el.css('overflowY'))) { 97 | return new ElementViewport($el); 98 | } 99 | $el = $el.parent(); 100 | } 101 | return new WindowViewport(); 102 | } 103 | 104 | _hookUpViewport() { 105 | this.viewport = this._initViewport(); 106 | 107 | if (this.virtualized) { 108 | let blockUntil = 0; 109 | 110 | const onViewportChange = () => { 111 | if (performance.now() > blockUntil) { 112 | this._scheduleRedraw(); 113 | } else if (!this._state.removed) { 114 | // If the scroll events are blocked, we shouldn't just swallow them. 115 | // Wait for 0.1 second and give another try. 116 | window.setTimeout(onViewportChange, 100); 117 | } 118 | }; 119 | 120 | this.viewport.on('change', onViewportChange); 121 | 122 | // 123 | // On keypress, we want to block the scroll events for 0.2 second to wait 124 | // for the animation to complete. Otherwise, the scroll would change the 125 | // geometry metrics and break the animation. The worst thing we may get is, 126 | // for 'HOME' and 'END' keys, the view doesn't scroll to the right position. 127 | // 128 | this.viewport.on('keypress', () => { 129 | blockUntil = performance.now() + 200; 130 | }); 131 | } 132 | } 133 | 134 | /** 135 | * Whether or not the list view is virtualized 136 | */ 137 | get virtualized() { 138 | return this._props.virtualized; 139 | } 140 | 141 | /** 142 | * Remove the view and unregister the event listeners. 143 | */ 144 | remove() { 145 | this._state.removed = true; 146 | if (this.viewport) { 147 | this.viewport.remove(); 148 | } 149 | super.remove(); 150 | } 151 | 152 | _applyPaddings({ paddingTop, paddingBottom }) { 153 | if (this.$topFiller && this.$bottomFiller) { 154 | this.$topFiller.height(paddingTop); 155 | this.$bottomFiller.height(paddingBottom); 156 | } 157 | } 158 | 159 | _processInvalidation() { 160 | const { items, events, listTemplate, model } = this.options; 161 | const { invalidation } = this._state; 162 | const eventsDOM = _.omit(events, LIST_VIEW_EVENTS); 163 | const eventsListView = _.pick(events, LIST_VIEW_EVENTS); 164 | 165 | if (invalidation & INVALIDATION_EVENTS) { 166 | this.undelegateEvents(); 167 | _.each(this._state.eventsListView || {}, (handler, event) => { 168 | this.off(event, handler); 169 | }); 170 | } 171 | if (invalidation & INVALIDATION_LIST) { 172 | const isInternalViewport = _.isString(this._props.viewport); 173 | if (isInternalViewport && this.viewport) { 174 | this.viewport.remove(); 175 | this.viewport = null; 176 | } 177 | this.$el.html(listTemplate(model)); 178 | if (!this.viewport) { 179 | this._hookUpViewport(); 180 | } 181 | this.$topFiller = this.$('.top-filler'); 182 | this.$bottomFiller = this.$('.bottom-filler'); 183 | this._applyPaddings({ 184 | paddingTop: 0, 185 | paddingBottom: this.itemHeights.read(items.length), 186 | }); 187 | _.extend(this._state, { indexFirst: 0, indexLast: 0 }); 188 | } 189 | if (invalidation & INVALIDATION_EVENTS) { 190 | this.delegateEvents(eventsDOM); 191 | _.each(eventsListView, (handler, event) => { 192 | this.on(event, handler); 193 | }); 194 | this._state.eventsListView = eventsListView; 195 | } 196 | const invalidateItems = invalidation & INVALIDATION_ITEMS; 197 | 198 | _.extend(this._state, { invalidation: INVALIDATION_NONE }); 199 | return invalidateItems; 200 | } 201 | 202 | // Private API, redraw immediately 203 | _redraw() { 204 | let invalidateItems = this._processInvalidation(); 205 | const { items, itemTemplate } = this.options; 206 | const { viewport, itemHeights, $topFiller, $bottomFiller, virtualized } = this; 207 | let { indexFirst, indexLast, anchor } = this._state; 208 | 209 | if (!invalidateItems && items.length === 0) { 210 | return; 211 | } 212 | 213 | /** 214 | * The event indicates the list will start redraw. 215 | * @event ListView#willRedraw 216 | */ 217 | this.trigger('willRedraw'); 218 | 219 | whileTrue(() => { 220 | let isCompleted = true; 221 | 222 | const metricsViewport = viewport.getMetrics(); 223 | const visibleTop = metricsViewport.outer.top; 224 | const visibleBot = metricsViewport.outer.bottom; 225 | const listTopCur = this.$topFiller.get(0).getBoundingClientRect().top; 226 | const scrollRatio = metricsViewport.scroll.ratioY; 227 | 228 | let renderTop = false; 229 | let renderBot = false; 230 | 231 | whileTrue(() => { 232 | const listTop = anchor ? anchor.top - itemHeights.read(anchor.index) : listTopCur; 233 | const targetFirst = virtualized ? itemHeights.lowerBound(visibleTop - listTop) : 0; 234 | const targetLast = virtualized ? Math.min(itemHeights.upperBound(visibleBot - listTop) + 1, items.length) : items.length; 235 | const renderFirst = Math.max(targetFirst - 10, 0); 236 | const renderLast = Math.min(targetLast + 10, items.length); 237 | 238 | let renderMore = false; 239 | 240 | // Clean up 241 | if (targetFirst >= indexLast || targetLast <= indexFirst || invalidateItems) { 242 | $topFiller.nextUntil($bottomFiller).remove(); 243 | indexFirst = indexLast = targetFirst; 244 | if (targetFirst !== targetLast && items.length > 0) { 245 | renderMore = true; 246 | } 247 | if (!anchor) { 248 | const index = Math.round(targetFirst * (1 - scrollRatio) + targetLast * scrollRatio); 249 | const top = listTopCur + itemHeights.read(index); 250 | anchor = { index, top }; 251 | } 252 | invalidateItems = false; 253 | } else if (!anchor) { 254 | const index = Math.round(indexFirst * (1 - scrollRatio) + indexLast * scrollRatio); 255 | const top = listTopCur + itemHeights.read(index); 256 | anchor = { index, top }; 257 | } 258 | 259 | // Render top 260 | if (targetFirst < indexFirst) { 261 | $topFiller.after(items.slice(renderFirst, indexFirst).map(itemTemplate)); 262 | $topFiller.nextUntil($bottomFiller).slice(0, indexFirst - renderFirst).each((offset, el) => { 263 | itemHeights.writeSingle(renderFirst + offset, el.getBoundingClientRect().height); 264 | }); 265 | indexFirst = renderFirst; 266 | renderMore = renderTop = true; 267 | } else if (renderBot && !renderTop && renderFirst > indexFirst) { 268 | const removal = []; 269 | $topFiller.nextUntil($bottomFiller).slice(0, renderFirst - indexFirst).each((offset, el) => removal.push(el)); 270 | $(removal).remove(); 271 | indexFirst = renderFirst; 272 | renderMore = true; 273 | } 274 | 275 | // Render bottom 276 | if (targetLast > indexLast) { 277 | $bottomFiller.before(items.slice(indexLast, renderLast).map(itemTemplate)); 278 | $topFiller.nextUntil($bottomFiller).slice(indexLast - indexFirst).each((offset, el) => { 279 | itemHeights.writeSingle(indexLast + offset, el.getBoundingClientRect().height); 280 | }); 281 | indexLast = renderLast; 282 | renderMore = renderBot = true; 283 | } else if (renderTop && !renderBot && renderLast < indexLast) { 284 | const removal = []; 285 | $topFiller.nextUntil($bottomFiller).slice(renderLast - indexFirst).each((offset, el) => removal.push(el)); 286 | $(removal).remove(); 287 | indexLast = renderLast; 288 | renderMore = true; 289 | } 290 | 291 | return renderMore; 292 | }); 293 | 294 | // Update the padding 295 | if (indexFirst !== this.indexFirst || indexLast !== this.indexLast) { 296 | this._applyPaddings({ 297 | paddingTop: itemHeights.read(indexFirst), 298 | paddingBottom: itemHeights.read(items.length) - itemHeights.read(indexLast), 299 | }); 300 | } 301 | 302 | // Adjust the scroll if it's changed significantly 303 | const listTop = anchor.top - itemHeights.read(anchor.index); 304 | const innerTop = listTop - (listTopCur - metricsViewport.inner.top); 305 | const scrollTop = Math.round(visibleTop - innerTop); 306 | let anchorNew = null; 307 | 308 | // Do a second scroll for a middle anchor after the item is rendered 309 | if (anchor.isMiddle) { 310 | const index = anchor.index; 311 | const itemTop = listTopCur + this.itemHeights.read(index); 312 | const itemBot = listTopCur + this.itemHeights.read(index + 1); 313 | 314 | anchorNew = { 315 | index, 316 | top: (visibleTop + visibleBot + itemTop - itemBot) / 2, 317 | }; 318 | isCompleted = false; 319 | } 320 | 321 | if (Math.abs(scrollTop - viewport.getMetrics().scroll.y) >= 1) { 322 | this.viewport.scrollTo({ y: scrollTop }); 323 | isCompleted = false; 324 | } 325 | 326 | anchor = anchorNew; 327 | 328 | return !isCompleted; 329 | }); 330 | 331 | // Write back the render state 332 | _.extend(this._state, { indexFirst, indexLast, anchor: null }); 333 | 334 | /** 335 | * The event indicates the list view have completed redraw. 336 | * @event ListView#didRedraw 337 | */ 338 | this.trigger('didRedraw'); 339 | } 340 | 341 | /** 342 | * Get the item at certain index. 343 | * @param {number} index The index of the item. 344 | * @return {Object} 345 | */ 346 | itemAt(index) { 347 | return _.first(this.options.items.slice(index, index + 1)); 348 | } 349 | 350 | /** 351 | * Get the rendered DOM element at certain index. 352 | * @param {number} index The index of the item. 353 | * @return {HTMLElement} 354 | */ 355 | elementAt(index) { 356 | const { indexFirst, indexLast } = this._state; 357 | 358 | if (index < indexFirst || index >= indexLast || !this.$topFiller || !this.$bottomFiller) { 359 | return null; 360 | } 361 | return this.$topFiller.nextUntil(this.$bottomFiller).get(index - indexFirst); 362 | } 363 | 364 | /** 365 | * The index of the first rendered item. 366 | * @type {number} 367 | */ 368 | get indexFirst() { 369 | return this._state.indexFirst; 370 | } 371 | 372 | /** 373 | * The index after the last rendered item. 374 | * @type {number} 375 | */ 376 | get indexLast() { 377 | return this._state.indexLast; 378 | } 379 | 380 | /** 381 | * The total count of the items. 382 | * @type {number} 383 | */ 384 | get length() { 385 | return this.options.items.length; 386 | } 387 | 388 | /** 389 | * The model object to render the skeleton of the list view. 390 | * @type {Object} 391 | */ 392 | get model() { 393 | return this.options.model; 394 | } 395 | 396 | /** 397 | * The template to render the skeleton of the list view. 398 | * @callback ListView~cbListTemplate 399 | * @param {Object} model The model object of the list view. 400 | */ 401 | 402 | /** 403 | * The template to render the skeleton of the list view. 404 | * @type {ListView~cbListTemplate} 405 | */ 406 | get listTemplate() { 407 | return this.options.listTemplate; 408 | } 409 | 410 | /** 411 | * The template to render a list item. 412 | * @callback ListView~cbItemTemplate 413 | * @param {Object} item The model object of the item 414 | */ 415 | 416 | /** 417 | * The template to render a list item. 418 | * @type {ListView~cbItemTemplate} 419 | */ 420 | get itemTemplate() { 421 | return this.options.itemTemplate; 422 | } 423 | 424 | /** 425 | * The default list item height. 426 | * @type {number} 427 | */ 428 | get defaultItemHeight() { 429 | return this.options.defaultItemHeight; 430 | } 431 | 432 | /** 433 | * @external BinaryIndexedTree 434 | * @see {@link https://microsoft.github.io/fast-binary-indexed-tree-js/BinaryIndexedTree.html} 435 | */ 436 | 437 | /** 438 | * The BinaryIndexedTree to get the heights and accumulated heights of items. 439 | * @type {external:BinaryIndexedTree} 440 | */ 441 | get itemHeights() { 442 | if (!this._itemHeights) { 443 | const { defaultItemHeight, items } = this.options; 444 | this._itemHeights = new BinaryIndexedTree({ 445 | defaultFrequency: Math.max(defaultItemHeight, 1), 446 | maxVal: items.length, 447 | }); 448 | } 449 | return this._itemHeights; 450 | } 451 | 452 | /** 453 | * Set the list view options. The following options can be set 454 | * 455 | * __model__: The model object to render the skeleton of the list view. 456 | * 457 | * __listTemplate__: The template to render the skeleton of the list view. 458 | * 459 | * * By default, it would render a single `UL`. 460 | * * __Note__: It must contain the following elements with specified class 461 | * names as the first and last siblings of the list items. All list items 462 | * will be rendered in between. 463 | * * `'top-filler'`: The filler block on top. 464 | * * `'bottom-filler'`: The filler block at bottom. 465 | * 466 | * __events__: The events hash in form of `{ "event selector": callback }`. 467 | * 468 | * * Refer to {@link http://backbonejs.org/#View-events|Backbone.View~events} 469 | * * In addition to the DOM events, it can also handle the `'willRedraw'` and 470 | * `'didRedraw'` events of the list view. 471 | * * __Note__: The callback __MUST__ be a function. Member function names are 472 | * not supported. 473 | * 474 | * __items__: The model objects of the list items. 475 | * 476 | * __itemTemplate__: The template to render a list item. 477 | * 478 | * * By default, it would render a single `LI` filled with `item.text`. 479 | * * __Note__: list items __MUST NOT__ have outer margins, otherwise the layout 480 | * calculation will be inaccurate. 481 | * 482 | * __defaultItemHeight__: The estimated height of a single item. 483 | * 484 | * * It's not necessary to be accurate. But the accurater it is, the less the 485 | * scroll bar is adjusted overtime. 486 | * 487 | * Refer to {@link ListView} for detail. 488 | * 489 | * @param {Object} options The new options. 490 | * @param {Object} options.model 491 | * @param {ListView~cbListTemplate} [options.listTemplate] 492 | * @param {Object} options.events 493 | * @param {Object[]} [options.items=[]] 494 | * @param {ListView~cbItemTemplate} [options.itemTemplate] 495 | * @param {number} [options.defaultItemHeight=20] 496 | * @param {function} [callback] The callback to notify completion. 497 | * @return {ListView} The list view itself. 498 | */ 499 | set(options = {}, callback = _.noop) { 500 | const isSet = key => !_.isUndefined(options[key]); 501 | const itemHeightsCur = this._itemHeights; 502 | let invalidation = 0; 503 | 504 | _.extend(this.options, options); 505 | 506 | if (_.some(['model', 'listTemplate'], isSet)) { 507 | invalidation |= INVALIDATION_ALL; 508 | } else { 509 | if (_.some(['items', 'itemTemplate', 'defaultItemHeight'], isSet)) { 510 | if (isSet('defaultItemHeight') || 511 | this.itemHeights.maxVal !== this.length) { 512 | this._itemHeights = null; 513 | } 514 | invalidation |= INVALIDATION_ITEMS; 515 | } 516 | if (isSet('events')) { 517 | invalidation |= INVALIDATION_EVENTS; 518 | } 519 | } 520 | 521 | if (invalidation) { 522 | if (this.viewport && this.$topFiller && this.$topFiller.length > 0 && itemHeightsCur) { 523 | const visibleTop = this.viewport.getMetrics().outer.top; 524 | const listTopCur = this.$topFiller.get(0).getBoundingClientRect().top; 525 | const visibleFirst = itemHeightsCur.lowerBound(visibleTop - listTopCur); 526 | 527 | if (visibleFirst < this.length) { 528 | const el = this.elementAt(visibleFirst); 529 | if (el) { 530 | const elTop = el.getBoundingClientRect().top; 531 | this._state.anchor = { 532 | index: visibleFirst, 533 | top: elTop, 534 | }; 535 | } 536 | } 537 | } 538 | 539 | this._invalidate(invalidation, callback); 540 | } else { 541 | callback(); 542 | } 543 | 544 | return this; 545 | } 546 | 547 | _invalidate(invalidation, callback) { 548 | this._state.invalidation |= invalidation; 549 | this._scheduleRedraw(true); 550 | this.once('didRedraw', callback); 551 | } 552 | 553 | /** 554 | * Invalidate the already rendered items and schedule another redraw. 555 | * @param {function} [callback] The callback to notify completion. 556 | */ 557 | invalidate(callback = _.noop) { 558 | this._invalidate(INVALIDATION_ITEMS, callback); 559 | } 560 | 561 | /** 562 | * Scroll to a certain item. 563 | * @param {number} index The index of the item. 564 | * @param {string|number} [position='default'] The position of the item. 565 | * 566 | * The valid positions are 567 | * * `'default'`, if the item is above the viewport top, scroll it to the 568 | * top, if the item is below the viewport bottom, scroll it to the bottom, 569 | * otherwise, keep the viewport unchanged. 570 | * * `'top'`, scroll the item to top of the viewport. 571 | * * `'middle'`, scroll the item to the vertical center of the viewport. 572 | * * `'bottom'`, scroll the item to the bottom of the viewport. 573 | * * `{number}`, scroll the item to the given offset from the viewport top. 574 | * 575 | * @param {function} [callback] The callback to notify completion. 576 | * 577 | */ 578 | scrollToItem(...args) { 579 | if (!this.$topFiller || !this.$bottomFiller) { 580 | throw new Error('Cannot scroll before the view is rendered'); 581 | } 582 | let index = 0; 583 | let position = 'default'; 584 | let callback = _.noop; 585 | 586 | if (args.length >= 3) { 587 | [index, position, callback] = args; 588 | } else if (args.length === 2) { 589 | if (_.isFunction(args[1])) { 590 | [index, callback] = args; 591 | } else { 592 | [index, position] = args; 593 | } 594 | } else if (args.length === 1) { 595 | index = args[0]; 596 | } 597 | this._scrollToItem(index, position, callback); 598 | } 599 | 600 | _scrollToItem(index, position, callback) { 601 | const metricsViewport = this.viewport.getMetrics(); 602 | const visibleTop = metricsViewport.outer.top; 603 | const visibleBot = metricsViewport.outer.bottom; 604 | const listTopCur = this.$topFiller.get(0).getBoundingClientRect().top; 605 | const itemTop = listTopCur + this.itemHeights.read(index); 606 | const itemBot = listTopCur + this.itemHeights.read(index + 1); 607 | let pos = position; 608 | 609 | if (pos === 'default') { 610 | if (itemTop < visibleTop) { 611 | pos = 'top'; 612 | } else if (itemBot > visibleBot) { 613 | pos = 'bottom'; 614 | } else { 615 | if (_.isFunction(callback)) { 616 | callback(); 617 | } 618 | return; 619 | } 620 | } 621 | 622 | if (pos === 'top') { 623 | this._state.anchor = { 624 | index, 625 | top: visibleTop, 626 | }; 627 | } else if (pos === 'bottom') { 628 | this._state.anchor = { 629 | index: index + 1, 630 | top: visibleBot, 631 | }; 632 | } else if (pos === 'middle') { 633 | this._state.anchor = { 634 | index: index, 635 | top: (visibleTop + visibleBot + itemTop - itemBot) / 2, 636 | isMiddle: true, 637 | }; 638 | } else if (typeof pos === 'number') { 639 | this._state.anchor = { 640 | index: index, 641 | top: visibleTop + pos, 642 | }; 643 | } else { 644 | throw new Error('Invalid position'); 645 | } 646 | 647 | this.once('didRedraw', callback); 648 | 649 | this._scheduleRedraw(); 650 | } 651 | 652 | /** 653 | * Render the list view. 654 | * @param {function} [callback] The callback to notify completion. 655 | */ 656 | render(callback = _.noop) { 657 | let animationFrameId = null; 658 | let timeoutId = null; 659 | 660 | const redraw = () => { 661 | animationFrameId = null; 662 | timeoutId = null; 663 | if (!this._state.removed) { 664 | this._redraw(); 665 | } 666 | }; 667 | 668 | this._scheduleRedraw = (ignoreAnimationFrame = false) => { 669 | if (!timeoutId) { 670 | if (ignoreAnimationFrame) { 671 | timeoutId = window.setTimeout(redraw, 0); 672 | if (animationFrameId) { 673 | window.cancelAnimationFrame(animationFrameId); 674 | animationFrameId = null; 675 | } 676 | } else if (!animationFrameId) { 677 | animationFrameId = window.requestAnimationFrame(redraw); 678 | } 679 | } 680 | }; 681 | // this._hookUpViewport(); 682 | this._invalidate(INVALIDATION_ALL, callback); 683 | return this; 684 | } 685 | 686 | } 687 | 688 | export default ListView; 689 | -------------------------------------------------------------------------------- /js/viewport.js: -------------------------------------------------------------------------------- 1 | import Backbone from 'backbone'; 2 | import $ from 'jquery'; 3 | import _ from 'underscore'; 4 | 5 | function getElementMetrics(el) { 6 | return _.pick(el.getBoundingClientRect(), [ 7 | 'left', 8 | 'top', 9 | 'right', 10 | 'bottom', 11 | 'width', 12 | 'height', 13 | ]); 14 | } 15 | 16 | function calculateRatio(scroll, scrollMax) { 17 | return scrollMax > 0 ? Math.min(Math.max(scroll / scrollMax, 0), 1) : 0; 18 | } 19 | 20 | export class Viewport { 21 | constructor($el) { 22 | _.extend(this, Backbone.Events); 23 | 24 | this.$el = $el; 25 | 26 | this.onScroll = () => { 27 | this.trigger('scroll'); 28 | this.trigger('change'); 29 | }; 30 | 31 | this.onResize = () => { 32 | this.trigger('resize'); 33 | this.trigger('change'); 34 | }; 35 | 36 | let keyCode = null; 37 | let timestamp = performance.now(); 38 | this.onKeydown = event => { 39 | // Consolidate the keydown events for the same key in 0.2 seconds 40 | if (keyCode !== event.keyCode || performance.now() > timestamp + 200) { 41 | keyCode = event.keyCode; 42 | timestamp = performance.now(); 43 | this.trigger('keypress', keyCode); 44 | } 45 | }; 46 | 47 | this.onKeyup = () => { 48 | keyCode = null; 49 | }; 50 | 51 | this.$el.on('resize', this.onResize); 52 | this.$el.on('scroll', this.onScroll); 53 | $(document).on('keydown', this.onKeydown); 54 | $(document).on('keyup', this.onKeyup); 55 | 56 | this.scrollTo = scrollNew => { 57 | if (_.isNumber(scrollNew.x)) { 58 | this.$el.scrollLeft(scrollNew.x); 59 | } 60 | if (_.isNumber(scrollNew.y)) { 61 | this.$el.scrollTop(scrollNew.y); 62 | } 63 | }; 64 | } 65 | 66 | remove() { 67 | this.$el.off('resize', this.onResize); 68 | this.$el.off('scroll', this.onScroll); 69 | $(document).off('keydown', this.onKeydown); 70 | $(document).off('keyup', this.onKeyup); 71 | } 72 | 73 | getMetrics() { 74 | throw new Error('Not implemented'); 75 | } 76 | } 77 | 78 | export class WindowViewport extends Viewport { 79 | constructor() { 80 | super($(window)); 81 | } 82 | 83 | getMetrics() { 84 | const inner = getElementMetrics(document.documentElement); 85 | 86 | inner.width = document.documentElement.scrollWidth; 87 | inner.height = document.documentElement.scrollHeight; 88 | inner.right = inner.left + inner.width; 89 | inner.bottom = inner.top + inner.height; 90 | 91 | const outer = { 92 | top: 0, 93 | bottom: window.innerHeight, 94 | left: 0, 95 | right: window.innerWidth, 96 | width: window.innerWidth, 97 | height: window.innerHeight, 98 | }; 99 | 100 | const scroll = { 101 | x: window.pageXOffset, 102 | y: window.pageYOffset, 103 | }; 104 | 105 | scroll.ratioX = calculateRatio(scroll.x, inner.width - outer.width); 106 | scroll.ratioY = calculateRatio(scroll.y, inner.height - outer.height); 107 | 108 | return { inner, outer, scroll }; 109 | } 110 | } 111 | 112 | const SCROLLABLE = ['auto', 'scroll']; 113 | 114 | export class ElementViewport extends Viewport { 115 | constructor(el) { 116 | super($(el)); 117 | 118 | this.el = this.$el.get(0); 119 | this.$el.css('overflowX', s => _.contains(SCROLLABLE, s) ? s : 'auto'); 120 | this.$el.css('overflowY', s => _.contains(SCROLLABLE, s) ? s : 'auto'); 121 | } 122 | 123 | getMetrics() { 124 | const outer = getElementMetrics(this.el); 125 | const scroll = { 126 | x: this.el.scrollLeft, 127 | y: this.el.scrollTop, 128 | }; 129 | const inner = { 130 | left: outer.left - scroll.x, 131 | top: outer.top - scroll.y, 132 | width: this.el.scrollWidth, 133 | height: this.el.scrollHeight, 134 | }; 135 | inner.right = inner.left + inner.width; 136 | inner.bottom = inner.top + inner.height; 137 | 138 | scroll.ratioX = calculateRatio(scroll.x, inner.width - outer.width); 139 | scroll.ratioY = calculateRatio(scroll.y, inner.height - outer.height); 140 | 141 | return { outer, inner, scroll }; 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": ["js"], 4 | "includePattern": ".+\\.js$" 5 | }, 6 | "opts": { 7 | "destination": "docs", 8 | "recurse": true 9 | }, 10 | "plugins": ["plugins/markdown"] 11 | } 12 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'); 2 | var path = require('path'); 3 | 4 | function getWebpackConfig() { 5 | var webpackConfig = _.omit(require('./webpack.config'), 'entry', 'externals'); 6 | _.defaults(webpackConfig, { module: {} }); 7 | 8 | webpackConfig.module.preLoaders = [{ 9 | test: /\.js$/, 10 | include: path.resolve('./js/'), 11 | loader: 'babel', 12 | }, { 13 | test: /\.js$/, 14 | include: path.resolve('./js/'), 15 | loader: 'isparta', 16 | }, { 17 | test: /sinon\.js$/, 18 | loader: 'imports?define=>false,require=>false', 19 | }].concat(webpackConfig.module.preLoaders || []); 20 | 21 | _.defaults(webpackConfig, { resolve: {} }); 22 | 23 | _.extend(webpackConfig.resolve.alias, { 24 | sinon: 'sinon/pkg/sinon.js', 25 | }); 26 | 27 | return webpackConfig; 28 | } 29 | 30 | module.exports = function (config) { 31 | config.set({ 32 | files: [ 33 | 'speclist.js', 34 | ], 35 | 36 | frameworks: [ 37 | 'mocha', 38 | ], 39 | 40 | client: { 41 | mocha: { 42 | reporter: 'html', // change Karma's debug.html to the mocha web reporter 43 | }, 44 | }, 45 | 46 | reporters: ['mocha', 'coverage'], 47 | 48 | preprocessors: { 49 | 'speclist.js': 'webpack', 50 | }, 51 | 52 | webpack: getWebpackConfig(), 53 | 54 | coverageReporter: { 55 | dir: 'coverage/', 56 | reporters: [ 57 | { type: 'html', subdir: 'report-html' }, 58 | { type: 'lcov', subdir: 'report-lcov' }, 59 | ], 60 | }, 61 | 62 | browsers: [ 63 | // 'Electron', 64 | 'Firefox', 65 | // 'Chrome', 66 | ], 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/backbone-virtualized-listview.js", 3 | "version": "0.6.8", 4 | "files": [ 5 | "dist", 6 | "docs" 7 | ], 8 | "scripts": { 9 | "test": "gulp static test coveralls", 10 | "prepublish": "gulp prepublish" 11 | }, 12 | "license": "MIT", 13 | "name": "backbone-virtualized-listview", 14 | "description": "Virtualized list view for Backbone", 15 | "keywords": [ 16 | "Backbone", 17 | "virtualization", 18 | "list", 19 | "Web UI" 20 | ], 21 | "author": { 22 | "name": "Wei Wei", 23 | "email": "wewei@microsoft.com" 24 | }, 25 | "contributors": [ 26 | { 27 | "name": "Wei Wei", 28 | "email": "lyweiwei@outlook.com" 29 | } 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/Microsoft/backbone-virtualized-listview.git" 34 | }, 35 | "devDependencies": { 36 | "babel-core": "^6.9.0", 37 | "babel-eslint": "^6.1.2", 38 | "babel-loader": "^6.2.4", 39 | "babel-polyfill": "^6.9.1", 40 | "babel-preset-es2015": "^6.9.0", 41 | "babel-preset-stage-3": "^6.11.0", 42 | "backbone": "^1.3.3", 43 | "bluebird": "^3.4.1", 44 | "chai": "^3.5.0", 45 | "css-loader": "^0.23.1", 46 | "del": "^2.2.0", 47 | "democase": "^0.1.9", 48 | "eslint": "^2.10.2", 49 | "eslint-config-xo": "^0.14.1", 50 | "eslint-config-xo-space": "^0.13.0", 51 | "eslint-plugin-babel": "^3.3.0", 52 | "fast-binary-indexed-tree": "~1.0.0", 53 | "gulp": "^3.9.1", 54 | "gulp-coveralls": "^0.1.4", 55 | "gulp-democase": "0.0.1", 56 | "gulp-eslint": "^2.0.0", 57 | "gulp-exclude-gitignore": "^1.0.0", 58 | "gulp-file": "^0.3.0", 59 | "gulp-jsdoc3": "^0.3.0", 60 | "gulp-util": "^3.0.7", 61 | "imports-loader": "^0.6.5", 62 | "isparta-loader": "^2.0.0", 63 | "jade": "^1.11.0", 64 | "jade-loader": "^0.8.0", 65 | "jquery": "^2.2.4", 66 | "jsdoc": "^3.4.0", 67 | "karma": "^0.13.22", 68 | "karma-chrome-launcher": "^1.0.1", 69 | "karma-coverage": "^1.0.0", 70 | "karma-firefox-launcher": "^1.0.0", 71 | "karma-mocha": "^1.0.1", 72 | "karma-mocha-reporter": "^2.0.3", 73 | "karma-sourcemap-loader": "^0.3.7", 74 | "karma-webpack": "^1.7.0", 75 | "less": "^2.7.1", 76 | "less-loader": "^2.2.3", 77 | "lodash": "^4.13.1", 78 | "mocha": "^2.5.2", 79 | "requirejs": "^2.2.0", 80 | "resolve": "^1.1.7", 81 | "sinon": "^1.17.4", 82 | "sinon-chai": "^2.8.0", 83 | "source-map-loader": "^0.1.5", 84 | "style-loader": "^0.13.1", 85 | "underscore": "^1.8.3", 86 | "wdio-junit-reporter": "0.0.2", 87 | "wdio-mocha-framework": "^0.2.13", 88 | "webdriverio": "^4.0.7", 89 | "webpack": "^1.13.1", 90 | "webpack-stream": "^3.2.0" 91 | }, 92 | "peerDependencies": { 93 | "backbone": "^1.3.3", 94 | "jquery": "^2.2.4", 95 | "fast-binary-indexed-tree": "~1.0.0", 96 | "underscore": "^1.8.3" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /spec/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | "rules": 3 | "no-unused-expressions": 0 4 | -------------------------------------------------------------------------------- /spec/alternative-list.jade: -------------------------------------------------------------------------------- 1 | h2=title 2 | .internal-viewport(style={ height: '500px' }) 3 | ul.list-container 4 | .top-filler 5 | .bottom-filler 6 | -------------------------------------------------------------------------------- /spec/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'underscore'; 3 | import Backbone from 'backbone'; 4 | import chai from 'chai'; 5 | import sinon from 'sinon'; 6 | import sinonChai from 'sinon-chai'; 7 | import ListView from '../js/index.js'; 8 | import template from './test-container.jade'; 9 | import initialListTemplate from './initial-list.jade'; 10 | import alternativeListTemplate from './alternative-list.jade'; 11 | import { doAsync, sleep } from './test-util.js'; 12 | 13 | chai.use(sinonChai); 14 | 15 | const expect = chai.expect; 16 | 17 | const redrawInterval = 100; 18 | 19 | describe('ListView', function () { 20 | let listView = null; 21 | 22 | function render() { 23 | return new Promise(resolve => listView.render(resolve)); 24 | } 25 | 26 | beforeEach(function () { 27 | $('body').html(template(this.currentTest)); 28 | }); 29 | 30 | afterEach(function () { 31 | $('body').empty(); 32 | }); 33 | 34 | it('should be a Backbone View', function () { 35 | expect(ListView.prototype).is.instanceof(Backbone.View); 36 | }); 37 | 38 | describe('Properties', function () { 39 | const count = 20000; 40 | 41 | const model = { title: 'Test Properties' }; 42 | const listTemplate = alternativeListTemplate; 43 | const itemTemplate = item => `