diff --git a/src/components/views/elements/LazyRenderList.js b/src/components/views/elements/LazyRenderList.js index d7d2a0ab99..0fc0ef6733 100644 --- a/src/components/views/elements/LazyRenderList.js +++ b/src/components/views/elements/LazyRenderList.js @@ -15,9 +15,7 @@ limitations under the License. */ import React from "react"; - -const OVERFLOW_ITEMS = 20; -const OVERFLOW_MARGIN = 5; +import PropTypes from 'prop-types'; class ItemRange { constructor(topCount, renderCount, bottomCount) { @@ -27,11 +25,22 @@ class ItemRange { } contains(range) { + // don't contain empty ranges + // as it will prevent clearing the list + // once it is scrolled far enough out of view + if (!range.renderCount && this.renderCount) { + return false; + } return range.topCount >= this.topCount && (range.topCount + range.renderCount) <= (this.topCount + this.renderCount); } expand(amount) { + // don't expand ranges that won't render anything + if (this.renderCount === 0) { + return this; + } + const topGrow = Math.min(amount, this.topCount); const bottomGrow = Math.min(amount, this.bottomCount); return new ItemRange( @@ -40,52 +49,80 @@ class ItemRange { this.bottomCount - bottomGrow, ); } + + totalSize() { + return this.topCount + this.renderCount + this.bottomCount; + } } export default class LazyRenderList extends React.Component { - constructor(props) { - super(props); - const renderRange = LazyRenderList.getVisibleRangeFromProps(props).expand(OVERFLOW_ITEMS); - this.state = {renderRange}; + static getDerivedStateFromProps(props, state) { + const range = LazyRenderList.getVisibleRangeFromProps(props); + const intersectRange = range.expand(props.overflowMargin); + const renderRange = range.expand(props.overflowItems); + const listHasChangedSize = !!state && renderRange.totalSize() !== state.renderRange.totalSize(); + // only update render Range if the list has shrunk/grown and we need to adjust padding OR + // if the new range + overflowMargin isn't contained by the old anymore + if (listHasChangedSize || !state || !state.renderRange.contains(intersectRange)) { + return {renderRange}; + } + return null; } static getVisibleRangeFromProps(props) { const {items, itemHeight, scrollTop, height} = props; const length = items ? items.length : 0; - const topCount = Math.max(0, Math.floor(scrollTop / itemHeight)); + const topCount = Math.min(Math.max(0, Math.floor(scrollTop / itemHeight)), length); const itemsAfterTop = length - topCount; - const renderCount = Math.min(Math.ceil(height / itemHeight), itemsAfterTop); + const visibleItems = height !== 0 ? Math.ceil(height / itemHeight) : 0; + const renderCount = Math.min(visibleItems, itemsAfterTop); const bottomCount = itemsAfterTop - renderCount; return new ItemRange(topCount, renderCount, bottomCount); } - componentWillReceiveProps(props) { - const state = this.state; - const range = LazyRenderList.getVisibleRangeFromProps(props); - const intersectRange = range.expand(OVERFLOW_MARGIN); - - const prevSize = this.props.items ? this.props.items.length : 0; - const listHasChangedSize = props.items.length !== prevSize; - // only update renderRange if the list has shrunk/grown and we need to adjust padding or - // if the new range isn't contained by the old anymore - if (listHasChangedSize || !state.renderRange || !state.renderRange.contains(intersectRange)) { - this.setState({renderRange: range.expand(OVERFLOW_ITEMS)}); - } - } - render() { const {itemHeight, items, renderItem} = this.props; - const {renderRange} = this.state; - const paddingTop = renderRange.topCount * itemHeight; - const paddingBottom = renderRange.bottomCount * itemHeight; + const {topCount, renderCount, bottomCount} = renderRange; + + const paddingTop = topCount * itemHeight; + const paddingBottom = bottomCount * itemHeight; const renderedItems = (items || []).slice( - renderRange.topCount, - renderRange.topCount + renderRange.renderCount, + topCount, + topCount + renderCount, ); - return (
- { renderedItems.map(renderItem) } -
); + const element = this.props.element || "div"; + const elementProps = { + "style": {paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`}, + "className": this.props.className, + }; + return React.createElement(element, elementProps, renderedItems.map(renderItem)); } } + +LazyRenderList.defaultProps = { + overflowItems: 20, + overflowMargin: 5, +}; + +LazyRenderList.propTypes = { + // height in pixels of the component returned by `renderItem` + itemHeight: PropTypes.number.isRequired, + // function to turn an element of `items` into a react component + renderItem: PropTypes.func.isRequired, + // scrollTop of the viewport (minus the height of any content above this list like other `LazyRenderList`s) + scrollTop: PropTypes.number.isRequired, + // the height of the viewport this content is scrolled in + height: PropTypes.number.isRequired, + // all items for the list. These should not be react components, see `renderItem`. + items: PropTypes.array, + // the amount of items to scroll before causing a rerender, + // should typically be less than `overflowItems` unless applying + // margins in the parent component when using multiple LazyRenderList in one viewport. + // use 0 to only rerender when items will come into view. + overflowMargin: PropTypes.number, + // the amount of items to add at the top and bottom to render, + // so not every scroll of causes a rerender. + overflowItems: PropTypes.number, +}; diff --git a/src/components/views/emojipicker/Category.js b/src/components/views/emojipicker/Category.js index ba48c8842b..ba525b76e2 100644 --- a/src/components/views/emojipicker/Category.js +++ b/src/components/views/emojipicker/Category.js @@ -16,9 +16,11 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; - +import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker"; import sdk from '../../../index'; +const OVERFLOW_ROWS = 3; + class Category extends React.PureComponent { static propTypes = { emojis: PropTypes.arrayOf(PropTypes.object).isRequired, @@ -30,22 +32,54 @@ class Category extends React.PureComponent { selectedEmojis: PropTypes.instanceOf(Set), }; + _renderEmojiRow = (rowIndex) => { + const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; + const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8); + const Emoji = sdk.getComponent("emojipicker.Emoji"); + return (
{ + emojisForRow.map(emoji => + ) + }
); + }; + render() { - const { onClick, onMouseEnter, onMouseLeave, emojis, name, selectedEmojis } = this.props; + const { emojis, name, heightBefore, viewportHeight, scrollTop } = this.props; if (!emojis || emojis.length === 0) { return null; } + const rows = new Array(Math.ceil(emojis.length / EMOJIS_PER_ROW)); + for (let counter = 0; counter < rows.length; ++counter) { + rows[counter] = counter; + } + const LazyRenderList = sdk.getComponent('elements.LazyRenderList'); + + const viewportTop = scrollTop; + const viewportBottom = viewportTop + viewportHeight; + const listTop = heightBefore + CATEGORY_HEADER_HEIGHT; + const listBottom = listTop + (rows.length * EMOJI_HEIGHT); + const top = Math.max(viewportTop, listTop); + const bottom = Math.min(viewportBottom, listBottom); + // the viewport height and scrollTop passed to the LazyRenderList + // is capped at the intersection with the real viewport, so lists + // out of view are passed height 0, so they won't render any items. + const localHeight = Math.max(0, bottom - top); + const localScrollTop = Math.max(0, scrollTop - listTop); - const Emoji = sdk.getComponent("emojipicker.Emoji"); return (

{name}

- + +
); } diff --git a/src/components/views/emojipicker/EmojiPicker.js b/src/components/views/emojipicker/EmojiPicker.js index 6d34804187..48713b90d8 100644 --- a/src/components/views/emojipicker/EmojiPicker.js +++ b/src/components/views/emojipicker/EmojiPicker.js @@ -58,6 +58,10 @@ EMOJIBASE.forEach(emoji => { emoji.filterString = `${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}`; }); +export const CATEGORY_HEADER_HEIGHT = 22; +export const EMOJI_HEIGHT = 37; +export const EMOJIS_PER_ROW = 8; + class EmojiPicker extends React.Component { static propTypes = { onChoose: PropTypes.func.isRequired, @@ -71,6 +75,11 @@ class EmojiPicker extends React.Component { this.state = { filter: "", previewEmoji: null, + scrollTop: 0, + // initial estimation of height, dialog is hardcoded to 450px height. + // should be enough to never have blank rows of emojis as + // 3 rows of overflow are also rendered. The actual value is updated on scroll. + viewportHeight: 280, }; this.recentlyUsed = recent.get().map(unicode => DATA_BY_EMOJI[unicode]); @@ -145,10 +154,20 @@ class EmojiPicker extends React.Component { this.updateVisibility = this.updateVisibility.bind(this); } + onScroll = () => { + const body = this.bodyRef.current; + this.setState({ + scrollTop: body.scrollTop, + viewportHeight: body.clientHeight, + }); + this.updateVisibility(); + }; + updateVisibility() { - const rect = this.bodyRef.current.getBoundingClientRect(); + const body = this.bodyRef.current; + const rect = body.getBoundingClientRect(); for (const cat of this.categories) { - const elem = this.bodyRef.current.querySelector(`[data-category-id="${cat.id}"]`); + const elem = body.querySelector(`[data-category-id="${cat.id}"]`); if (!elem) { cat.visible = false; cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible"); @@ -210,23 +229,36 @@ class EmojiPicker extends React.Component { } } + _categoryHeightForEmojiCount(count) { + if (count === 0) { + return 0; + } + return CATEGORY_HEADER_HEIGHT + (Math.ceil(count / EMOJIS_PER_ROW) * EMOJI_HEIGHT); + } + render() { const Header = sdk.getComponent("emojipicker.Header"); const Search = sdk.getComponent("emojipicker.Search"); const Category = sdk.getComponent("emojipicker.Category"); const Preview = sdk.getComponent("emojipicker.Preview"); const QuickReactions = sdk.getComponent("emojipicker.QuickReactions"); + let heightBefore = 0; return (
-
- {this.categories.map(category => ( - + {this.categories.map(category => { + const emojis = this.memoizedDataByCategory[category.id]; + const categoryElement = ( - ))} + selectedEmojis={this.props.selectedEmojis} />); + const height = this._categoryHeightForEmojiCount(emojis.length); + heightBefore += height; + return categoryElement; + })}
{this.state.previewEmoji || !this.props.showQuickReactions ?