Code-split emoji-mart picker and data (#5175)
This commit is contained in:
		
							parent
							
								
									d841af4e80
								
							
						
					
					
						commit
						b9c612b561
					
				
					 7 changed files with 348 additions and 10 deletions
				
			
		|  | @ -1,6 +1,6 @@ | |||
| import api from '../api'; | ||||
| import { emojiIndex } from 'emoji-mart'; | ||||
| import { throttle } from 'lodash'; | ||||
| import { search as emojiSearch } from '../emoji_index_light'; | ||||
| 
 | ||||
| import { | ||||
|   updateTimeline, | ||||
|  | @ -261,7 +261,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => | |||
| }, 200, { leading: true, trailing: true }); | ||||
| 
 | ||||
| const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { | ||||
|   const results = emojiIndex.search(token.replace(':', ''), { maxResults: 5 }); | ||||
|   const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); | ||||
|   dispatch(readyComposeSuggestionsEmojis(token, results)); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										17
									
								
								app/javascript/mastodon/emoji_data_light.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/javascript/mastodon/emoji_data_light.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | |||
| // @preval
 | ||||
| const data = require('emoji-mart/dist/data').default; | ||||
| const pick = require('lodash/pick'); | ||||
| 
 | ||||
| const condensedEmojis = {}; | ||||
| Object.keys(data.emojis).forEach(key => { | ||||
|   condensedEmojis[key] = pick(data.emojis[key], ['short_names', 'unified', 'search']); | ||||
| }); | ||||
| 
 | ||||
| // JSON.parse/stringify is to emulate what @preval is doing and avoid any
 | ||||
| // inconsistent behavior in dev mode
 | ||||
| module.exports = JSON.parse(JSON.stringify({ | ||||
|   emojis: condensedEmojis, | ||||
|   skins: data.skins, | ||||
|   categories: data.categories, | ||||
|   short_names: data.short_names, | ||||
| })); | ||||
							
								
								
									
										154
									
								
								app/javascript/mastodon/emoji_index_light.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								app/javascript/mastodon/emoji_index_light.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,154 @@ | |||
| // This code is largely borrowed from:
 | ||||
| // https://github.com/missive/emoji-mart/blob/bbd4fbe/src/utils/emoji-index.js
 | ||||
| 
 | ||||
| import data from './emoji_data_light'; | ||||
| import { getData, getSanitizedData, intersect } from './emoji_utils'; | ||||
| 
 | ||||
| let index = {}; | ||||
| let emojisList = {}; | ||||
| let emoticonsList = {}; | ||||
| let previousInclude = []; | ||||
| let previousExclude = []; | ||||
| 
 | ||||
