/* eslint-disable prefer-const,no-labels,no-inner-declarations */ import { groups as defaultGroups, allGroups as groupsWithCustom } from '../../groups' import { MIN_SEARCH_TEXT_LENGTH, NUM_SKIN_TONES } from '../../../shared/constants' import { requestIdleCallback } from '../../utils/requestIdleCallback' import { hasZwj } from '../../utils/hasZwj' import { detectEmojiSupportLevel, supportedZwjEmojis } from '../../utils/emojiSupport' import { applySkinTone } from '../../utils/applySkinTone' import { halt } from '../../utils/halt' import { incrementOrDecrement } from '../../utils/incrementOrDecrement' import { DEFAULT_NUM_COLUMNS, MOST_COMMONLY_USED_EMOJI, TIMEOUT_BEFORE_LOADING_MESSAGE } from '../../constants' import { uniqBy } from '../../../shared/uniqBy' import { summarizeEmojisForUI } from '../../utils/summarizeEmojisForUI' import { calculateWidth } from '../../utils/widthCalculator' import { checkZwjSupport } from '../../utils/checkZwjSupport' import { requestPostAnimationFrame } from '../../utils/requestPostAnimationFrame' import { requestAnimationFrame } from '../../utils/requestAnimationFrame' import { uniq } from '../../../shared/uniq' import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.js' import { render } from './PickerTemplate.js' import { createState } from './reactivity.js' import { arraysAreEqualByFunction } from '../../utils/arraysAreEqualByFunction.js' // constants const EMPTY_ARRAY = [] const { assign } = Object export function createRoot (shadowRoot, props) { const refs = {} const abortController = new AbortController() const abortSignal = abortController.signal const { state, createEffect } = createState(abortSignal) // initial state assign(state, { skinToneEmoji: undefined, i18n: undefined, database: undefined, customEmoji: undefined, customCategorySorting: undefined, emojiVersion: undefined }) // public props assign(state, props) // private props assign(state, { initialLoad: true, currentEmojis: [], currentEmojisWithCategories: [], rawSearchText: '', searchText: '', searchMode: false, activeSearchItem: -1, message: undefined, skinTonePickerExpanded: false, skinTonePickerExpandedAfterAnimation: false, currentSkinTone: 0, activeSkinTone: 0, skinToneButtonText: undefined, pickerStyle: undefined, skinToneButtonLabel: '', skinTones: [], currentFavorites: [], defaultFavoriteEmojis: undefined, numColumns: DEFAULT_NUM_COLUMNS, isRtl: false, scrollbarWidth: 0, currentGroupIndex: 0, groups: defaultGroups, databaseLoaded: false, activeSearchItemId: undefined }) // // Update the current group based on the currentGroupIndex // createEffect(() => { if (state.currentGroup !== state.groups[state.currentGroupIndex]) { state.currentGroup = state.groups[state.currentGroupIndex] } }) // // Utils/helpers // const focus = id => { shadowRoot.getElementById(id).focus() } const emojiToDomNode = emoji => shadowRoot.getElementById(`emo-${emoji.id}`) // fire a custom event that crosses the shadow boundary const fireEvent = (name, detail) => { refs.rootElement.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) } // // Comparison utils // const compareEmojiArrays = (a, b) => a.id === b.id const compareCurrentEmojisWithCategories = (a, b) => { const { category: aCategory, emojis: aEmojis } = a const { category: bCategory, emojis: bEmojis } = b if (aCategory !== bCategory) { return false } return arraysAreEqualByFunction(aEmojis, bEmojis, compareEmojiArrays) } // // Update utils to avoid excessive re-renders // // avoid excessive re-renders by checking the value before setting const updateCurrentEmojis = (newEmojis) => { if (!arraysAreEqualByFunction(state.currentEmojis, newEmojis, compareEmojiArrays)) { state.currentEmojis = newEmojis } } // avoid excessive re-renders const updateSearchMode = (newSearchMode) => { if (state.searchMode !== newSearchMode) { state.searchMode = newSearchMode } } // avoid excessive re-renders const updateCurrentEmojisWithCategories = (newEmojisWithCategories) => { if (!arraysAreEqualByFunction(state.currentEmojisWithCategories, newEmojisWithCategories, compareCurrentEmojisWithCategories)) { state.currentEmojisWithCategories = newEmojisWithCategories } } // Helpers used by PickerTemplate const unicodeWithSkin = (emoji, currentSkinTone) => ( (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode ) const labelWithSkin = (emoji, currentSkinTone) => ( uniq([ (emoji.name || unicodeWithSkin(emoji, currentSkinTone)), emoji.annotation, ...(emoji.shortcodes || EMPTY_ARRAY) ].filter(Boolean)).join(', ') ) const titleForEmoji = (emoji) => ( emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ') ) const helpers = { labelWithSkin, titleForEmoji, unicodeWithSkin } const events = { onClickSkinToneButton, onEmojiClick, onNavClick, onNavKeydown, onSearchKeydown, onSkinToneOptionsClick, onSkinToneOptionsFocusOut, onSkinToneOptionsKeydown, onSkinToneOptionsKeyup, onSearchInput } const actions = { calculateEmojiGridStyle } let firstRender = true createEffect(() => { render(shadowRoot, state, helpers, events, actions, refs, abortSignal, firstRender) firstRender = false }) // // Determine the emoji support level (in requestIdleCallback) // // mount logic if (!state.emojiVersion) { detectEmojiSupportLevel().then(level => { // Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo /* istanbul ignore next */ if (!level) { state.message = state.i18n.emojiUnsupportedMessage } }) } // // Set or update the database object // createEffect(() => { // show a Loading message if it takes a long time, or show an error if there's a network/IDB error async function handleDatabaseLoading () { let showingLoadingMessage = false const timeoutHandle = setTimeout(() => { showingLoadingMessage = true state.message = state.i18n.loadingMessage }, TIMEOUT_BEFORE_LOADING_MESSAGE) try { await state.database.ready() state.databaseLoaded = true // eslint-disable-line no-unused-vars } catch (err) { console.error(err) state.message = state.i18n.networkErrorMessage } finally { clearTimeout(timeoutHandle) if (showingLoadingMessage) { // Seems safer than checking the i18n string, which may change showingLoadingMessage = false state.message = '' // eslint-disable-line no-unused-vars } } } if (state.database) { /* no await */ handleDatabaseLoading() } }) // // Global styles for the entire picker // createEffect(() => { state.pickerStyle = ` --num-groups: ${state.groups.length}; --indicator-opacity: ${state.searchMode ? 0 : 1}; --num-skintones: ${NUM_SKIN_TONES};` }) // // Set or update the customEmoji // createEffect(() => { if (state.customEmoji && state.database) { console.log('updating custom emoji') updateCustomEmoji() // re-run whenever customEmoji change } }) createEffect(() => { if (state.customEmoji && state.customEmoji.length) { if (state.groups !== groupsWithCustom) { // don't update unnecessarily state.groups = groupsWithCustom } } else if (state.groups !== defaultGroups) { if (state.currentGroupIndex) { // If the current group is anything other than "custom" (which is first), decrement. // This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji state.currentGroupIndex-- } state.groups = defaultGroups } }) // // Set or update the preferred skin tone // createEffect(() => { async function updatePreferredSkinTone () { if (state.databaseLoaded) { state.currentSkinTone = await state.database.getPreferredSkinTone() } } /* no await */ updatePreferredSkinTone() }) createEffect(() => { console.log('setting skinTones') state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i)) }) createEffect(() => { console.log('setting skinToneButtonText') state.skinToneButtonText = state.skinTones[state.currentSkinTone] }) createEffect(() => { state.skinToneButtonLabel = state.i18n.skinToneLabel.replace('{skinTone}', state.i18n.skinTones[state.currentSkinTone]) }) // // Set or update the favorites emojis // createEffect(() => { async function updateDefaultFavoriteEmojis () { const { database } = state const favs = (await Promise.all(MOST_COMMONLY_USED_EMOJI.map(unicode => ( database.getEmojiByUnicodeOrName(unicode) )))).filter(Boolean) // filter because in Jest tests we don't have all the emoji in the DB state.defaultFavoriteEmojis = favs } if (state.databaseLoaded) { /* no await */ updateDefaultFavoriteEmojis() } }) function updateCustomEmoji () { // Certain effects have an implicit dependency on customEmoji since it affects the database // Getting it here on the state ensures this effect re-runs when customEmoji change. // Setting it on the database is pointless but prevents this code from being removed by a minifier. state.database.customEmoji = state.customEmoji || EMPTY_ARRAY } createEffect(() => { async function updateFavorites () { console.log('updateFavorites') updateCustomEmoji() // re-run whenever customEmoji change const { database, defaultFavoriteEmojis, numColumns } = state const dbFavorites = await database.getTopFavoriteEmoji(numColumns) const favorites = await summarizeEmojis(uniqBy([ ...dbFavorites, ...defaultFavoriteEmojis ], _ => (_.unicode || _.name)).slice(0, numColumns)) state.currentFavorites = favorites } if (state.databaseLoaded && state.defaultFavoriteEmojis) { /* no await */ updateFavorites() } }) // // Calculate the width of the emoji grid. This serves two purposes: // 1) Re-calculate the --num-columns var because it may have changed // 2) Re-calculate the scrollbar width because it may have changed // (i.e. because the number of items changed) // 3) Re-calculate whether we're in RTL mode or not. // // The benefit of doing this in one place is to align with rAF/ResizeObserver // and do all the calculations in one go. RTL vs LTR is not strictly width-related, // but since we're already reading the style here, and since it's already aligned with // the rAF loop, this is the most appropriate place to do it perf-wise. // function calculateEmojiGridStyle (node) { calculateWidth(node, abortSignal, width => { /* istanbul ignore next */ if (process.env.NODE_ENV !== 'test') { // jsdom throws errors for this kind of fancy stuff // read all the style/layout calculations we need to make const style = getComputedStyle(refs.rootElement) const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10) const newIsRtl = style.getPropertyValue('direction') === 'rtl' const parentWidth = node.parentElement.getBoundingClientRect().width const newScrollbarWidth = parentWidth - width // write to state variables state.numColumns = newNumColumns state.scrollbarWidth = newScrollbarWidth // eslint-disable-line no-unused-vars state.isRtl = newIsRtl // eslint-disable-line no-unused-vars } }) } // // Set or update the currentEmojis. Check for invalid ZWJ renderings // (i.e. double emoji). // createEffect(() => { async function updateEmojis () { console.log('updateEmojis') const { searchText, currentGroup, databaseLoaded, customEmoji } = state if (!databaseLoaded) { state.currentEmojis = [] state.searchMode = false } else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) { const newEmojis = await getEmojisBySearchQuery(searchText) if (state.searchText === searchText) { // if the situation changes asynchronously, do not update updateCurrentEmojis(newEmojis) updateSearchMode(true) } } else { // database is loaded and we're not in search mode, so we're in normal category mode const { id: currentGroupId } = currentGroup // avoid race condition where currentGroupId is -1 and customEmoji is undefined/empty if (currentGroupId !== -1 || (customEmoji && customEmoji.length)) { const newEmojis = await getEmojisByGroup(currentGroupId) if (state.currentGroup.id === currentGroupId) { // if the situation changes asynchronously, do not update updateCurrentEmojis(newEmojis) updateSearchMode(false) } } } } /* no await */ updateEmojis() }) // Some emojis have their ligatures rendered as two or more consecutive emojis // We want to treat these the same as unsupported emojis, so we compare their // widths against the baseline widths and remove them as necessary createEffect(() => { const { currentEmojis, emojiVersion } = state const zwjEmojisToCheck = currentEmojis .filter(emoji => emoji.unicode) // filter custom emoji .filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode)) if (!emojiVersion && zwjEmojisToCheck.length) { // render now, check their length later updateCurrentEmojis(currentEmojis) requestAnimationFrame(() => checkZwjSupportAndUpdate(zwjEmojisToCheck)) } else { const newEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported) updateCurrentEmojis(newEmojis) // Reset scroll top to 0 when emojis change requestAnimationFrame(() => resetScrollTopIfPossible(refs.tabpanelElement)) } }) function checkZwjSupportAndUpdate (zwjEmojisToCheck) { checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode) // force update // eslint-disable-next-line no-self-assign state.currentEmojis = state.currentEmojis } function isZwjSupported (emoji) { return !emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode) } async function filterEmojisByVersion (emojis) { const emojiSupportLevel = state.emojiVersion || await detectEmojiSupportLevel() // !version corresponds to custom emoji return emojis.filter(({ version }) => !version || version <= emojiSupportLevel) } async function summarizeEmojis (emojis) { return summarizeEmojisForUI(emojis, state.emojiVersion || await detectEmojiSupportLevel()) } async function getEmojisByGroup (group) { console.log('getEmojiByGroup', group) // -1 is custom emoji const emoji = group === -1 ? state.customEmoji : await state.database.getEmojiByGroup(group) return summarizeEmojis(await filterEmojisByVersion(emoji)) } async function getEmojisBySearchQuery (query) { return summarizeEmojis(await filterEmojisByVersion(await state.database.getEmojiBySearchQuery(query))) } createEffect(() => { // consider initialLoad to be complete when the first tabpanel and favorites are rendered /* istanbul ignore next */ if (process.env.NODE_ENV !== 'production' || process.env.PERF) { if (state.currentEmojis.length && state.currentFavorites.length && state.initialLoad) { state.initialLoad = false requestPostAnimationFrame(() => performance.measure('initialLoad', 'initialLoad')) } } }) // // Derive currentEmojisWithCategories from currentEmojis. This is always done even if there // are no categories, because it's just easier to code the HTML this way. // createEffect(() => { function calculateCurrentEmojisWithCategories () { const { searchMode, currentEmojis } = state if (searchMode) { return [ { category: '', emojis: currentEmojis } ] } const categoriesToEmoji = new Map() for (const emoji of currentEmojis) { const category = emoji.category || '' let emojis = categoriesToEmoji.get(category) if (!emojis) { emojis = [] categoriesToEmoji.set(category, emojis) } emojis.push(emoji) } return [...categoriesToEmoji.entries()] .map(([category, emojis]) => ({ category, emojis })) .sort((a, b) => state.customCategorySorting(a.category, b.category)) } const newEmojisWithCategories = calculateCurrentEmojisWithCategories() updateCurrentEmojisWithCategories(newEmojisWithCategories) }) // // Handle active search item (i.e. pressing up or down while searching) // createEffect(() => { state.activeSearchItemId = state.activeSearchItem !== -1 && state.currentEmojis[state.activeSearchItem].id }) // // Handle user input on the search input // createEffect(() => { const { rawSearchText } = state requestIdleCallback(() => { state.searchText = (rawSearchText || '').trim() // defer to avoid input delays, plus we can trim here state.activeSearchItem = -1 }) }) function onSearchKeydown (event) { if (!state.searchMode || !state.currentEmojis.length) { return } const goToNextOrPrevious = (previous) => { halt(event) state.activeSearchItem = incrementOrDecrement(previous, state.activeSearchItem, state.currentEmojis) } switch (event.key) { case 'ArrowDown': return goToNextOrPrevious(false) case 'ArrowUp': return goToNextOrPrevious(true) case 'Enter': if (state.activeSearchItem === -1) { // focus the first option in the list since the list must be non-empty at this point (it's verified above) state.activeSearchItem = 0 } else { // there is already an active search item halt(event) return clickEmoji(state.currentEmojis[state.activeSearchItem].id) } } } // // Handle user input on nav // function onNavClick (event) { const { target } = event const closestTarget = target.closest('.nav-button') /* istanbul ignore if */ if (!closestTarget) { return // This should never happen, but makes me nervous not to have it } const groupId = parseInt(closestTarget.dataset.groupId, 10) refs.searchElement.value = '' // clear search box input state.rawSearchText = '' state.searchText = '' state.activeSearchItem = -1 state.currentGroupIndex = state.groups.findIndex(_ => _.id === groupId) } function onNavKeydown (event) { const { target, key } = event const doFocus = el => { if (el) { halt(event) el.focus() } } switch (key) { case 'ArrowLeft': return doFocus(target.previousElementSibling) case 'ArrowRight': return doFocus(target.nextElementSibling) case 'Home': return doFocus(target.parentElement.firstElementChild) case 'End': return doFocus(target.parentElement.lastElementChild) } } // // Handle user input on an emoji // async function clickEmoji (unicodeOrName) { const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName) const emojiSummary = [...state.currentEmojis, ...state.currentFavorites] .find(_ => (_.id === unicodeOrName)) const skinTonedUnicode = emojiSummary.unicode && unicodeWithSkin(emojiSummary, state.currentSkinTone) await state.database.incrementFavoriteEmojiCount(unicodeOrName) fireEvent('emoji-click', { emoji, skinTone: state.currentSkinTone, ...(skinTonedUnicode && { unicode: skinTonedUnicode }), ...(emojiSummary.name && { name: emojiSummary.name }) }) } async function onEmojiClick (event) { const { target } = event /* istanbul ignore if */ if (!target.classList.contains('emoji')) { // This should never happen, but makes me nervous not to have it return } halt(event) const id = target.id.substring(4) // replace 'emo-' or 'fav-' prefix /* no await */ clickEmoji(id) } // // Handle user input on the skintone picker // function changeSkinTone (skinTone) { state.currentSkinTone = skinTone state.skinTonePickerExpanded = false focus('skintone-button') fireEvent('skin-tone-change', { skinTone }) /* no await */ state.database.setPreferredSkinTone(skinTone) } function onSkinToneOptionsClick (event) { const { target: { id } } = event const match = id && id.match(/^skintone-(\d)/) // skintone option format /* istanbul ignore if */ if (!match) { // not a skintone option return // This should never happen, but makes me nervous not to have it } halt(event) const skinTone = parseInt(match[1], 10) // remove 'skintone-' prefix changeSkinTone(skinTone) } function onClickSkinToneButton (event) { state.skinTonePickerExpanded = !state.skinTonePickerExpanded state.activeSkinTone = state.currentSkinTone // this should always be true, since the button is obscured by the listbox, so this `if` is just to be sure if (state.skinTonePickerExpanded) { halt(event) requestAnimationFrame(() => focus('skintone-list')) } } // To make the animation nicer, change the z-index of the skintone picker button // *after* the animation has played. This makes it appear that the picker box // is expanding "below" the button createEffect(() => { if (state.skinTonePickerExpanded) { refs.skinToneDropdown.addEventListener('transitionend', () => { state.skinTonePickerExpandedAfterAnimation = true // eslint-disable-line no-unused-vars }, { once: true }) } else { state.skinTonePickerExpandedAfterAnimation = false // eslint-disable-line no-unused-vars } }) function onSkinToneOptionsKeydown (event) { // this should never happen, but makes me nervous not to have it /* istanbul ignore if */ if (!state.skinTonePickerExpanded) { return } const changeActiveSkinTone = async nextSkinTone => { halt(event) state.activeSkinTone = nextSkinTone } switch (event.key) { case 'ArrowUp': return changeActiveSkinTone(incrementOrDecrement(true, state.activeSkinTone, state.skinTones)) case 'ArrowDown': return changeActiveSkinTone(incrementOrDecrement(false, state.activeSkinTone, state.skinTones)) case 'Home': return changeActiveSkinTone(0) case 'End': return changeActiveSkinTone(state.skinTones.length - 1) case 'Enter': // enter on keydown, space on keyup. this is just how browsers work for buttons // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html halt(event) return changeSkinTone(state.activeSkinTone) case 'Escape': halt(event) state.skinTonePickerExpanded = false return focus('skintone-button') } } function onSkinToneOptionsKeyup (event) { // this should never happen, but makes me nervous not to have it /* istanbul ignore if */ if (!state.skinTonePickerExpanded) { return } switch (event.key) { case ' ': // enter on keydown, space on keyup. this is just how browsers work for buttons // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html halt(event) return changeSkinTone(state.activeSkinTone) } } async function onSkinToneOptionsFocusOut (event) { // On blur outside of the skintone listbox, collapse the skintone picker. const { relatedTarget } = event // The `else` should never happen, but makes me nervous not to have it /* istanbul ignore else */ if (!relatedTarget || relatedTarget.id !== 'skintone-list') { state.skinTonePickerExpanded = false } } function onSearchInput (event) { state.rawSearchText = event.target.value } return { $set (newState) { assign(state, newState) }, $destroy () { abortController.abort() console.log('Component destroyed') } } }