├── .babelrc ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── Viewport-API.md └── VirtualScroller-API.md ├── index.html ├── package.json ├── rollup.config.js ├── src ├── components │ ├── List.js │ ├── Updater.js │ └── VirtualScroller.js ├── index.js ├── modules │ ├── Position.js │ ├── Rectangle.js │ ├── ScrollTracker.js │ ├── Viewport.js │ └── requestAnimationFrame.js └── utlis │ ├── createScheduler.js │ ├── findIndex.js │ └── findNewSlice.js ├── test └── findNewSlice.js └── website ├── .gitignore ├── core └── Footer.js ├── i18n └── en.json ├── package.json ├── pages ├── demo-simple-list.html ├── demo-window-as-scroller.html └── en │ ├── demo-index.js │ ├── help.js │ ├── index.js │ └── users.js ├── sidebars.json ├── siteConfig.js └── static ├── css └── custom.css ├── img ├── docusaurus.svg ├── favicon.png ├── favicon │ └── favicon.ico └── oss_logo.png └── js ├── react-virtual-scroller.umd.js └── react-virtual-scroller.umd.min.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "modules": false 7 | } 8 | ] 9 | ], 10 | "plugins": [ 11 | ["transform-object-rest-spread", { "useBuiltIns": true }], 12 | "transform-class-properties", 13 | [ 14 | "transform-react-jsx", 15 | { 16 | "useBuiltIns": true 17 | } 18 | ], 19 | "transform-react-remove-prop-types" 20 | ], 21 | "env": { 22 | "commonjs": { 23 | "presets": [ 24 | [ 25 | "env", 26 | { 27 | "modules": "commonjs" 28 | } 29 | ] 30 | ], 31 | "plugins": ["transform-class-properties"] 32 | }, 33 | "esm": { 34 | "presets": [ 35 | [ 36 | "env", 37 | { 38 | "modules": false 39 | } 40 | ] 41 | ], 42 | "plugins": ["transform-class-properties"] 43 | }, 44 | "umd": { 45 | "presets": [ 46 | [ 47 | "env", 48 | { 49 | "targets": { 50 | "ie": 9 51 | }, 52 | "modules": false 53 | } 54 | ] 55 | ], 56 | "plugins": ["transform-class-properties"] 57 | }, 58 | "test": { 59 | "plugins": ["transform-es2015-modules-commonjs"] 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:react/recommended' 5 | ], 6 | plugins: ['react'], 7 | parser: 'babel-eslint', 8 | parserOptions: { 9 | ecmaVersion: 6, 10 | sourceType: 'module', 11 | ecmaFeatures: { 12 | jsx: true, 13 | generators: true, 14 | experimentalObjectRestSpread: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | jest: true, 22 | node: true, 23 | }, 24 | globals: {}, 25 | rules: { 26 | 'prefer-const': 'error', 27 | 'object-shorthand': "error", 28 | 'no-param-reassign': ['error', { props: true }], 29 | 'no-underscore-dangle': 0, 30 | 'no-bitwise': 0, 31 | 'no-use-before-define': 'error', 32 | 'no-shadow': ['error', { builtinGlobals: false, hoist: 'functions' }], 33 | 'new-cap': 1, 34 | eqeqeq: 1, 35 | 'no-console': 1, 36 | 'no-unused-vars': [ 37 | 'error', 38 | { 39 | args: 'after-used', 40 | argsIgnorePattern: '^_$', 41 | }, 42 | ], 43 | camelcase: 1, 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | package-lock.json 4 | yarn.lock 5 | 6 | /dist 7 | /es 8 | /lib 9 | /website/build/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 X.L 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-virtual-scroller 2 | 3 | A react implementation of [twitter VirtualScroller](http://itsze.ro/blog/2017/04/09/infinite-list-and-react.html). 4 | 5 | More in [Document](https://liximomo.github.io/react-virtual-scroller/). 6 | -------------------------------------------------------------------------------- /docs/Viewport-API.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: Viewport 3 | title: Viewport 4 | --- 5 | 6 | 7 | ## Constructor 8 | ```js 9 | constructor(window, scroller = window) 10 | ``` 11 | 12 | ### window 13 | `window` object. 14 | 15 | ### scroller 16 | Scrollable container element of `VirtualScroller` or window. 17 | -------------------------------------------------------------------------------- /docs/VirtualScroller-API.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: VirtualScroller 3 | title: VirtualScroller 4 | --- 5 | 6 | |Name|Type|Default|Description| 7 | |:---|:---|:---|:---| 8 | |items|any[]||Data list.| 9 | |renderItem|function||`(item, index) => element`. Responsible for rendering a item. | 10 | |viewport|object||A instance of [Viewport](Viewport.html).| 11 | |identityFunction|function| a => a|`item => number or string`. return a unique identity for item.| 12 | |offscreenToViewportRatio|number|1.8|Ratio to determine how height to render above/below the visible bounds of the list. | 13 | |assumedItemHeight|number|400|Estimated average height of items.| 14 | |nearStartProximityRatio|number|1.75|distance between start and nearStart / `viewport` height.| 15 | |nearEndProximityRatio|number|0.25|distance between nearEnd and end / `viewport` height.| 16 | |onAtStart|function|| `info => void` called when scroll to list start.| 17 | |onNearStart|function||called when scroll to list near start.| 18 | |onNearEnd|function||called when scroll to list near end.| 19 | |onAtEnd|function||called when scroll to list end.| 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | preview 8 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | 51 | 52 | 135 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liximomo/react-virtual-scroller", 3 | "version": "1.0.0-alpha.3", 4 | "main": "lib/index.js", 5 | "jsnext:main": "es/index.js", 6 | "module": "es/index.js", 7 | "browser": "dist/react-virtual-scroller.umd.js", 8 | "author": "liximomo (https://liximomo.github.io/)", 9 | "repository": { 10 | "url": "git@github.com:liximomo/react-virtual-scroller.git", 11 | "type": "git" 12 | }, 13 | "license": "MIT", 14 | "files": [ 15 | "dist", 16 | "es", 17 | "lib" 18 | ], 19 | "scripts": { 20 | "start": "npm-run-all --parallel watch:*", 21 | "watch:js": "BABEL_ENV=umd NODE_ENV=development rollup -c -w", 22 | "prebuild": "npm run lint", 23 | "build": "npm run build:commonjs && npm run build:esm && npm run build:umd && npm run build:umd:min", 24 | "build:commonjs": "BABEL_ENV=commonjs NODE_ENV=production babel src --out-dir lib", 25 | "build:esm": "BABEL_ENV=esm NODE_ENV=production babel src --out-dir es", 26 | "build:umd": "BABEL_ENV=umd NODE_ENV=development rollup -c", 27 | "build:umd:min": "BABEL_ENV=umd NODE_ENV=production rollup -c", 28 | "postbuild": "cp dist/* website/static/js/", 29 | "lint": "eslint src/**/*.js", 30 | "test": "jest" 31 | }, 32 | "devDependencies": { 33 | "babel-cli": "^6.26.0", 34 | "babel-core": "^6.26.0", 35 | "babel-eslint": "^8.1.2", 36 | "babel-plugin-external-helpers": "^6.22.0", 37 | "babel-plugin-transform-class-properties": "^6.24.1", 38 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 39 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 40 | "babel-plugin-transform-react-jsx": "^6.24.1", 41 | "babel-plugin-transform-regenerator": "^6.26.0", 42 | "babel-preset-env": "^1.6.1", 43 | "eslint": "^4.14.0", 44 | "jest": "^22.4.2", 45 | "npm-run-all": "^4.1.2", 46 | "rollup": "^0.53.0", 47 | "rollup-plugin-babel": "^3.0.3", 48 | "rollup-plugin-commonjs": "^8.2.6", 49 | "rollup-plugin-node-resolve": "^3.0.0", 50 | "rollup-plugin-replace": "^2.0.0", 51 | "rollup-plugin-uglify": "^2.0.1" 52 | }, 53 | "dependencies": { 54 | "babel-plugin-transform-react-remove-prop-types": "^0.4.13", 55 | "eslint-plugin-react": "^7.7.0", 56 | "lodash.throttle": "^4.1.1", 57 | "prop-types": "^15.6.1", 58 | "recomputed": "1.0.1-alpha.4" 59 | }, 60 | "peerDependencies": { 61 | "react": "^16.2.0", 62 | "react-dom": "^16.2.0" 63 | }, 64 | "jest": { 65 | "testMatch": [ 66 | "**/test/*.js" 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | import replace from 'rollup-plugin-replace' 5 | import uglify from 'rollup-plugin-uglify'; 6 | import pkg from './package.json'; 7 | 8 | const input = 'src/index.js'; 9 | 10 | const onwarn = function(warning) { 11 | // Skip certain warnings 12 | 13 | // should intercept ... but doesn't in some rollup versions 14 | if ( warning.code === 'THIS_IS_UNDEFINED' ) { return; } 15 | 16 | // console.warn everything else 17 | console.log('\x1b[33m(!) %s\x1b[0m', warning.message || warning); 18 | }; 19 | 20 | const plugins = [ 21 | replace({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) }), 22 | resolve(), // so Rollup can resolve node_modules 23 | babel({ 24 | exclude: ['node_modules/**'], 25 | plugins: ["external-helpers"] 26 | }), 27 | commonjs(), // so Rollup can convert commonjs to an ES module 28 | ]; 29 | 30 | 31 | // browser-friendly UMD build 32 | const umd = { 33 | input, 34 | output: { 35 | file: pkg.browser, 36 | format: 'umd', 37 | name: 'VirtualScroller', 38 | }, 39 | onwarn, 40 | plugins 41 | }; 42 | 43 | if (process.env.NODE_ENV === 'development') { 44 | // nothing current 45 | } else if (process.env.NODE_ENV === 'production') { 46 | const prodPlugins = [ 47 | uglify({ 48 | compress: { 49 | warnings: false, 50 | // Disabled because of an issue with Uglify breaking seemingly valid code: 51 | // https://github.com/facebookincubator/create-react-app/issues/2376 52 | // Pending further investigation: 53 | // https://github.com/mishoo/UglifyJS2/issues/2011 54 | comparisons: false, 55 | }, 56 | output: { 57 | comments: false, 58 | // Turned on because emoji and regex is not minified properly using default 59 | // https://github.com/facebookincubator/create-react-app/issues/2488 60 | ascii_only: true, 61 | }, 62 | }) 63 | ] 64 | umd.output.file = umd.output.file.replace(/js$/, 'min.js'); 65 | umd.plugins = umd.plugins.concat(prodPlugins); 66 | } 67 | 68 | export default umd; 69 | -------------------------------------------------------------------------------- /src/components/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const nullFunc = () => null; 5 | 6 | function defaultGetHeightForDomNode(node) { 7 | return node ? node.getBoundingClientRect().height : 0; 8 | } 9 | 10 | class List extends React.PureComponent { 11 | static defaultProps = { 12 | getHeightForDomNode: defaultGetHeightForDomNode, 13 | }; 14 | 15 | static propTypes = { 16 | getHeightForDomNode: PropTypes.func, 17 | blankSpaceAbove: PropTypes.number.isRequired, 18 | blankSpaceBelow: PropTypes.number.isRequired, 19 | renderItem: PropTypes.func.isRequired, 20 | list: PropTypes.arrayOf(PropTypes.any), 21 | }; 22 | 23 | constructor(props) { 24 | super(props); 25 | 26 | this._refs = {}; 27 | this._handleViewRefUpdate = this._handleViewRefUpdate.bind(this); 28 | } 29 | 30 | _handleViewRefUpdate(ref) { 31 | this._view = ref; 32 | } 33 | 34 | _handleItemRefUpdate(id, ref) { 35 | if (!ref) { 36 | delete this._refs[id]; 37 | } else { 38 | this._refs[id] = ref; 39 | } 40 | } 41 | 42 | _renderContent() { 43 | return this.props.list.map((item, index) => { 44 | const id = item.id; 45 | const data = item.data; 46 | const reactElement = this.props.renderItem(data, index); 47 | const savedRefFunc = 'function' === typeof reactElement.ref ? reactElement.ref : nullFunc; 48 | return React.cloneElement(reactElement, { 49 | key: id, 50 | ref: r => { 51 | this._handleItemRefUpdate(id, r); 52 | savedRefFunc(r); 53 | }, 54 | }); 55 | }); 56 | } 57 | 58 | getWrapperNode() { 59 | return this._view; 60 | } 61 | 62 | getItemHeights() { 63 | const { list, getHeightForDomNode } = this.props; 64 | 65 | return list.reduce((heightsMap, item) => { 66 | const id = item.id; 67 | const node = this._refs[id]; 68 | 69 | // eslint-disable-next-line no-param-reassign 70 | heightsMap[id] = getHeightForDomNode(node); 71 | return heightsMap; 72 | }, {}); 73 | } 74 | 75 | render() { 76 | const { blankSpaceAbove, blankSpaceBelow } = this.props; 77 | 78 | return ( 79 |
87 | {this._renderContent()} 88 |
89 | ); 90 | } 91 | } 92 | 93 | export default List; 94 | -------------------------------------------------------------------------------- /src/components/Updater.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import recomputed from 'recomputed'; 4 | import throttle from 'lodash.throttle'; 5 | import List from './List'; 6 | import createScheduler from '../utlis/createScheduler'; 7 | import findIndex from '../utlis/findIndex'; 8 | import findNewSlice from '../utlis/findNewSlice'; 9 | import Position from '../modules/Position'; 10 | import Rectangle from '../modules/Rectangle'; 11 | import Viewport from '../modules/Viewport'; 12 | import requestAnimationFrame from '../modules/requestAnimationFrame'; 13 | 14 | function findAnchor(prevPos, nextPos) { 15 | const viewportRect = prevPos.getViewportRect(); 16 | 17 | const findBest = (list, comparator) => { 18 | if (list.length <= 0) { 19 | return null; 20 | } 21 | 22 | return list.reduce((best, item) => { 23 | return comparator(item, best) > 0 ? item : best; 24 | }); 25 | }; 26 | 27 | const inViewport = rect => rect && rect.doesIntersectWith(viewportRect); 28 | 29 | const distanceToViewportTop = rect => 30 | rect ? Math.abs(viewportRect.getTop() - rect.getTop()) : 1 / 0; 31 | 32 | const boolCompartor = (getValue, a, b) => { 33 | const aResult = getValue(a); 34 | const bResult = getValue(b); 35 | if (aResult && !bResult) { 36 | return 1; 37 | } else if (!aResult && bResult) { 38 | return -1; 39 | } else { 40 | return 0; 41 | } 42 | }; 43 | 44 | const numberCompartor = (getValue, a, b) => { 45 | const aResult = getValue(a); 46 | const bResult = getValue(b); 47 | return bResult - aResult; 48 | }; 49 | 50 | const bothRendered = nextPos.getList().filter(item => { 51 | const id = item.id; 52 | return prevPos.isRendered(id) && nextPos.isRendered(id); 53 | }); 54 | 55 | if (bothRendered.length <= 0) { 56 | return null; 57 | } 58 | 59 | const theBest = findBest(bothRendered, (current, best) => { 60 | const item = prevPos.getItemRect(current.id); 61 | const bestItem = prevPos.getItemRect(best.id); 62 | return ( 63 | boolCompartor(inViewport, item, bestItem) || 64 | numberCompartor(distanceToViewportTop, item, bestItem) 65 | ); 66 | }); 67 | 68 | return theBest; 69 | } 70 | 71 | function offsetCorrection(prevPos, nextPos) { 72 | const anchor = findAnchor(prevPos, nextPos); 73 | if (!anchor) { 74 | return 0; 75 | } 76 | 77 | const anchorId = anchor.id; 78 | const offsetToViewport = 79 | prevPos.getItemRect(anchorId).getTop() - prevPos.getViewportRect().getTop(); 80 | return ( 81 | nextPos.getItemRect(anchorId).getTop() - nextPos.getViewportRect().getTop() - offsetToViewport 82 | ); 83 | } 84 | 85 | function collectRect(list, heights, defaultHeight) { 86 | const rects = {}; 87 | let top = 0; 88 | list.forEach(item => { 89 | const id = item.id; 90 | const height = heights[id] !== undefined ? heights[id] : defaultHeight; 91 | rects[id] = new Rectangle({ 92 | top, 93 | height, 94 | }); 95 | 96 | // eslint-disable-next-line no-param-reassign 97 | top += height; 98 | }); 99 | 100 | return rects; 101 | } 102 | 103 | class Updater extends React.PureComponent { 104 | static propTypes = { 105 | list: PropTypes.arrayOf(PropTypes.any).isRequired, 106 | renderItem: PropTypes.func.isRequired, 107 | viewport: PropTypes.instanceOf(Viewport).isRequired, 108 | onPositioningUpdate: PropTypes.func, 109 | assumedItemHeight: PropTypes.number, 110 | offscreenToViewportRatio: PropTypes.number, 111 | }; 112 | 113 | static defaultProps = { 114 | offscreenToViewportRatio: 1.8, 115 | assumedItemHeight: 400, 116 | }; 117 | 118 | constructor(props) { 119 | super(props); 120 | 121 | this._heights = {}; 122 | 123 | /* eslint-disable no-shadow */ 124 | this._getRectangles = recomputed( 125 | this, 126 | props => props.list, 127 | (_, __, obj) => obj._heights, 128 | props => props.assumedItemHeight, 129 | collectRect 130 | ); 131 | 132 | this._getSlice = recomputed( 133 | this, 134 | props => props.list, 135 | (_, state) => state.sliceStart, 136 | (_, state) => state.sliceEnd, 137 | (list, sliceStart, sliceEnd) => list.slice(sliceStart, sliceEnd) 138 | ); 139 | /* eslint-enable no-shadow */ 140 | 141 | // $todo add initItemIndex props 142 | this.state = this._getDefaultSlice(props.list); 143 | 144 | this._handleRefUpdate = this._handleRefUpdate.bind(this); 145 | this._update = this._update.bind(this); 146 | this._notifyPositioning = this._notifyPositioning.bind(this); 147 | 148 | this._scheduleUpdate = createScheduler(this._update, requestAnimationFrame); 149 | this._schedulePositioningNotification = createScheduler( 150 | this._notifyPositioning, 151 | requestAnimationFrame 152 | ); 153 | this._handleScroll = throttle(this._scheduleUpdate, 100, { trailing: true }); 154 | } 155 | 156 | _handleRefUpdate(ref) { 157 | this._listRef = ref; 158 | } 159 | 160 | _onHeightsUpdate(prevPostion, nextPostion) { 161 | this.props.viewport.scrollBy(offsetCorrection(prevPostion, nextPostion)); 162 | } 163 | 164 | _recordHeights() { 165 | if (!this._listRef) { 166 | return { 167 | heightDelta: 0, 168 | }; 169 | } 170 | 171 | const itemHeights = this._listRef.getItemHeights(); 172 | 173 | const heightDelta = Object.keys(itemHeights).reduce((accHeight, key) => { 174 | const itemHeight = this._heights.hasOwnProperty(key) 175 | ? this._heights[key] 176 | : this.props.assumedItemHeight; 177 | return accHeight + itemHeights[key] - itemHeight; 178 | }, 0); 179 | 180 | if (heightDelta !== 0) { 181 | this._heights = Object.assign({}, this._heights, itemHeights); 182 | } 183 | 184 | return { 185 | heightDelta, 186 | }; 187 | } 188 | 189 | _postRenderProcessing(hasListChanged) { 190 | const heightState = this._recordHeights(); 191 | const wasHeightChange = heightState.heightDelta !== 0; 192 | if ((hasListChanged || wasHeightChange) && this._prevPositioning) { 193 | this._onHeightsUpdate(this._prevPositioning, this.getPositioning()); 194 | } 195 | 196 | if (hasListChanged || Math.abs(heightState.heightDelta) >= this.props.assumedItemHeight) { 197 | this._scheduleUpdate(); 198 | } 199 | this._schedulePositioningNotification(); 200 | } 201 | 202 | _getDefaultSlice(list) { 203 | const startIndex = 0; 204 | const withNewList = { 205 | ...this, 206 | props: { 207 | ...this.props, 208 | list, 209 | }, 210 | }; 211 | 212 | const viewport = this.props.viewport; 213 | const viewportHeight = viewport.getRect().getHeight(); 214 | const rects = this._getRectangles(withNewList); 215 | 216 | const startId = list[startIndex].id; 217 | const startOffset = rects[startId].getTop(); 218 | let endIndex = findIndex( 219 | list, 220 | item => rects[item.id].getTop() - startOffset >= viewportHeight, 221 | startIndex 222 | ); 223 | if (endIndex < 0) { 224 | endIndex = list.length; 225 | } 226 | 227 | return { 228 | sliceStart: startIndex, 229 | sliceEnd: endIndex, 230 | }; 231 | } 232 | 233 | _computeBlankSpace() { 234 | const { list } = this.props; 235 | const { sliceStart, sliceEnd } = this.state; 236 | const rects = this._getRectangles(); 237 | const lastIndex = list.length - 1; 238 | return { 239 | blankSpaceAbove: 240 | list.length <= 0 ? 0 : rects[list[sliceStart].id].getTop() - rects[list[0].id].getTop(), 241 | blankSpaceBelow: 242 | sliceEnd >= list.length 243 | ? 0 244 | : rects[list[lastIndex].id].getBottom() - rects[list[sliceEnd].id].getTop(), 245 | }; 246 | } 247 | 248 | _getRelativeViewportRect() { 249 | if (!this._listRef) { 250 | return new Rectangle({ top: 0, height: 0 }); 251 | } 252 | 253 | const listNode = this._listRef.getWrapperNode(); 254 | // const offsetTop = Math.ceil(listNode.getBoundingClientRect().top); 255 | return this.props.viewport.getRectRelativeTo(listNode); 256 | } 257 | 258 | _update() { 259 | const { list } = this.props; 260 | 261 | if (this._unmounted || 0 === list.length) { 262 | return; 263 | } 264 | 265 | const viewportRect = this._getRelativeViewportRect(); 266 | const offscreenHeight = viewportRect.getHeight() * this.props.offscreenToViewportRatio; 267 | const renderRectTop = viewportRect.getTop() - offscreenHeight; 268 | const renderRectBottom = viewportRect.getBottom() + offscreenHeight; 269 | 270 | const rects = this._getRectangles(); 271 | 272 | let startIndex = findIndex(list, item => rects[item.id].getBottom() > renderRectTop); 273 | if (startIndex < 0) { 274 | startIndex = list.length - 1; 275 | } 276 | 277 | let endIndex = findIndex(list, item => rects[item.id].getTop() >= renderRectBottom, startIndex); 278 | if (endIndex < 0) { 279 | endIndex = list.length; 280 | } 281 | 282 | this._schedulePositioningNotification(); 283 | this._setSlice(startIndex, endIndex); 284 | } 285 | 286 | _setSlice(start, end) { 287 | const { sliceStart, sliceEnd } = this.state; 288 | 289 | if (sliceStart !== start || sliceEnd !== end) { 290 | this.setState({ 291 | sliceStart: start, 292 | sliceEnd: end, 293 | }); 294 | } 295 | } 296 | 297 | _notifyPositioning() { 298 | if (!this._unmounted && this.props.onPositioningUpdate) { 299 | this.props.onPositioningUpdate(this.getPositioning()); 300 | } 301 | } 302 | 303 | scrollToIndex(index) { 304 | const { list, viewport } = this.props; 305 | const targetItem = list[index]; 306 | const rects = this._getRectangles(); 307 | viewport.scrollTo(rects[targetItem.id].getTop() + viewport.getOffsetTop()); 308 | } 309 | 310 | getPositioning() { 311 | const { sliceStart, sliceEnd } = this.state; 312 | return new Position({ 313 | viewportRectangle: this._getRelativeViewportRect(), 314 | list: this.props.list, 315 | rectangles: this._getRectangles(), 316 | sliceStart, 317 | sliceEnd, 318 | }); 319 | } 320 | 321 | componentDidMount() { 322 | this._unlistenScroll = this.props.viewport.addScrollListener(this._handleScroll); 323 | this._postRenderProcessing(true); 324 | } 325 | 326 | componentWillReceiveProps(nextProps) { 327 | const prevList = this.props.list; 328 | const prevState = this.state; 329 | const nextList = nextProps.list; 330 | if (prevList !== nextList) { 331 | const slice = 332 | findNewSlice(prevList, nextList, prevState.sliceStart, prevState.sliceEnd) || 333 | this._getDefaultSlice(nextList); 334 | this._setSlice(slice.sliceStart, slice.sliceEnd); 335 | } 336 | } 337 | 338 | componentWillUpdate() { 339 | this._prevPositioning = this.getPositioning(); 340 | } 341 | 342 | componentDidUpdate(prevProps) { 343 | this._postRenderProcessing(prevProps.list !== this.props.list); 344 | } 345 | 346 | componentWillUnmount() { 347 | this._unmounted = true; 348 | 349 | if (this._unlistenScroll) { 350 | this._unlistenScroll(); 351 | } 352 | } 353 | 354 | render() { 355 | const { blankSpaceAbove, blankSpaceBelow } = this._computeBlankSpace(); 356 | const { sliceStart } = this.state; 357 | 358 | // eslint-disable-next-line 359 | const { renderItem } = this.props; 360 | 361 | return ( 362 | { 368 | return renderItem(data, sliceStart + index); 369 | }} 370 | /> 371 | ); 372 | } 373 | } 374 | 375 | export default Updater; 376 | -------------------------------------------------------------------------------- /src/components/VirtualScroller.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import recomputed from 'recomputed'; 4 | import Updater from './Updater'; 5 | import Viewport from '../modules/Viewport'; 6 | import ScrollTracker, { Condition } from '../modules/ScrollTracker'; 7 | 8 | const defaultIdentityFunction = a => a.id; 9 | 10 | const nullFunction = () => null; 11 | 12 | class VirtualScroller extends React.PureComponent { 13 | static propTypes = { 14 | items: PropTypes.arrayOf(PropTypes.any).isRequired, 15 | renderItem: PropTypes.func.isRequired, 16 | viewport: PropTypes.instanceOf(Viewport).isRequired, 17 | identityFunction: PropTypes.func, 18 | offscreenToViewportRatio: PropTypes.number, 19 | assumedItemHeight: PropTypes.number, 20 | nearEndProximityRatio: PropTypes.number, 21 | nearStartProximityRatio: PropTypes.number, 22 | onAtStart: PropTypes.func, 23 | onNearStart: PropTypes.func, 24 | onNearEnd: PropTypes.func, 25 | onAtEnd: PropTypes.func, 26 | }; 27 | 28 | static defaultProps = { 29 | identityFunction: defaultIdentityFunction, 30 | offscreenToViewportRatio: 1.8, 31 | assumedItemHeight: 400, 32 | nearEndProximityRatio: 1.75, 33 | nearStartProximityRatio: 0.25, 34 | onAtStart: nullFunction, 35 | onNearStart: nullFunction, 36 | onNearEnd: nullFunction, 37 | onAtEnd: nullFunction, 38 | }; 39 | 40 | constructor(props) { 41 | super(props); 42 | 43 | /* eslint-disable no-shadow */ 44 | this._getList = recomputed( 45 | this, 46 | props => props.items, 47 | items => { 48 | const idMap = {}; 49 | const resultList = []; 50 | 51 | items.forEach(item => { 52 | const id = this.props.identityFunction(item); 53 | if (idMap.hasOwnProperty(id)) { 54 | // eslint-disable-next-line no-console 55 | console.warn( 56 | `Duplicate item id generated in VirtualScroller. Latter item (id = "${id}") will be discarded` 57 | ); 58 | return; 59 | } 60 | 61 | resultList.push({ 62 | id, 63 | data: item, 64 | }); 65 | idMap[id] = true; 66 | }); 67 | 68 | return resultList; 69 | } 70 | ); 71 | /* eslint-enable no-shadow */ 72 | 73 | this._handleRefUpdate = this._handleRefUpdate.bind(this); 74 | this._handlePositioningUpdate = this._handlePositioningUpdate.bind(this); 75 | this._createScrollTracker(props.nearStartProximityRatio, props.nearEndProximityRatio); 76 | } 77 | 78 | _handleRefUpdate(ref) { 79 | this._updater = ref; 80 | } 81 | 82 | _handlePositioningUpdate(position) { 83 | if (this._scrollTracker) { 84 | this._scrollTracker.handlePositioningUpdate(position); 85 | } 86 | } 87 | 88 | _createScrollTracker(nearStartProximityRatio, nearEndProximityRatio) { 89 | this._scrollTracker = new ScrollTracker([ 90 | { 91 | condition: Condition.nearTop(5), 92 | callback: info => { 93 | return this.props.onAtStart(info); 94 | }, 95 | }, 96 | { 97 | condition: Condition.nearTopRatio(nearStartProximityRatio), 98 | callback: info => { 99 | return this.props.onNearStart(info); 100 | }, 101 | }, 102 | { 103 | condition: Condition.nearBottomRatio(nearEndProximityRatio), 104 | callback: info => { 105 | return this.props.onNearEnd(info); 106 | }, 107 | }, 108 | { 109 | condition: Condition.nearBottom(5), 110 | callback: info => { 111 | return this.props.onAtEnd(info); 112 | }, 113 | }, 114 | ]); 115 | } 116 | 117 | // only can scroll to knwon height item 118 | scrollToIndex(index) { 119 | if (this._updater) { 120 | this._updater.scrollToIndex(index); 121 | } 122 | } 123 | 124 | componentWillReceiveProps(nextProps) { 125 | this._createScrollTracker(nextProps.nearStartProximityRatio, nextProps.nearEndProximityRatio); 126 | } 127 | 128 | render() { 129 | const { renderItem, assumedItemHeight, viewport } = this.props; 130 | 131 | return ( 132 | 140 | ); 141 | } 142 | } 143 | 144 | export default VirtualScroller; 145 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import VirtualScroller from './components/VirtualScroller'; 2 | import Viewport from './modules/Viewport'; 3 | 4 | export default VirtualScroller; 5 | 6 | export { Viewport }; 7 | -------------------------------------------------------------------------------- /src/modules/Position.js: -------------------------------------------------------------------------------- 1 | import Rectangle from './Rectangle'; 2 | import findIndex from '../utlis/findIndex'; 3 | 4 | class Position { 5 | constructor({ viewportRectangle, list, rectangles, sliceStart, sliceEnd }) { 6 | this._viewportRectangle = viewportRectangle; 7 | this._list = list; 8 | this._rectangles = rectangles; 9 | this._sliceStart = sliceStart; 10 | this._sliceEnd = sliceEnd; 11 | } 12 | 13 | getViewportRect() { 14 | return this._viewportRectangle; 15 | } 16 | 17 | getListRect() { 18 | const list = this._list; 19 | if (list.length <= 0) { 20 | return new Rectangle({ 21 | top: 0, 22 | height: 0, 23 | }); 24 | } 25 | 26 | const rects = this._rectangles; 27 | const firstItemId = list[0].id; 28 | const lastItemId = list[list.length - 1].id; 29 | const top = rects[firstItemId].getTop(); 30 | const height = rects[lastItemId].getBottom() - top; 31 | return new Rectangle({ 32 | top, 33 | height, 34 | }); 35 | } 36 | 37 | getAllItems() { 38 | return this._list.map(item => { 39 | const id = item.id; 40 | return { 41 | id, 42 | rectangle: this._rectangles[id], 43 | }; 44 | }); 45 | } 46 | 47 | getList() { 48 | return this._list; 49 | } 50 | 51 | getItemRect(id) { 52 | return this._rectangles[id]; 53 | } 54 | 55 | findVisibleItems() { 56 | const viewportRectangle = this._viewportRectangle; 57 | const rectangles = this._rectangles; 58 | const list = this._list; 59 | const startIndex = findIndex(list, item => { 60 | const id = item.id; 61 | return rectangles[id].doesIntersectWith(viewportRectangle); 62 | }); 63 | if (startIndex < 0) { 64 | return []; 65 | } 66 | 67 | let endIndex = findIndex( 68 | list, 69 | item => { 70 | const id = item.id; 71 | return !rectangles[id].doesIntersectWith(viewportRectangle); 72 | }, 73 | startIndex 74 | ); 75 | if (endIndex < 0) { 76 | endIndex = list.length; 77 | } 78 | 79 | return list 80 | .slice(startIndex, endIndex) 81 | .filter(item => { 82 | const id = item.id; 83 | return this.isRendered(id); 84 | }) 85 | .map(item => { 86 | const id = item.id; 87 | return { 88 | id, 89 | rectangle: rectangles[id], 90 | }; 91 | }); 92 | } 93 | 94 | getRenderedItems() { 95 | const rectangles = this._rectangles; 96 | return this._list.slice(this._sliceStart, this._sliceEnd).map(item => { 97 | const id = item.id; 98 | return { 99 | id, 100 | rectangle: rectangles[id], 101 | }; 102 | }); 103 | } 104 | 105 | isRendered(id) { 106 | return this._getRenderedIdSet().hasOwnProperty(id); 107 | } 108 | 109 | _getRenderedIdSet() { 110 | if (!this._renderedIdSet) { 111 | this._renderedIdSet = {}; 112 | for (let t = this._sliceStart; t < this._sliceEnd; t++) { 113 | this._renderedIdSet[this._list[t].id] = true; 114 | } 115 | } 116 | 117 | return this._renderedIdSet; 118 | } 119 | } 120 | 121 | export default Position; 122 | -------------------------------------------------------------------------------- /src/modules/Rectangle.js: -------------------------------------------------------------------------------- 1 | function isBetween(value, begin, end) { 2 | return value >= begin && value < end; 3 | } 4 | 5 | class Rectangle { 6 | constructor({ top = 0, left = 0, height = 0, width = 0 } = {}) { 7 | this._top = top; 8 | this._left = left; 9 | this._height = height; 10 | this._width = width; 11 | } 12 | 13 | getTop() { 14 | return this._top; 15 | } 16 | 17 | getBottom() { 18 | return this._top + this._height; 19 | } 20 | 21 | getLeft() { 22 | return this._left; 23 | } 24 | 25 | getRight() { 26 | return this._left + this._width; 27 | } 28 | 29 | getHeight() { 30 | return this._height; 31 | } 32 | 33 | getWidth() { 34 | return this._width; 35 | } 36 | 37 | doesIntersectWith(rect) { 38 | const top = this.getTop(); 39 | const bottom = this.getBottom(); 40 | const left = this.getLeft(); 41 | const right = this.getRight(); 42 | const aTop = rect.getTop(); 43 | const aBottom = rect.getBottom(); 44 | const aLeft = rect.getLeft(); 45 | const aRight = rect.getRight(); 46 | 47 | return ( 48 | isBetween(top, aTop, aBottom) || 49 | isBetween(aTop, top, bottom) || 50 | isBetween(left, aLeft, aRight) || 51 | isBetween(aLeft, left, right) 52 | ); 53 | } 54 | 55 | contains(point) { 56 | if (point.x === undefined) { 57 | return isBetween(point.y, this.getTop(), this.getBottom()); 58 | } else if (point.y === undefined) { 59 | return isBetween(point.x, this.getLeft(), this.getRight()); 60 | } else { 61 | return ( 62 | isBetween(point.y, this.getTop(), this.getBottom()) && 63 | isBetween(point.x, this.getLeft(), this.getRight()) 64 | ); 65 | } 66 | } 67 | 68 | translateBy(x, y) { 69 | let left = this.getLeft(); 70 | let top = this.getTop(); 71 | 72 | if (x) { 73 | left += x; 74 | } 75 | 76 | if (y) { 77 | top += y; 78 | } 79 | 80 | return new Rectangle({ 81 | left, 82 | top, 83 | width: this.getWidth(), 84 | height: this.getHeight(), 85 | }); 86 | } 87 | } 88 | 89 | export default Rectangle; 90 | -------------------------------------------------------------------------------- /src/modules/ScrollTracker.js: -------------------------------------------------------------------------------- 1 | const PROXIMITY = { 2 | INSIDE: 'inside', 3 | OUTSIDE: 'outside', 4 | }; 5 | 6 | const TRIGGER_CAUSE = { 7 | INITIAL_POSITION: 'init', 8 | MOVEMENT: 'movement', 9 | LIST_UPDATE: 'list-update', 10 | }; 11 | 12 | function getProximity(condition, position) { 13 | return condition(position.getListRect(), position.getViewportRect()) 14 | ? PROXIMITY.INSIDE 15 | : PROXIMITY.OUTSIDE; 16 | } 17 | 18 | function findCause(prevState, nextState) { 19 | const isInit = !prevState.proximity && nextState.proximity === PROXIMITY.INSIDE; 20 | if (isInit) { 21 | return TRIGGER_CAUSE.INITIAL_POSITION; 22 | } 23 | 24 | const isMovement = 25 | prevState.proximity === PROXIMITY.OUTSIDE && nextState.proximity === PROXIMITY.INSIDE; 26 | if (isMovement) { 27 | return TRIGGER_CAUSE.MOVEMENT; 28 | } 29 | 30 | const stay = prevState.proximity === PROXIMITY.INSIDE && nextState.proximity === PROXIMITY.INSIDE; 31 | if (stay && prevState.listLength !== nextState.listLength) { 32 | return TRIGGER_CAUSE.LIST_UPDATE; 33 | } 34 | 35 | return null; 36 | } 37 | 38 | const Condition = { 39 | nearTop(distance) { 40 | return (list, viewport) => { 41 | return viewport.getTop() - list.getTop() <= distance; 42 | }; 43 | }, 44 | nearBottom(distance) { 45 | return (list, viewport) => { 46 | return list.getBottom() - viewport.getBottom() <= distance; 47 | }; 48 | }, 49 | nearTopRatio(ratio) { 50 | return (list, viewport) => { 51 | const viewportHeight = viewport.getHeight(); 52 | const distance = ratio * viewportHeight; 53 | return viewport.getTop() - list.getTop() <= distance; 54 | }; 55 | }, 56 | nearBottomRatio(ratio) { 57 | return (list, viewport) => { 58 | const viewportHeight = viewport.getHeight(); 59 | const distance = ratio * viewportHeight; 60 | return list.getBottom() - viewport.getBottom() <= distance; 61 | }; 62 | }, 63 | }; 64 | 65 | class ScrollTracker { 66 | constructor(zones) { 67 | this._handlers = zones.map(zone => ({ 68 | zone, 69 | state: {}, 70 | })); 71 | } 72 | 73 | handlePositioningUpdate(position) { 74 | this._handlers.forEach(({ zone, state }) => { 75 | const { condition, callback } = zone; 76 | const newProximity = getProximity(condition, position); 77 | const newListLength = position.getList().length; 78 | const triggerCause = findCause(state, { 79 | proximity: newProximity, 80 | listLength: newListLength, 81 | }); 82 | 83 | /* eslint-disable no-param-reassign */ 84 | state.proximity = newProximity; 85 | state.listLength = newListLength; 86 | /* eslint-enable */ 87 | 88 | if (triggerCause) { 89 | callback({ 90 | triggerCause, 91 | }); 92 | } 93 | }); 94 | } 95 | } 96 | 97 | export default ScrollTracker; 98 | 99 | export { Condition }; 100 | -------------------------------------------------------------------------------- /src/modules/Viewport.js: -------------------------------------------------------------------------------- 1 | import Rectangle from './Rectangle'; 2 | 3 | class Viewport { 4 | constructor(window, scroller = window) { 5 | this._scroller = scroller; 6 | this._window = window; 7 | this._programticScrollListeners = []; 8 | this._offsetTop = 0; 9 | this._useWindow = this._scroller === this._window; 10 | } 11 | 12 | _getScrollerHeight() { 13 | if (this._useWindow) { 14 | return Math.ceil(this._window.document.documentElement.clientHeight); 15 | } 16 | 17 | return this._scroller.clientHeight; 18 | } 19 | 20 | _addListener(event, listener, useWindow) { 21 | const target = useWindow ? this._window : this._scroller; 22 | 23 | const eventCallback = () => { 24 | return listener(); 25 | }; 26 | 27 | target.addEventListener(event, eventCallback); 28 | 29 | return () => { 30 | target.removeEventListener(event, eventCallback); 31 | }; 32 | } 33 | 34 | getRectRelativeTo(node) { 35 | const top = node.getBoundingClientRect().top; 36 | const scrollerTop = this._useWindow ? 0 : this._scroller.getBoundingClientRect().top; 37 | 38 | return this.getRect().translateBy(0, Math.ceil(scrollerTop - top)); 39 | } 40 | 41 | getOffsetTop() { 42 | return this._offsetTop; 43 | } 44 | 45 | setOffsetTop(value) { 46 | this._offsetTop = Math.ceil(value); 47 | } 48 | 49 | getRect() { 50 | const windowHeight = this._getScrollerHeight(); 51 | const height = Math.max(0, windowHeight - this._offsetTop); 52 | return new Rectangle({ top: this._offsetTop, height }); 53 | } 54 | 55 | // get scroll left 56 | scrollX() { 57 | if (this._useWindow) { 58 | return -1 * this._window.document.body.getBoundingClientRect().left; 59 | } 60 | 61 | return this._scroller.scrollLeft; 62 | } 63 | 64 | // get scroll top 65 | scrollY() { 66 | if (this._useWindow) { 67 | return -1 * this._window.document.body.getBoundingClientRect().top; 68 | } 69 | 70 | return this._scroller.scrollTop; 71 | } 72 | 73 | scrollBy(vertically) { 74 | if (this._useWindow) { 75 | this._window.scrollBy(0, vertically); 76 | } else { 77 | this._scroller.scrollTop += vertically; 78 | } 79 | 80 | this._programticScrollListeners.forEach(listener => listener(vertically)); 81 | } 82 | 83 | scrollTo(yPos) { 84 | if (this._useWindow) { 85 | this._window.scrollTo(0, yPos); 86 | } else { 87 | this._scroller.scrollTop = yPos; 88 | } 89 | 90 | this._programticScrollListeners.forEach(listener => listener(yPos)); 91 | } 92 | 93 | addRectChangeListener(listener) { 94 | return this._addListener('resize', listener, true); 95 | } 96 | 97 | addScrollListener(listener) { 98 | return this._addListener('scroll', listener, false); 99 | } 100 | 101 | // listener triggered by programmatic scroll 102 | addProgrammaticScrollListener(listener) { 103 | if (this._programticScrollListeners.indexOf(listener) < 0) 104 | this._programticScrollListeners.push(listener); 105 | return () => this.removeProgrammaticScrollListener(listener); 106 | } 107 | 108 | removeProgrammaticScrollListener(listener) { 109 | const index = this._programticScrollListeners.indexOf(listener); 110 | if (index > -1) this._programticScrollListeners.splice(index, 1); 111 | } 112 | } 113 | 114 | export default Viewport; 115 | -------------------------------------------------------------------------------- /src/modules/requestAnimationFrame.js: -------------------------------------------------------------------------------- 1 | const requestAnimationFrame = (window.requestAnimationFrame = 2 | window.requestAnimationFrame || 3 | window.webkitRequestAnimationFrame || 4 | window.mozRequestAnimationFrame || 5 | window.oRequestAnimationFrame || 6 | window.msRequestAnimationFrame || 7 | function(cb) { 8 | window.setTimeout(cb, 1000 / 60); 9 | }); 10 | 11 | export default requestAnimationFrame; 12 | -------------------------------------------------------------------------------- /src/utlis/createScheduler.js: -------------------------------------------------------------------------------- 1 | function createScheduler(callback, scheduler) { 2 | let ticking = false; 3 | 4 | const update = () => { 5 | ticking = false; 6 | callback(); 7 | }; 8 | 9 | const requestTick = () => { 10 | if (!ticking) { 11 | scheduler(update); 12 | } 13 | ticking = true; 14 | }; 15 | 16 | return requestTick; 17 | } 18 | 19 | export default createScheduler; 20 | -------------------------------------------------------------------------------- /src/utlis/findIndex.js: -------------------------------------------------------------------------------- 1 | function findIndex(list, predictor, startIndex = 0) { 2 | for (let index = 0 + startIndex; index < list.length; index++) { 3 | if (predictor(list[index], index)) { 4 | return index; 5 | } 6 | } 7 | 8 | return -1; 9 | } 10 | 11 | export default findIndex; 12 | -------------------------------------------------------------------------------- /src/utlis/findNewSlice.js: -------------------------------------------------------------------------------- 1 | import findIndex from './findIndex'; 2 | 3 | function searchIndexWhen(list, initSearchIndex, predicator) { 4 | if (initSearchIndex < 0 || initSearchIndex >= list.length) { 5 | return -1; 6 | } 7 | 8 | if (predicator(list[initSearchIndex])) { 9 | return initSearchIndex; 10 | } 11 | 12 | for (let step = 1; ; step++) { 13 | const back = initSearchIndex - step; 14 | const forward = initSearchIndex + step; 15 | const illegal = back < 0; 16 | const outOfBound = forward >= list.length; 17 | if (illegal && outOfBound) { 18 | break; 19 | } 20 | if (!outOfBound && predicator(list[forward])) { 21 | return forward; 22 | } 23 | 24 | if (!illegal && predicator(list[back])) { 25 | return back; 26 | } 27 | } 28 | 29 | return -1; 30 | } 31 | 32 | function findNewSlice(originList, newList, sliceStart, sliceEnd) { 33 | const newIds = newList.reduce((ids, item) => { 34 | // eslint-disable-next-line no-param-reassign 35 | ids[item.id] = true; 36 | return ids; 37 | }, {}); 38 | 39 | const commonItemIndex = searchIndexWhen(originList, sliceStart, item => newIds[item.id]); 40 | if (-1 === commonItemIndex) { 41 | return null; 42 | } 43 | 44 | const newSliceStart = findIndex(newList, item => originList[commonItemIndex].id === item.id); 45 | 46 | return { 47 | sliceStart: newSliceStart, 48 | sliceEnd: Math.min(newList.length, newSliceStart + sliceEnd - sliceStart), 49 | }; 50 | } 51 | 52 | export default findNewSlice; 53 | -------------------------------------------------------------------------------- /test/findNewSlice.js: -------------------------------------------------------------------------------- 1 | import findNewSlice from '../src/utlis/findNewSlice'; 2 | 3 | describe('findNewSlice', () => { 4 | test('should find new slice when insert new elements at start', () => { 5 | const slice = findNewSlice( 6 | [{ id: 1 }, { id: 2 }, { id: 3 }], 7 | [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }], 8 | 1, 9 | 3 10 | ); 11 | 12 | expect(slice).toEqual({ 13 | sliceStart: 2, 14 | sliceEnd: 4, 15 | }); 16 | }); 17 | 18 | test('should find new slice when insert new elements at end', () => { 19 | const slice = findNewSlice( 20 | [{ id: 1 }, { id: 2 }, { id: 3 }], 21 | [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4}], 22 | 1, 23 | 3 24 | ); 25 | 26 | expect(slice).toEqual({ 27 | sliceStart: 1, 28 | sliceEnd: 3, 29 | }); 30 | }); 31 | 32 | test('should find new slice when delete some element', () => { 33 | const slice = findNewSlice( 34 | [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }], 35 | [{ id: 1 }, { id: 3 }, { id: 5}, { id: 6 }], 36 | 1, 37 | 5 38 | ); 39 | 40 | expect(slice).toEqual({ 41 | sliceStart: 1, 42 | sliceEnd: 4, 43 | }); 44 | }); 45 | 46 | test('should not find new slice when no common item', () => { 47 | const slice = findNewSlice( 48 | [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }], 49 | [{ id: 7 }, { id: 8 }, { id: 9 }], 50 | 1, 51 | 5 52 | ); 53 | 54 | expect(slice).toBe(null); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | lib/core/metadata.js 4 | lib/core/MetadataBlog.js 5 | website/translated_docs 6 | website/build/ 7 | website/yarn.lock 8 | website/node_modules 9 | 10 | website/i18n/* 11 | !website/i18n/en.json 12 | -------------------------------------------------------------------------------- /website/core/Footer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | class Footer extends React.Component { 11 | docUrl(doc, language) { 12 | const baseUrl = this.props.config.baseUrl; 13 | return baseUrl + 'docs/' + (language ? language + '/' : '') + doc; 14 | } 15 | 16 | pageUrl(doc, language) { 17 | const baseUrl = this.props.config.baseUrl; 18 | return baseUrl + (language ? language + '/' : '') + doc; 19 | } 20 | 21 | render() { 22 | return null; 23 | // const currentYear = new Date().getFullYear(); 24 | // return ( 25 | // 81 | // ); 82 | } 83 | } 84 | 85 | module.exports = Footer; 86 | -------------------------------------------------------------------------------- /website/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment": "This file is auto-generated by write-translations.js", 3 | "localized-strings": { 4 | "next": "Next", 5 | "previous": "Previous", 6 | "tagline": "Using virtual scroller with infinite list", 7 | "Viewport": "Viewport", 8 | "VirtualScroller": "VirtualScroller", 9 | "API": "API", 10 | "GitHub": "GitHub", 11 | "Components": "Components", 12 | "Modules": "Modules" 13 | }, 14 | "pages-strings": { 15 | "Help Translate|recruit community translators for your project": "Help Translate", 16 | "Edit this Doc|recruitment message asking to edit the doc source": "Edit", 17 | "Translate this Doc|recruitment message asking to translate the docs": "Translate" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "examples": "docusaurus-examples", 4 | "start": "docusaurus-start", 5 | "build": "docusaurus-build", 6 | "publish-gh-pages": "GIT_USER=liximomo USE_SSH=true docusaurus-publish", 7 | "write-translations": "docusaurus-write-translations", 8 | "version": "docusaurus-version", 9 | "rename-version": "docusaurus-rename-version" 10 | }, 11 | "devDependencies": { 12 | "docusaurus": "^1.0.7" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /website/pages/demo-simple-list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Simple List 8 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /website/pages/demo-window-as-scroller.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Window as scroller 8 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /website/pages/en/demo-index.js: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | 3 | const CompLibrary = require('../../core/CompLibrary.js'); 4 | const Container = CompLibrary.Container; 5 | const GridBlock = CompLibrary.GridBlock; 6 | 7 | const siteConfig = require(process.cwd() + '/siteConfig.js'); 8 | 9 | function pageUrl(page, language) { 10 | return siteConfig.baseUrl + (language ? language + '/' : '') + page; 11 | } 12 | 13 | const Block = props => ( 14 | 18 | 19 | 20 | ); 21 | 22 | class Index extends React.Component { 23 | render() { 24 | return ( 25 |
26 | 27 | {[ 28 | {}, 29 | { 30 | title: `[Simple list](${pageUrl('demo-simple-list.html')})`, 31 | }, 32 | {}, 33 | ]} 34 | 35 | 36 | {[ 37 | {}, 38 | { 39 | title: `[Window scroller](${pageUrl('demo-window-as-scroller.html')})`, 40 | }, 41 | {}, 42 | ]} 43 | 44 |
45 | ); 46 | } 47 | } 48 | 49 | module.exports = Index; 50 | -------------------------------------------------------------------------------- /website/pages/en/help.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | const CompLibrary = require('../../core/CompLibrary.js'); 11 | const Container = CompLibrary.Container; 12 | const GridBlock = CompLibrary.GridBlock; 13 | 14 | const siteConfig = require(process.cwd() + '/siteConfig.js'); 15 | 16 | class Help extends React.Component { 17 | render() { 18 | const supportLinks = [ 19 | { 20 | content: 21 | 'Learn more using the [documentation on this site.](/test-site/docs/en/doc1.html)', 22 | title: 'Browse Docs', 23 | }, 24 | { 25 | content: 'Ask questions about the documentation and project', 26 | title: 'Join the community', 27 | }, 28 | { 29 | content: "Find out what's new with this project", 30 | title: 'Stay up to date', 31 | }, 32 | ]; 33 | 34 | return ( 35 |
36 | 37 |
38 |
39 |

Need help?

40 |
41 |

This project is maintained by a dedicated group of people.

42 | 43 |
44 |
45 |
46 | ); 47 | } 48 | } 49 | 50 | module.exports = Help; 51 | -------------------------------------------------------------------------------- /website/pages/en/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | const CompLibrary = require('../../core/CompLibrary.js'); 11 | const MarkdownBlock = CompLibrary.MarkdownBlock; /* Used to read markdown */ 12 | const Container = CompLibrary.Container; 13 | const GridBlock = CompLibrary.GridBlock; 14 | 15 | const siteConfig = require(process.cwd() + '/siteConfig.js'); 16 | 17 | function imgUrl(img) { 18 | return siteConfig.baseUrl + 'img/' + img; 19 | } 20 | 21 | function docUrl(doc, language) { 22 | return siteConfig.baseUrl + 'docs/' + (language ? language + '/' : '') + doc; 23 | } 24 | 25 | function pageUrl(page, language) { 26 | return siteConfig.baseUrl + (language ? language + '/' : '') + page; 27 | } 28 | 29 | class Button extends React.Component { 30 | render() { 31 | return ( 32 |
33 | 34 | {this.props.children} 35 | 36 |
37 | ); 38 | } 39 | } 40 | 41 | Button.defaultProps = { 42 | target: '_self', 43 | }; 44 | 45 | const SplashContainer = props => ( 46 |
47 |
48 |
{props.children}
49 |
50 |
51 | ); 52 | 53 | const Logo = props => ( 54 |
55 | 56 |
57 | ); 58 | 59 | const ProjectTitle = props => ( 60 |

61 | {siteConfig.title} 62 | {siteConfig.tagline} 63 |

64 | ); 65 | 66 | const PromoSection = props => ( 67 |
68 |
69 |
{props.children}
70 |
71 |
72 | ); 73 | 74 | class HomeSplash extends React.Component { 75 | render() { 76 | let language = this.props.language || ''; 77 | return ( 78 | 79 | 80 |
81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 | ); 89 | } 90 | } 91 | 92 | const Block = props => ( 93 | 97 | 98 | 99 | ); 100 | 101 | const Features = props => ( 102 |
103 | 104 | {[ 105 | { 106 | title: 'Easy To Use', 107 | }, 108 | { 109 | title: 'Infinite List', 110 | }, 111 | ]} 112 | 113 | 114 | {[ 115 | { 116 | title: 'Scroll Load & Scroll Position Restoration', 117 | }, 118 | { 119 | title: 'Custom List Container', 120 | }, 121 | ]} 122 | 123 |
124 | ); 125 | 126 | const FeatureCallout = props => ( 127 |
130 |

Feature Callout

131 | These are features of this project 132 |
133 | ); 134 | 135 | class Index extends React.Component { 136 | render() { 137 | let language = this.props.language || ''; 138 | 139 | return ( 140 |
141 | 142 |
143 | 144 |
145 |
146 | ); 147 | } 148 | } 149 | 150 | module.exports = Index; 151 | -------------------------------------------------------------------------------- /website/pages/en/users.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | const CompLibrary = require('../../core/CompLibrary.js'); 11 | const Container = CompLibrary.Container; 12 | 13 | const siteConfig = require(process.cwd() + '/siteConfig.js'); 14 | 15 | class Users extends React.Component { 16 | render() { 17 | const showcase = siteConfig.users.map((user, i) => { 18 | return ( 19 | 20 | 21 | 22 | ); 23 | }); 24 | 25 | return ( 26 |
27 | 28 |
29 |
30 |

Who's Using This?

31 |

This project is used by many folks

32 |
33 |
{showcase}
34 |

Are you using this project?

35 | 38 | Add your company 39 | 40 |
41 |
42 |
43 | ); 44 | } 45 | } 46 | 47 | module.exports = Users; 48 | -------------------------------------------------------------------------------- /website/sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "api": { 3 | "Components": ["VirtualScroller"], 4 | "Modules": ["Viewport"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /website/siteConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /* List of projects/orgs using your project for the users page */ 9 | const users = [ 10 | { 11 | caption: 'User1', 12 | image: '/test-site/img/docusaurus.svg', 13 | infoLink: 'https://www.facebook.com', 14 | pinned: true, 15 | }, 16 | ]; 17 | 18 | const siteConfig = { 19 | title: 'React Virtual Scroller' /* title for your website */, 20 | tagline: 'Using virtual scroller with infinite list', 21 | url: 'https://liximomo.github.io' /* your website url */, 22 | baseUrl: '/react-virtual-scroller/' /* base url for your project */, 23 | projectName: 'react-virtual-scroller', 24 | headerLinks: [ 25 | {doc: 'VirtualScroller', label: 'API'}, 26 | { href: 'https://github.com/liximomo/react-virtual-scroller', label: 'GitHub' }, 27 | // {blog: true, label: 'Blog'}, 28 | ], 29 | users, 30 | /* path to images for header/footer */ 31 | headerIcon: 'img/docusaurus.svg', 32 | footerIcon: 'img/docusaurus.svg', 33 | favicon: 'img/favicon.png', 34 | /* colors for website */ 35 | colors: { 36 | primaryColor: '#009688', 37 | secondaryColor: '#6e7891', 38 | }, 39 | /* custom fonts for website */ 40 | /*fonts: { 41 | myFont: [ 42 | "Times New Roman", 43 | "Serif" 44 | ], 45 | myOtherFont: [ 46 | "-apple-system", 47 | "system-ui" 48 | ] 49 | },*/ 50 | // This copyright info is used in /core/Footer.js and blog rss/atom feeds. 51 | copyright: 52 | 'Copyright © ' + 53 | new Date().getFullYear() + 54 | ' liximomo', 55 | organizationName: 'liximomo', // or set an env variable ORGANIZATION_NAME 56 | highlight: { 57 | // Highlight.js theme to use for syntax highlighting in code blocks 58 | theme: 'default', 59 | }, 60 | scripts: ['https://buttons.github.io/buttons.js'], 61 | // You may provide arbitrary config keys to be used as needed by your template. 62 | repoUrl: 'https://github.com/liximomo/react-virtual-scroller', 63 | }; 64 | 65 | module.exports = siteConfig; 66 | -------------------------------------------------------------------------------- /website/static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* your custom css */ 2 | 3 | @media only screen and (min-device-width: 360px) and (max-device-width: 736px) { 4 | } 5 | 6 | @media only screen and (min-width: 1024px) { 7 | } 8 | 9 | @media only screen and (max-width: 1023px) { 10 | } 11 | 12 | @media only screen and (min-width: 1400px) { 13 | } 14 | 15 | @media only screen and (min-width: 1500px) { 16 | } -------------------------------------------------------------------------------- /website/static/img/docusaurus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liximomo/react-virtual-scroller/3e4793bbbfcc7f6d1fed3d49fd8f7b3262978853/website/static/img/favicon.png -------------------------------------------------------------------------------- /website/static/img/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liximomo/react-virtual-scroller/3e4793bbbfcc7f6d1fed3d49fd8f7b3262978853/website/static/img/favicon/favicon.ico -------------------------------------------------------------------------------- /website/static/img/oss_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liximomo/react-virtual-scroller/3e4793bbbfcc7f6d1fed3d49fd8f7b3262978853/website/static/img/oss_logo.png -------------------------------------------------------------------------------- /website/static/js/react-virtual-scroller.umd.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"===typeof exports&&"undefined"!==typeof module?e(exports,require("react")):"function"===typeof define&&define.amd?define(["exports","react"],e):e(t.VirtualScroller={},t.React)}(this,function(t,e){"use strict";e=e&&e.hasOwnProperty("default")?e.default:e;var n="undefined"!==typeof window?window:"undefined"!==typeof global?global:"undefined"!==typeof self?self:{};function i(t,e){return t(e={exports:{}},e.exports),e.exports}function r(t){return function(){return t}}var o=function(){};o.thatReturns=r,o.thatReturnsFalse=r(!1),o.thatReturnsTrue=r(!0),o.thatReturnsNull=r(null),o.thatReturnsThis=function(){return this},o.thatReturnsArgument=function(t){return t};var s=o;var u,a=function(t,e,n,i,r,o,s,u){if(!t){var a;if(void 0===e)a=new Error("Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.");else{var c=[n,i,r,o,s,u],l=0;(a=new Error(e.replace(/%s/g,function(){return c[l++]}))).name="Invariant Violation"}throw a.framesToPop=1,a}},c="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED",l=(i(function(t){t.exports=function(){function t(t,e,n,i,r,o){o!==c&&a(!1,"Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types")}function e(){return t}t.isRequired=t;var n={array:t,bool:t,func:t,number:t,object:t,string:t,symbol:t,any:t,arrayOf:e,element:t,instanceOf:e,node:t,objectOf:e,oneOf:e,oneOfType:e,shape:e,exact:e};return n.checkPropTypes=s,n.PropTypes=n,n}()}),i(function(t,e){function n(t,e){return t===e}function i(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:n,i=null,r=null;return function(){return function(t,e,n){if(null===e||null===n||e.length!==n.length)return!1;for(var i=e.length,r=0;r1?e-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:o;if("object"!==typeof t)throw new Error("createStructuredSelector expects first argument to be an object where each property is a selector, instead received a "+typeof t);var n=Object.keys(t);return e(n.map(function(e){return t[e]}),function(){for(var t=arguments.length,e=Array(t),i=0;i1?e-1:0),i=1;i0&&void 0!==arguments[0]?arguments[0]:t;return r.apply(void 0,h(d(e)).concat([e]))}},g="Expected a function",v=NaN,_="[object Symbol]",y=/^\s+|\s+$/g,m=/^[-+]0x[0-9a-f]+$/i,w=/^0b[01]+$/i,S=/^0o[0-7]+$/i,R=parseInt,k="object"==typeof n&&n&&n.Object===Object&&n,b="object"==typeof self&&self&&self.Object===Object&&self,T=k||b||Function("return this")(),I=Object.prototype.toString,P=Math.max,E=Math.min,O=function(){return T.Date.now()};function L(t,e,n){var i,r,o,s,u,a,c=0,l=!1,f=!1,h=!0;if("function"!=typeof t)throw new TypeError(g);function d(e){var n=i,o=r;return i=r=void 0,c=e,s=t.apply(o,n)}function p(t){var n=t-a;return void 0===a||n>=e||n<0||f&&t-c>=o}function v(){var t,n,i=O();if(p(i))return _(i);u=setTimeout(v,(n=e-((t=i)-a),f?E(n,o-(t-c)):n))}function _(t){return u=void 0,h&&i?d(t):(i=r=void 0,s)}function y(){var t,n=O(),o=p(n);if(i=arguments,r=this,a=n,o){if(void 0===u)return c=t=a,u=setTimeout(v,e),l?d(t):s;if(f)return u=setTimeout(v,e),d(a)}return void 0===u&&(u=setTimeout(v,e)),s}return e=U(e)||0,x(n)&&(l=!!n.leading,o=(f="maxWait"in n)?P(U(n.maxWait)||0,e):o,h="trailing"in n?!!n.trailing:h),y.cancel=function(){void 0!==u&&clearTimeout(u),c=0,i=a=r=u=void 0},y.flush=function(){return void 0===u?s:_(O())},y}function x(t){var e=typeof t;return!!t&&("object"==e||"function"==e)}function U(t){if("number"==typeof t)return t;if("symbol"==typeof(e=t)||(n=e)&&"object"==typeof n&&I.call(e)==_)return v;var e,n;if(x(t)){var i="function"==typeof t.valueOf?t.valueOf():t;t=x(i)?i+"":i}if("string"!=typeof t)return 0===t?t:+t;t=t.replace(y,"");var r=w.test(t);return r||S.test(t)?R(t.slice(2),r?2:8):m.test(t)?v:+t}var j=function(t,e,n){var i=!0,r=!0;if("function"!=typeof t)throw new TypeError(g);return x(n)&&(i="leading"in n?!!n.leading:i,r="trailing"in n?!!n.trailing:r),L(t,e,{leading:i,maxWait:e,trailing:r})},B=function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")},A=function(){function t(t,e){for(var n=0;n2&&void 0!==arguments[2]?arguments[2]:0);n=e&&t0&&void 0!==arguments[0]?arguments[0]:{},n=e.top,i=void 0===n?0:n,r=e.left,o=void 0===r?0:r,s=e.height,u=void 0===s?0:s,a=e.width,c=void 0===a?0:a;B(this,t),this._top=i,this._left=o,this._height=u,this._width=c}return A(t,[{key:"getTop",value:function(){return this._top}},{key:"getBottom",value:function(){return this._top+this._height}},{key:"getLeft",value:function(){return this._left}},{key:"getRight",value:function(){return this._left+this._width}},{key:"getHeight",value:function(){return this._height}},{key:"getWidth",value:function(){return this._width}},{key:"doesIntersectWith",value:function(t){var e=this.getTop(),n=this.getBottom(),i=this.getLeft(),r=this.getRight(),o=t.getTop(),s=t.getBottom(),u=t.getLeft(),a=t.getRight();return C(e,o,s)||C(o,e,n)||C(i,u,a)||C(u,i,r)}},{key:"contains",value:function(t){return void 0===t.x?C(t.y,this.getTop(),this.getBottom()):void 0===t.y?C(t.x,this.getLeft(),this.getRight()):C(t.y,this.getTop(),this.getBottom())&&C(t.x,this.getLeft(),this.getRight())}},{key:"translateBy",value:function(e,n){var i=this.getLeft(),r=this.getTop();return e&&(i+=e),n&&(r+=n),new t({left:i,top:r,width:this.getWidth(),height:this.getHeight()})}}]),t}(),z=function(){function t(e){var n=e.viewportRectangle,i=e.list,r=e.rectangles,o=e.sliceStart,s=e.sliceEnd;B(this,t),this._viewportRectangle=n,this._list=i,this._rectangles=r,this._sliceStart=o,this._sliceEnd=s}return A(t,[{key:"getViewportRect",value:function(){return this._viewportRectangle}},{key:"getListRect",value:function(){var t=this._list;if(t.length<=0)return new F({top:0,height:0});var e=this._rectangles,n=t[0].id,i=t[t.length-1].id,r=e[n].getTop(),o=e[i].getBottom()-r;return new F({top:r,height:o})}},{key:"getAllItems",value:function(){var t=this;return this._list.map(function(e){var n=e.id;return{id:n,rectangle:t._rectangles[n]}})}},{key:"getList",value:function(){return this._list}},{key:"getItemRect",value:function(t){return this._rectangles[t]}},{key:"findVisibleItems",value:function(){var t=this,e=this._viewportRectangle,n=this._rectangles,i=this._list,r=M(i,function(t){var i=t.id;return n[i].doesIntersectWith(e)});if(r<0)return[];var o=M(i,function(t){var i=t.id;return!n[i].doesIntersectWith(e)},r);return o<0&&(o=i.length),i.slice(r,o).filter(function(e){var n=e.id;return t.isRendered(n)}).map(function(t){var e=t.id;return{id:e,rectangle:n[e]}})}},{key:"getRenderedItems",value:function(){var t=this._rectangles;return this._list.slice(this._sliceStart,this._sliceEnd).map(function(e){var n=e.id;return{id:n,rectangle:t[n]}})}},{key:"isRendered",value:function(t){return this._getRenderedIdSet().hasOwnProperty(t)}},{key:"_getRenderedIdSet",value:function(){if(!this._renderedIdSet){this._renderedIdSet={};for(var t=this._sliceStart;t1&&void 0!==arguments[1]?arguments[1]:e;B(this,t),this._scroller=n,this._window=e,this._programticScrollListeners=[],this._offsetTop=0,this._useWindow=this._scroller===this._window}return A(t,[{key:"_getScrollerHeight",value:function(){return this._useWindow?Math.ceil(this._window.document.documentElement.clientHeight):this._scroller.clientHeight}},{key:"_addListener",value:function(t,e,n){var i=n?this._window:this._scroller,r=function(){return e()};return i.addEventListener(t,r),function(){i.removeEventListener(t,r)}}},{key:"getRectRelativeTo",value:function(t){var e=t.getBoundingClientRect().top,n=this._useWindow?0:this._scroller.getBoundingClientRect().top;return this.getRect().translateBy(0,Math.ceil(n-e))}},{key:"setOffsetTop",value:function(t){this._offsetTop=t}},{key:"getRect",value:function(){var t=this._getScrollerHeight(),e=Math.max(0,t-this._offsetTop);return new F({top:this._offsetTop,height:e})}},{key:"scrollX",value:function(){return this._useWindow?-1*this._window.document.body.getBoundingClientRect().left:this._scroller.scrollLeft}},{key:"scrollY",value:function(){return this._useWindow?-1*this._window.document.body.getBoundingClientRect().top:this._scroller.scrollTop}},{key:"scrollBy",value:function(t){this._useWindow?this._window.scrollBy(0,t):this._scroller.scrollTop+=t,this._programticScrollListeners.forEach(function(e){return e(t)})}},{key:"addRectChangeListener",value:function(t){return this._addListener("resize",t,!0)}},{key:"addScrollListener",value:function(t){return this._addListener("scroll",t,!1)}},{key:"addProgrammaticScrollListener",value:function(t){var e=this;return this._programticScrollListeners.indexOf(t)<0&&this._programticScrollListeners.push(t),function(){return e.removeProgrammaticScrollListener(t)}}},{key:"removeProgrammaticScrollListener",value:function(t){var e=this._programticScrollListeners.indexOf(t);e>-1&&this._programticScrollListeners.splice(e,1)}}]),t}();function $(t,e){var n,i,r,o,s,u,a,c,l=(i=e,s=(n=t).getViewportRect(),u=function(t){return t&&t.doesIntersectWith(s)},a=function(t){return t?Math.abs(s.getTop()-t.getTop()):1/0},(c=i.getList().filter(function(t){var e=t.id;return n.isRendered(e)&&i.isRendered(e)})).length<=0?null:(o=function(t,e){var i,r,o,s,c,l,f,h=n.getItemRect(t.id),d=n.getItemRect(e.id);return c=d,l=(s=u)(h),f=s(c),(l&&!f?1:!l&&f?-1:0)||(r=d,o=(i=a)(h),i(r)-o)},(r=c).length<=0?null:r.reduce(function(t,e){return o(e,t)>0?e:t})));if(!l)return 0;var f=l.id,h=t.getItemRect(f).getTop()-t.getViewportRect().getTop();return e.getItemRect(f).getTop()-e.getViewportRect().getTop()-h}function Y(t,e,n){var i={},r=0;return t.forEach(function(t){var o=t.id,s=void 0!==e[o]?e[o]:n;i[o]=new F({top:r,height:s}),r+=s}),i}var X=function(t){function n(t){B(this,n);var e=D(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,t));return e._heights={},e._getRectangles=p(e,function(t){return t.list},function(t,e,n){return n._heights},function(t){return t.assumedItemHeight},Y),e._getSlice=p(e,function(t){return t.list},function(t,e){return e.sliceStart},function(t,e){return e.sliceEnd},function(t,e,n){return t.slice(e,n)}),e.state=e._getDefaultSlice(t.list),e._handleRefUpdate=e._handleRefUpdate.bind(e),e._update=e._update.bind(e),e._notifyPositioning=e._notifyPositioning.bind(e),e._scheduleUpdate=W(e._update,window.requestAnimationFrame),e._schedulePositioningNotification=W(e._notifyPositioning,window.requestAnimationFrame),e._handleScroll=j(e._scheduleUpdate,100,{trailing:!0}),e}return N(n,t),A(n,[{key:"_handleRefUpdate",value:function(t){this._listRef=t}},{key:"_onHeightsUpdate",value:function(t,e){this.props.viewport.scrollBy($(t,e))}},{key:"_recordHeights",value:function(){var t=this;if(!this._listRef)return{heightDelta:0};var e=this._listRef.getItemHeights(),n=Object.keys(e).reduce(function(n,i){var r=t._heights.hasOwnProperty(i)?t._heights[i]:t.props.assumedItemHeight;return n+e[i]-r},0);return 0!==n&&(this._heights=Object.assign({},this._heights,e)),{heightDelta:n}}},{key:"_postRenderProcessing",value:function(t){var e=this._recordHeights(),n=0!==e.heightDelta;(t||n)&&this._prevPositioning&&this._onHeightsUpdate(this._prevPositioning,this.getPositioning()),(t||Math.abs(e.heightDelta)>=this.props.assumedItemHeight)&&this._scheduleUpdate(),this._schedulePositioningNotification()}},{key:"_getDefaultSlice",value:function(t){var e=Object.assign({},this,{props:Object.assign({},this.props,{list:t})}),n=this.props.viewport.getRect().getHeight(),i=this._getRectangles(e),r=t[0].id,o=i[r].getTop(),s=M(t,function(t){return i[t.id].getTop()-o>=n},0);return s<0&&(s=t.length),{sliceStart:0,sliceEnd:s}}},{key:"_computeBlankSpace",value:function(){var t=this.props.list,e=this.state,n=e.sliceStart,i=e.sliceEnd,r=this._getRectangles(),o=t.length-1;return{blankSpaceAbove:t.length<=0?0:r[t[n].id].getTop()-r[t[0].id].getTop(),blankSpaceBelow:i>=t.length?0:r[t[o].id].getBottom()-r[t[i].id].getTop()}}},{key:"_getRelativeViewportRect",value:function(){if(!this._listRef)return new F({top:0,height:0});var t=this._listRef.getWrapperNode();return this.props.viewport.getRectRelativeTo(t)}},{key:"_update",value:function(){var t=this.props.list;if(!this._unmounted&&0!==t.length){var e=this._getRelativeViewportRect(),n=e.getHeight()*this.props.offscreenToViewportRatio,i=e.getTop()-n,r=e.getBottom()+n,o=this._getRectangles(),s=M(t,function(t){return o[t.id].getBottom()>i});s<0&&(s=t.length-1);var u=M(t,function(t){return o[t.id].getTop()>=r},s);u<0&&(u=t.length),this._schedulePositioningNotification(),this._setSlice(s,u)}}},{key:"_setSlice",value:function(t,e){var n=this.state,i=n.sliceStart,r=n.sliceEnd;i===t&&r===e||this.setState({sliceStart:t,sliceEnd:e})}},{key:"_notifyPositioning",value:function(){!this._unmounted&&this.props.onPositioningUpdate&&this.props.onPositioningUpdate(this.getPositioning())}},{key:"getPositioning",value:function(){var t=this.state,e=t.sliceStart,n=t.sliceEnd;return new z({viewportRectangle:this._getRelativeViewportRect(),list:this.props.list,rectangles:this._getRectangles(),sliceStart:e,sliceEnd:n})}},{key:"componentDidMount",value:function(){this._unlistenScroll=this.props.viewport.addScrollListener(this._handleScroll),this._postRenderProcessing(!0)}},{key:"componentWillReceiveProps",value:function(t){var e=this.props.list,n=this.state,i=t.list;if(e!==i){var r=function(t,e,n,i){var r=e.reduce(function(t,e){return t[e.id]=!0,t},{}),o=function(t,e,n){if(e<0||e>=t.length)return-1;if(n(t[e]))return e;for(var i=1;;i++){var r=e-i,o=e+i,s=r<0,u=o>=t.length;if(s&&u)break;if(!u&&n(t[o]))return o;if(!s&&n(t[r]))return r}return-1}(t,n,function(t){return r[t.id]});if(-1===o)return null;var s=M(e,function(e){return t[o].id===e.id});return{sliceStart:s,sliceEnd:Math.min(e.length,s+i-n)}}(e,i,n.sliceStart,n.sliceEnd)||this._getDefaultSlice(i);this._setSlice(r.sliceStart,r.sliceEnd)}}},{key:"componentWillUpdate",value:function(){this._prevPositioning=this.getPositioning()}},{key:"componentDidUpdate",value:function(t){this._postRenderProcessing(t.list!==this.props.list)}},{key:"componentWillUnmount",value:function(){this._unmounted=!0,this._unlistenScroll&&this._unlistenScroll()}},{key:"render",value:function(){var t=this._computeBlankSpace(),n=t.blankSpaceAbove,i=t.blankSpaceBelow,r=this.state.sliceStart,o=this.props.renderItem;return e.createElement(V,{ref:this._handleRefUpdate,list:this._getSlice(),blankSpaceAbove:n,blankSpaceBelow:i,renderItem:function(t,e){return o(t,r+e)}})}}]),n}(e.PureComponent);X.defaultProps={offscreenToViewportRatio:1.8,assumedItemHeight:400};var G={INSIDE:"inside",OUTSIDE:"outside"},J={INITIAL_POSITION:"init",MOVEMENT:"movement",LIST_UPDATE:"list-update"};var K=function(t){return function(e,n){return n.getTop()-e.getTop()<=t}},Q=function(t){return function(e,n){return e.getBottom()-n.getBottom()<=t}},Z=function(t){return function(e,n){var i=n.getHeight(),r=t*i;return n.getTop()-e.getTop()<=r}},tt=function(t){return function(e,n){var i=n.getHeight(),r=t*i;return e.getBottom()-n.getBottom()<=r}},et=function(){function t(e){B(this,t),this._handlers=e.map(function(t){return{zone:t,state:{}}})}return A(t,[{key:"handlePositioningUpdate",value:function(t){this._handlers.forEach(function(e){var n,i,r,o=e.zone,s=e.state,u=o.condition,a=o.callback,c=u((n=t).getListRect(),n.getViewportRect())?G.INSIDE:G.OUTSIDE,l=t.getList().length,f=(r={proximity:c,listLength:l},(i=s).proximity||r.proximity!==G.INSIDE?i.proximity===G.OUTSIDE&&r.proximity===G.INSIDE?J.MOVEMENT:i.proximity===G.INSIDE&&r.proximity===G.INSIDE&&i.listLength!==r.listLength?J.LIST_UPDATE:null:J.INITIAL_POSITION);s.proximity=c,s.listLength=l,f&&a({triggerCause:f})})}}]),t}(),nt=function(){return null},it=function(t){function n(t){B(this,n);var e=D(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,t));return e._getList=p(e,function(t){return t.items},function(t){var n={},i=[];return t.forEach(function(t){var r=e.props.identityFunction(t);n.hasOwnProperty(r)?console.warn('Duplicate item id generated in VirtualScroller. Latter item (id = "'+r+'") will be discarded'):(i.push({id:r,data:t}),n[r]=!0)}),i}),e._handlePositioningUpdate=e._handlePositioningUpdate.bind(e),e._createScrollTracker(t.nearStartProximityRatio,t.nearEndProximityRatio),e}return N(n,t),A(n,[{key:"_handlePositioningUpdate",value:function(t){this._scrollTracker&&this._scrollTracker.handlePositioningUpdate(t)}},{key:"_createScrollTracker",value:function(t,e){var n=this;this._scrollTracker=new et([{condition:K(5),callback:function(t){return n.props.onAtStart(t)}},{condition:Z(t),callback:function(t){return n.props.onNearStart(t)}},{condition:tt(e),callback:function(t){return n.props.onNearEnd(t)}},{condition:Q(5),callback:function(t){return n.props.onAtEnd(t)}}])}},{key:"componentWillReceiveProps",value:function(t){this._createScrollTracker(t.nearStartProximityRatio,t.nearEndProximityRatio)}},{key:"render",value:function(){var t=this.props,n=t.renderItem,i=t.assumedItemHeight,r=t.viewport;return e.createElement(X,{list:this._getList(),renderItem:n,assumedItemHeight:i,viewport:r,onPositioningUpdate:this._handlePositioningUpdate})}}]),n}(e.PureComponent);it.defaultProps={identityFunction:function(t){return t.id},offscreenToViewportRatio:1.8,assumedItemHeight:400,nearEndProximityRatio:1.75,nearStartProximityRatio:.25,onAtStart:nt,onNearStart:nt,onNearEnd:nt,onAtEnd:nt},t.default=it,t.Viewport=q,Object.defineProperty(t,"__esModule",{value:!0})}); 2 | --------------------------------------------------------------------------------