| for (let emoji in data.emojis) { | ||||
|   let emojiData = data.emojis[emoji], | ||||
|     { short_names, emoticons } = emojiData, | ||||
|     id = short_names[0]; | ||||
| 
 | ||||
|   for (let emoticon of (emoticons || [])) { | ||||
|     if (!emoticonsList[emoticon]) { | ||||
|       emoticonsList[emoticon] = id; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   emojisList[id] = getSanitizedData(id); | ||||
| } | ||||
| 
 | ||||
| function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) { | ||||
|   maxResults = maxResults || 75; | ||||
|   include = include || []; | ||||
|   exclude = exclude || []; | ||||
| 
 | ||||
|   if (custom.length) { | ||||
|     for (const emoji of custom) { | ||||
|       data.emojis[emoji.id] = getData(emoji); | ||||
|       emojisList[emoji.id] = getSanitizedData(emoji); | ||||
|     } | ||||
| 
 | ||||
|     data.categories.push({ | ||||
|       name: 'Custom', | ||||
|       emojis: custom.map(emoji => emoji.id), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   let results = null; | ||||
|   let pool = data.emojis; | ||||
| 
 | ||||
|   if (value.length) { | ||||
|     if (value === '-' || value === '-1') { | ||||
|       return [emojisList['-1']]; | ||||
|     } | ||||
| 
 | ||||
|     let values = value.toLowerCase().split(/[\s|,|\-|_]+/); | ||||
| 
 | ||||
|     if (values.length > 2) { | ||||
|       values = [values[0], values[1]]; | ||||
|     } | ||||
| 
 | ||||
|     if (include.length || exclude.length) { | ||||
|       pool = {}; | ||||
| 
 | ||||
|       if (previousInclude !== include.sort().join(',') || previousExclude !== exclude.sort().join(',')) { | ||||
|         previousInclude = include.sort().join(','); | ||||
|         previousExclude = exclude.sort().join(','); | ||||
|         index = {}; | ||||
|       } | ||||
| 
 | ||||
|       for (let category of data.categories) { | ||||
|         let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true; | ||||
|         let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false; | ||||
|         if (!isIncluded || isExcluded) { | ||||
|           continue; | ||||
|         } | ||||
| 
 | ||||
|         for (let emojiId of category.emojis) { | ||||
|           pool[emojiId] = data.emojis[emojiId]; | ||||
|         } | ||||
|       } | ||||
|     } else if (previousInclude.length || previousExclude.length) { | ||||
|       index = {}; | ||||
|     } | ||||
| 
 | ||||
|     let allResults = values.map((value) => { | ||||
|       let aPool = pool; | ||||
|       let aIndex = index; | ||||
|       let length = 0; | ||||
| 
 | ||||
|       for (let char of value.split('')) { | ||||
|         length++; | ||||
| 
 | ||||
|         aIndex[char] = aIndex[char] || {}; | ||||
|         aIndex = aIndex[char]; | ||||
| 
 | ||||
|         if (!aIndex.results) { | ||||
|           let scores = {}; | ||||
| 
 | ||||
|           aIndex.results = []; | ||||
|           aIndex.pool = {}; | ||||
| 
 | ||||
|           for (let id in aPool) { | ||||
|             let emoji = aPool[id], | ||||
|               { search } = emoji, | ||||
|               sub = value.substr(0, length), | ||||
|               subIndex = search.indexOf(sub); | ||||
| 
 | ||||
|             if (subIndex !== -1) { | ||||
|               let score = subIndex + 1; | ||||
|               if (sub === id) { | ||||
|                 score = 0; | ||||
|               } | ||||
| 
 | ||||
|               aIndex.results.push(emojisList[id]); | ||||
|               aIndex.pool[id] = emoji; | ||||
| 
 | ||||
|               scores[id] = score; | ||||
|             } | ||||
|           } | ||||
| 
 | ||||
|           aIndex.results.sort((a, b) => { | ||||
|             let aScore = scores[a.id], | ||||
|               bScore = scores[b.id]; | ||||
| 
 | ||||
|             return aScore - bScore; | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         aPool = aIndex.pool; | ||||
|       } | ||||
| 
 | ||||
|       return aIndex.results; | ||||
|     }).filter(a => a); | ||||
| 
 | ||||
|     if (allResults.length > 1) { | ||||
|       results = intersect(...allResults); | ||||
|     } else if (allResults.length) { | ||||
|       results = allResults[0]; | ||||
|     } else { | ||||
|       results = []; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (results) { | ||||
|     if (emojisToShowFilter) { | ||||
|       results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified)); | ||||
|     } | ||||
| 
 | ||||
|     if (results && results.length > maxResults) { | ||||
|       results = results.slice(0, maxResults); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return results; | ||||
| } | ||||
| 
 | ||||
| export { search }; | ||||
							
								
								
									
										137
									
								
								app/javascript/mastodon/emoji_utils.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								app/javascript/mastodon/emoji_utils.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,137 @@ | |||
| // This code is largely borrowed from:
 | ||||
| // https://github.com/missive/emoji-mart/blob/bbd4fbe/src/utils/index.js
 | ||||
| 
 | ||||
| import data from './emoji_data_light'; | ||||
| 
 | ||||
| const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/; | ||||
| 
 | ||||
| function buildSearch(thisData) { | ||||
|   const search = []; | ||||
| 
 | ||||
|   let addToSearch = (strings, split) => { | ||||
|     if (!strings) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     (Array.isArray(strings) ? strings : [strings]).forEach((string) => { | ||||
|       (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => { | ||||
|         s = s.toLowerCase(); | ||||
| 
 | ||||
|         if (search.indexOf(s) === -1) { | ||||
|           search.push(s); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   addToSearch(thisData.short_names, true); | ||||
|   addToSearch(thisData.name, true); | ||||
|   addToSearch(thisData.keywords, false); | ||||
|   addToSearch(thisData.emoticons, false); | ||||
| 
 | ||||
|   return search; | ||||
| } | ||||
| 
 | ||||
| function unifiedToNative(unified) { | ||||
|   let unicodes = unified.split('-'), | ||||
|     codePoints = unicodes.map((u) => `0x${u}`); | ||||
| 
 | ||||
|   return String.fromCodePoint(...codePoints); | ||||
| } | ||||
| 
 | ||||
| function sanitize(emoji) { | ||||
|   let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji, | ||||
|     id = emoji.id || short_names[0], | ||||
|     colons = `:${id}:`; | ||||
| 
 | ||||
|   if (custom) { | ||||
|     return { | ||||
|       id, | ||||
|       name, | ||||
|       colons, | ||||
|       emoticons, | ||||
|       custom, | ||||
|       imageUrl, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   if (skin_tone) { | ||||
|     colons += `:skin-tone-${skin_tone}:`; | ||||
|   } | ||||
| 
 | ||||
|   return { | ||||
|     id, | ||||
|     name, | ||||
|     colons, | ||||
|     emoticons, | ||||
|     unified: unified.toLowerCase(), | ||||
|     skin: skin_tone || (skin_variations ? 1 : null), | ||||
|     native: unifiedToNative(unified), | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| function getSanitizedData(emoji) { | ||||
|   return sanitize(getData(emoji)); | ||||
| } | ||||
| 
 | ||||
| function getData(emoji) { | ||||
|   let emojiData = {}; | ||||
| 
 | ||||
|   if (typeof emoji === 'string') { | ||||
|     let matches = emoji.match(COLONS_REGEX); | ||||
| 
 | ||||
|     if (matches) { | ||||
|       emoji = matches[1]; | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     if (data.short_names.hasOwnProperty(emoji)) { | ||||
|       emoji = data.short_names[emoji]; | ||||
|     } | ||||
| 
 | ||||
|     if (data.emojis.hasOwnProperty(emoji)) { | ||||
|       emojiData = data.emojis[emoji]; | ||||
|     } | ||||
|   } else if (emoji.custom) { | ||||
|     emojiData = emoji; | ||||
| 
 | ||||
|     emojiData.search = buildSearch({ | ||||
|       short_names: emoji.short_names, | ||||
|       name: emoji.name, | ||||
|       keywords: emoji.keywords, | ||||
|       emoticons: emoji.emoticons, | ||||
|     }); | ||||
| 
 | ||||
|     emojiData.search = emojiData.search.join(','); | ||||
|   } else if (emoji.id) { | ||||
|     if (data.short_names.hasOwnProperty(emoji.id)) { | ||||
|       emoji.id = data.short_names[emoji.id]; | ||||
|     } | ||||
| 
 | ||||
|     if (data.emojis.hasOwnProperty(emoji.id)) { | ||||
|       emojiData = data.emojis[emoji.id]; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   emojiData.emoticons = emojiData.emoticons || []; | ||||
|   emojiData.variations = emojiData.variations || []; | ||||
| 
 | ||||
|   if (emojiData.variations && emojiData.variations.length) { | ||||
|     emojiData = JSON.parse(JSON.stringify(emojiData)); | ||||
|     emojiData.unified = emojiData.variations.shift(); | ||||
|   } | ||||
| 
 | ||||
|   return emojiData; | ||||
| } | ||||
| 
 | ||||
| function intersect(a, b) { | ||||
|   let aSet = new Set(a); | ||||
|   let bSet = new Set(b); | ||||
|   let intersection = new Set( | ||||
|     [...aSet].filter(x => bSet.has(x)) | ||||
|   ); | ||||
| 
 | ||||
|   return Array.from(intersection); | ||||
| } | ||||
| 
 | ||||
| export { getData, getSanitizedData, intersect }; | ||||
|  | @ -1,11 +1,12 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { Picker, Emoji } from 'emoji-mart'; | ||||
| import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; | ||||
| import { Overlay } from 'react-overlays'; | ||||
| import classNames from 'classnames'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import detectPassiveEvents from 'detect-passive-events'; | ||||
| import { buildCustomEmojis } from '../../../emoji'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, | ||||
|  | @ -25,6 +26,8 @@ const messages = defineMessages({ | |||
| }); | ||||
| 
 | ||||
| const assetHost = process.env.CDN_HOST || ''; | ||||
| let EmojiPicker, Emoji; // load asynchronously
 | ||||
| 
 | ||||
| const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; | ||||
| const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; | ||||
| 
 | ||||
|  | @ -131,6 +134,7 @@ class EmojiPickerMenu extends React.PureComponent { | |||
| 
 | ||||
|   static propTypes = { | ||||
|     custom_emojis: ImmutablePropTypes.list, | ||||
|     loading: PropTypes.bool, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     onPick: PropTypes.func.isRequired, | ||||
|     style: PropTypes.object, | ||||
|  | @ -142,6 +146,7 @@ class EmojiPickerMenu extends React.PureComponent { | |||
| 
 | ||||
|   static defaultProps = { | ||||
|     style: {}, | ||||
|     loading: true, | ||||
|     placement: 'bottom', | ||||
|   }; | ||||
| 
 | ||||
|  | @ -216,13 +221,18 @@ class EmojiPickerMenu extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { style, intl } = this.props; | ||||
|     const { loading, style, intl } = this.props; | ||||
| 
 | ||||
|     if (loading) { | ||||
|       return <div style={{ width: 299 }} />; | ||||
|     } | ||||
| 
 | ||||
|     const title = intl.formatMessage(messages.emoji); | ||||
|     const { modifierOpen, modifier } = this.state; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> | ||||
|         <Picker | ||||
|         <EmojiPicker | ||||
|           perLine={8} | ||||
|           emojiSize={22} | ||||
|           sheetSize={32} | ||||
|  | @ -260,6 +270,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { | |||
| 
 | ||||
|   state = { | ||||
|     active: false, | ||||
|     loading: false, | ||||
|   }; | ||||
| 
 | ||||
|   setRef = (c) => { | ||||
|  | @ -268,6 +279,20 @@ export default class EmojiPickerDropdown extends React.PureComponent { | |||
| 
 | ||||
|   onShowDropdown = () => { | ||||
|     this.setState({ active: true }); | ||||
| 
 | ||||
|     if (!EmojiPicker) { | ||||
|       this.setState({ loading: true }); | ||||
| 
 | ||||
|       EmojiPickerAsync().then(EmojiMart => { | ||||
|         EmojiPicker = EmojiMart.Picker; | ||||
|         Emoji = EmojiMart.Emoji; | ||||
|         // populate custom emoji in search
 | ||||
|         EmojiMart.emojiIndex.search('', { custom: buildCustomEmojis(this.props.custom_emojis) }); | ||||
|         this.setState({ loading: false }); | ||||
|       }).catch(() => { | ||||
|         this.setState({ loading: false }); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onHideDropdown = () => { | ||||
|  | @ -275,7 +300,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   onToggle = (e) => { | ||||
|     if (!e.key || e.key === 'Enter') { | ||||
|     if (!this.state.loading && (!e.key || e.key === 'Enter')) { | ||||
|       if (this.state.active) { | ||||
|         this.onHideDropdown(); | ||||
|       } else { | ||||
|  | @ -301,13 +326,13 @@ export default class EmojiPickerDropdown extends React.PureComponent { | |||
|   render () { | ||||
|     const { intl, onPickEmoji } = this.props; | ||||
|     const title = intl.formatMessage(messages.emoji); | ||||
|     const { active } = this.state; | ||||
|     const { active, loading } = this.state; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> | ||||
|         <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> | ||||
|           <img | ||||
|             className='emojione' | ||||
|             className={classNames('emojione', { 'pulse-loading': active && loading })} | ||||
|             alt='🙂' | ||||
|             src={`${assetHost}/emoji/1f602.svg`} | ||||
|           /> | ||||
|  | @ -316,6 +341,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { | |||
|         <Overlay show={active} placement='bottom' target={this.findTarget}> | ||||
|           <EmojiPickerMenu | ||||
|             custom_emojis={this.props.custom_emojis} | ||||
|             loading={loading} | ||||
|             onClose={this.onHideDropdown} | ||||
|             onPick={onPickEmoji} | ||||
|           /> | ||||
|  |  | |||
|  | @ -1,3 +1,7 @@ | |||
| export function EmojiPicker () { | ||||
|   return import(/* webpackChunkName: "emoji_picker" */'emoji-mart'); | ||||
| } | ||||
| 
 | ||||
| export function Compose () { | ||||
|   return import(/* webpackChunkName: "features/compose" */'../../compose'); | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import { List as ImmutableList } from 'immutable'; | ||||
| import { STORE_HYDRATE } from '../actions/store'; | ||||
| import { emojiIndex } from 'emoji-mart'; | ||||
| import { search as emojiSearch } from '../emoji_index_light'; | ||||
| import { buildCustomEmojis } from '../emoji'; | ||||
| 
 | ||||
| const initialState = ImmutableList(); | ||||
|  | @ -8,7 +8,7 @@ const initialState = ImmutableList(); | |||
| export default function custom_emojis(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case STORE_HYDRATE: | ||||
|     emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); | ||||
|     emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); | ||||
|     return action.state.get('custom_emojis'); | ||||
|   default: | ||||
|     return state; | ||||
|  |  | |||
		Reference in a new issue