* Fix behavior while the button is invisible e.g. pointer cursor, couldn't open contextmenu * Avoid rendering the button to remove blank space if no more items are available or no items are rendered
		
			
				
	
	
		
			215 lines
		
	
	
	
		
			6.3 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			215 lines
		
	
	
	
		
			6.3 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import React, { PureComponent } from 'react';
 | 
						|
import { ScrollContainer } from 'react-router-scroll';
 | 
						|
import PropTypes from 'prop-types';
 | 
						|
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
 | 
						|
import LoadMore from './load_more';
 | 
						|
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
 | 
						|
import { throttle } from 'lodash';
 | 
						|
import { List as ImmutableList } from 'immutable';
 | 
						|
 | 
						|
export default class ScrollableList extends PureComponent {
 | 
						|
 | 
						|
  static contextTypes = {
 | 
						|
    router: PropTypes.object,
 | 
						|
  };
 | 
						|
 | 
						|
  static propTypes = {
 | 
						|
    scrollKey: PropTypes.string.isRequired,
 | 
						|
    onScrollToBottom: PropTypes.func,
 | 
						|
    onScrollToTop: PropTypes.func,
 | 
						|
    onScroll: PropTypes.func,
 | 
						|
    trackScroll: PropTypes.bool,
 | 
						|
    shouldUpdateScroll: PropTypes.func,
 | 
						|
    isLoading: PropTypes.bool,
 | 
						|
    hasMore: PropTypes.bool,
 | 
						|
    prepend: PropTypes.node,
 | 
						|
    emptyMessage: PropTypes.node,
 | 
						|
    children: PropTypes.node,
 | 
						|
  };
 | 
						|
 | 
						|
  static defaultProps = {
 | 
						|
    trackScroll: true,
 | 
						|
  };
 | 
						|
 | 
						|
  state = {
 | 
						|
    lastMouseMove: null,
 | 
						|
  };
 | 
						|
 | 
						|
  intersectionObserverWrapper = new IntersectionObserverWrapper();
 | 
						|
 | 
						|
  handleScroll = throttle(() => {
 | 
						|
    if (this.node) {
 | 
						|
      const { scrollTop, scrollHeight, clientHeight } = this.node;
 | 
						|
      const offset = scrollHeight - scrollTop - clientHeight;
 | 
						|
      this._oldScrollPosition = scrollHeight - scrollTop;
 | 
						|
 | 
						|
      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
 | 
						|
        this.props.onScrollToBottom();
 | 
						|
      } else if (scrollTop < 100 && this.props.onScrollToTop) {
 | 
						|
        this.props.onScrollToTop();
 | 
						|
      } else if (this.props.onScroll) {
 | 
						|
        this.props.onScroll();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }, 150, {
 | 
						|
    trailing: true,
 | 
						|
  });
 | 
						|
 | 
						|
  handleMouseMove = throttle(() => {
 | 
						|
    this._lastMouseMove = new Date();
 | 
						|
  }, 300);
 | 
						|
 | 
						|
  handleMouseLeave = () => {
 | 
						|
    this._lastMouseMove = null;
 | 
						|
  }
 | 
						|
 | 
						|
  componentDidMount () {
 | 
						|
    this.attachScrollListener();
 | 
						|
    this.attachIntersectionObserver();
 | 
						|
 | 
						|
    // Handle initial scroll posiiton
 | 
						|
    this.handleScroll();
 | 
						|
  }
 | 
						|
 | 
						|
  componentDidUpdate (prevProps) {
 | 
						|
    const someItemInserted = React.Children.count(prevProps.children) > 0 &&
 | 
						|
      React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
 | 
						|
      this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
 | 
						|
 | 
						|
    // Reset the scroll position when a new child comes in in order not to
 | 
						|
    // jerk the scrollbar around if you're already scrolled down the page.
 | 
						|
    if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
 | 
						|
      const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
 | 
						|
 | 
						|
      if (this.node.scrollTop !== newScrollTop) {
 | 
						|
        this.node.scrollTop = newScrollTop;
 | 
						|
      }
 | 
						|
    } else {
 | 
						|
      this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  componentWillUnmount () {
 | 
						|
    this.detachScrollListener();
 | 
						|
    this.detachIntersectionObserver();
 | 
						|
  }
 | 
						|
 | 
						|
  attachIntersectionObserver () {
 | 
						|
    this.intersectionObserverWrapper.connect({
 | 
						|
      root: this.node,
 | 
						|
      rootMargin: '300% 0px',
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  detachIntersectionObserver () {
 | 
						|
    this.intersectionObserverWrapper.disconnect();
 | 
						|
  }
 | 
						|
 | 
						|
  attachScrollListener () {
 | 
						|
    this.node.addEventListener('scroll', this.handleScroll);
 | 
						|
  }
 | 
						|
 | 
						|
  detachScrollListener () {
 | 
						|
    this.node.removeEventListener('scroll', this.handleScroll);
 | 
						|
  }
 | 
						|
 | 
						|
  getFirstChildKey (props) {
 | 
						|
    const { children } = props;
 | 
						|
    let firstChild = children;
 | 
						|
    if (children instanceof ImmutableList) {
 | 
						|
      firstChild = children.get(0);
 | 
						|
    } else if (Array.isArray(children)) {
 | 
						|
      firstChild = children[0];
 | 
						|
    }
 | 
						|
    return firstChild && firstChild.key;
 | 
						|
  }
 | 
						|
 | 
						|
  setRef = (c) => {
 | 
						|
    this.node = c;
 | 
						|
  }
 | 
						|
 | 
						|
  handleLoadMore = (e) => {
 | 
						|
    e.preventDefault();
 | 
						|
    this.props.onScrollToBottom();
 | 
						|
  }
 | 
						|
 | 
						|
  _recentlyMoved () {
 | 
						|
    return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
 | 
						|
  }
 | 
						|
 | 
						|
  handleKeyDown = (e) => {
 | 
						|
    if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
 | 
						|
      const article = (() => {
 | 
						|
        switch (e.key) {
 | 
						|
        case 'PageDown':
 | 
						|
          return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
 | 
						|
        case 'PageUp':
 | 
						|
          return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
 | 
						|
        case 'End':
 | 
						|
          return this.node.querySelector('[role="feed"] > article:last-of-type');
 | 
						|
        case 'Home':
 | 
						|
          return this.node.querySelector('[role="feed"] > article:first-of-type');
 | 
						|
        default:
 | 
						|
          return null;
 | 
						|
        }
 | 
						|
      })();
 | 
						|
 | 
						|
 | 
						|
      if (article) {
 | 
						|
        e.preventDefault();
 | 
						|
        article.focus();
 | 
						|
        article.scrollIntoView();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  render () {
 | 
						|
    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
 | 
						|
    const childrenCount = React.Children.count(children);
 | 
						|
 | 
						|
    const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
 | 
						|
    let scrollableArea = null;
 | 
						|
 | 
						|
    if (isLoading || childrenCount > 0 || !emptyMessage) {
 | 
						|
      scrollableArea = (
 | 
						|
        <div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
 | 
						|
          <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
 | 
						|
            {prepend}
 | 
						|
 | 
						|
            {React.Children.map(this.props.children, (child, index) => (
 | 
						|
              <IntersectionObserverArticleContainer
 | 
						|
                key={child.key}
 | 
						|
                id={child.key}
 | 
						|
                index={index}
 | 
						|
                listLength={childrenCount}
 | 
						|
                intersectionObserverWrapper={this.intersectionObserverWrapper}
 | 
						|
                saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
 | 
						|
              >
 | 
						|
                {child}
 | 
						|
              </IntersectionObserverArticleContainer>
 | 
						|
            ))}
 | 
						|
 | 
						|
            {loadMore}
 | 
						|
          </div>
 | 
						|
        </div>
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      scrollableArea = (
 | 
						|
        <div className='empty-column-indicator' ref={this.setRef}>
 | 
						|
          {emptyMessage}
 | 
						|
        </div>
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    if (trackScroll) {
 | 
						|
      return (
 | 
						|
        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
 | 
						|
          {scrollableArea}
 | 
						|
        </ScrollContainer>
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      return scrollableArea;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
}
 |