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.

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