mirror of
https://github.com/element-hq/element-web.git
synced 2024-12-18 09:42:14 +03:00
Merge pull request #3565 from matrix-org/bwindels/speedupemojipicker
Improve opening emoji picker performance
This commit is contained in:
commit
2bdd27938a
3 changed files with 149 additions and 46 deletions
|
@ -15,9 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
const OVERFLOW_ITEMS = 20;
|
|
||||||
const OVERFLOW_MARGIN = 5;
|
|
||||||
|
|
||||||
class ItemRange {
|
class ItemRange {
|
||||||
constructor(topCount, renderCount, bottomCount) {
|
constructor(topCount, renderCount, bottomCount) {
|
||||||
|
@ -27,11 +25,22 @@ class ItemRange {
|
||||||
}
|
}
|
||||||
|
|
||||||
contains(range) {
|
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 &&
|
return range.topCount >= this.topCount &&
|
||||||
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
|
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
expand(amount) {
|
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 topGrow = Math.min(amount, this.topCount);
|
||||||
const bottomGrow = Math.min(amount, this.bottomCount);
|
const bottomGrow = Math.min(amount, this.bottomCount);
|
||||||
return new ItemRange(
|
return new ItemRange(
|
||||||
|
@ -40,52 +49,80 @@ class ItemRange {
|
||||||
this.bottomCount - bottomGrow,
|
this.bottomCount - bottomGrow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalSize() {
|
||||||
|
return this.topCount + this.renderCount + this.bottomCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class LazyRenderList extends React.Component {
|
export default class LazyRenderList extends React.Component {
|
||||||
constructor(props) {
|
static getDerivedStateFromProps(props, state) {
|
||||||
super(props);
|
const range = LazyRenderList.getVisibleRangeFromProps(props);
|
||||||
const renderRange = LazyRenderList.getVisibleRangeFromProps(props).expand(OVERFLOW_ITEMS);
|
const intersectRange = range.expand(props.overflowMargin);
|
||||||
this.state = {renderRange};
|
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) {
|
static getVisibleRangeFromProps(props) {
|
||||||
const {items, itemHeight, scrollTop, height} = props;
|
const {items, itemHeight, scrollTop, height} = props;
|
||||||
const length = items ? items.length : 0;
|
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 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;
|
const bottomCount = itemsAfterTop - renderCount;
|
||||||
return new ItemRange(topCount, renderCount, bottomCount);
|
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() {
|
render() {
|
||||||
const {itemHeight, items, renderItem} = this.props;
|
const {itemHeight, items, renderItem} = this.props;
|
||||||
|
|
||||||
const {renderRange} = this.state;
|
const {renderRange} = this.state;
|
||||||
const paddingTop = renderRange.topCount * itemHeight;
|
const {topCount, renderCount, bottomCount} = renderRange;
|
||||||
const paddingBottom = renderRange.bottomCount * itemHeight;
|
|
||||||
|
const paddingTop = topCount * itemHeight;
|
||||||
|
const paddingBottom = bottomCount * itemHeight;
|
||||||
const renderedItems = (items || []).slice(
|
const renderedItems = (items || []).slice(
|
||||||
renderRange.topCount,
|
topCount,
|
||||||
renderRange.topCount + renderRange.renderCount,
|
topCount + renderCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (<div style={{paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`}}>
|
const element = this.props.element || "div";
|
||||||
{ renderedItems.map(renderItem) }
|
const elementProps = {
|
||||||
</div>);
|
"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,
|
||||||
|
};
|
||||||
|
|
|
@ -16,9 +16,11 @@ limitations under the License.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker";
|
||||||
import sdk from '../../../index';
|
import sdk from '../../../index';
|
||||||
|
|
||||||
|
const OVERFLOW_ROWS = 3;
|
||||||
|
|
||||||
class Category extends React.PureComponent {
|
class Category extends React.PureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
emojis: PropTypes.arrayOf(PropTypes.object).isRequired,
|
emojis: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
@ -30,22 +32,54 @@ class Category extends React.PureComponent {
|
||||||
selectedEmojis: PropTypes.instanceOf(Set),
|
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 (<div key={rowIndex}>{
|
||||||
|
emojisForRow.map(emoji =>
|
||||||
|
<Emoji key={emoji.hexcode} emoji={emoji} selectedEmojis={selectedEmojis}
|
||||||
|
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />)
|
||||||
|
}</div>);
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { onClick, onMouseEnter, onMouseLeave, emojis, name, selectedEmojis } = this.props;
|
const { emojis, name, heightBefore, viewportHeight, scrollTop } = this.props;
|
||||||
if (!emojis || emojis.length === 0) {
|
if (!emojis || emojis.length === 0) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<section className="mx_EmojiPicker_category" data-category-id={this.props.id}>
|
<section className="mx_EmojiPicker_category" data-category-id={this.props.id}>
|
||||||
<h2 className="mx_EmojiPicker_category_label">
|
<h2 className="mx_EmojiPicker_category_label">
|
||||||
{name}
|
{name}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mx_EmojiPicker_list">
|
<LazyRenderList
|
||||||
{emojis.map(emoji => <Emoji key={emoji.hexcode} emoji={emoji} selectedEmojis={selectedEmojis}
|
element="ul" className="mx_EmojiPicker_list"
|
||||||
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />)}
|
itemHeight={EMOJI_HEIGHT} items={rows}
|
||||||
</ul>
|
scrollTop={localScrollTop}
|
||||||
|
height={localHeight}
|
||||||
|
overflowItems={OVERFLOW_ROWS}
|
||||||
|
overflowMargin={0}
|
||||||
|
renderItem={this._renderEmojiRow}>
|
||||||
|
</LazyRenderList>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,10 @@ EMOJIBASE.forEach(emoji => {
|
||||||
emoji.filterString = `${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}`;
|
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 {
|
class EmojiPicker extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
onChoose: PropTypes.func.isRequired,
|
onChoose: PropTypes.func.isRequired,
|
||||||
|
@ -71,6 +75,11 @@ class EmojiPicker extends React.Component {
|
||||||
this.state = {
|
this.state = {
|
||||||
filter: "",
|
filter: "",
|
||||||
previewEmoji: null,
|
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]);
|
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);
|
this.updateVisibility = this.updateVisibility.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onScroll = () => {
|
||||||
|
const body = this.bodyRef.current;
|
||||||
|
this.setState({
|
||||||
|
scrollTop: body.scrollTop,
|
||||||
|
viewportHeight: body.clientHeight,
|
||||||
|
});
|
||||||
|
this.updateVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
updateVisibility() {
|
updateVisibility() {
|
||||||
const rect = this.bodyRef.current.getBoundingClientRect();
|
const body = this.bodyRef.current;
|
||||||
|
const rect = body.getBoundingClientRect();
|
||||||
for (const cat of this.categories) {
|
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) {
|
if (!elem) {
|
||||||
cat.visible = false;
|
cat.visible = false;
|
||||||
cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
|
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() {
|
render() {
|
||||||
const Header = sdk.getComponent("emojipicker.Header");
|
const Header = sdk.getComponent("emojipicker.Header");
|
||||||
const Search = sdk.getComponent("emojipicker.Search");
|
const Search = sdk.getComponent("emojipicker.Search");
|
||||||
const Category = sdk.getComponent("emojipicker.Category");
|
const Category = sdk.getComponent("emojipicker.Category");
|
||||||
const Preview = sdk.getComponent("emojipicker.Preview");
|
const Preview = sdk.getComponent("emojipicker.Preview");
|
||||||
const QuickReactions = sdk.getComponent("emojipicker.QuickReactions");
|
const QuickReactions = sdk.getComponent("emojipicker.QuickReactions");
|
||||||
|
let heightBefore = 0;
|
||||||
return (
|
return (
|
||||||
<div className="mx_EmojiPicker">
|
<div className="mx_EmojiPicker">
|
||||||
<Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory} />
|
<Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory} />
|
||||||
<Search query={this.state.filter} onChange={this.onChangeFilter} />
|
<Search query={this.state.filter} onChange={this.onChangeFilter} />
|
||||||
<div className="mx_EmojiPicker_body" ref={this.bodyRef} onScroll={this.updateVisibility}>
|
<div className="mx_EmojiPicker_body" ref={this.bodyRef} onScroll={this.onScroll}>
|
||||||
{this.categories.map(category => (
|
{this.categories.map(category => {
|
||||||
<Category key={category.id} id={category.id} name={category.name}
|
const emojis = this.memoizedDataByCategory[category.id];
|
||||||
emojis={this.memoizedDataByCategory[category.id]} onClick={this.onClickEmoji}
|
const categoryElement = (<Category key={category.id} id={category.id} name={category.name}
|
||||||
|
heightBefore={heightBefore} viewportHeight={this.state.viewportHeight}
|
||||||
|
scrollTop={this.state.scrollTop} emojis={emojis} onClick={this.onClickEmoji}
|
||||||
onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd}
|
onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd}
|
||||||
selectedEmojis={this.props.selectedEmojis} />
|
selectedEmojis={this.props.selectedEmojis} />);
|
||||||
))}
|
const height = this._categoryHeightForEmojiCount(emojis.length);
|
||||||
|
heightBefore += height;
|
||||||
|
return categoryElement;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{this.state.previewEmoji || !this.props.showQuickReactions
|
{this.state.previewEmoji || !this.props.showQuickReactions
|
||||||
? <Preview emoji={this.state.previewEmoji} />
|
? <Preview emoji={this.state.previewEmoji} />
|
||||||
|
|
Loading…
Reference in a new issue