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.

204 lines
6.0 KiB

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