├── .eslintignore
├── .eslintrc
├── LICENSE.md
├── README.md
├── package.json
└── src
├── SlickScroll.js
└── __tests__
└── SlickScroll-test.js
/.eslintignore:
--------------------------------------------------------------------------------
1 | karma.conf.js
2 | tests.webpack.js
3 | src/lib/*
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "parser": "babel-eslint",
4 | "settings": {
5 | "import/parser": "babel-eslint",
6 | "import/resolve": {
7 | moduleDirectory: ["node_modules", "src"]
8 | }
9 | },
10 |
11 | "env": {
12 | "browser": true,
13 | "node": true,
14 | "es6": true,
15 | "mocha": true
16 | },
17 |
18 | "ecmaFeatures": {
19 | "modules": true,
20 | "jsx": true,
21 | "spread": true,
22 | "experimentalObjectRestSpread": true
23 | },
24 |
25 | "globals": {
26 | "__DEVELOPMENT__": true,
27 | "__CLIENT__": true,
28 | "__SERVER__": true,
29 | "__DISABLE_SSR__": true,
30 | "__DEVTOOLS__": true,
31 | "__WINDOWS__": true,
32 | "__ELECTRON__": true,
33 | "__NODE_MODULES__": true,
34 | "__DEV_AUTO_LOGIN__": true,
35 | "socket": true,
36 | "webpackIsomorphicTools": true
37 | },
38 | "plugins": [
39 | "react",
40 | "import"
41 | ],
42 | "rules": {
43 | "accessor-pairs": 2,
44 | "arrow-spacing": [1, { "before": true, "after": true }],
45 | "brace-style": [1, "1tbs", { "allowSingleLine": true }],
46 | "comma-dangle": 0,
47 | "comma-spacing": [1, { "before": false, "after": true }],
48 | "comma-style": [2, "last"],
49 | "constructor-super": 2,
50 | "curly": [2, "multi-line"],
51 | "dot-location": [2, "property"],
52 | "eol-last": 1,
53 | "eqeqeq": [2, "allow-null"],
54 | "generator-star-spacing": [1, { "before": true, "after": true }],
55 | "handle-callback-err": [1, "^(err|error)$" ],
56 | "indent": [2, 2, { "SwitchCase": 1 }],
57 | "jsx-quotes": [1, "prefer-single"],
58 | "key-spacing": [1, { "beforeColon": false, "afterColon": true }],
59 | "new-cap": [2, { "newIsCap": true, "capIsNew": false }],
60 | "new-parens": 2,
61 | "no-array-constructor": 2,
62 | "no-caller": 2,
63 | "no-class-assign": 2,
64 | "no-cond-assign": 2,
65 | "no-console": 1,
66 | "no-const-assign": 2,
67 | "no-control-regex": 2,
68 | "no-debugger": 1,
69 | "no-delete-var": 2,
70 | "no-dupe-args": 2,
71 | "no-dupe-keys": 2,
72 | "no-duplicate-case": 2,
73 | "no-empty-character-class": 2,
74 | "no-empty-label": 2,
75 | "no-eval": 1,
76 | "no-ex-assign": 2,
77 | "no-extend-native": 2,
78 | "no-extra-bind": 2,
79 | "no-extra-boolean-cast": 2,
80 | "no-extra-parens": [2, "functions"],
81 | "no-fallthrough": 2,
82 | "no-floating-decimal": 2,
83 | "no-func-assign": 2,
84 | "no-implied-eval": 2,
85 | "no-inner-declarations": [2, "functions"],
86 | "no-invalid-regexp": 2,
87 | "no-irregular-whitespace": 1,
88 | "no-iterator": 2,
89 | "no-label-var": 2,
90 | "no-labels": 2,
91 | "no-lone-blocks": 2,
92 | "no-mixed-spaces-and-tabs": 1,
93 | "no-multi-spaces": 0,
94 | "no-multi-str": 2,
95 | "no-multiple-empty-lines": [1, { "max": 1 }],
96 | "no-native-reassign": 2,
97 | "no-negated-in-lhs": 2,
98 | "no-new": 2,
99 | "no-new-func": 2,
100 | "no-new-object": 2,
101 | "no-new-require": 2,
102 | "no-new-wrappers": 2,
103 | "no-obj-calls": 2,
104 | "no-octal": 2,
105 | "no-octal-escape": 2,
106 | "no-proto": 2,
107 | "no-redeclare": 2,
108 | "no-regex-spaces": 2,
109 | "no-return-assign": 2,
110 | "no-self-compare": 2,
111 | "no-sequences": 2,
112 | "no-shadow-restricted-names": 2,
113 | "no-spaced-func": 1,
114 | "no-sparse-arrays": 2,
115 | "no-this-before-super": 2,
116 | "no-throw-literal": 2,
117 | "no-trailing-spaces": 0,
118 | "no-undef": 2,
119 | "no-undef-init": 2,
120 | "no-unexpected-multiline": 2,
121 | "no-unneeded-ternary": 1,
122 | "no-unreachable": 2,
123 | "no-unused-vars": [1, { "vars": "all", "args": "none" }],
124 | "no-use-before-define": [2, "nofunc"],
125 | "no-useless-call": 2,
126 | "no-with": 2,
127 | "no-var": 1,
128 | "object-curly-spacing": [1, "always"],
129 | "one-var": [1, { "initialized": "never" }],
130 | "operator-linebreak": 0,
131 | "quotes": [1, "single", "avoid-escape"],
132 | "radix": 2,
133 | "require-yield": 2,
134 | "semi": [1, "always"],
135 | "space-after-keywords": [1, "always"],
136 | "space-before-blocks": [1, "always"],
137 | "space-before-function-paren": [1, "never"],
138 | "space-in-parens": [1, "never"],
139 | "space-infix-ops": 1,
140 | "space-return-throw-case": 1,
141 | "space-unary-ops": [1, { "words": true, "nonwords": false }],
142 | "spaced-comment": 0,
143 | "use-isnan": 2,
144 | "valid-typeof": 2,
145 | "wrap-iife": [2, "any"],
146 | "yoda": 0,
147 |
148 | "import/default": 2,
149 | "import/no-duplicates": 2,
150 | "import/named": 2,
151 | "import/namespace": 0,
152 | "import/no-unresolved": 2,
153 | "import/no-named-as-default": 2,
154 |
155 | "react/display-name": 0,
156 | "react/jsx-boolean-value": 2,
157 | "react/jsx-no-undef": 2,
158 | "react/jsx-sort-prop-types": 0,
159 | "react/jsx-sort-props": 0,
160 | "react/jsx-uses-react": 2,
161 | "react/jsx-uses-vars": 2,
162 | "react/no-did-mount-set-state": 1,
163 | "react/no-did-update-set-state": 2,
164 | "react/no-multi-comp": 0,
165 | "react/no-unknown-property": 2,
166 | "react/prop-types": 1,
167 | "react/react-in-jsx-scope": 2,
168 | "react/self-closing-comp": 1,
169 | "react/sort-comp": 0,
170 | "react/wrap-multilines": 2
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Celartem, Inc. dba Extensis
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SlickScroll
2 |
3 | _Warning: very early pre-release alpha hazard zone code_
4 |
5 | Virtual list that can scroll *trillions* of items. Potentially up to `MAX_SAFE_INTEGER - 1`. Features:
6 |
7 | * Lots of freakin' items
8 | * Multiple items per row
9 | * Dynamic items per row via rAF
10 |
11 | Inspired by [SlickGrid's Implementation](https://github.com/mleibman/SlickGrid/issues/22)
12 |
13 | # How it works
14 |
15 | Read the [JSFiddle](http://jsfiddle.net/SDa2B/4/) that inspired this. This is basically a port to React of that code.
16 |
17 | # Installation
18 |
19 | No npm module yet. You'll need to copy and paste.
20 |
21 | # Docs
22 |
23 | Read the code (and then issue a PR =).
24 |
25 | # Usage
26 |
27 | So far, I've only tested this within flexbox.
28 |
29 | ```jsx
30 | function renderRow(rows) => {
31 | const rowElements = rows.map(row => {
32 | const itemElements = row.map(item =>
Item number {item}
)
33 | return {itemElements}
;
34 | });
35 |
36 | return {rowItems}
37 | }
38 |
39 |
40 |
45 |
46 | ```
47 |
48 | # Is it Web Scale?
49 |
50 | This is mega-super-alpha. Please file issues or PRs if you would like!
51 |
52 | # Known issues
53 |
54 | On Safari, scrolling will loose inertia due to virtual items disappearing
55 | - FIX/TODO: Looks like there's a fix, thanks to @jounik: [https://github.com/mleibman/SlickGrid/issues/22#issuecomment-192616461](https://github.com/mleibman/SlickGrid/issues/22#issuecomment-192616461)
56 |
57 | # TODO
58 |
59 | - Packaging and publishing to npm
60 | - Migrate tests to jsdom and run tests on travis
61 | - probably a ton of other stuff
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "slick-scroll",
3 | "version": "0.1.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "",
10 | "license": "MIT"
11 | }
12 |
--------------------------------------------------------------------------------
/src/SlickScroll.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | const { number, func, object } = React.PropTypes;
4 |
5 | export default class SlickScroll extends Component {
6 | static propTypes = {
7 | itemCount: number, // How many in your list
8 | rowHeight: number.isRequired,
9 | renderRow: func.isRequired, // Function to render your rows
10 | vpBuffer: number, // Buffer scaled by viewport height
11 | itemWidth: number.isRequired, // Used in rAF calculations for dynamic perRow
12 |
13 | // Optional map of items passed into your renderRow fn. This is probably
14 | // not necessary and may be removed
15 | itemMap: object,
16 | }
17 |
18 | static defaultProps = {
19 | itemCount: 0,
20 | vpBuffer: 4,
21 | }
22 |
23 | state = {
24 | rows: [],
25 | perRow: 3,
26 | viewTop: 0,
27 | fakeTop: 0,
28 | }
29 |
30 | boundOnScroll = this.onScroll.bind(this)
31 |
32 | onScroll() {
33 | const scrollTop = this.refs.viewport.scrollTop;
34 |
35 | // TODO: A lot of these are one-time calculations. May optimize out into
36 | // constructor.
37 |
38 | this.h = 1000000; // real scrollable height
39 | this.ph = this.h / 100; // page height
40 |
41 | // th == virtual height
42 | this.th = Math.ceil(this.nextProps.itemCount / this.state.perRow) * this.nextProps.rowHeight;
43 |
44 | if (this.h > this.th) {
45 | this.h = this.th;
46 | this.ph = this.th;
47 | }
48 |
49 | this.n = Math.ceil(this.th / this.ph); // number of pages
50 | this.ph = this.th / this.n; // Adjust page height for rounding of total count
51 | this.cj = (this.th - this.h) / (this.n - 1); // "jumpiness" coefficient
52 |
53 | if (this.n <= 1) {
54 | this.cj = 1;
55 | }
56 |
57 | this.page = this.page || 0; // current page
58 | this.offset = this.offset || 0; // current page offset
59 | this.prevScrollTop = this.prevScrollTop || 0;
60 |
61 | this.vp = this.refs.viewport.clientHeight;
62 | this.vw = this.refs.viewport.clientWidth;
63 |
64 | // jumpThresh is how much a user has to scroll in one event loop to trigger
65 | // moving via "jump", such that scrollbar position maps directly to item location
66 | let jumpThresh = 4;
67 | if (Math.abs(scrollTop - this.prevScrollTop) > this.vp * jumpThresh) {
68 | this.onJump();
69 | } else {
70 | this.onNearScroll();
71 | }
72 |
73 | this.renderViewport();
74 | }
75 |
76 | onNearScroll() {
77 | const scrollTop = this.refs.viewport.scrollTop;
78 |
79 | // next page
80 | if (scrollTop + this.offset > (this.page + 1) * this.ph) {
81 | this.page++;
82 | this.offset = Math.round(this.page * this.cj);
83 | this.refs.viewport.scrollTop = this.prevScrollTop = scrollTop - this.cj;
84 | this.rows = [];
85 | } else if (scrollTop + this.offset < this.page * this.ph) {
86 | // prev page
87 | this.page--;
88 | this.offset = Math.round(this.page * this.cj);
89 | this.refs.viewport.scrollTop = this.prevScrollTop = scrollTop + this.cj;
90 | this.rows = [];
91 | } else {
92 | this.prevScrollTop = scrollTop;
93 | }
94 | }
95 |
96 | onJump() {
97 | const scrollTop = this.refs.viewport.scrollTop;
98 | this.page = Math.floor(
99 | scrollTop * ((this.th - this.vp) / (this.h - this.vp)) * (1 / this.ph)
100 | );
101 | this.offset = Math.round(this.page * this.cj);
102 | this.prevScrollTop = scrollTop;
103 |
104 | this.rows = [];
105 | }
106 |
107 | renderViewport() {
108 | // calculate the viewport + buffer
109 | let y = this.refs.viewport.scrollTop + this.offset;
110 | let buffer = Math.max(1, this.vp * this.props.vpBuffer);
111 | let top = Math.floor((y - buffer) / this.nextProps.rowHeight);
112 | let bottom = Math.ceil((y + this.vp + buffer) / this.nextProps.rowHeight);
113 |
114 | top = Math.max(0, top);
115 | bottom = Math.min(this.th / this.nextProps.rowHeight, bottom);
116 |
117 | // Do the row dance
118 |
119 | this.rows = [];
120 | for (let rowIdx = top; rowIdx < bottom; rowIdx++) {
121 | let items = [];
122 | for (let rowItemIdx = 0; rowItemIdx < this.state.perRow; rowItemIdx++) {
123 | let realItemIdx = rowIdx * this.state.perRow + rowItemIdx;
124 | if (realItemIdx >= this.nextProps.itemCount) {
125 | break;
126 | }
127 | items.push(realItemIdx);
128 | }
129 | this.rows.push(items);
130 | }
131 |
132 | if (!this.rows[0] || !this.state.rows[0]) {
133 | this.setState({ rows: this.rows });
134 | } else if (this.state.rows[0][0] !== this.rows[0][0]
135 | || this.state.rows.length !== this.rows.length
136 | || this.state.rows[0].length !== this.rows[0].length
137 | ) {
138 | this.setState({ rows: this.rows });
139 | }
140 | }
141 |
142 | componentWillReceiveProps(nextProps) {
143 | this.nextProps = nextProps;
144 | this.boundOnScroll();
145 | }
146 |
147 | rAFLoop() {
148 | const height = this.refs.viewport.clientHeight;
149 | const width = this.refs.viewport.clientWidth;
150 |
151 | if (this.lastHeight !== height || this.lastWidth !== width) {
152 | this.lastHeight = height;
153 | this.lastWidth = width;
154 |
155 | const newPerRow = (this.props.itemWidth && width) ?
156 | Math.floor(width / this.props.itemWidth) :
157 | this.state.perRow;
158 |
159 | this.setState({ perRow: newPerRow });
160 |
161 | this.boundOnScroll();
162 |
163 | // Using setState to trigger a rerender if it's necessary
164 | this.setState({ h: this.lastHeight, w: this.lastWidth });
165 | }
166 |
167 | this.rAFid = window.requestAnimationFrame(this.rAFLoop.bind(this));
168 | }
169 |
170 | componentDidMount() {
171 | this.nextProps = this.props;
172 | this.boundOnScroll();
173 | this.rAFid = window.requestAnimationFrame(this.rAFLoop.bind(this));
174 | }
175 |
176 | componentWillUnmount() {
177 | window.cancelAnimationFrame(this.rAFid);
178 | }
179 |
180 | render() {
181 | const outerStyle = {
182 | overflow: 'auto',
183 | maxHeight: '100%',
184 | flex: 1,
185 | };
186 |
187 | const contentStyle = {
188 | position: 'relative',
189 | zIndex: 1, // Prevent weird screen tearing render on chrome
190 | overflow: 'hidden',
191 | height: this.h,
192 | };
193 |
194 | return (
195 |
196 |
197 | {this.state.rows.map(row => {
198 | return (
199 |
204 | {this.props.renderRow(row, this.nextProps.itemMap)}
205 |
206 | );
207 | })}
208 |
209 |
210 | );
211 | }
212 | }
213 |
--------------------------------------------------------------------------------
/src/__tests__/SlickScroll-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { assert } from 'chai';
3 | import { mount } from 'enzyme';
4 |
5 | import SlickScroll from '../SlickScroll';
6 |
7 | describe('SlickScroll', () => {
8 | let slickScroll;
9 | let _rowHeight = 52;
10 | let _viewHeight = 503;
11 | let _itemWidth = 160;
12 |
13 | function renderComponent(props, options) {
14 | if (slickScroll) {
15 | slickScroll.detach();
16 | }
17 |
18 | options = {
19 | rowHeight: _rowHeight,
20 | viewHeight: _viewHeight,
21 | itemWidth: _itemWidth,
22 | ...options,
23 | };
24 |
25 | props = {
26 | itemCount: 1234567, // 1.2 million
27 | itemMap: {},
28 | renderRow: row => Foo {row[0]}
,
29 | rowHeight: options.rowHeight,
30 | itemWidth: options.itemWidth,
31 | vpBuffer: 1, // TOOD: Test with different buffers!
32 | ...props,
33 | };
34 |
35 | let root = document.createElement('div');
36 | root.style.position = 'absolute';
37 | root.style.width = '501px';
38 | root.style.height = `${options.viewHeight}px`;
39 | root.style.flex = '1';
40 | root.style.display = 'flex';
41 |
42 | document.body.appendChild(root);
43 |
44 | slickScroll = mount(, { attachTo: root });
45 | };
46 |
47 | beforeEach(() => renderComponent());
48 | afterEach(() => slickScroll.detach());
49 |
50 | it('renders rows with a buffer of rows', () => {
51 | let found = slickScroll.find('.slick-row');
52 | assert.isAbove(found.length, Math.floor(_viewHeight / _rowHeight) * 2);
53 | assert.isBelow(found.length, Math.floor(_viewHeight / _rowHeight) * 3);
54 |
55 | let newRowHeight = 22;
56 | let newViewHeight = 600;
57 |
58 | renderComponent({}, { rowHeight: newRowHeight, viewHeight: newViewHeight });
59 |
60 | found = slickScroll.find('.slick-row');
61 | assert.isAbove(found.length, Math.floor(newViewHeight / newRowHeight) * 2);
62 | assert.isBelow(found.length, Math.floor(newViewHeight / newRowHeight) * 3);
63 | });
64 |
65 | it('renders a big honkin virtual viewport', () => {
66 | const content = slickScroll.find('.slick-content').get(0);
67 | assert.isAbove(content.clientHeight, 1e5);
68 | });
69 |
70 | it('renders a big viewport.. but not too big!', () => {
71 | const content = slickScroll.find('.slick-content').get(0);
72 | assert.isBelow(content.clientHeight, 1e8);
73 | });
74 |
75 | it('scrolls normally when scroll delta is small', async function() {
76 | const view = slickScroll.find('.slick-viewport').first();
77 |
78 | const getFirstRowText = () => view.find('.slick-row').first().text();
79 | const getLastRowText = () => view.find('.slick-row').last() .text();
80 |
81 | // Should start off on the first row..
82 | assert.equal(getFirstRowText(), 'Foo 0');
83 | // And render a buffer of who-know-how-much..?
84 | assert.equal(getLastRowText(), 'Foo 57');
85 |
86 | view.get(0).scrollTop += 35;
87 | await new Promise(resolve => setTimeout(() => resolve(), 100));
88 |
89 | assert.equal(getFirstRowText(), 'Foo 0');
90 | assert.equal(getLastRowText(), 'Foo 60');
91 |
92 | view.get(0).scrollTop += 100;
93 | await new Promise(resolve => setTimeout(() => resolve(), 100));
94 |
95 | assert.equal(getFirstRowText(), 'Foo 0');
96 | assert.equal(getLastRowText(), 'Foo 63');
97 |
98 | view.get(0).scrollTop += 100;
99 | await new Promise(resolve => setTimeout(() => resolve(), 100));
100 |
101 | assert.equal(getFirstRowText(), 'Foo 0');
102 | assert.equal(getLastRowText(), 'Foo 69');
103 |
104 | view.get(0).scrollTop += 500;
105 | await new Promise(resolve => setTimeout(() => resolve(), 100));
106 |
107 | assert.equal(getFirstRowText(), 'Foo 12');
108 | assert.equal(getLastRowText(), 'Foo 99');
109 | });
110 |
111 | // Reference: Our 'totalRealHeight' is 1e6, but the virtual height is
112 | // totalRows * rowHeight, which is ~ 7e7.
113 | it('scrolls all big-like when a big delta gets goin', async function() {
114 | const view = slickScroll.find('.slick-viewport').first();
115 |
116 | const getFirstRowText = () => view.find('.slick-row').first().text();
117 | const getLastRowText = () => view.find('.slick-row').last() .text();
118 |
119 | assert.equal(getFirstRowText(), 'Foo 0');
120 | assert.equal(getLastRowText(), 'Foo 57');
121 |
122 | view.get(0).scrollTop += 35;
123 | await new Promise(resolve => setTimeout(() => resolve(), 100));
124 |
125 | assert.equal(getFirstRowText(), 'Foo 0');
126 | assert.equal(getLastRowText(), 'Foo 60');
127 |
128 | let totalRealHeight = slickScroll.find('.slick-content').get(0).clientHeight;
129 | assert.equal(totalRealHeight, 1e6);
130 | view.get(0).scrollTop = totalRealHeight / 2;
131 | await new Promise(resolve => setTimeout(() => resolve(), 100));
132 |
133 | /// ~612k is about half of our 1.2mil
134 | assert.equal(getFirstRowText(), 'Foo 617529');
135 | assert.equal(getLastRowText(), 'Foo 617616');
136 |
137 | });
138 |
139 | });
140 |
--------------------------------------------------------------------------------