├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json └── src ├── .npmignore ├── js ├── AutoAdjust.jsx ├── HorizontalWrapper.jsx ├── LightweightRow.jsx ├── Row.jsx ├── Scrollable.jsx ├── constants.js ├── index.jsx ├── models.js ├── propTypes.js └── utils.js └── less └── scrollable.less /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-object-rest-spread"], 3 | "presets": ["react", "es2015"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "ecmaFeatures": { 4 | "jsx": true 5 | }, 6 | "env": { 7 | "es6": true, 8 | "node": true 9 | }, 10 | "extends": "airbnb", 11 | "plugins": [ 12 | "react" 13 | ], 14 | "globals": { 15 | "window": true, 16 | "document": true 17 | }, 18 | "rules": { 19 | "camelcase": [ 20 | 2, 21 | { 22 | "properties": "always" 23 | } 24 | ], 25 | "array-bracket-spacing": 0, 26 | "comma-dangle": [2, "never"], 27 | "func-names": 0, 28 | "import/prefer-default-export": 0, 29 | "indent": ["error", 2, { "SwitchCase": 1 }], 30 | "jsx-quotes": [2, "prefer-single"], 31 | "max-len": [2, 120], 32 | "new-cap": 0, 33 | "no-confusing-arrow": 0, 34 | "no-continue": 0, 35 | "no-mixed-spaces-and-tabs": 2, 36 | "no-multi-spaces": 2, 37 | "no-new": 2, 38 | "no-param-reassign": ["error", { props: false }], 39 | "no-trailing-spaces": 2, 40 | "no-undef": 2, 41 | "no-underscore-dangle": 0, 42 | "no-unused-vars": 2, 43 | "object-curly-spacing": 0, 44 | "object-shorthand": [2, "always"], 45 | "react/display-name": 2, 46 | "react/jsx-indent": "off", 47 | "react/jsx-no-undef": 2, 48 | "react/jsx-quotes": 0, 49 | "react/jsx-sort-props": 2, 50 | "react/prefer-stateless-function": 1, 51 | "react/sort-prop-types": 2, 52 | "sort-vars": 2, 53 | "space-before-function-paren": [2, "never"], 54 | "space-in-parens": [2, "never"], 55 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 56 | "vars-on-top": 0 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Tyler Wanek 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rickscroll 2 | **This package is deprecated, please use [https://www.npmjs.com/package/react-window](react-window) or [https://www.npmjs.com/package/react-virtualized](react-virtualized).** 3 | 4 | A high performance scrolling utility for React. Featured in [GitKraken](http://www.gitkraken.com/) 5 | 6 | Basic Usage 7 | ------ 8 | Install with npm: `npm install rickscroll` 9 | 10 | ```javascript 11 | import { Rickscroll } from 'rickscroll'; 12 | 13 | const list = new Array(10000).fill({}).map(() => ({ 14 | contentComponent() { return Hello world!; }, 15 | height: 20 16 | })); 17 | 18 | function renderList() { 19 | return ; 20 | } 21 | ``` 22 | 23 | *Example written in ES6* 24 | 25 | Features 26 | ------ 27 | Rickscroll offloads scrolling onto the GPU by using CSS 3d translations. Rickscroll partitions display rows in small groups that are easy for a browser to paint. This module tries to minimize repaints to at most 1 partition at a time during a scroll event. Not only does this work well, it is incredibly fast, and it can support millions of nodes. 28 | 29 | Rickscroll does not use an infinite loader. When you scroll, your scrollbar will not scale in response to adding more partitions to the DOM. Rickscroll will never grow the DOM to more than 2 + the number of partitions that would reasonably fit in a given display area. 30 | 31 | Rickscroll supports horizontal scrolling. There are other implementations of large, performant scrollers, but none that support decent horizontal scrolling. Rickscroll will horizontally scroll your content component for you but can optionally pass through scroll offsets to your content component, instead. For situations like the graph in GitKraken, it's necessary to have more fine grain control over horizontal scrolling. 32 | 33 | Rickscroll provides gutter functionality. Gutters are an option provided by Rickscroll to provide advanced horizontal scrolling. Often when building large display lists, we want to lock static content to the left or right of our main content. Rickscroll addresses this by providing gutters that work with horizontal scrolling and also provides resizable gutter capabilities. 34 | 35 | Rickscroll provides scrollTo methods for navigating to any location in Rickscroll through props. 36 | 37 | Rickscroll also works well with multi-lists. Multi-lists can specify headers for each list where these headers gain special properties in Rickscroll. Headers are collapsible. Headers are lockable. Headers are stackable. There are methods provided to scrollTo the exact location of a header. No calculations on your part. 38 | 39 | Most importantly, Rickscroll will: 40 | - always gonna scroll you up 41 | - always gonna scroll you down 42 | - never gonna run around and desert you 43 | - never gonna make you cry 44 | - never gonna say goodbye 45 | - never gonna tell a lie and hurt you 46 | 47 | Public Methods 48 | ------ 49 | - scrollTo({ x: **integer**, y: **integer** }): Scrolls to the provided pixel value position. 50 | - scrollRowToMiddle(rowIndex: **integer**, x: **integer**): Scrolls the given row index into view with a preference toward the middle of the viewable content. Does not include headers in index calculations. 51 | - scrollToHeader(headerIndex: **integer**, x: **integer**): Scrolls to the header at headerIndex. 52 | - toggleSection(sectionIndex: **integer**): Toggles the visibility of the rows under headerIndex. 53 | - updateDimensions(): Calling this will internally recalculate the dimensions of rickscroll. Rick scroll is already listening to the window resize event, but if you wrap rickscroll with a resizable container, you may need to call updateDimensions when the resizable container is resized. 54 | 55 | PropTypes 56 | ------ 57 | - className: **string** *(optional)* 58 | - disableBidirectionalScrolling: **bool** *(optional)* - [default: false] 59 | - dynamicColumn: **string** *(optional)* - [default: 'middle'] 60 | - guttersConfig: **object** *(optional)* 61 | - left: **object** *(optional)* 62 | - className: **string** *(optional)* 63 | - handleClassName: **string** *(optional)* 64 | - handleWidth: **number** *(optional)* 65 | - maxPosition: **number** *(optional)* 66 | - minPosition: **number** *(optional)* 67 | - onResize: **function(number)** *(optional)* 68 | - onResizeEnd: **function(number)** *(optional)* 69 | - position: **number** 70 | - right: **object** *(optional)* 71 | - className: **string** *(optional)* 72 | - handleClassName: **string** *(optional)* 73 | - handleWidth: **number** *(optional)* 74 | - maxPosition: **number** *(optional)* 75 | - minPosition: **number** *(optional)* 76 | - onResize: **function(number)** *(optional)* 77 | - onResizeEnd: **function(number)** *(optional)* 78 | - position: **number** 79 | - headerType: **string** *(optional)* - [default: 'default'] 80 | - heightAdjust: **string** *(optional)* 81 | - horizontalScrollConfig: **object** *(optional)* 82 | - className: **string** *(optional)* 83 | - contentWidth: **number** 84 | - passthroughOffsets: **bool** *(optional)* - [default: false] 85 | - scaleWithCenterContent: **bool** *(optional)* - [default: false] 86 | - scrollbarHeight: **number** *(optional)* - [default: 15] 87 | - onScroll: **function(number)** *(optional)* 88 | - light: **bool** *(optional)* 89 | - list \*: **array** of **objects** containing 90 | - className: **string** *(optional)* 91 | - contentClassName: **string** *(optional)* 92 | - contentComponent: **React.Component** 93 | - gutters: **object** *(optional)* 94 | - left: **object** *(optional)* 95 | - className: **string** *(optional)* 96 | - contentComponent: **React.Component** 97 | - handleClassName: **string** *(optional)* 98 | - props: **object** *(optional)* 99 | - right: **object** *(optional)* 100 | - className: **string** *(optional)* 101 | - contentComponent: **React.Component** 102 | - handleClassName: **string** *(optional)* 103 | - props: **object** *(optional)* 104 | - height: **number** 105 | - key: **string** *(optional)* 106 | - props: **object** *(optional)* 107 | - lists \*: **array** of **objects** containing 108 | - headerClassName: **string** *(optional)* 109 | - headerComponent: **React.Component** 110 | - headerKey: **string** *(optional)* 111 | - headerProps: **object** *(optional)* 112 | - height: **number** 113 | - initCollapsed: **boolean** *(optional)* 114 | - rows: *(see definition of **list** prop type above)* 115 | - onRegisteredScrollTo: **function** *(optional)* 116 | - scrollTo: **object** *(optional)* 117 | - location: **object** *(optional)* 118 | - x: **number** *(optional)* - [default: 0] 119 | - y: **number** *(optional)* - [default: 0] 120 | - preserveHorizontal: **booelan** *(optional)* - [default: false] 121 | - preserveVertical: **booelan** *(optional)* - [default: false] 122 | - type: **string** *(optional)* - [default: 'row'] 123 | - verticalScrollConfig: **object** *(optional)* 124 | - className: **string** *(optional)* 125 | - scrollbarWidth: **number** *(optional)* - [default: 15] 126 | - onScroll: **function(number)** *(optional)* 127 | - widthAdjust: **string** *(optional)* 128 | 129 | 130 | \* rickscroll requires only one of list/lists. One must be set and no more than one should be set. 131 | 132 | Keying Rows 133 | ------ 134 | If you choose to use the optional keys on the headers and rows, it's all-or-none. 135 | You must supply completely unique keys to all rows + headers, which are pooled together. 136 | If some keys are undefined, an error will occur. If some keys are duplicated, an error will occur. 137 | It's very important that these keys are unique, because if keys collide, you will see missing rows in viewable area. 138 | 139 | Positioning the Gutters 140 | ------ 141 | There are 3 columns in Rickscroll, the left gutter, content area, and right gutter. 142 | The columns are partitioned by a handle, which is specified in the guttersconfig. 143 | The size of the columns are determined by the size position of the handles. 144 | There is now a configuration property on Rickscroll for which column inherits dynamic scaling, dynamicColumn. 145 | 146 | Dynamic column can be one of 147 | - left 148 | - middle 149 | - right 150 | 151 | When dynamic column is right the handle positions are 0 based with respect to the left border of Rickscroll, such that: 152 | 153 | ``` 154 | | | | | 155 | 0 l r w 156 | ``` 157 | 158 | With the left column being dynamic, the positions are 0 indexed from the right border, such that: 159 | ``` 160 | | | | | 161 | w l r 0 162 | ``` 163 | 164 | With the middle column being dynamic, the left gutter is indexed off of 0 from the left border and the right gutter is indexed off of 0 from the right border, such that: 165 | ``` 166 | | | | | 167 | 0 l r 0 168 | ``` 169 | 170 | Supplying a Static Width to Rickscroll 171 | ------ 172 | If you have a situation where you need to control the width/height of rickscroll explicitly, 173 | on the Rickscroll import pull out the Static class: 174 | 175 | ```javascript 176 | import { Rickscroll } from 'rickscroll'; 177 | 178 | const list = new Array(10000).fill({}).map(() => ({ 179 | contentComponent() { return Hello world!; }, 180 | height: 20 181 | })); 182 | 183 | function renderList() { 184 | return ; 185 | } 186 | ``` 187 | 188 | *Example written in ES6* 189 | 190 | Triggering a ScrollTo Event through Props 191 | ------ 192 | There are 3 types of scroll to events that can be triggered, row, header, and position. 193 | The location property of the scrollTo prop has an x and a y field, when the scroll type is set to header or row, y refers to the index of that header or row. 194 | X always refers to a pixel value location in the horizontal space. 195 | If a onRegisteredScrollTo callback is passed as a prop, Rickscroll will call that when it starts a scrollTo operation. 196 | 197 | Listening for Scroll Events 198 | ------ 199 | Rickscroll will pass an additional prop to every row/gutter component describing scroll operations. 200 | The isScrolling prop will be true during scrolling. 201 | The isFastScrolling prop represents whether the velocity of a given scroll event is considered fast. 202 | If a scroll event has a sufficient velocity, every row will receive the true as isFastScrolling. 203 | If you are rendering heavy content in every row, 204 | you can use this prop to render a lighter weight representation of your rows. 205 | 206 | Lighter Render Steps 207 | ------ 208 | Specify light on Rickscroll to bypass all gutter configuration at the row level. 209 | 210 | Dependencies 211 | ------ 212 | Rickscroll is written using es6 via babel. So we ask that you provide `babel-polyfill@^0.6.13` 213 | 214 | Rickscroll is written for `react@^0.14.0` 215 | 216 | License 217 | ------ 218 | Copyright (c) 2016 Tyler Wanek 219 | 220 | 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: 221 | 222 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 223 | 224 | 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. 225 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rickscroll", 3 | "version": "0.9.3", 4 | "description": "Optimized scrollable view for react", 5 | "main": "lib/js/index.js", 6 | "scripts": { 7 | "compile": "lessc src/less/scrollable.less lib/css/scrollable.css && babel -d ./lib/js ./src/js", 8 | "lint": "eslint --ext .jsx --ext .js src/js --quiet", 9 | "prepublish": "npm run compile" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Axosoft/rickscroll.git" 14 | }, 15 | "files": [ 16 | "lib" 17 | ], 18 | "author": "Axosoft, LLC", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/Axosoft/rickscroll/issues" 22 | }, 23 | "homepage": "https://github.com/Axosoft/rickscroll", 24 | "dependencies": { 25 | "animation-timer": "^1.0.12", 26 | "classnames": "^2.2.5", 27 | "functional-easing": "^1.0.8", 28 | "lodash": "^4.14.2", 29 | "resize-observer-polyfill": "^1.2.1" 30 | }, 31 | "devDependencies": { 32 | "babel-cli": "^6.11.4", 33 | "babel-core": "^6.9.1", 34 | "babel-eslint": "^6.1.2", 35 | "babel-plugin-transform-object-rest-spread": "^6.19.0", 36 | "babel-preset-es2015": "^6.9.0", 37 | "babel-preset-react": "^6.5.0", 38 | "eslint": "^3.3.1", 39 | "eslint-config-airbnb": "^10.0.1", 40 | "eslint-plugin-import": "^1.13.0", 41 | "eslint-plugin-jsx-a11y": "^2.1.0", 42 | "eslint-plugin-react": "^6.1.2", 43 | "less": "^2.7.1", 44 | "react": "^15.0.0" 45 | }, 46 | "peerDependencies": { 47 | "babel-polyfill": "^6.13.0", 48 | "react": "^0.14.0 || ^15.0.0" 49 | }, 50 | "keywords": [ 51 | "scrolling", 52 | "scrollable", 53 | "scroll", 54 | "performance", 55 | "optimized", 56 | "gpu", 57 | "translate3d", 58 | "fast" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /src/.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslintrc 3 | src 4 | -------------------------------------------------------------------------------- /src/js/AutoAdjust.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes as types } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | import Rickscroll from './Scrollable'; 5 | 6 | export default class AutoAdjust extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | height: 0, 11 | width: 0 12 | }; 13 | this._autoAdjustDiv = null; 14 | this._getRef = this._getRef.bind(this); 15 | this._getRickscroll = this._getRickscroll.bind(this); 16 | this._updateDimensions = this._updateDimensions.bind(this); 17 | this._resizeObserver = new ResizeObserver(this._updateDimensions); 18 | this.scrollRowToMiddle = this.scrollRowToMiddle.bind(this); 19 | this.scrollTo = this.scrollTo.bind(this); 20 | this.scrollToHeader = this.scrollToHeader.bind(this); 21 | this.toggleSection = this.toggleSection.bind(this); 22 | } 23 | 24 | componentDidMount() { 25 | window.addEventListener('resize', this._updateDimensions); 26 | this._resizeObserver.observe(this._autoAdjustDiv); 27 | this._updateDimensions(); 28 | } 29 | 30 | componentDidUpdate() { 31 | this._updateDimensions(); 32 | } 33 | 34 | componentWillUnmount() { 35 | window.removeEventListener('resize', this._updateDimensions); 36 | this._resizeObserver.unobserve(this._autoAdjustDiv); 37 | this.setState({ height: 0, width: 0 }); 38 | this.autoAdjustDiv = null; 39 | } 40 | 41 | _getRef(ref) { 42 | this._autoAdjustDiv = ref; 43 | } 44 | 45 | _getRickscroll(ref) { 46 | this._rickscroll = ref; 47 | } 48 | 49 | _updateDimensions() { 50 | const { 51 | _autoAdjustDiv, 52 | _autoAdjustDiv: { 53 | clientHeight: height, 54 | clientWidth: width 55 | }, 56 | state: { 57 | height: prevHeight, 58 | width: prevWidth 59 | } 60 | } = this; 61 | 62 | if (!_autoAdjustDiv || (prevHeight === height && prevWidth === width)) { 63 | return; 64 | } 65 | 66 | this.setState({ height, width }); 67 | } 68 | 69 | // forward rickscroll public functions 70 | scrollRowToMiddle(...args) { 71 | return this._rickscroll && this._rickscroll.scrollRowToMiddle(...args); 72 | } 73 | 74 | scrollTo(...args) { 75 | return this._rickscroll && this._rickscroll.scrollTo(...args); 76 | } 77 | 78 | scrollToHeader(...args) { 79 | return this._rickscroll && this._rickscroll.scrollToHeader(...args); 80 | } 81 | 82 | toggleSection(...args) { 83 | return this._rickscroll && this._rickscroll.toggleSection(...args); 84 | } 85 | 86 | render() { 87 | const { 88 | props: { 89 | heightAdjust, 90 | widthAdjust, 91 | ...props 92 | }, 93 | state: { 94 | height, 95 | width 96 | }, 97 | _autoAdjustDiv 98 | } = this; 99 | 100 | const style = { 101 | height: heightAdjust 102 | ? `calc(100% - ${heightAdjust})` 103 | : '100%', 104 | width: widthAdjust 105 | ? `calc(100% - ${widthAdjust})` 106 | : '100%' 107 | }; 108 | 109 | const child = _autoAdjustDiv 110 | ? 111 | : null; 112 | 113 | return ( 114 |
115 | {child} 116 |
117 | ); 118 | } 119 | } 120 | 121 | AutoAdjust.propTypes = { 122 | heightAdjust: types.oneOfType([types.string, types.number]), 123 | widthAdjust: types.oneOfType([types.string, types.number]) 124 | }; 125 | -------------------------------------------------------------------------------- /src/js/HorizontalWrapper.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes as types } from 'react'; 2 | import _ from 'lodash'; 3 | 4 | export default class HorizontalWrapper extends React.Component { 5 | shouldComponentUpdate(nextProps, nextState) { 6 | return !_.isEqual(nextProps, this.props) || !_.isEqual(nextState, this.state); 7 | } 8 | 9 | render() { 10 | const { children, offset } = this.props; 11 | const horizontalWrapperStyle = { 12 | transform: `translate3d(-${offset}px, -0px, 0px)` 13 | }; 14 | return
{children}
; 15 | } 16 | } 17 | 18 | HorizontalWrapper.propTypes = { 19 | children: types.node.isRequired, 20 | offset: types.number.isRequired 21 | }; 22 | -------------------------------------------------------------------------------- /src/js/LightweightRow.jsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { PropTypes as types } from 'react'; 3 | 4 | import HorizontalWrapper from './HorizontalWrapper'; 5 | import * as customTypes from './propTypes'; 6 | 7 | export default class LightweightRow extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | this._buildContentComponent = this._buildContentComponent.bind(this); 12 | this._cacheMiss = this._cacheMiss.bind(this); 13 | this._getContentComponentWithShallowCaching = this._getContentComponentWithShallowCaching.bind(this); 14 | this._shouldWrapComponent = this._shouldWrapComponent.bind(this); 15 | 16 | this._cache = { 17 | props: {}, 18 | renderedRow: null 19 | }; 20 | } 21 | 22 | _buildContentComponent() { 23 | const { 24 | contentComponent: ContentComponent, 25 | horizontalTransform, 26 | isFastScrolling, 27 | isScrolling, 28 | passthroughOffsets, 29 | rowProps, 30 | scrollsHorizontally 31 | } = this.props; 32 | 33 | // save from last render 34 | this._cache.props = { 35 | horizontalTransform, 36 | isFastScrolling, 37 | isScrolling, 38 | passthroughOffsets, 39 | rowProps, 40 | scrollsHorizontally 41 | }; 42 | 43 | if (scrollsHorizontally && !passthroughOffsets) { 44 | this._cache.renderedRow = ( 45 | 52 | ); 53 | } else { 54 | this._cache.renderedRow = ( 55 | 62 | ); 63 | } 64 | 65 | return this._cache.renderedRow; 66 | } 67 | 68 | _cacheMiss() { 69 | return !this._cache.renderedRow 70 | || this._cache.props.horizontalTransform !== this.props.horizontalTransform 71 | || this._cache.props.isFastScrolling !== this.props.isFastScrolling 72 | || this._cache.props.isScrolling !== this.props.isScrolling 73 | || this._cache.props.passthroughOffsets !== this.props.passthroughOffsets 74 | || this._cache.props.rowProps !== this.props.rowProps 75 | || this._cache.props.scrollsHorizontally !== this.props.scrollsHorizontally; 76 | } 77 | 78 | _getContentComponentWithShallowCaching() { 79 | return this._cacheMiss() 80 | ? this._buildContentComponent() 81 | : this._cache.renderedRow; 82 | } 83 | 84 | _shouldWrapComponent() { 85 | return this.props.scrollsHorizontally && !this.props.passthroughOffsets; 86 | } 87 | 88 | render() { 89 | const { 90 | className: thisRowClassName, 91 | contentClassName: thisContentClassName, 92 | horizontalTransform, 93 | onClick, 94 | rowHeight, 95 | width 96 | } = this.props; 97 | const rowStyle = { 98 | height: `${rowHeight}px`, 99 | width: `${width}px` 100 | }; 101 | 102 | const contentComponent = this._getContentComponentWithShallowCaching(); 103 | 104 | const horizontallyWrappedContentComponent = 105 | this._shouldWrapComponent() 106 | ? ( 107 | 108 | {contentComponent} 109 | 110 | ) 111 | : contentComponent; 112 | 113 | const rowClassName = classnames('rickscroll__row', thisRowClassName); 114 | const contentClassName = classnames('rickscroll__content', thisContentClassName); 115 | 116 | return ( 117 |
118 | 119 | {horizontallyWrappedContentComponent} 120 | 121 |
122 | ); 123 | } 124 | } 125 | 126 | LightweightRow.propTypes = { 127 | className: types.string, 128 | contentClassName: types.string, 129 | contentComponent: customTypes.renderableComponent, 130 | horizontalTransform: types.number, 131 | isFastScrolling: types.bool, 132 | isScrolling: types.bool, 133 | onClick: types.func, 134 | passthroughOffsets: types.bool, 135 | rowHeight: types.number.isRequired, 136 | rowProps: types.object, 137 | scrollsHorizontally: types.bool.isRequired, 138 | width: types.number.isRequired 139 | }; 140 | -------------------------------------------------------------------------------- /src/js/Row.jsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { PropTypes as types } from 'react'; 3 | import _ from 'lodash'; 4 | 5 | import * as constants from './constants'; 6 | import HorizontalWrapper from './HorizontalWrapper'; 7 | import * as customTypes from './propTypes'; 8 | import { getGutterWidths, getWidthStyle } from './utils'; 9 | 10 | export default class Row extends React.Component { 11 | constructor(props) { 12 | super(props); 13 | this._getRenderableHandle = this._getRenderableHandle.bind(this); 14 | } 15 | 16 | shouldComponentUpdate(nextProps, nextState) { 17 | return !_.isEqual(nextProps, this.props) || !_.isEqual(nextState, this.state); 18 | } 19 | 20 | _getRenderableGutter(side, index, { 21 | className: thisGutterClassName, 22 | contentComponent: ContentComponent, 23 | props = {} 24 | } = {}, width, gutterClassName) { 25 | const { 26 | isFastScrolling = false, 27 | isScrolling = false 28 | } = this.props; 29 | const gutterStyle = getWidthStyle(width); 30 | const className = classnames('rickscroll__gutter', gutterClassName, thisGutterClassName); 31 | return ContentComponent && gutterStyle ? ( 32 | 33 | 39 | 40 | ) : undefined; 41 | } 42 | 43 | _getRenderableHandle(side, { contentComponent, handleClassName: thisHandleClassName } = {}, width, handleClassName) { 44 | const { 45 | guttersConfig: { 46 | [side]: { 47 | onResize 48 | } = {} 49 | } = {}, 50 | onStartResize = (() => () => {}) 51 | } = this.props; 52 | const handleStyle = getWidthStyle(width); 53 | const className = classnames( 54 | handleClassName, 55 | thisHandleClassName, 56 | 'rickscroll__handle', 57 | { 'rickscroll__handle--grabbable': !!onResize }, 58 | `rickscroll__handle--${side}` 59 | ); 60 | return contentComponent && handleStyle ? ( 61 | 67 | ) : undefined; 68 | } 69 | 70 | render() { 71 | const { 72 | className: thisRowClassName, 73 | contentComponent: ContentComponent, 74 | contentClassName: thisContentClassName, 75 | dynamicColumn = constants.columns.MIDDLE, 76 | guttersConfig, 77 | guttersConfig: { 78 | left: leftCanExist, 79 | left: { 80 | className: leftGutterClassName, 81 | handleClassName: leftHandleClassName, 82 | handleWidth: leftHandleWidth, 83 | position: leftHandlePosition 84 | } = {}, 85 | right: rightCanExist, 86 | right: { 87 | className: rightGutterClassName, 88 | handleClassName: rightHandleClassName, 89 | handleWidth: rightHandleWidth = constants.RIGHT_HANDLE_WIDTH, 90 | position: rightHandlePosition 91 | } = {} 92 | } = {}, 93 | gutters = {}, 94 | horizontalTransform, 95 | index, 96 | isFastScrolling = false, 97 | isScrolling = false, 98 | onClick, 99 | passthroughOffsets, 100 | rowHeight, 101 | rowProps = {}, 102 | scrollsHorizontally = false, 103 | width 104 | } = this.props; 105 | const rowStyle = { 106 | height: `${rowHeight}px`, 107 | width: `${width}px` 108 | }; 109 | 110 | const { leftGutterWidth, rightGutterWidth } = getGutterWidths({ 111 | dynamicColumn: guttersConfig 112 | ? dynamicColumn 113 | : constants.columns.MIDDLE, 114 | leftHandlePosition, 115 | leftHandleWidth, 116 | rightHandlePosition, 117 | rightHandleWidth, 118 | width 119 | }); 120 | 121 | const leftComponent = leftCanExist 122 | ? this._getRenderableGutter( 123 | constants.columns.LEFT, 124 | index, 125 | gutters.left, 126 | leftGutterWidth, 127 | leftGutterClassName 128 | ) 129 | : null; 130 | const leftHandleComponent = leftCanExist 131 | ? this._getRenderableHandle( 132 | constants.columns.LEFT, 133 | gutters.left, 134 | leftHandleWidth, 135 | leftHandleClassName 136 | ) 137 | : null; 138 | const rightComponent = rightCanExist 139 | ? this._getRenderableGutter( 140 | constants.columns.RIGHT, 141 | index, 142 | gutters.right, 143 | rightGutterWidth, 144 | rightGutterClassName 145 | ) 146 | : null; 147 | const rightHandleComponent = rightCanExist 148 | ? this._getRenderableHandle( 149 | constants.columns.RIGHT, 150 | gutters.right, 151 | rightHandleWidth, 152 | rightHandleClassName 153 | ) 154 | : null; 155 | 156 | let contentComponent; 157 | if (scrollsHorizontally) { 158 | contentComponent = passthroughOffsets 159 | ? ( 160 | 167 | ) 168 | : ( 169 | 170 | 171 | 172 | ); 173 | } else { 174 | contentComponent = ( 175 | 176 | ); 177 | } 178 | 179 | const rowClassName = classnames('rickscroll__row', thisRowClassName); 180 | const contentClassName = classnames('rickscroll__content', thisContentClassName); 181 | 182 | return ( 183 |
184 | {leftComponent} 185 | {leftHandleComponent} 186 | {contentComponent} 187 | {rightHandleComponent} 188 | {rightComponent} 189 |
190 | ); 191 | } 192 | } 193 | 194 | Row.propTypes = { 195 | className: types.string, 196 | contentClassName: types.string, 197 | contentComponent: customTypes.renderableComponent, 198 | dynamicColumn: customTypes.column, 199 | gutters: customTypes.gutters, 200 | guttersConfig: customTypes.guttersConfig, 201 | horizontalTransform: types.number, 202 | index: types.number.isRequired, 203 | isFastScrolling: types.bool, 204 | isScrolling: types.bool, 205 | onClick: types.func, 206 | onStartResize: types.func, 207 | passthroughOffsets: types.bool, 208 | rowHeight: types.number.isRequired, 209 | rowProps: types.object, 210 | scrollsHorizontally: types.bool.isRequired, 211 | width: types.number.isRequired 212 | }; 213 | -------------------------------------------------------------------------------- /src/js/Scrollable.jsx: -------------------------------------------------------------------------------- 1 | import { AnimationTimer } from 'animation-timer'; 2 | import classnames from 'classnames'; 3 | import { Easer } from 'functional-easing'; 4 | import React, { PropTypes as types } from 'react'; 5 | import _ from 'lodash'; 6 | 7 | import * as constants from './constants'; 8 | import * as customTypes from './propTypes'; 9 | import LightweightRow from './LightweightRow'; 10 | import { Point } from './models'; 11 | import Row from './Row'; 12 | import { 13 | buildRowConfig, 14 | getGutterWidths, 15 | getMaxHeight, 16 | getResizeValues, 17 | getVelocityInfo, 18 | getVerticalScrollValues, 19 | returnWidthIfComponentExists 20 | } from './utils'; 21 | 22 | const easer = new Easer() 23 | .using('out-cubic'); 24 | 25 | export default class Scrollable extends React.Component { 26 | constructor(props) { 27 | super(props); 28 | [ 29 | '_applyScrollChange', 30 | '_getBottomGutterHeight', 31 | '_getContentWidth', 32 | '_getDimensions', 33 | '_getThrottledHorizontalAnimationFrameFn', 34 | '_getThrottledVerticalAnimationFrameFn', 35 | '_getTopGutterHeight', 36 | '_getWeightedWidth', 37 | '_onHorizontalScroll', 38 | '_onResize', 39 | '_onMouseWheel', 40 | '_onVerticalScroll', 41 | '_renderContents', 42 | '_renderCorner', 43 | '_renderHorizontalScrollbar', 44 | '_renderHeader', 45 | '_renderVerticalScrollbar', 46 | '_shouldRenderScrollbars', 47 | '_startResize', 48 | '_stopResize', 49 | '_switchScrollProp', 50 | 'scrollRowToMiddle', 51 | 'scrollToHeader', 52 | 'toggleSection' 53 | ].forEach(method => { this[method] = this[method].bind(this); }); 54 | this._debouncedStartHorizontalScroll = _.debounce(this._startHorizontalScroll, 100); 55 | this._endScroll = _.debounce(() => this.setState({ 56 | isFastScrolling: false, 57 | isScrolling: false, 58 | velocityQueue: [[0, 0]] 59 | }), 50, { trailing: true }); 60 | this._endVerticalScroll = _.debounce(() => this.setState({ 61 | isFastScrolling: false, 62 | isScrolling: false, 63 | velocityQueue: [[0, 0]] 64 | }), 200, { trailing: true }); 65 | this._onThrottledMouseWheel = _.throttle(this._applyScrollChange, constants.ANIMATION_FPS_120, { trailing: true }); 66 | const { 67 | headerType = constants.headerType.DEFAULT, 68 | height, 69 | list, 70 | lists, 71 | width 72 | } = props; 73 | const stackingHeaders = headerType === constants.headerType.STACKING; 74 | const listContainer = list || lists; 75 | const { 76 | avgRowHeight, 77 | collapsedSections, 78 | contentHeight, 79 | headers, 80 | partitions, 81 | rowOffsets, 82 | rows 83 | } = buildRowConfig(listContainer, stackingHeaders); 84 | const { 85 | displayBuffer, 86 | shouldRender 87 | } = this._getDimensions(avgRowHeight, contentHeight, height, width); 88 | 89 | this.state = { 90 | animation: null, 91 | avgRowHeight, 92 | collapsedSections, 93 | contentHeight, 94 | displayBuffer, 95 | isFastScrolling: false, 96 | headers, 97 | horizontalTransform: 0, 98 | partitions, 99 | resize: { 100 | basePosition: 0, 101 | currentPosition: 0, 102 | performing: false, 103 | side: '', 104 | startingPosition: 0 105 | }, 106 | rowOffsets, 107 | rows, 108 | isScrolling: false, 109 | scrollingToPosition: new Point(0, 0), 110 | shouldRender, 111 | topPartitionIndex: 0, 112 | velocityQueue: [[0, 0]], 113 | verticalTransform: 0 114 | }; 115 | } 116 | 117 | componentDidMount() { 118 | const { 119 | scrollTo: scrollToWithoutDefaults 120 | } = this.props; 121 | 122 | if (!scrollToWithoutDefaults) { 123 | return; 124 | } 125 | 126 | this._switchScrollProp(_.merge( 127 | _.cloneDeep(constants.defaultScrollTo), 128 | _.cloneDeep(scrollToWithoutDefaults) 129 | )); 130 | } 131 | 132 | componentWillReceiveProps({ 133 | guttersConfig, 134 | headerType = constants.headerType.DEFAULT, 135 | height, 136 | horizontalScrollConfig, 137 | list: nextList, 138 | lists: nextLists, 139 | scrollTo: scrollToWithoutDefaults = {}, 140 | verticalScrollConfig, 141 | width 142 | }) { 143 | const { 144 | props: { 145 | guttersConfig: prevGuttersConfig, 146 | height: prevHeight, 147 | horizontalScrollConfig: prevHorizontalScrollConfig, 148 | list: prevList, 149 | lists: prevLists, 150 | scrollTo: prevScrollToWithoutDefaults = {}, 151 | verticalScrollConfig: prevVerticalScrollConfig, 152 | width: prevWidth 153 | }, 154 | state: { 155 | avgRowHeight: prevAvgRowHeight, 156 | collapsedSections: oldCollapsedSections, 157 | contentHeight: prevContentHeight 158 | } 159 | } = this; 160 | 161 | const stackingHeaders = headerType === constants.headerType.STACKING; 162 | const prevListContainer = prevList || prevLists; 163 | const nextListContainer = nextList || nextLists; 164 | 165 | if (prevListContainer !== nextListContainer || !_.isEqual(prevListContainer, nextListContainer)) { 166 | const { 167 | avgRowHeight, 168 | collapsedSections, 169 | contentHeight, 170 | headers, 171 | partitions, 172 | rowOffsets, 173 | rows 174 | } = buildRowConfig(nextListContainer, stackingHeaders, oldCollapsedSections); 175 | this.setState({ 176 | avgRowHeight, 177 | collapsedSections, 178 | contentHeight, 179 | headers, 180 | partitions, 181 | rowOffsets, 182 | rows, 183 | ...this._getDimensions(avgRowHeight, contentHeight, height, width) 184 | }); 185 | } else if ( 186 | height !== prevHeight 187 | || width !== prevWidth 188 | || !_.isEqual(prevGuttersConfig, guttersConfig) 189 | || !_.isEqual(prevHorizontalScrollConfig, horizontalScrollConfig) 190 | || !_.isEqual(prevVerticalScrollConfig, verticalScrollConfig) 191 | ) { 192 | this.setState(this._getDimensions(prevAvgRowHeight, prevContentHeight, height, width)); 193 | } 194 | 195 | const prevScrollTo = _.merge( 196 | _.cloneDeep(constants.defaultScrollTo), 197 | _.cloneDeep(prevScrollToWithoutDefaults) 198 | ); 199 | const scrollTo = _.merge( 200 | _.cloneDeep(constants.defaultScrollTo), 201 | _.cloneDeep(scrollToWithoutDefaults) 202 | ); 203 | 204 | if (!_.isEqual(prevScrollTo, scrollTo)) { 205 | this._switchScrollProp(scrollTo); 206 | } 207 | } 208 | 209 | shouldComponentUpdate(nextProps, nextState) { 210 | return this.state.verticalTransform !== nextState.verticalTransform 211 | || this.state.horizontalTransform !== nextState.horizontalTransform 212 | || this.props.height !== nextProps.height 213 | || this.props.width !== nextProps.width 214 | || !_.isEqual(this.props, nextProps) 215 | || !_.isEqual(this.state, nextState); 216 | } 217 | 218 | // private 219 | 220 | _applyScrollChange({ deltaX: _deltaX, deltaY: _deltaY }) { 221 | const { 222 | props: { 223 | disableBidirectionalScrolling = false, 224 | height, 225 | horizontalScrollConfig, 226 | horizontalScrollConfig: { 227 | onScroll: onHorizontalScroll = () => {} 228 | } = {}, 229 | verticalScrollConfig: { 230 | onScroll: onVerticalScroll = () => {} 231 | } = {} 232 | }, 233 | state: { 234 | contentHeight, 235 | isFastScrolling: oldFastScrollTrip, 236 | partitions, 237 | shouldRender, 238 | velocityQueue: oldVelocityQueue 239 | }, 240 | _horizontalScrollbar, 241 | _verticalScrollbar 242 | } = this; 243 | const withHorizontalScrolling = !!horizontalScrollConfig && shouldRender.horizontalScrollbar; 244 | 245 | let deltaX = _deltaX; 246 | let deltaY = _deltaY; 247 | 248 | if (disableBidirectionalScrolling) { 249 | if (Math.abs(deltaX) > Math.abs(deltaY)) { 250 | deltaY = 0; 251 | } else { 252 | deltaX = 0; 253 | } 254 | } 255 | 256 | const scrollChanges = { 257 | isScrolling: true 258 | }; 259 | 260 | // vertical 261 | if (shouldRender.verticalScrollbar) { 262 | const maxHeight = getMaxHeight(contentHeight, _verticalScrollbar.offsetHeight); 263 | const verticalTransform = this.state.verticalTransform + deltaY; 264 | const { averageVelocity, velocityQueue } = getVelocityInfo(deltaY, oldVelocityQueue); 265 | 266 | const isFastScrolling = oldFastScrollTrip 267 | || Math.abs(averageVelocity) > (height * constants.USER_INITIATED_FAST_SCROLL_FACTOR); 268 | 269 | _.assign( 270 | scrollChanges, 271 | getVerticalScrollValues(verticalTransform, maxHeight, partitions), 272 | { isFastScrolling, velocityQueue } 273 | ); 274 | } 275 | 276 | // horizontal scrolling 277 | if (withHorizontalScrolling) { 278 | scrollChanges.horizontalTransform = _.clamp( 279 | this.state.horizontalTransform + deltaX, 280 | 0, 281 | _horizontalScrollbar.scrollWidth - _horizontalScrollbar.offsetWidth 282 | ); 283 | } 284 | 285 | this.setState(scrollChanges, () => { 286 | if (shouldRender.verticalScrollbar) { 287 | if (_verticalScrollbar.scrollTop !== scrollChanges.verticalTransform) { 288 | onVerticalScroll(scrollChanges.verticalTransform); 289 | } 290 | _verticalScrollbar.scrollTop = scrollChanges.verticalTransform; 291 | } 292 | 293 | if (withHorizontalScrolling) { 294 | if (_horizontalScrollbar.scrollLeft !== scrollChanges.horizontalTransform) { 295 | onHorizontalScroll(scrollChanges.horizontalTransform); 296 | } 297 | _horizontalScrollbar.scrollLeft = scrollChanges.horizontalTransform; 298 | } 299 | 300 | this._endScroll(); 301 | }); 302 | } 303 | 304 | _getBottomGutterHeight() { 305 | const { 306 | props: { 307 | height, 308 | headerType = constants.headerType.DEFAULT, 309 | horizontalScrollConfig: { 310 | scrollbarHeight = constants.HORIZONTAL_SCROLLBAR_HEIGHT 311 | } = {} 312 | }, 313 | state: { 314 | headers, 315 | shouldRender, 316 | verticalTransform 317 | } 318 | } = this; 319 | 320 | if (headerType !== constants.headerType.STACKING) { 321 | return 0; 322 | } 323 | 324 | const adjustedHeight = shouldRender.horizontalScrollbar 325 | ? height - scrollbarHeight 326 | : height; 327 | 328 | let slidingWindowOfGutterHeight = 0; 329 | const indexOfFirstFreeGutter = headers.length - _.findIndex( 330 | _.reverse(_.clone(headers)), 331 | ({ height: headerHeight, realOffset }) => { 332 | const adjustedTransform = (verticalTransform + adjustedHeight) - slidingWindowOfGutterHeight; 333 | const gutterIsLocked = adjustedTransform < realOffset; 334 | if (gutterIsLocked) { 335 | slidingWindowOfGutterHeight = headerHeight; 336 | } 337 | return !gutterIsLocked; 338 | } 339 | ); 340 | 341 | return _(headers) 342 | .slice(indexOfFirstFreeGutter) 343 | .reduce((prevHeight, { height: headerHeight }) => prevHeight + headerHeight, 0); 344 | } 345 | 346 | _getTopGutterHeight() { 347 | const { 348 | props: { 349 | headerType = constants.headerType.DEFAULT 350 | }, 351 | state: { 352 | headers, 353 | verticalTransform 354 | } 355 | } = this; 356 | 357 | if (headerType === constants.headerType.DEFAULT) { 358 | return 0; 359 | } 360 | 361 | const findNextHeaderIndex = _.findIndex(headers, ({ lockPosition }) => lockPosition > verticalTransform); 362 | const nextHeaderIndex = findNextHeaderIndex === -1 363 | ? headers.length 364 | : findNextHeaderIndex; 365 | 366 | if (headerType === constants.headerType.LOCKING) { 367 | const header = headers[nextHeaderIndex - 1]; 368 | return header.height; 369 | } 370 | 371 | return _(headers) 372 | .slice(0, nextHeaderIndex) 373 | .reduce((prevHeight, { height }) => prevHeight + height, 0); 374 | } 375 | 376 | _getContentWidth() { 377 | const { 378 | props: { 379 | dynamicColumn = constants.columns.MIDDLE, 380 | guttersConfig, 381 | guttersConfig: { 382 | left, 383 | left: { 384 | handleWidth: leftHandleWidth = constants.LEFT_HANDLE_WIDTH, 385 | position: leftGutterPosition = 0 386 | } = {}, 387 | right, 388 | right: { 389 | handleWidth: rightHandleWidth = constants.RIGHT_HANDLE_WIDTH, 390 | position: rightGutterPosition = 0 391 | } = {} 392 | } = {}, 393 | horizontalScrollConfig: { 394 | contentWidth = 0 395 | } = {}, 396 | width 397 | } 398 | } = this; 399 | 400 | let leftGutterWidth; 401 | let rightGutterWidth; 402 | switch (guttersConfig ? dynamicColumn : constants.columns.MIDDLE) { 403 | case constants.columns.LEFT: 404 | leftGutterWidth = width - leftGutterPosition - leftHandleWidth; 405 | rightGutterWidth = rightGutterPosition; 406 | break; 407 | case constants.columns.RIGHT: 408 | leftGutterWidth = leftGutterPosition; 409 | rightGutterWidth = width - rightGutterPosition - rightHandleWidth; 410 | break; 411 | default: 412 | leftGutterWidth = leftGutterPosition; 413 | rightGutterWidth = rightGutterPosition; 414 | } 415 | 416 | return _.sum([ 417 | contentWidth, 418 | leftGutterWidth, 419 | left ? leftHandleWidth : 0, 420 | right ? rightHandleWidth : 0, 421 | rightGutterWidth 422 | ]); 423 | } 424 | 425 | _getDimensions(avgRowHeight, contentHeight, height, width) { 426 | const { 427 | scrollbarHeight = constants.HORIZONTAL_SCROLLBAR_HEIGHT 428 | } = this.props.horizontalScrollConfig || {}; 429 | const shouldRender = this._shouldRenderScrollbars(contentHeight, height, width); 430 | const contentsDivHeight = height - (shouldRender.horizontalScrollbar ? scrollbarHeight : 0); 431 | const numRowsInContents = _.ceil(contentsDivHeight / avgRowHeight); 432 | 433 | let displayBuffer = numRowsInContents + (2 * constants.OFFSET_BUFFER); 434 | displayBuffer += constants.OFFSET_BUFFER - (displayBuffer % constants.OFFSET_BUFFER); 435 | 436 | const newState = { 437 | displayBuffer, 438 | shouldRender 439 | }; 440 | 441 | if (!shouldRender.verticalScrollbar) { 442 | newState.verticalTransform = 0; 443 | newState.topPartitionIndex = 0; 444 | if (this._verticalScrollbar) { 445 | this._verticalScrollbar.scrollTop = 0; 446 | } 447 | } 448 | 449 | if (!shouldRender.horizontalScrollbar) { 450 | newState.horizontalTransform = 0; 451 | if (this._horizontalScrollbar) { 452 | this._horizontalScrollbar.scrollLeft = 0; 453 | } 454 | } 455 | 456 | return newState; 457 | } 458 | 459 | _getThrottledHorizontalAnimationFrameFn(scrollTo) { 460 | const { 461 | props: { 462 | horizontalScrollConfig: { 463 | onScroll: onHorizontalScroll = () => {} 464 | } = {} 465 | }, 466 | state: { 467 | horizontalTransform, 468 | verticalTransform 469 | } 470 | } = this; 471 | 472 | const delta = new Point(scrollTo.x, 0) 473 | .sub(new Point(horizontalTransform, 0)); 474 | 475 | this.setState({ 476 | scrolling: true 477 | }); 478 | 479 | return _.throttle(easer(easedElapsedTime => { 480 | const { 481 | props: { 482 | horizontalScrollConfig 483 | }, 484 | state: { 485 | scrollingToPosition: latestScrollingToPosition, 486 | shouldRender 487 | }, 488 | _horizontalScrollbar 489 | } = this; 490 | if (!_.isEqual(scrollTo, latestScrollingToPosition)) { 491 | return; 492 | } 493 | 494 | const withHorizontalScrolling = !!horizontalScrollConfig && shouldRender.horizontalScrollbar; 495 | const elapsedTime = easedElapsedTime > 0.999 ? 1 : easedElapsedTime; 496 | const deltaScrolled = new Point(delta.x, delta.y) 497 | .scale(elapsedTime); 498 | const newTransform = new Point(horizontalTransform, verticalTransform) 499 | .add(deltaScrolled); 500 | 501 | const scrollChanges = { 502 | isScrolling: true 503 | }; 504 | 505 | if (withHorizontalScrolling) { 506 | scrollChanges.horizontalTransform = _.clamp( 507 | newTransform.x, 508 | 0, 509 | _horizontalScrollbar.scrollWidth - _horizontalScrollbar.offsetWidth 510 | ); 511 | } 512 | 513 | this.setState(scrollChanges, () => { 514 | if (withHorizontalScrolling) { 515 | if (_horizontalScrollbar.scrollLeft !== scrollChanges.horizontalTransform) { 516 | onHorizontalScroll(scrollChanges.horizontalTransform); 517 | } 518 | _horizontalScrollbar.scrollLeft = scrollChanges.horizontalTransform; 519 | } 520 | 521 | this._endScroll(); 522 | }); 523 | }), constants.ANIMATION_FPS_120, { leading: true }); 524 | } 525 | 526 | _getThrottledVerticalAnimationFrameFn(scrollTo) { 527 | const { 528 | props: { 529 | height, 530 | verticalScrollConfig: { 531 | onScroll: onVerticalScroll = () => {} 532 | } = {} 533 | }, 534 | state: { 535 | horizontalTransform, 536 | verticalTransform 537 | } 538 | } = this; 539 | const delta = new Point(0, scrollTo.y) 540 | .sub(new Point(0, verticalTransform)); 541 | 542 | const initStateUpdate = { 543 | scrolling: true 544 | }; 545 | 546 | if (Math.abs(delta.y) > height * constants.SCROLL_TO_FAST_SCROLL_FACTOR) { 547 | initStateUpdate.isFastScrolling = true; 548 | } 549 | 550 | this.setState(initStateUpdate); 551 | 552 | return _.throttle(easer(easedElapsedTime => { 553 | const { 554 | state: { 555 | contentHeight, 556 | partitions, 557 | scrollingToPosition: latestScrollingToPosition, 558 | shouldRender 559 | }, 560 | _verticalScrollbar 561 | } = this; 562 | if (!_.isEqual(scrollTo, latestScrollingToPosition)) { 563 | return; 564 | } 565 | 566 | const elapsedTime = easedElapsedTime > 0.999 ? 1 : easedElapsedTime; 567 | const deltaScrolled = new Point(delta.x, delta.y) 568 | .scale(elapsedTime); 569 | const newTransform = new Point(horizontalTransform, verticalTransform) 570 | .add(deltaScrolled); 571 | 572 | const scrollChanges = { 573 | isScrolling: true 574 | }; 575 | 576 | if (shouldRender.verticalScrollbar) { 577 | const maxHeight = getMaxHeight(contentHeight, _verticalScrollbar.offsetHeight); 578 | _.assign(scrollChanges, getVerticalScrollValues(newTransform.y, maxHeight, partitions)); 579 | } 580 | 581 | this.setState(scrollChanges, () => { 582 | if (shouldRender.verticalScrollbar) { 583 | if (_verticalScrollbar.scrollTop !== scrollChanges.verticalTransform) { 584 | onVerticalScroll(scrollChanges.verticalTransform); 585 | } 586 | _verticalScrollbar.scrollTop = scrollChanges.verticalTransform; 587 | } 588 | 589 | this._endScroll(); 590 | }); 591 | }), constants.ANIMATION_FPS_120, { leading: true }); 592 | } 593 | 594 | _getWeightedWidth() { 595 | const { 596 | props: { 597 | verticalScrollConfig: { 598 | scrollbarWidth = constants.VERTICAL_SCROLLBAR_WIDTH 599 | } = {}, 600 | width 601 | }, 602 | state: { shouldRender } 603 | } = this; 604 | 605 | return width - (shouldRender.verticalScrollbar ? scrollbarWidth : 0); 606 | } 607 | 608 | _onHorizontalScroll() { 609 | const { 610 | props: { 611 | horizontalScrollConfig: { 612 | onScroll = () => {} 613 | } = {} 614 | }, 615 | state: { 616 | horizontalTransform 617 | }, 618 | _horizontalScrollbar 619 | } = this; 620 | 621 | if (!_horizontalScrollbar || _horizontalScrollbar.scrollLeft === horizontalTransform) { 622 | return; 623 | } 624 | 625 | const { scrollLeft = 0 } = _horizontalScrollbar || {}; 626 | this.setState({ horizontalTransform: scrollLeft }); 627 | onScroll(scrollLeft); 628 | } 629 | 630 | _onMouseWheel({ deltaX, deltaY }) { 631 | this._onThrottledMouseWheel({ deltaX, deltaY }); 632 | } 633 | 634 | /** 635 | * Performs a calculation to determine the size difference between each movement of the mouse cursor. Only occurs when 636 | * a resize is active. Will call the onResize handler for the gutter that is being resized with the new width of the 637 | * gutter. 638 | * @param {number} clientX the position of the mouse cursor horizontally 639 | */ 640 | _onResize({ clientX }) { 641 | const { basePosition, performing, side, startingPosition } = this.state.resize; 642 | const width = this._getWeightedWidth(); 643 | const { 644 | dynamicColumn = constants.columns.MIDDLE, 645 | guttersConfig, 646 | guttersConfig: { 647 | left, 648 | left: { 649 | handleWidth: leftHandleWidth = constants.LEFT_HANDLE_WIDTH, 650 | position: leftHandlePosition 651 | } = {}, 652 | right, 653 | right: { 654 | handleWidth: rightHandleWidth = constants.RIGHT_HANDLE_WIDTH, 655 | position: rightHandlePosition 656 | } = {}, 657 | [side]: { 658 | minPosition = 0, 659 | maxPosition = width, 660 | onResize = (() => {}) 661 | } = {} 662 | } = {} 663 | } = this.props; 664 | if (performing) { 665 | const deltaPosition = startingPosition - clientX; 666 | const { max, min, mod } = getResizeValues({ 667 | dynamicColumn: guttersConfig 668 | ? dynamicColumn 669 | : constants.columns.MIDDLE, 670 | leftExists: Boolean(left), 671 | leftHandlePosition, 672 | leftHandleWidth, 673 | rightExists: Boolean(right), 674 | rightHandlePosition, 675 | rightHandleWidth, 676 | side, 677 | width 678 | }); 679 | 680 | onResize(_.clamp( 681 | basePosition + (mod * deltaPosition), 682 | Math.max(minPosition, min), 683 | Math.min(maxPosition, max) 684 | )); 685 | } 686 | } 687 | 688 | _onVerticalScroll() { 689 | const { 690 | props: { 691 | height, 692 | verticalScrollConfig: { 693 | onScroll = () => {} 694 | } = {} 695 | }, 696 | state: { 697 | contentHeight, 698 | isFastScrolling: wasFastScrolling, 699 | partitions, 700 | velocityQueue: oldVelocityQueue, 701 | verticalTransform 702 | }, 703 | _verticalScrollbar, 704 | _verticalScrollbar: { 705 | offsetHeight, 706 | scrollTop 707 | } = {} 708 | } = this; 709 | 710 | if (!_verticalScrollbar || scrollTop === verticalTransform) { 711 | return; 712 | } 713 | 714 | const maxHeight = getMaxHeight(contentHeight, offsetHeight); 715 | 716 | const nextScrollState = getVerticalScrollValues(scrollTop, maxHeight, partitions); 717 | const { 718 | averageVelocity, 719 | velocityQueue 720 | } = getVelocityInfo(nextScrollState.verticalTransform - verticalTransform, oldVelocityQueue); 721 | const isFastScrolling = wasFastScrolling || Math.abs(averageVelocity) > (height * 3) / 4; 722 | 723 | this.setState(_.assign( 724 | nextScrollState, 725 | { 726 | averageVelocity, 727 | isFastScrolling, 728 | isScrolling: true, 729 | velocityQueue 730 | } 731 | ), () => this._endScroll()); 732 | onScroll(nextScrollState.verticalTransform); 733 | } 734 | 735 | _renderContents() { 736 | const { 737 | props: { 738 | dynamicColumn = constants.columns.MIDDLE, 739 | guttersConfig, 740 | horizontalScrollConfig, 741 | horizontalScrollConfig: { 742 | passthroughOffsets = false 743 | } = {}, 744 | light = false, 745 | verticalScrollConfig: { 746 | scrollbarWidth = constants.VERTICAL_SCROLLBAR_WIDTH 747 | } = {} 748 | }, 749 | state: { 750 | displayBuffer, 751 | isFastScrolling, 752 | horizontalTransform, 753 | partitions, 754 | rows, 755 | isScrolling, 756 | shouldRender, 757 | topPartitionIndex, 758 | verticalTransform 759 | } 760 | } = this; 761 | 762 | const contentsStyle = shouldRender.verticalScrollbar ? { 763 | width: `calc(100% - ${scrollbarWidth}px)` 764 | } : undefined; 765 | 766 | const weightedPartitionIndex = topPartitionIndex * constants.OFFSET_BUFFER; 767 | const startingRowIndex = Math.min(weightedPartitionIndex, rows.length); 768 | const endingRowIndex = weightedPartitionIndex + displayBuffer; 769 | const weightedWidth = this._getWeightedWidth(); 770 | 771 | const rowsWeWillRender = _.slice(rows, startingRowIndex, endingRowIndex); 772 | const partitionedRows = _.chunk(rowsWeWillRender, constants.OFFSET_BUFFER); 773 | const renderedPartitions = _.map(partitionedRows, (row, outerIndex) => { 774 | const partitionIndex = outerIndex + topPartitionIndex; 775 | const basePartitionOffset = partitions[partitionIndex]; 776 | const partitionStyle = { 777 | transform: `translate3d(-0px, ${basePartitionOffset - verticalTransform}px, 0px)` 778 | }; 779 | 780 | return ( 781 |
782 | {_.map( 783 | row, 784 | ({ 785 | className, 786 | contentComponent, 787 | contentClassName, 788 | gutters, 789 | height, 790 | isHeader, 791 | key, 792 | props: rowProps 793 | }, innerIndex) => light 794 | ? ( 795 | 809 | ) 810 | : ( 811 | 830 | ) 831 | )} 832 |
833 | ); 834 | }); 835 | 836 | const { bottomHeaderGutter, header, topHeaderGutter } = this._renderHeader(); 837 | 838 | // TODO remove partitions and shift the contents of the div 839 | return ( 840 |
841 | {header} 842 | {topHeaderGutter} 843 | {renderedPartitions} 844 | {bottomHeaderGutter} 845 |
846 | ); 847 | } 848 | 849 | _renderCorner() { 850 | const { 851 | props: { 852 | horizontalScrollConfig, 853 | horizontalScrollConfig: { 854 | scrollbarHeight = constants.HORIZONTAL_SCROLLBAR_HEIGHT 855 | } = {}, 856 | verticalScrollConfig: { 857 | scrollbarWidth = constants.VERTICAL_SCROLLBAR_WIDTH 858 | } = {} 859 | }, 860 | state: { shouldRender } 861 | } = this; 862 | 863 | const shouldRenderCorner = !!horizontalScrollConfig && shouldRender.verticalScrollbar; 864 | 865 | if (!shouldRenderCorner) { 866 | return null; 867 | } 868 | 869 | const cornerStyle = { 870 | height: `${scrollbarHeight}px`, 871 | width: `${scrollbarWidth}px` 872 | }; 873 | 874 | return
; 875 | } 876 | 877 | _renderHeader() { 878 | const { 879 | props: { 880 | dynamicColumn = constants.columns.MIDDLE, 881 | guttersConfig, 882 | headerType = constants.headerType.DEFAULT, 883 | height, 884 | horizontalScrollConfig: { 885 | scrollbarHeight = constants.HORIZONTAL_SCROLLBAR_HEIGHT 886 | } = {}, 887 | light 888 | }, 889 | state: { 890 | headers, 891 | isScrolling, 892 | isFastScrolling, 893 | rows, 894 | shouldRender, 895 | verticalTransform 896 | } 897 | } = this; 898 | 899 | if (!headers || headers.length === 0) { 900 | return {}; 901 | } 902 | 903 | const { lockPosition: maxLockPosition } = headers[headers.length - 1]; 904 | const findNextHeaderIndex = _.findIndex(headers, ({ lockPosition }) => lockPosition > verticalTransform); 905 | const nextHeaderIndex = findNextHeaderIndex === -1 ? headers.length : findNextHeaderIndex; 906 | const weightedWidth = this._getWeightedWidth(); 907 | 908 | if (headerType === constants.headerType.STACKING) { 909 | const topHeaderGutter = ( 910 |
911 | {_.times(nextHeaderIndex, headerIndex => { 912 | const { index: headerRowIndex } = headers[headerIndex]; 913 | const { className, contentComponent, height: rowHeight, key, props: rowProps } = rows[headerRowIndex]; 914 | 915 | return light 916 | ? ( 917 | 930 | ) 931 | : ( 932 | 948 | ); 949 | })} 950 |
951 | ); 952 | 953 | let bottomGutterStartIndex = nextHeaderIndex; 954 | /* We want to erase headers as they come into view in the contents view from the header gutter 955 | * We solve for the vertical transform that we need to remove a header from the bottom gutter: 956 | * height: height of the header we are transitioning 957 | * topHeight: height of all other gutters pinned to the top, not including baseHeight 958 | * realOffset: the verticalTransform that aligns the next header with the top of the rickscroll__contents 959 | * bottomHeight: the height of the bottom gutter of combined headers 960 | * adjustedBottomHeight: the total height of the headers in the bottom gutter with the baseHeight 961 | * adjustedTransform: the vertical transform that is adjusted to the scale of viewable contents 962 | * ------------------------------------------------------------------------------------------------------------ 963 | * we should delete the top header from the bottom gutter if the adjusted transform is smaller than the 964 | * height of contents window 965 | */ 966 | const contentsDivHeight = height - (shouldRender.horizontalScrollbar ? scrollbarHeight : 0); 967 | const { height: baseHeight } = headers[0]; 968 | const { 969 | adjustHeaderOffset: topHeight, 970 | realOffset: removeFirstHeaderOffset 971 | } = headers[nextHeaderIndex] || headers[nextHeaderIndex - 1]; 972 | const { adjustHeaderOffset: bottomHeight } = headers[headers.length - 1]; 973 | const adjustedBottomHeight = (baseHeight + bottomHeight) - topHeight; 974 | const adjustedTransform = (removeFirstHeaderOffset - verticalTransform) + adjustedBottomHeight; 975 | if (bottomGutterStartIndex !== headers.length && adjustedTransform <= contentsDivHeight - 1) { 976 | bottomGutterStartIndex++; 977 | const skipHeadersUntil = _(headers) 978 | .slice(bottomGutterStartIndex) 979 | .findIndex(({ adjustHeaderOffset, realOffset }) => { 980 | const restHeight = bottomHeight - adjustHeaderOffset; 981 | return realOffset + topHeight >= ((contentsDivHeight + verticalTransform) - restHeight); 982 | }); 983 | 984 | if (skipHeadersUntil >= 0) { 985 | bottomGutterStartIndex += skipHeadersUntil; 986 | } else { 987 | bottomGutterStartIndex = headers.length; 988 | } 989 | } 990 | 991 | const bottomHeaderGutter = ( 992 |
993 | {_(headers).slice(bottomGutterStartIndex).map(({ index: headerRowIndex, lockPosition }, index) => { 994 | const headerIndex = bottomGutterStartIndex + index; 995 | const { className, contentComponent, height: rowHeight, key, props: rowProps } = rows[headerRowIndex]; 996 | 997 | return light 998 | ? ( 999 | 1012 | ) 1013 | : ( 1014 | 1030 | ); 1031 | }).value()} 1032 |
1033 | ); 1034 | 1035 | return { bottomHeaderGutter, topHeaderGutter }; 1036 | } else if (headerType === constants.headerType.LOCKING) { 1037 | const headerIndex = nextHeaderIndex - 1; 1038 | const { lockPosition } = headers[nextHeaderIndex] || headers[headerIndex]; 1039 | 1040 | const { index: headerRowIndex } = headers[headerIndex]; 1041 | const { className, contentComponent, height: rowHeight, key, props: rowProps } = rows[headerRowIndex]; 1042 | 1043 | const headerStyle = { 1044 | height: `${rowHeight}px`, 1045 | transform: 'translate3d(0px, 0px, 0px)' 1046 | }; 1047 | 1048 | if (verticalTransform < maxLockPosition && verticalTransform >= lockPosition - rowHeight) { 1049 | const overlap = (lockPosition - verticalTransform); 1050 | const headerOffset = rowHeight - overlap; 1051 | headerStyle.transform = `translate3d(0px, -${headerOffset}px, 0px)`; 1052 | } 1053 | 1054 | const rowContent = light 1055 | ? ( 1056 | 1069 | ) 1070 | : ( 1071 | 1087 | ); 1088 | 1089 | const header = ( 1090 |
1091 | {rowContent} 1092 |
1093 | ); 1094 | 1095 | return { header }; 1096 | } 1097 | 1098 | return {}; 1099 | } 1100 | 1101 | /** 1102 | * Decides whether or not to render the horizontal scroll bar 1103 | * @return null or a container with horizontal scrollbar and maybe the corner piece 1104 | */ 1105 | _renderHorizontalScrollbar() { 1106 | const { 1107 | props: { 1108 | dynamicColumn = constants.columns.MIDDLE, 1109 | guttersConfig, 1110 | guttersConfig: { 1111 | left, 1112 | left: { 1113 | handleWidth: leftHandleWidth = constants.LEFT_HANDLE_WIDTH, 1114 | position: leftHandlePosition 1115 | } = {}, 1116 | right, 1117 | right: { 1118 | handleWidth: rightHandleWidth = constants.RIGHT_HANDLE_WIDTH, 1119 | position: rightHandlePosition 1120 | } = {} 1121 | } = {}, 1122 | horizontalScrollConfig, 1123 | horizontalScrollConfig: { 1124 | className, 1125 | scaleWithCenterContent = false, 1126 | scrollbarHeight = constants.HORIZONTAL_SCROLLBAR_HEIGHT 1127 | } = {}, 1128 | verticalScrollConfig: { 1129 | scrollbarWidth = constants.VERTICAL_SCROLLBAR_WIDTH 1130 | } = {} 1131 | }, 1132 | state: { shouldRender } 1133 | } = this; 1134 | 1135 | const withHorizontalScrolling = !!horizontalScrollConfig && shouldRender.horizontalScrollbar; 1136 | 1137 | if (!withHorizontalScrolling) { 1138 | return null; 1139 | } 1140 | 1141 | // TODO fix scaleWithCenterContent 1142 | const contentWidth = this._getContentWidth(); 1143 | const { leftGutterWidth, rightGutterWidth } = getGutterWidths({ 1144 | dynamicColumn: guttersConfig 1145 | ? dynamicColumn 1146 | : constants.columns.MIDDLE, 1147 | leftHandlePosition, 1148 | leftHandleWidth, 1149 | rightHandlePosition, 1150 | rightHandleWidth, 1151 | width: this._getWeightedWidth() 1152 | }); 1153 | const shouldRenderCorner = !!horizontalScrollConfig && shouldRender.verticalScrollbar; 1154 | const cornerWidth = returnWidthIfComponentExists(scrollbarWidth, shouldRenderCorner); 1155 | let adjustedContentWidth = contentWidth - cornerWidth; 1156 | let leftWidth; 1157 | let position; 1158 | let scaledWidth; 1159 | 1160 | // If the scale with center content flag is enabled, we will adjust the scrollbar to be in the correct position 1161 | // and set up the width to be equivelant to the center content 1162 | // we will also have to adjust the size of the filler content by the gutters 1163 | if (scaleWithCenterContent) { 1164 | const rightWidth = returnWidthIfComponentExists(rightHandleWidth + rightGutterWidth, right); 1165 | 1166 | leftWidth = returnWidthIfComponentExists(leftHandleWidth + leftGutterWidth, left); 1167 | adjustedContentWidth -= leftWidth + rightWidth; 1168 | position = 'relative'; 1169 | scaledWidth = `calc(100% - ${leftWidth}px - ${rightWidth}px - ${cornerWidth}px)`; 1170 | } 1171 | 1172 | const wrapperStyle = { 1173 | height: `${scrollbarHeight}px` 1174 | }; 1175 | 1176 | const scrollBarDivStyle = { 1177 | height: `${scrollbarHeight}px`, 1178 | left: leftWidth, 1179 | position, 1180 | width: scaledWidth 1181 | }; 1182 | 1183 | const fillerStyle = { height: '1px', width: `${adjustedContentWidth}px` }; 1184 | 1185 | const getHorizontalScrollbarRef = r => { this._horizontalScrollbar = r; }; 1186 | const horizontalScrollbarClassName = classnames('rickscroll__horizontal-scrollbar', className); 1187 | 1188 | return ( 1189 |
1190 |
1197 |
{/* this causes the scrollbar to appear */} 1198 |
1199 | {this._renderCorner()} 1200 |
1201 | ); 1202 | } 1203 | 1204 | /** 1205 | * Decides whether or not to render the vertical scroll bar 1206 | * @return null or a container with vertical scrollbar 1207 | */ 1208 | _renderVerticalScrollbar() { 1209 | const { 1210 | props: { 1211 | verticalScrollConfig: { 1212 | className, 1213 | scrollbarWidth = constants.VERTICAL_SCROLLBAR_WIDTH 1214 | } = {} 1215 | }, 1216 | state: { contentHeight, shouldRender } 1217 | } = this; 1218 | 1219 | if (!shouldRender.verticalScrollbar) { 1220 | return null; 1221 | } 1222 | 1223 | const fillerStyle = { 1224 | height: `${contentHeight}px`, 1225 | width: '1px' 1226 | }; 1227 | const verticalScrollbarStyle = { 1228 | minWidth: `${scrollbarWidth}px` 1229 | }; 1230 | 1231 | const getVerticalScrollbarRef = r => { this._verticalScrollbar = r; }; 1232 | const verticalScrollbarCassName = classnames('rickscroll__vertical-scrollbar', className); 1233 | return ( 1234 |
1240 |
{/* this causes the scrollbar to appear */} 1241 |
1242 | ); 1243 | } 1244 | 1245 | /** 1246 | * Decides which scrollbars should be showing based off of the dimensions of the content and rickscroll container. 1247 | * @return { horizontalScrollbar, verticalScrollbar } a pair of booleans that tell rickscroll whether or not to render 1248 | * the horizontal and vertical scrollbars 1249 | */ 1250 | _shouldRenderScrollbars(contentHeight, height, width) { 1251 | const { 1252 | props: { 1253 | horizontalScrollConfig: { 1254 | scrollbarHeight = constants.HORIZONTAL_SCROLLBAR_HEIGHT 1255 | } = {}, 1256 | verticalScrollConfig: { 1257 | scrollbarWidth = constants.VERTICAL_SCROLLBAR_WIDTH 1258 | } = {} 1259 | } 1260 | } = this; 1261 | 1262 | const clientHeightTooSmall = height < contentHeight; 1263 | const clientHeightTooSmallWithHorizontalScrollbar = height < (contentHeight + scrollbarHeight); 1264 | 1265 | const contentWidth = this._getContentWidth(); 1266 | const clientWidthTooSmall = width < contentWidth; 1267 | const clientWidthTooSmallWithVerticalScrollbar = width < (contentWidth + scrollbarWidth); 1268 | 1269 | const shouldRenderVerticalScrollbar = clientHeightTooSmall || ( 1270 | clientWidthTooSmall && clientHeightTooSmallWithHorizontalScrollbar 1271 | ); 1272 | const shouldRenderHorizontalScrollbar = clientWidthTooSmall || ( 1273 | clientHeightTooSmall && clientWidthTooSmallWithVerticalScrollbar 1274 | ); 1275 | 1276 | return { 1277 | horizontalScrollbar: shouldRenderHorizontalScrollbar, 1278 | verticalScrollbar: shouldRenderVerticalScrollbar 1279 | }; 1280 | } 1281 | 1282 | _startHorizontalScroll(scrollTo) { 1283 | const { 1284 | animation: oldAnimation 1285 | } = this.state; 1286 | 1287 | const animation = new AnimationTimer() 1288 | .on('tick', this._getThrottledHorizontalAnimationFrameFn(scrollTo)) 1289 | .play(); 1290 | this.setState({ animation }, () => { 1291 | if (oldAnimation) { 1292 | oldAnimation.stop(); 1293 | } 1294 | animation.play(); 1295 | }); 1296 | } 1297 | 1298 | _startResize(side) { 1299 | return ({ clientX }) => { 1300 | document.addEventListener('mouseup', this._stopResize, { capture: true }); 1301 | const { 1302 | guttersConfig: { 1303 | [side]: { 1304 | position: basePosition 1305 | } 1306 | } = {} 1307 | } = this.props; 1308 | this.setState({ 1309 | resize: { 1310 | basePosition, 1311 | performing: true, 1312 | side, 1313 | startingPosition: clientX 1314 | } 1315 | }); 1316 | }; 1317 | } 1318 | 1319 | _stopResize() { 1320 | document.removeEventListener('mouseup', this._stopResize, { capture: true }); 1321 | const { side } = this.state.resize; 1322 | const { 1323 | guttersConfig: { 1324 | [side]: { 1325 | onResizeEnd = (() => {}), 1326 | position 1327 | } = {} 1328 | } = {} 1329 | } = this.props; 1330 | this.setState({ 1331 | resize: { 1332 | basePosition: 0, 1333 | performing: false, 1334 | side: '', 1335 | startingPosition: 0 1336 | } 1337 | }); 1338 | onResizeEnd(position); 1339 | } 1340 | 1341 | _switchScrollProp(scrollTo) { 1342 | const { 1343 | horizontalTransform, 1344 | verticalTransform 1345 | } = this.state; 1346 | 1347 | if ( 1348 | (scrollTo.location.x === 0 && scrollTo.location.y === 0) 1349 | || (scrollTo.preserveHorizontal && scrollTo.preserveVertical) 1350 | ) { 1351 | return; 1352 | } 1353 | 1354 | const x = scrollTo.preserveHorizontal 1355 | ? horizontalTransform 1356 | : scrollTo.location.x; 1357 | const y = scrollTo.preserveVertical 1358 | ? verticalTransform 1359 | : scrollTo.location.y; 1360 | 1361 | switch (scrollTo.type) { 1362 | case constants.scrollType.ROW: 1363 | this.scrollRowToMiddle(y, x); 1364 | break; 1365 | case constants.scrollType.HEADER: 1366 | this.scrollToHeader(y, x); 1367 | break; 1368 | default: 1369 | this.scrollTo({ x, y }); 1370 | break; 1371 | } 1372 | } 1373 | 1374 | // public 1375 | 1376 | scrollRowToMiddle(rowIndex, x = 0) { 1377 | const { 1378 | props: { 1379 | height, 1380 | horizontalScrollConfig: { 1381 | scrollbarHeight = constants.HORIZONTAL_SCROLLBAR_HEIGHT 1382 | } 1383 | }, 1384 | state: { 1385 | rowOffsets, 1386 | shouldRender, 1387 | verticalTransform 1388 | } 1389 | } = this; 1390 | 1391 | if (rowIndex < 0 || rowIndex >= rowOffsets.length) { 1392 | return; 1393 | } 1394 | 1395 | const { 1396 | height: rowHeight, 1397 | offset 1398 | } = rowOffsets[rowIndex]; 1399 | 1400 | let adjustedHeight = shouldRender.horizontalScrollbar 1401 | ? height - scrollbarHeight 1402 | : height; 1403 | adjustedHeight -= this._getBottomGutterHeight(); 1404 | 1405 | const adjustedVerticalTransform = verticalTransform + this._getTopGutterHeight(); 1406 | const needsYScroll = adjustedVerticalTransform > offset 1407 | || verticalTransform + adjustedHeight < offset + rowHeight; 1408 | const y = needsYScroll 1409 | ? offset - (adjustedHeight / 2) 1410 | : verticalTransform; 1411 | 1412 | this.scrollTo({ x, y }); 1413 | } 1414 | 1415 | scrollTo({ x = 0, y = 0 }) { 1416 | const { 1417 | props: { 1418 | onRegisteredScrollTo = () => {} 1419 | }, state: { 1420 | animation, 1421 | horizontalTransform, 1422 | verticalTransform 1423 | } 1424 | } = this; 1425 | 1426 | if (horizontalTransform === x && verticalTransform === y) { 1427 | return; 1428 | } 1429 | 1430 | const scrollingToPosition = new Point(x, y); 1431 | const stateTransition = { 1432 | scrollingToPosition 1433 | }; 1434 | 1435 | if (horizontalTransform === x && verticalTransform !== y) { 1436 | stateTransition.animation = new AnimationTimer() 1437 | .on('tick', this._getThrottledVerticalAnimationFrameFn(scrollingToPosition)); 1438 | } else if (horizontalTransform !== x && verticalTransform === y) { 1439 | this._debouncedStartHorizontalScroll(scrollingToPosition); 1440 | } else { 1441 | stateTransition.animation = new AnimationTimer() 1442 | .on('tick', this._getThrottledVerticalAnimationFrameFn(scrollingToPosition)) 1443 | .on('stop', () => { 1444 | if (!_.isEqual(this.state.scrollingToPosition, scrollingToPosition)) { 1445 | return; 1446 | } 1447 | 1448 | this._debouncedStartHorizontalScroll(scrollingToPosition); 1449 | }); 1450 | } 1451 | 1452 | this.setState(stateTransition, () => { 1453 | if (animation) { 1454 | animation.stop(); 1455 | } 1456 | if (stateTransition.animation) { 1457 | stateTransition.animation.play(); 1458 | } 1459 | }); 1460 | onRegisteredScrollTo(); 1461 | } 1462 | 1463 | scrollToHeader(headerIndex, x = 0) { 1464 | const { 1465 | props: { lists }, 1466 | state: { headers } 1467 | } = this; 1468 | 1469 | if (!lists || headerIndex >= lists.length || headerIndex < 0) { 1470 | return; 1471 | } 1472 | 1473 | this.scrollTo({ x, y: headers[headerIndex].lockPosition }); 1474 | } 1475 | 1476 | toggleSection(sectionIndex) { 1477 | const { 1478 | props: { 1479 | headerType = constants.headerType.DEFAULT, 1480 | lists 1481 | }, 1482 | state: { collapsedSections: oldCollapsedSections } 1483 | } = this; 1484 | const stackHeaders = headerType === constants.headerType.STACKING; 1485 | 1486 | if (!lists || sectionIndex >= lists.length || sectionIndex < 0) { 1487 | return; 1488 | } 1489 | 1490 | const collapsedState = !oldCollapsedSections[sectionIndex]; 1491 | oldCollapsedSections[sectionIndex] = collapsedState; 1492 | 1493 | const { 1494 | avgRowHeight, 1495 | collapsedSections, 1496 | contentHeight, 1497 | headers, 1498 | partitions, 1499 | rowOffsets, 1500 | rows 1501 | } = buildRowConfig(lists, stackHeaders, oldCollapsedSections); 1502 | this.setState({ avgRowHeight, collapsedSections, contentHeight, headers, partitions, rowOffsets, rows }); 1503 | } 1504 | 1505 | render() { 1506 | const { 1507 | className, 1508 | height, 1509 | horizontalScrollConfig, 1510 | horizontalScrollConfig: { 1511 | scrollbarHeight = constants.HORIZONTAL_SCROLLBAR_HEIGHT 1512 | } = {}, 1513 | style = {}, 1514 | width, 1515 | wrappedWithAutoAdjust 1516 | } = this.props; 1517 | const { 1518 | resize: { performing }, 1519 | shouldRender 1520 | } = this.state; 1521 | 1522 | const scrollableClassName = classnames('rickscroll', { 1523 | 'rickscroll--performing-resize': performing 1524 | }, className); 1525 | const topWrapperStyle = !!horizontalScrollConfig && shouldRender.horizontalScrollbar ? { 1526 | height: `calc(100% - ${scrollbarHeight}px)` 1527 | } : undefined; 1528 | 1529 | const rickscrollStyle = wrappedWithAutoAdjust 1530 | ? style 1531 | : { 1532 | ...style, 1533 | height: `${height}px`, 1534 | width: `${width}px` 1535 | }; 1536 | 1537 | return ( 1538 |
1539 |
1547 | {this._renderContents()} 1548 | {this._renderVerticalScrollbar()} 1549 |
1550 | {this._renderHorizontalScrollbar()} 1551 |
1552 | ); 1553 | } 1554 | } 1555 | 1556 | Scrollable.propTypes = { 1557 | className: types.string, 1558 | disableBidirectionalScrolling: types.bool, 1559 | dynamicColumn: customTypes.column, 1560 | guttersConfig: customTypes.guttersConfig, 1561 | headerType: customTypes.headerType, 1562 | height: types.number.isRequired, 1563 | horizontalScrollConfig: customTypes.horizontalScrollConfig, 1564 | light: types.bool, 1565 | list: customTypes.list, 1566 | lists: customTypes.lists, 1567 | onRegisteredScrollTo: types.func, 1568 | scrollTo: customTypes.scrollTo, 1569 | style: types.object, 1570 | verticalScrollConfig: customTypes.verticalScrollConfig, 1571 | width: types.number.isRequired, 1572 | wrappedWithAutoAdjust: types.bool 1573 | }; 1574 | -------------------------------------------------------------------------------- /src/js/constants.js: -------------------------------------------------------------------------------- 1 | export const ANIMATION_FPS_120 = 1000 / 120; 2 | 3 | export const columns = { 4 | LEFT: 'left', 5 | MIDDLE: 'middle', 6 | RIGHT: 'right' 7 | }; 8 | 9 | export const headerType = { 10 | DEFAULT: 'default', 11 | LOCKING: 'locking', 12 | STACKING: 'stacking' 13 | }; 14 | 15 | export const scrollType = { 16 | HEADER: 'header', 17 | POSITION: 'position', 18 | ROW: 'row' 19 | }; 20 | 21 | export const defaultScrollTo = { 22 | location: { 23 | x: 0, 24 | y: 0 25 | }, 26 | preserveHorizontal: false, 27 | preserveVertical: false, 28 | type: scrollType.ROW 29 | }; 30 | 31 | 32 | export const LEFT_GUTTER_WIDTH = 0; 33 | 34 | export const LEFT_HANDLE_WIDTH = 0; 35 | 36 | export const HORIZONTAL_SCROLLBAR_HEIGHT = 15; 37 | 38 | export const OFFSET_BUFFER = 6; 39 | 40 | export const RIGHT_GUTTER_WIDTH = 0; 41 | 42 | export const RIGHT_HANDLE_WIDTH = 0; 43 | 44 | export const SCROLL_TO_FAST_SCROLL_FACTOR = 2; 45 | 46 | export const USER_INITIATED_FAST_SCROLL_FACTOR = 3 / 4; 47 | 48 | export const VERTICAL_SCROLLBAR_WIDTH = 15; 49 | -------------------------------------------------------------------------------- /src/js/index.jsx: -------------------------------------------------------------------------------- 1 | import AutoAdjust from './AutoAdjust'; 2 | import HorizontalWrapper from './HorizontalWrapper'; 3 | import Scrollable from './Scrollable'; 4 | 5 | const Rickscroll = AutoAdjust; 6 | Rickscroll.Static = Scrollable; 7 | 8 | module.exports = { 9 | Rickscroll, 10 | HorizontalWrapper 11 | }; 12 | -------------------------------------------------------------------------------- /src/js/models.js: -------------------------------------------------------------------------------- 1 | export class Point { 2 | constructor(x, y) { 3 | this.x = x; 4 | this.y = y; 5 | } 6 | 7 | add(dPoint) { 8 | this.x += dPoint.x; 9 | this.y += dPoint.y; 10 | return this; 11 | } 12 | 13 | sub(dPoint) { 14 | this.x -= dPoint.x; 15 | this.y -= dPoint.y; 16 | return this; 17 | } 18 | 19 | scale(c) { 20 | this.x *= c; 21 | this.y *= c; 22 | return this; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/js/propTypes.js: -------------------------------------------------------------------------------- 1 | import { 2 | isValidElement, 3 | PropTypes as types 4 | } from 'react'; 5 | import _ from 'lodash'; 6 | 7 | import * as constants from './constants'; 8 | 9 | export const renderableComponent = types.oneOfType([types.func, types.element]); 10 | 11 | export const gutterConfig = types.shape({ 12 | className: types.string, 13 | handleClassName: types.string, 14 | handleWidth: types.number, 15 | minPosition: types.number, 16 | maxPosition: types.number, 17 | position: types.number.isRequired, 18 | onResize: types.func 19 | }); 20 | 21 | export const guttersConfig = types.shape({ 22 | left: gutterConfig, 23 | right: gutterConfig 24 | }); 25 | 26 | export const column = types.oneOf(_.values(constants.columns)); 27 | 28 | export const headerType = types.oneOf(_.values(constants.headerType)); 29 | 30 | export const horizontalScrollConfig = types.shape({ 31 | className: types.string, 32 | contentWidth: types.number.isRequired, 33 | onScroll: types.function, 34 | passthroughOffsets: types.bool, 35 | scaleWithCenterContent: types.bool, 36 | scrollbarHeight: types.number 37 | }); 38 | 39 | export const rowGutter = types.shape({ 40 | className: types.string, 41 | contentComponent: renderableComponent.isRequired, 42 | handleClassName: types.string, 43 | props: types.object 44 | }); 45 | 46 | export const gutters = types.shape({ 47 | left: rowGutter, 48 | right: rowGutter 49 | }); 50 | 51 | export const row = types.shape({ 52 | className: types.string, 53 | contentClassName: types.string, 54 | contentComponent: renderableComponent.isRequired, 55 | gutters, 56 | height: types.number.isRequired, 57 | key: types.string, 58 | props: types.object 59 | }); 60 | 61 | export const scrollTo = types.shape({ 62 | location: types.shape({ 63 | x: types.number, 64 | y: types.number 65 | }), 66 | preserveHorizontal: types.bool, 67 | preserveVertical: types.bool, 68 | type: types.oneOf(_.values(constants.scrollType)) 69 | }); 70 | 71 | export const verticalScrollConfig = types.shape({ 72 | className: types.string, 73 | onScroll: types.function, 74 | scrollbarWidth: types.number 75 | }); 76 | 77 | function findInvalid(container, fn) { 78 | let invalid = null; 79 | _.forEach(container, (innerContainer, index) => { 80 | invalid = fn(innerContainer, index); 81 | if (invalid) { 82 | return false; 83 | } 84 | return true; 85 | }); 86 | 87 | return invalid; 88 | } 89 | 90 | function validateRenderable(props, propName, location) { 91 | if (!_.isFunction(props[propName]) && !isValidElement(props[propName])) { 92 | return new Error(`Invalid ${location} \`${propName}\` supplied to \`Rickscroll\`.`); 93 | } 94 | return null; 95 | } 96 | 97 | function validateGutter(props, side, location) { 98 | const { [side]: gutterProp } = props; 99 | const fullLocation = `${location}.side`; 100 | 101 | if (_.isUndefined(gutterProp)) { 102 | return null; 103 | } 104 | 105 | if (!_.isObject(gutterProp)) { 106 | return new Error(`Invalid ${location} \`${side}\` supplied to \`Rickscroll\`.`); 107 | } 108 | 109 | if (!_.isUndefined(gutterProp.className) && !_.isString(gutterProp.className)) { 110 | return new Error(`Invalid ${fullLocation} \`className\` supplied to \`Rickscroll\`.`); 111 | } 112 | 113 | const invalid = validateRenderable(gutterProp, 'contentComponent', fullLocation); 114 | if (invalid) { 115 | return invalid; 116 | } 117 | 118 | if (!_.isUndefined(gutterProp.handleClassName) && !_.isString(gutterProp.handleClassName)) { 119 | return new Error(`Invalid ${fullLocation} \`handleClassName\` supplied to \`Rickscroll\`.`); 120 | } 121 | 122 | if (!_.isUndefined(gutterProp.props) && !_.isObject(gutterProp.props)) { 123 | return new Error(`Invalid prop at ${location} \`props\` supplied to \`Rickscroll\`.`); 124 | } 125 | 126 | return null; 127 | } 128 | 129 | function validateGutters(props, location) { 130 | const { gutters: guttersProp } = props; 131 | const fullLocation = `${location}.gutters`; 132 | 133 | if (_.isUndefined(guttersProp)) { 134 | return null; 135 | } 136 | 137 | if (!_.isObject(guttersProp)) { 138 | return new Error(`Invalid ${location} \`gutters\` supplied to \`Rickscroll\`.`); 139 | } 140 | 141 | let invalid = validateGutter(props, 'left', fullLocation); 142 | if (invalid) { 143 | return invalid; 144 | } 145 | 146 | invalid = validateGutter(props, 'right', fullLocation); 147 | if (invalid) { 148 | return invalid; 149 | } 150 | 151 | return null; 152 | } 153 | 154 | function validateRow(props, location) { 155 | if (!_.isUndefined(props.className) && !_.isString(props.className)) { 156 | return new Error(`Invalid ${location} \`className\` supplied to \`Rickscroll\`.`); 157 | } 158 | 159 | if (!_.isUndefined(props.contentClassName) && !_.isString(props.contentClassName)) { 160 | return new Error(`Invalid ${location} \`contentClassName\` supplied to \`Rickscroll\`.`); 161 | } 162 | 163 | let invalid = validateRenderable(props, 'contentComponent', location); 164 | if (invalid) { 165 | return invalid; 166 | } 167 | 168 | invalid = validateGutters(props, location); 169 | if (invalid) { 170 | return invalid; 171 | } 172 | 173 | if (!_.isNumber(props.height)) { 174 | return new Error(`Invalid prop at ${location} \`height\` supplied to \`Rickscroll\`.`); 175 | } 176 | 177 | if (!_.isUndefined(props.props) && !_.isObject(props.props)) { 178 | return new Error(`Invalid prop at ${location} \`props\` supplied to \`Rickscroll\`.`); 179 | } 180 | 181 | return null; 182 | } 183 | 184 | function validateRows(rows, locationSuffix, keySet = new Set()) { 185 | const initialKeyCount = keySet.size; 186 | const invalidRow = findInvalid(rows, (listRow, index) => { 187 | if (listRow.key === 0 || listRow.key) { 188 | // covers the case where we have a duplicate key 189 | if (keySet.has(listRow.key)) { 190 | return new Error(`Invalid props supplied to \`Rickscroll\`. Duplicate row key ${listRow.key}.`); 191 | } 192 | 193 | keySet.add(listRow.key); 194 | } else if (keySet.size) { 195 | return new Error(`Invalid props supplied to \`Rickscroll\`. Missing row key at ${index}.`); 196 | } 197 | 198 | return validateRow(listRow, `${locationSuffix}[${index}]`); 199 | }); 200 | 201 | if (invalidRow) { 202 | return invalidRow; 203 | } 204 | 205 | // covers the case where some rows don't have a key 206 | if (keySet.size !== 0 && ((keySet.size - initialKeyCount) !== rows.length)) { 207 | return new Error('Invalid props supplied to `Rickscroll`. Missing keys on row declarations.'); 208 | } 209 | 210 | return null; 211 | } 212 | 213 | export function list(props) { 214 | const { list: listProp, lists: listsProp } = props; 215 | 216 | if ((!listProp && !listsProp) || (listProp && listsProp)) { 217 | return new Error('Invalid props supplied to `Rickscroll`. Must supply at maximum one of list or lists.'); 218 | } 219 | 220 | if (!listProp && listsProp) { 221 | return null; 222 | } 223 | 224 | if (!_.isArray(listProp)) { 225 | return new Error('Invalid prop `list` supplied to `Rickscroll`. Must be an array.'); 226 | } 227 | 228 | return validateRows(listProp, 'list'); 229 | } 230 | 231 | export function lists(props) { 232 | const { list: listProp, lists: listsProp } = props; 233 | 234 | if ((!listProp && !listsProp) || (listProp && listsProp)) { 235 | return new Error('Invalid props supplied to `Rickscroll`. Must supply at maximum one of list or lists.'); 236 | } 237 | 238 | if (listProp && !listsProp) { 239 | return null; 240 | } 241 | 242 | if (!_.isArray(listsProp)) { 243 | return new Error('Invalid prop `lists` supplied to `Rickscroll`. Must be an array.'); 244 | } 245 | 246 | // used to catch duplicate or missing keys 247 | const keySet = new Set(); 248 | let numberOfNonHeaderRows = 0; 249 | 250 | const listsAreInvalid = findInvalid(listsProp, (listContainer, containerIndex) => { 251 | if (!_.isUndefined(props.headerClassName) && !_.isString(props.headerClassName)) { 252 | return new Error(`Invalid lists[${containerIndex}] \`headerClassName\` supplied to \`Rickscroll\`.`); 253 | } 254 | 255 | const invalidRenderable = validateRenderable(listContainer, 'headerComponent', `lists[${containerIndex}]`); 256 | if (invalidRenderable) { 257 | return invalidRenderable; 258 | } 259 | 260 | if (listContainer.headerKey === 0 || listContainer.headerKey) { 261 | if (keySet.has(listContainer.headerKey)) { 262 | return new Error(`Duplicate \`headerKey\` at lists[${containerIndex}] supplied to \`Rickscroll\`.`); 263 | } 264 | keySet.add(listContainer.headerKey); 265 | } else if (keySet.size) { 266 | return new Error(`Invalid props supplied to \`Rickscroll\`. Missing headerKey at lists[${containerIndex}].`); 267 | } 268 | 269 | if (!_.isUndefined(listContainer.headerProps) && !_.isObject(listContainer.headerProps)) { 270 | return new Error(`Invalid prop at lists[${containerIndex}] \`headerProps\` supplied to \`Rickscroll\`.`); 271 | } 272 | 273 | if (!_.isNumber(listContainer.height)) { 274 | return new Error(`Invalid prop at lists[${containerIndex}] \`height\` supplied to \`Rickscroll\`.`); 275 | } 276 | 277 | if (!_.isUndefined(props.initCollapsed) && !_.isBoolean(props.initCollapsed)) { 278 | return new Error(`Invalid lists[${containerIndex}] \`initCollapsed\` supplied to \`Rickscroll\`.`); 279 | } 280 | 281 | const initalKeyCount = keySet.size; 282 | const rowsInvalid = validateRows(listContainer.rows, 'lists.rows', keySet); 283 | if (rowsInvalid) { 284 | return rowsInvalid; 285 | } 286 | 287 | if (listContainer.headerKey && listContainer.rows.length && keySet.size === initalKeyCount) { 288 | return new Error('Invalid props supplied to `Rickscroll`. headerKey supplied, but no keys defined for ' + 289 | `lists[${containerIndex}] \`rows\`.`); 290 | } 291 | 292 | if (!listContainer.headerKey && keySet.size !== initalKeyCount) { 293 | return new Error(`Invalid props supplied to \`Rickscroll\`. No headerKey supplied to lists[${containerIndex}]`); 294 | } 295 | 296 | 297 | numberOfNonHeaderRows += listContainer.rows.length; 298 | 299 | return null; 300 | }); 301 | 302 | if (listsAreInvalid) { 303 | return listsAreInvalid; 304 | } 305 | 306 | if (keySet.size !== 0 && (numberOfNonHeaderRows + listsProp.length) !== keySet.size) { 307 | return new Error('Invalid props supplied to `Rickscroll`. Missing keys on header declarations.'); 308 | } 309 | 310 | return null; 311 | } 312 | -------------------------------------------------------------------------------- /src/js/utils.js: -------------------------------------------------------------------------------- 1 | import fp from 'lodash/fp'; 2 | 3 | import * as constants from './constants'; 4 | 5 | export function avgRowHeight(config) { 6 | const { 7 | contentHeight, 8 | rows: { length } 9 | } = config; 10 | return fp.assign(config, { 11 | avgRowHeight: length && fp.ceil(contentHeight / length) 12 | }); 13 | } 14 | 15 | /** 16 | * Initializes the reduce call to reduceRowsIntoRowConfig. The step where we convert a list prop into lists is done here 17 | * @param {array} list List or lists prop. 18 | * @param {boolean} stackHeaders Signifies that we should do the math to calculate stacking header offsets 19 | * @param {Array} [collapsedSections=[]] The collapsedSection state from the scrollable class 20 | * @return {object} Reduces the list/lists into consumable state for the scrollable 21 | */ 22 | export function buildRowConfig(list, stackHeaders, collapsedSections = []) { 23 | let offsetCount = constants.OFFSET_BUFFER - 1; 24 | 25 | if (list.length === 0) { 26 | return { contentHeight: 0, headers: null, partitions: [], rows: [] }; 27 | } 28 | 29 | const isMultiList = Boolean(list[0] && list[0].headerComponent); 30 | 31 | const lists = isMultiList ? list : [{ rows: list }]; 32 | 33 | let numRowsWithoutHeaders = 0; 34 | let listIterator = lists.length; 35 | while (listIterator--) { 36 | numRowsWithoutHeaders += lists[listIterator].rows.length; 37 | } 38 | 39 | const numRows = isMultiList 40 | ? numRowsWithoutHeaders + lists.length 41 | : numRowsWithoutHeaders; 42 | 43 | const rows = new Array(numRows); 44 | const rowOffsets = new Array(numRowsWithoutHeaders); 45 | const headers = []; 46 | const partitions = []; 47 | 48 | let controlIterator = numRows; 49 | let rowIterator = -1; 50 | let insertionIterator = 0; 51 | let offsetInsertionIterator = 0; 52 | let adjustHeaderOffset = 0; 53 | let contentHeight = 0; 54 | let listItem; 55 | 56 | listIterator = 0; 57 | 58 | while (controlIterator--) { 59 | if (rowIterator === -1) { 60 | listItem = lists[listIterator]; 61 | rowIterator++; 62 | 63 | if (listItem.headerComponent) { 64 | offsetCount++; 65 | offsetCount %= constants.OFFSET_BUFFER; 66 | 67 | if (offsetCount === 0) { 68 | partitions.push(contentHeight); 69 | } 70 | 71 | headers.push({ 72 | adjustHeaderOffset, 73 | index: insertionIterator, 74 | height: listItem.height, 75 | lockPosition: contentHeight - adjustHeaderOffset, 76 | realOffset: contentHeight 77 | }); 78 | 79 | if (stackHeaders) { 80 | adjustHeaderOffset += listItem.height; 81 | } 82 | 83 | if (collapsedSections[listIterator] === undefined) { 84 | collapsedSections[listIterator] = Boolean(listItem.initCollapsed); 85 | } 86 | 87 | rows[insertionIterator++] = { 88 | className: listItem.headerClassName, 89 | contentComponent: listItem.headerComponent, 90 | height: listItem.height, 91 | isHeader: true, 92 | key: listItem.headerKey, 93 | props: listItem.headerProps 94 | }; 95 | 96 | contentHeight += listItem.height; 97 | } 98 | 99 | if (isMultiList) { 100 | if (!listItem.rows.length) { 101 | listIterator++; 102 | rowIterator = -1; 103 | } 104 | continue; 105 | } 106 | } 107 | 108 | if (collapsedSections[listIterator]) { 109 | controlIterator -= listItem.rows.length - 1; 110 | continue; 111 | } 112 | 113 | const row = listItem.rows[rowIterator++]; 114 | 115 | offsetCount++; 116 | offsetCount %= constants.OFFSET_BUFFER; 117 | 118 | if (offsetCount === 0) { 119 | partitions.push(contentHeight); 120 | } 121 | 122 | rows[insertionIterator++] = row; 123 | rowOffsets[offsetInsertionIterator++] = { 124 | height: row.height, 125 | offset: contentHeight 126 | }; 127 | 128 | contentHeight += row.height; 129 | 130 | if (listItem.rows.length === rowIterator) { 131 | listIterator++; 132 | rowIterator = -1; 133 | } 134 | } 135 | 136 | return avgRowHeight({ 137 | adjustHeaderOffset, 138 | collapsedSections, 139 | contentHeight, 140 | headers, 141 | stackHeaders, 142 | offsetCount, 143 | partitions, 144 | rowOffsets, 145 | rows 146 | }); 147 | } 148 | 149 | /** 150 | * assign meaning to math 151 | * @param {number} contentHeight the height of the content 152 | * @param {number} offsetHeight the offsetHeight from the vertical scrollbar 153 | * @return {number} the difference between contentHeight and offsetHeight 154 | */ 155 | export function getMaxHeight(contentHeight, offsetHeight) { 156 | return contentHeight - offsetHeight; 157 | } 158 | 159 | export const getResizeValues = fp.cond([ 160 | // Dynamic column : LEFT --------------------------------------------------------------------------------------------- 161 | [ // resizing LEFT side 162 | fp.isMatch({ 163 | dynamicColumn: constants.columns.LEFT, 164 | side: constants.columns.LEFT 165 | }), 166 | ({ leftHandleWidth, rightExists, rightHandlePosition, rightHandleWidth, width }) => ({ 167 | max: width - leftHandleWidth, 168 | mod: 1, 169 | min: rightExists 170 | ? rightHandlePosition + rightHandleWidth 171 | : 0 172 | }) 173 | ], 174 | [ // resizing RIGHT side 175 | fp.isMatch({ 176 | dynamicColumn: constants.columns.LEFT, 177 | side: constants.columns.RIGHT 178 | }), 179 | ({ leftExists, leftHandlePosition, rightHandleWidth, width }) => ({ 180 | max: leftExists 181 | ? leftHandlePosition - rightHandleWidth 182 | : width - rightHandleWidth, 183 | mod: 1, 184 | min: 0 185 | }) 186 | ], 187 | // dynamic column : RIGHT -------------------------------------------------------------------------------------------- 188 | [ // resizing LEFT side 189 | fp.isMatch({ 190 | dynamicColumn: constants.columns.RIGHT, 191 | side: constants.columns.LEFT 192 | }), 193 | ({ leftHandleWidth, rightExists, rightHandlePosition, width }) => ({ 194 | max: rightExists 195 | ? rightHandlePosition - leftHandleWidth 196 | : width - leftHandleWidth, 197 | mod: -1, 198 | min: 0 199 | }) 200 | ], 201 | [ // resizing RIGHT side 202 | fp.isMatch({ 203 | dynamicColumn: constants.columns.RIGHT, 204 | side: constants.columns.RIGHT 205 | }), 206 | ({ leftExists, leftHandlePosition, leftHandleWidth, rightHandleWidth, width }) => ({ 207 | max: width - rightHandleWidth, 208 | mod: -1, 209 | min: leftExists 210 | ? leftHandlePosition + leftHandleWidth 211 | : 0 212 | }) 213 | ], 214 | // dynamic column : MIDDLE ------------------------------------------------------------------------------------------- 215 | [ // resizing LEFT side 216 | fp.isMatch({ 217 | dynamicColumn: constants.columns.MIDDLE, 218 | side: constants.columns.LEFT 219 | }), 220 | ({ leftHandleWidth, rightExists, rightHandlePosition, rightHandleWidth, width }) => ({ 221 | max: rightExists 222 | ? width - (rightHandlePosition + rightHandleWidth) - leftHandleWidth 223 | : width - leftHandleWidth, 224 | mod: -1, 225 | min: 0 226 | }) 227 | ], 228 | [ // resizing RIGHT side 229 | fp.isMatch({ 230 | dynamicColumn: constants.columns.MIDDLE, 231 | side: constants.columns.RIGHT 232 | }), 233 | ({ leftExists, leftHandlePosition, leftHandleWidth, rightHandleWidth, width }) => ({ 234 | max: leftExists 235 | ? width - (leftHandlePosition + leftHandleWidth) - rightHandleWidth 236 | : width - rightHandleWidth, 237 | mod: 1, 238 | min: 0 239 | }) 240 | ] 241 | ]); 242 | 243 | const getMiddleColumnWidths = ({ leftHandlePosition, rightHandlePosition }) => ({ 244 | leftGutterWidth: leftHandlePosition, 245 | rightGutterWidth: rightHandlePosition 246 | }); 247 | export const getGutterWidths = fp.cond([ 248 | [ 249 | fp.isMatch({ dynamicColumn: constants.columns.LEFT }), 250 | ({ leftHandlePosition, leftHandleWidth, rightHandlePosition, width }) => ({ 251 | leftGutterWidth: width - leftHandlePosition - leftHandleWidth, 252 | rightGutterWidth: rightHandlePosition 253 | }) 254 | ], 255 | [ 256 | fp.isMatch({ dynamicColumn: constants.columns.RIGHT }), 257 | ({ leftHandlePosition, rightHandlePosition, rightHandleWidth, width }) => ({ 258 | leftGutterWidth: leftHandlePosition, 259 | rightGutterWidth: width - rightHandlePosition - rightHandleWidth 260 | }) 261 | ], 262 | [fp.isMatch({ dynamicColumn: constants.columns.MIDDLE }), getMiddleColumnWidths], 263 | [fp.stubTrue, getMiddleColumnWidths] 264 | ]); 265 | 266 | /** 267 | * calculates which partition is the top of our renderable content and clamps verticalTransform to within min/max values 268 | * @param {number} preClampTransform vertical transform before we clamp it between min / max values 269 | * @param {number} maxHeight maximum height our vertical transform can reach 270 | * @param {array} partitions list of numbers which represent the starting vertical transformation of partitions 271 | * by index (partitions[5] is the starting vertical transform of the 6th partition) 272 | * @return {object} topPartionIndex and the clamped verticalTransform 273 | */ 274 | export function getVerticalScrollValues(preClampTransform, maxHeight, partitions) { 275 | const verticalTransform = fp.clamp(0, maxHeight, preClampTransform); 276 | const topPartitionIndex = fp.findIndex( 277 | partitionStartingIndex => partitionStartingIndex > verticalTransform, 278 | partitions 279 | ) - 1; 280 | return { topPartitionIndex, verticalTransform }; 281 | } 282 | 283 | /** 284 | * helper function that returns a very strict width object for gutters 285 | * @param {Number} [width=0] the width we wish to express as a style object 286 | * @return {object} a style object representing a the given width 287 | */ 288 | export function getWidthStyle(width = 0) { 289 | return fp.isNumber(width) && width > 0 ? { 290 | minWidth: `${width}px`, 291 | width: `${width}px` 292 | } : undefined; 293 | } 294 | 295 | export const returnWidthIfComponentExists = (width, component) => 296 | component ? width : 0; 297 | 298 | export function getVelocityInfo(velocity, queue) { 299 | const now = window.performance.now(); 300 | const velocityQueue = fp.reject(([timestamp]) => timestamp < now - 10, queue); 301 | 302 | if (velocityQueue.length === 20) { 303 | velocityQueue.shift(); 304 | } 305 | 306 | velocityQueue.push([window.performance.now(), velocity]); 307 | 308 | const _averageVelocity = fp.flow([ 309 | fp.map(fp.last), 310 | fp.mean 311 | ])(velocityQueue); 312 | 313 | const averageVelocity = fp.isNaN(_averageVelocity) 314 | ? Number.POSITIVE_INFINITY 315 | : _averageVelocity; 316 | 317 | return { averageVelocity, velocityQueue}; 318 | } 319 | -------------------------------------------------------------------------------- /src/less/scrollable.less: -------------------------------------------------------------------------------- 1 | .rickscroll { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | overflow: hidden; 7 | 8 | &--performing-resize { 9 | -webkit-touch-callout: none; 10 | -webkit-user-select: none; 11 | -khtml-user-select: none; 12 | -moz-user-select: none; 13 | -ms-user-select: none; 14 | user-select: none; 15 | } 16 | 17 | &__bottom-wrapper { 18 | display: flex; 19 | flex-direction: row; 20 | justify-content: space-between; 21 | z-index: 2; 22 | } 23 | 24 | &__top-wrapper { 25 | width: 100%; 26 | height: 100%; 27 | display: flex; 28 | flex-direction: row; 29 | } 30 | 31 | &__contents { 32 | position: relative; 33 | top: 0; 34 | bottom: 0; 35 | right: 0; 36 | left: 0; 37 | width: 100%; 38 | } 39 | 40 | &__vertical-scrollbar { 41 | overflow-x: hidden; 42 | overflow-y: scroll; 43 | z-index: 2; 44 | } 45 | 46 | &__horizontal-scrollbar { 47 | width: 100%; 48 | overflow-x: scroll; 49 | overflow-y: hidden; 50 | } 51 | 52 | &__row { 53 | display: flex; 54 | flex-direction: row; 55 | justify-content: space-between; 56 | } 57 | 58 | &__content { 59 | overflow: hidden; 60 | flex-grow: 1; 61 | z-index: 1; 62 | } 63 | 64 | &__horizontal-wrapper { 65 | z-index: 1; 66 | width: 100%; 67 | height: 100%; 68 | } 69 | 70 | &__gutter { 71 | z-index: 2; 72 | } 73 | 74 | &__handle { 75 | background-color: #000000; 76 | z-index: 2; 77 | 78 | &--grabbable { 79 | cursor: col-resize; 80 | } 81 | } 82 | 83 | &__header { 84 | position: absolute; 85 | width: 100%; 86 | z-index: 2; 87 | } 88 | 89 | &__header-gutter { 90 | position: absolute; 91 | width: 100%; 92 | z-index: 2; 93 | &--top { 94 | top: 0; 95 | } 96 | &--bottom { 97 | bottom: 0; 98 | } 99 | } 100 | 101 | &__partition { 102 | position: absolute; 103 | left: 0; 104 | right: 0; 105 | } 106 | } 107 | --------------------------------------------------------------------------------