You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

215 lines
6.3 KiB

  1. import React, { PureComponent } from 'react';
  2. import { ScrollContainer } from 'react-router-scroll';
  3. import PropTypes from 'prop-types';
  4. import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
  5. import LoadMore from './load_more';
  6. import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
  7. import { throttle } from 'lodash';
  8. import { List as ImmutableList } from 'immutable';
  9. export default class ScrollableList extends PureComponent {
  10. static contextTypes = {
  11. router: PropTypes.object,
  12. };
  13. static propTypes = {
  14. scrollKey: PropTypes.string.isRequired,
  15. onScrollToBottom: PropTypes.func,
  16. onScrollToTop: PropTypes.func,
  17. onScroll: PropTypes.func,
  18. trackScroll: PropTypes.bool,
  19. shouldUpdateScroll: PropTypes.func,
  20. isLoading: PropTypes.bool,
  21. hasMore: PropTypes.bool,
  22. prepend: PropTypes.node,
  23. emptyMessage: PropTypes.node,
  24. children: PropTypes.node,
  25. };
  26. static defaultProps = {
  27. trackScroll: true,
  28. };
  29. state = {
  30. lastMouseMove: null,
  31. };
  32. intersectionObserverWrapper = new IntersectionObserverWrapper();
  33. handleScroll = throttle(() => {
  34. if (this.node) {
  35. const { scrollTop, scrollHeight, clientHeight } = this.node;
  36. const offset = scrollHeight - scrollTop - clientHeight;
  37. this._oldScrollPosition = scrollHeight - scrollTop;
  38. if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
  39. this.props.onScrollToBottom();
  40. } else if (scrollTop < 100 && this.props.onScrollToTop) {
  41. this.props.onScrollToTop();
  42. } else if (this.props.onScroll) {
  43. this.props.onScroll();
  44. }
  45. }
  46. }, 150, {
  47. trailing: true,
  48. });
  49. handleMouseMove = throttle(() => {
  50. this._lastMouseMove = new Date();
  51. }, 300);
  52. handleMouseLeave = () => {
  53. this._lastMouseMove = null;
  54. }
  55. componentDidMount () {
  56. this.attachScrollListener();
  57. this.attachIntersectionObserver();
  58. // Handle initial scroll posiiton
  59. this.handleScroll();
  60. }
  61. componentDidUpdate (prevProps) {
  62. const someItemInserted = React.Children.count(prevProps.children) > 0 &&
  63. React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
  64. this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
  65. // Reset the scroll position when a new child comes in in order not to
  66. // jerk the scrollbar around if you're already scrolled down the page.
  67. if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
  68. const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
  69. if (this.node.scrollTop !== newScrollTop) {
  70. this.node.scrollTop = newScrollTop;
  71. }
  72. } else {
  73. this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
  74. }
  75. }
  76. componentWillUnmount () {
  77. this.detachScrollListener();
  78. this.detachIntersectionObserver();
  79. }
  80. attachIntersectionObserver () {
  81. this.intersectionObserverWrapper.connect({
  82. root: this.node,
  83. rootMargin: '300% 0px',
  84. });
  85. }
  86. detachIntersectionObserver () {
  87. this.intersectionObserverWrapper.disconnect();
  88. }
  89. attachScrollListener () {
  90. this.node.addEventListener('scroll', this.handleScroll);
  91. }
  92. detachScrollListener () {
  93. this.node.removeEventListener('scroll', this.handleScroll);
  94. }
  95. getFirstChildKey (props) {
  96. const { children } = props;
  97. let firstChild = children;
  98. if (children instanceof ImmutableList) {
  99. firstChild = children.get(0);
  100. } else if (Array.isArray(children)) {
  101. firstChild = children[0];
  102. }
  103. return firstChild && firstChild.key;
  104. }
  105. setRef = (c) => {
  106. this.node = c;
  107. }
  108. handleLoadMore = (e) => {
  109. e.preventDefault();
  110. this.props.onScrollToBottom();
  111. }
  112. _recentlyMoved () {
  113. return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
  114. }
  115. handleKeyDown = (e) => {
  116. if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
  117. const article = (() => {
  118. switch (e.key) {
  119. case 'PageDown':
  120. return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
  121. case 'PageUp':
  122. return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
  123. case 'End':
  124. return this.node.querySelector('[role="feed"] > article:last-of-type');
  125. case 'Home':
  126. return this.node.querySelector('[role="feed"] > article:first-of-type');
  127. default:
  128. return null;
  129. }
  130. })();
  131. if (article) {
  132. e.preventDefault();
  133. article.focus();
  134. article.scrollIntoView();
  135. }
  136. }
  137. }
  138. render () {
  139. const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
  140. const childrenCount = React.Children.count(children);
  141. const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
  142. let scrollableArea = null;
  143. if (isLoading || childrenCount > 0 || !emptyMessage) {
  144. scrollableArea = (
  145. <div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
  146. <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
  147. {prepend}
  148. {React.Children.map(this.props.children, (child, index) => (
  149. <IntersectionObserverArticleContainer
  150. key={child.key}
  151. id={child.key}
  152. index={index}
  153. listLength={childrenCount}
  154. intersectionObserverWrapper={this.intersectionObserverWrapper}
  155. saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
  156. >
  157. {child}
  158. </IntersectionObserverArticleContainer>
  159. ))}
  160. {loadMore}
  161. </div>
  162. </div>
  163. );
  164. } else {
  165. scrollableArea = (
  166. <div className='empty-column-indicator' ref={this.setRef}>
  167. {emptyMessage}
  168. </div>
  169. );
  170. }
  171. if (trackScroll) {
  172. return (
  173. <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
  174. {scrollableArea}
  175. </ScrollContainer>
  176. );
  177. } else {
  178. return scrollableArea;
  179. }
  180. }
  181. }