UNPKG

emoji-picker-element

Version:

Lightweight emoji picker distributed as a web component

1,364 lines (1,185 loc) β€’ 69.4 kB
import Database from './database.js'; // via https://unpkg.com/browse/emojibase-data@6.0.0/meta/groups.json const allGroups = [ [-1, '✨', 'custom'], [0, 'πŸ˜€', 'smileys-emotion'], [1, 'πŸ‘‹', 'people-body'], [3, '🐱', 'animals-nature'], [4, '🍎', 'food-drink'], [5, '🏠️', 'travel-places'], [6, '⚽', 'activities'], [7, 'πŸ“', 'objects'], [8, '⛔️', 'symbols'], [9, '🏁', 'flags'] ].map(([id, emoji, name]) => ({ id, emoji, name })); const groups = allGroups.slice(1); const MIN_SEARCH_TEXT_LENGTH = 2; const NUM_SKIN_TONES = 6; /* istanbul ignore next */ const rIC = typeof requestIdleCallback === 'function' ? requestIdleCallback : setTimeout; // check for ZWJ (zero width joiner) character function hasZwj (emoji) { return emoji.unicode.includes('\u200d') } // Find one good representative emoji from each version to test by checking its color. // Ideally it should have color in the center. For some inspiration, see: // https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/ // // Note that for certain versions (12.1, 13.1), there is no point in testing them explicitly, because // all the emoji from this version are compound-emoji from previous versions. So they would pass a color // test, even in browsers that display them as double emoji. (E.g. "face in clouds" might render as // "face without mouth" plus "fog".) These emoji can only be filtered using the width test, // which happens in checkZwjSupport.js. const versionsAndTestEmoji = { '🫩': 16, // face with bags under eyes '🫨': 15.1, // shaking head, technically from v15 but see note above '🫠': 14, 'πŸ₯²': 13.1, // smiling face with tear, technically from v13 but see note above 'πŸ₯»': 12.1, // sari, technically from v12 but see note above 'πŸ₯°': 11, '🀩': 5, 'πŸ‘±β€β™€οΈ': 4, '🀣': 3, 'πŸ‘οΈβ€πŸ—¨οΈ': 2, 'πŸ˜€': 1, '😐️': 0.7, 'πŸ˜ƒ': 0.6 }; const TIMEOUT_BEFORE_LOADING_MESSAGE = 1000; // 1 second const DEFAULT_SKIN_TONE_EMOJI = 'πŸ–οΈ'; const DEFAULT_NUM_COLUMNS = 8; // Based on https://fivethirtyeight.com/features/the-100-most-used-emojis/ and // https://blog.emojipedia.org/facebook-reveals-most-and-least-used-emojis/ with // a bit of my own curation. (E.g. avoid the "OK" gesture because of connotations: // https://emojipedia.org/ok-hand/) const MOST_COMMONLY_USED_EMOJI = [ '😊', 'πŸ˜’', '❀️', 'πŸ‘οΈ', '😍', 'πŸ˜‚', '😭', '☺️', 'πŸ˜”', '😩', '😏', 'πŸ’•', 'πŸ™Œ', '😘' ]; // It's important to list Twemoji Mozilla before everything else, because Mozilla bundles their // own font on some platforms (notably Windows and Linux as of this writing). Typically, Mozilla // updates faster than the underlying OS, and we don't want to render older emoji in one font and // newer emoji in another font: // https://github.com/nolanlawson/emoji-picker-element/pull/268#issuecomment-1073347283 const FONT_FAMILY = '"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' + '"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif'; /* istanbul ignore next */ const DEFAULT_CATEGORY_SORTING = (a, b) => a < b ? -1 : a > b ? 1 : 0; // Test if an emoji is supported by rendering it to canvas and checking that the color is not black // See https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/ // and https://www.npmjs.com/package/if-emoji for inspiration // This implementation is largely borrowed from if-emoji, adding the font-family const getTextFeature = (text, color) => { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1; const ctx = canvas.getContext('2d', { // Improves the performance of `getImageData()` // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently willReadFrequently: true }); ctx.textBaseline = 'top'; ctx.font = `100px ${FONT_FAMILY}`; ctx.fillStyle = color; ctx.scale(0.01, 0.01); ctx.fillText(text, 0, 0); return ctx.getImageData(0, 0, 1, 1).data }; const compareFeatures = (feature1, feature2) => { const feature1Str = [...feature1].join(','); const feature2Str = [...feature2].join(','); // This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes. // Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is // 0,0,0,61 - there is a transparency here. return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,') }; function testColorEmojiSupported (text) { // Render white and black and then compare them to each other and ensure they're the same // color, and neither one is black. This shows that the emoji was rendered in color. const feature1 = getTextFeature(text, '#000'); const feature2 = getTextFeature(text, '#fff'); return feature1 && feature2 && compareFeatures(feature1, feature2) } // rather than check every emoji ever, which would be expensive, just check some representatives from the // different emoji releases to determine what the font supports function determineEmojiSupportLevel () { const entries = Object.entries(versionsAndTestEmoji); try { // start with latest emoji and work backwards for (const [emoji, version] of entries) { if (testColorEmojiSupported(emoji)) { return version } } } catch (e) { // canvas error } finally { } // In case of an error, be generous and just assume all emoji are supported (e.g. for canvas errors // due to anti-fingerprinting add-ons). Better to show some gray boxes than nothing at all. return entries[0][1] // first one in the list is the most recent version } // Check which emojis we know for sure aren't supported, based on Unicode version level let promise; const detectEmojiSupportLevel = () => { if (!promise) { // Delay so it can run while the IDB database is being created by the browser (on another thread). // This helps especially with first load – we want to start pre-populating the database on the main thread, // and then wait for IDB to commit everything, and while waiting we run this check. promise = new Promise(resolve => ( rIC(() => ( resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating )) )); } return promise }; // determine which emojis containing ZWJ (zero width joiner) characters // are supported (rendered as one glyph) rather than unsupported (rendered as two or more glyphs) const supportedZwjEmojis = new Map(); const VARIATION_SELECTOR = '\ufe0f'; const SKINTONE_MODIFIER = '\ud83c'; const ZWJ = '\u200d'; const LIGHT_SKIN_TONE = 0x1F3FB; const LIGHT_SKIN_TONE_MODIFIER = 0xdffb; // TODO: this is a naive implementation, we can improve it later // It's only used for the skintone picker, so as long as people don't customize with // really exotic emoji then it should work fine function applySkinTone (str, skinTone) { if (skinTone === 0) { return str } const zwjIndex = str.indexOf(ZWJ); if (zwjIndex !== -1) { return str.substring(0, zwjIndex) + String.fromCodePoint(LIGHT_SKIN_TONE + skinTone - 1) + str.substring(zwjIndex) } if (str.endsWith(VARIATION_SELECTOR)) { str = str.substring(0, str.length - 1); } return str + SKINTONE_MODIFIER + String.fromCodePoint(LIGHT_SKIN_TONE_MODIFIER + skinTone - 1) } function halt (event) { event.preventDefault(); event.stopPropagation(); } // Implementation left/right or up/down navigation, circling back when you // reach the start/end of the list function incrementOrDecrement (decrement, val, arr) { val += (decrement ? -1 : 1); if (val < 0) { val = arr.length - 1; } else if (val >= arr.length) { val = 0; } return val } // like lodash's uniqBy but much smaller function uniqBy (arr, func) { const set = new Set(); const res = []; for (const item of arr) { const key = func(item); if (!set.has(key)) { set.add(key); res.push(item); } } return res } // We don't need all the data on every emoji, and there are specific things we need // for the UI, so build a "view model" from the emoji object we got from the database function summarizeEmojisForUI (emojis, emojiSupportLevel) { const toSimpleSkinsMap = skins => { const res = {}; for (const skin of skins) { // ignore arrays like [1, 2] with multiple skin tones // also ignore variants that are in an unsupported emoji version // (these do exist - variants from a different version than their base emoji) if (typeof skin.tone === 'number' && skin.version <= emojiSupportLevel) { res[skin.tone] = skin.unicode; } } return res }; return emojis.map(({ unicode, skins, shortcodes, url, name, category, annotation }) => ({ unicode, name, shortcodes, url, category, annotation, id: unicode || name, skins: skins && toSimpleSkinsMap(skins) })) } // import rAF from one place so that the bundle size is a bit smaller const rAF = requestAnimationFrame; // "Svelte action"-like utility to detect layout changes via ResizeObserver. // If ResizeObserver is unsupported, we just use rAF once and don't bother to update. let resizeObserverSupported = typeof ResizeObserver === 'function'; function resizeObserverAction (node, abortSignal, onUpdate) { let resizeObserver; if (resizeObserverSupported) { resizeObserver = new ResizeObserver(onUpdate); resizeObserver.observe(node); } else { // just run once, don't bother trying to track it rAF(onUpdate); } // cleanup function (called on destroy) abortSignal.addEventListener('abort', () => { if (resizeObserver) { resizeObserver.disconnect(); } }); } // get the width of the text inside of a DOM node, via https://stackoverflow.com/a/59525891/680742 function calculateTextWidth (node) { // skip running this in jest/vitest because we don't need to check for emoji support in that environment /* istanbul ignore else */ { const range = document.createRange(); range.selectNode(node.firstChild); return range.getBoundingClientRect().width } } let baselineEmojiWidth; /** * Check if the given emojis containing ZWJ characters are supported by the current browser (don't render * as double characters) and return true if all are supported. * @param zwjEmojisToCheck * @param baselineEmoji * @param emojiToDomNode */ function checkZwjSupport (zwjEmojisToCheck, baselineEmoji, emojiToDomNode) { let allSupported = true; for (const emoji of zwjEmojisToCheck) { const domNode = emojiToDomNode(emoji); // sanity check to make sure the node is defined properly /* istanbul ignore if */ if (!domNode) { // This is a race condition that can occur when the component is unmounted/remounted // It doesn't really matter what we do here since the old context is not going to render anymore. // Just bail out of emoji support detection and return `allSupported=true` since the rendering context is gone continue } const emojiWidth = calculateTextWidth(domNode); if (typeof baselineEmojiWidth === 'undefined') { // calculate the baseline emoji width only once baselineEmojiWidth = calculateTextWidth(baselineEmoji); } // On Windows, some supported emoji are ~50% bigger than the baseline emoji, but what we really want to guard // against are the ones that are 2x the size, because those are truly broken (person with red hair = person with // floating red wig, black cat = cat with black square, polar bear = bear with snowflake, etc.) // So here we set the threshold at 1.8 times the size of the baseline emoji. const supported = emojiWidth / 1.8 < baselineEmojiWidth; supportedZwjEmojis.set(emoji.unicode, supported); if (!supported) { allSupported = false; } } return allSupported } // like lodash's uniq function uniq (arr) { return uniqBy(arr, _ => _) } // Note we put this in its own function outside Picker.js to avoid Svelte doing an invalidation on the "setter" here. // At best the invalidation is useless, at worst it can cause infinite loops: // https://github.com/nolanlawson/emoji-picker-element/pull/180 // https://github.com/sveltejs/svelte/issues/6521 // Also note tabpanelElement can be null if the element is disconnected immediately after connected function resetScrollTopIfPossible (element) { /* istanbul ignore else */ if (element) { // Makes me nervous not to have this `if` guard element.scrollTop = 0; } } function getFromMap (cache, key, func) { let cached = cache.get(key); if (!cached) { cached = func(); cache.set(key, cached); } return cached } function toString (value) { return '' + value } function parseTemplate (htmlString) { const template = document.createElement('template'); template.innerHTML = htmlString; return template } const parseCache = new WeakMap(); const domInstancesCache = new WeakMap(); // This needs to be a symbol because it needs to be different from any possible output of a key function const unkeyedSymbol = Symbol('un-keyed'); // Not supported in Safari <=13 const hasReplaceChildren = 'replaceChildren' in Element.prototype; function replaceChildren (parentNode, newChildren) { /* istanbul ignore else */ if (hasReplaceChildren) { parentNode.replaceChildren(...newChildren); } else { // minimal polyfill for Element.prototype.replaceChildren parentNode.innerHTML = ''; parentNode.append(...newChildren); } } function doChildrenNeedRerender (parentNode, newChildren) { let oldChild = parentNode.firstChild; let oldChildrenCount = 0; // iterate using firstChild/nextSibling because browsers use a linked list under the hood while (oldChild) { const newChild = newChildren[oldChildrenCount]; // check if the old child and new child are the same if (newChild !== oldChild) { return true } oldChild = oldChild.nextSibling; oldChildrenCount++; } // if new children length is different from old, we must re-render return oldChildrenCount !== newChildren.length } function patchChildren (newChildren, instanceBinding) { const { targetNode } = instanceBinding; let { targetParentNode } = instanceBinding; let needsRerender = false; if (targetParentNode) { // already rendered once needsRerender = doChildrenNeedRerender(targetParentNode, newChildren); } else { // first render of list needsRerender = true; instanceBinding.targetNode = undefined; // placeholder node not needed anymore, free memory instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode; } // avoid re-rendering list if the dom nodes are exactly the same before and after if (needsRerender) { replaceChildren(targetParentNode, newChildren); } } function patch (expressions, instanceBindings) { for (const instanceBinding of instanceBindings) { const { targetNode, currentExpression, binding: { expressionIndex, attributeName, attributeValuePre, attributeValuePost } } = instanceBinding; const expression = expressions[expressionIndex]; if (currentExpression === expression) { // no need to update, same as before continue } instanceBinding.currentExpression = expression; if (attributeName) { // attribute replacement if (expression === null) { // null is treated as a special case by the framework - we don't render an attribute at all in this case targetNode.removeAttribute(attributeName); } else { // attribute value is not null; set a new attribute const newValue = attributeValuePre + toString(expression) + attributeValuePost; targetNode.setAttribute(attributeName, newValue); } } else { // text node / child element / children replacement let newNode; if (Array.isArray(expression)) { // array of DOM elements produced by tag template literals patchChildren(expression, instanceBinding); } else if (expression instanceof Element) { // html tag template returning a DOM element newNode = expression; targetNode.replaceWith(newNode); } else { // primitive - string, number, etc // nodeValue is faster than textContent supposedly https://www.youtube.com/watch?v=LY6y3HbDVmg // note we may be replacing the value in a placeholder text node targetNode.nodeValue = toString(expression); } if (newNode) { instanceBinding.targetNode = newNode; } } } } function parse (tokens) { let htmlString = ''; let withinTag = false; let withinAttribute = false; let elementIndexCounter = -1; // depth-first traversal order const elementsToBindings = new Map(); const elementIndexes = []; let skipTokenChars = 0; for (let i = 0, len = tokens.length; i < len; i++) { const token = tokens[i]; htmlString += token.slice(skipTokenChars); if (i === len - 1) { break // no need to process characters - no more expressions to be found } for (let j = 0; j < token.length; j++) { const char = token.charAt(j); switch (char) { case '<': { const nextChar = token.charAt(j + 1); if (nextChar === '/') { // closing tag // leaving an element elementIndexes.pop(); } else { // not a closing tag withinTag = true; elementIndexes.push(++elementIndexCounter); } break } case '>': { withinTag = false; withinAttribute = false; break } case '=': { withinAttribute = true; break } } } const elementIndex = elementIndexes[elementIndexes.length - 1]; const bindings = getFromMap(elementsToBindings, elementIndex, () => []); let attributeName; let attributeValuePre; let attributeValuePost; if (withinAttribute) { // I never use single-quotes for attribute values in HTML, so just support double-quotes or no-quotes const attributePreMatch = /(\S+)="?([^"=]*)$/.exec(token); attributeName = attributePreMatch[1]; attributeValuePre = attributePreMatch[2]; const attributePostMatch = /^([^">]*)("?)/.exec(tokens[i + 1]); attributeValuePost = attributePostMatch[1]; // Optimization: remove the attribute itself, so we don't create a default attribute which is either empty or just // the "pre" text, e.g. `<div foo>` or `<div foo="prefix">`. It will be replaced by the expression anyway. htmlString = htmlString.slice(0, -1 * attributePreMatch[0].length); skipTokenChars = attributePostMatch[0].length; } else { skipTokenChars = 0; } const binding = { attributeName, attributeValuePre, attributeValuePost, expressionIndex: i }; bindings.push(binding); if (!withinTag && !withinAttribute) { // Add a placeholder text node, so we can find it later. Note we only support one dynamic child text node htmlString += ' '; } } const template = parseTemplate(htmlString); return { template, elementsToBindings } } function applyBindings (bindings, element, instanceBindings) { for (let i = 0; i < bindings.length; i++) { const binding = bindings[i]; const targetNode = binding.attributeName ? element // attribute binding, just use the element itself : element.firstChild; // not an attribute binding, so has a placeholder text node const instanceBinding = { binding, targetNode, targetParentNode: undefined, currentExpression: undefined }; instanceBindings.push(instanceBinding); } } function traverseAndSetupBindings (rootElement, elementsToBindings) { const instanceBindings = []; let topLevelBindings; if (elementsToBindings.size === 1 && (topLevelBindings = elementsToBindings.get(0))) { // Optimization for the common case where there's only one element and one binding // Skip creating a TreeWalker entirely and just handle the root DOM element applyBindings(topLevelBindings, rootElement, instanceBindings); } else { // traverse dom const treeWalker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT); let element = rootElement; let elementIndex = -1; do { const bindings = elementsToBindings.get(++elementIndex); if (bindings) { applyBindings(bindings, element, instanceBindings); } } while ((element = treeWalker.nextNode())) } return instanceBindings } function parseHtml (tokens) { // All templates and bound expressions are unique per tokens array const { template, elementsToBindings } = getFromMap(parseCache, tokens, () => parse(tokens)); // When we parseHtml, we always return a fresh DOM instance ready to be updated const dom = template.cloneNode(true).content.firstElementChild; const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings); return function updateDomInstance (expressions) { patch(expressions, instanceBindings); return dom } } function createFramework (state) { const domInstances = getFromMap(domInstancesCache, state, () => new Map()); let domInstanceCacheKey = unkeyedSymbol; function html (tokens, ...expressions) { // Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes, // which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map(). const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map()); const updateDomInstance = getFromMap(domInstancesForTokens, domInstanceCacheKey, () => parseHtml(tokens)); return updateDomInstance(expressions) // update with expressions } function map (array, callback, keyFunction) { return array.map((item, index) => { const originalCacheKey = domInstanceCacheKey; domInstanceCacheKey = keyFunction(item); try { return callback(item, index) } finally { domInstanceCacheKey = originalCacheKey; } }) } return { map, html } } function render (container, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender) { const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers; const { html, map } = createFramework(state); function emojiList (emojis, searchMode, prefix) { return map(emojis, (emoji, i) => { return html`<button role="${searchMode ? 'option' : 'menuitem'}" aria-selected="${searchMode ? i === state.activeSearchItem : null}" aria-label="${labelWithSkin(emoji, state.currentSkinTone)}" title="${titleForEmoji(emoji)}" class="${ 'emoji' + (searchMode && i === state.activeSearchItem ? ' active' : '') + (emoji.unicode ? '' : ' custom-emoji') }" id="${`${prefix}-${emoji.id}`}" style="${emoji.unicode ? null : `--custom-emoji-background: url(${JSON.stringify(emoji.url)})`}">${ emoji.unicode ? unicodeWithSkin(emoji, state.currentSkinTone) : '' }</button>` // It's important for the cache key to be unique based on the prefix, because the framework caches based on the // unique tokens + cache key, and the same emoji may be used in the tab as well as in the fav bar }, emoji => `${prefix}-${emoji.id}`) } const section = () => { return html`<section data-ref="rootElement" class="picker" aria-label="${state.i18n.regionLabel}" style="${state.pickerStyle || ''}"><div class="pad-top"></div><div class="search-row"><div class="search-wrapper"><input id="search" class="search" type="search" role="combobox" enterkeyhint="search" placeholder="${state.i18n.searchLabel}" autocapitalize="none" autocomplete="off" spellcheck="true" aria-expanded="${!!(state.searchMode && state.currentEmojis.length)}" aria-controls="search-results" aria-describedby="search-description" aria-autocomplete="list" aria-activedescendant="${state.activeSearchItemId ? `emo-${state.activeSearchItemId}` : null}" data-ref="searchElement" data-on-input="onSearchInput" data-on-keydown="onSearchKeydown"><label class="sr-only" for="search">${state.i18n.searchLabel}</label> <span id="search-description" class="sr-only">${state.i18n.searchDescription}</span></div><div class="skintone-button-wrapper ${state.skinTonePickerExpandedAfterAnimation ? 'expanded' : ''}"><button id="skintone-button" class="emoji ${state.skinTonePickerExpanded ? 'hide-focus' : ''}" aria-label="${state.skinToneButtonLabel}" title="${state.skinToneButtonLabel}" aria-describedby="skintone-description" aria-haspopup="listbox" aria-expanded="${state.skinTonePickerExpanded}" aria-controls="skintone-list" data-on-click="onClickSkinToneButton">${state.skinToneButtonText || ''}</button></div><span id="skintone-description" class="sr-only">${state.i18n.skinToneDescription}</span><div data-ref="skinToneDropdown" id="skintone-list" class="skintone-list hide-focus ${state.skinTonePickerExpanded ? '' : 'hidden no-animate'}" style="transform:translateY(${state.skinTonePickerExpanded ? 0 : 'calc(-1 * var(--num-skintones) * var(--total-emoji-size))'})" role="listbox" aria-label="${state.i18n.skinTonesLabel}" aria-activedescendant="skintone-${state.activeSkinTone}" aria-hidden="${!state.skinTonePickerExpanded}" tabIndex="-1" data-on-focusout="onSkinToneOptionsFocusOut" data-on-click="onSkinToneOptionsClick" data-on-keydown="onSkinToneOptionsKeydown" data-on-keyup="onSkinToneOptionsKeyup">${ map(state.skinTones, (skinTone, i) => { return html`<div id="skintone-${i}" class="emoji ${i === state.activeSkinTone ? 'active' : ''}" aria-selected="${i === state.activeSkinTone}" role="option" title="${state.i18n.skinTones[i]}" aria-label="${state.i18n.skinTones[i]}">${skinTone}</div>` }, skinTone => skinTone) }</div></div><div class="nav" role="tablist" style="grid-template-columns:repeat(${state.groups.length},1fr)" aria-label="${state.i18n.categoriesLabel}" data-on-keydown="onNavKeydown" data-on-click="onNavClick">${ map(state.groups, (group) => { return html`<button role="tab" class="nav-button" aria-controls="tab-${group.id}" aria-label="${state.i18n.categories[group.name]}" aria-selected="${!state.searchMode && state.currentGroup.id === group.id}" title="${state.i18n.categories[group.name]}" data-group-id="${group.id}"><div class="nav-emoji emoji">${group.emoji}</div></button>` }, group => group.id) }</div><div class="indicator-wrapper"><div class="indicator" style="transform:translateX(${(/* istanbul ignore next */ (state.isRtl ? -1 : 1)) * state.currentGroupIndex * 100}%)"></div></div><div class="message ${state.message ? '' : 'gone'}" role="alert" aria-live="polite">${state.message || ''}</div><div data-ref="tabpanelElement" class="tabpanel ${(!state.databaseLoaded || state.message) ? 'gone' : ''}" role="${state.searchMode ? 'region' : 'tabpanel'}" aria-label="${state.searchMode ? state.i18n.searchResultsLabel : state.i18n.categories[state.currentGroup.name]}" id="${state.searchMode ? null : `tab-${state.currentGroup.id}`}" tabIndex="0" data-on-click="onEmojiClick"><div data-action="calculateEmojiGridStyle">${ map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { return html`<div><div id="menu-label-${i}" class="category ${state.currentEmojisWithCategories.length === 1 && state.currentEmojisWithCategories[0].category === '' ? 'gone' : ''}" aria-hidden="true">${ state.searchMode ? state.i18n.searchResultsLabel : ( emojiWithCategory.category ? emojiWithCategory.category : ( state.currentEmojisWithCategories.length > 1 ? state.i18n.categories.custom : state.i18n.categories[state.currentGroup.name] ) ) }</div><div class="emoji-menu ${i !== 0 && !state.searchMode && state.currentGroup.id === -1 ? 'visibility-auto' : ''}" style="${`--num-rows: ${Math.ceil(emojiWithCategory.emojis.length / state.numColumns)}`}" data-action="updateOnIntersection" role="${state.searchMode ? 'listbox' : 'menu'}" aria-labelledby="menu-label-${i}" id="${state.searchMode ? 'search-results' : null}">${ emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo') }</div></div>` }, emojiWithCategory => emojiWithCategory.category) }</div></div><div class="favorites onscreen emoji-menu ${state.message ? 'gone' : ''}" role="menu" aria-label="${state.i18n.favoritesLabel}" data-on-click="onEmojiClick">${ emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav') }</div><button data-ref="baselineEmoji" aria-hidden="true" tabindex="-1" class="abs-pos hidden emoji baseline-emoji">πŸ˜€</button></section>` }; const rootDom = section(); // helper for traversing the dom, finding elements by an attribute, and getting the attribute value const forElementWithAttribute = (attributeName, callback) => { for (const element of container.querySelectorAll(`[${attributeName}]`)) { callback(element, element.getAttribute(attributeName)); } }; if (firstRender) { // not a re-render container.appendChild(rootDom); // we only bind events/refs once - there is no need to find them again given this component structure // bind events for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) { forElementWithAttribute(`data-on-${eventName}`, (element, listenerName) => { element.addEventListener(eventName, events[listenerName]); }); } // find refs forElementWithAttribute('data-ref', (element, ref) => { refs[ref] = element; }); // destroy/abort logic abortSignal.addEventListener('abort', () => { container.removeChild(rootDom); }); } // set up actions - these are re-bound on every render forElementWithAttribute('data-action', (element, action) => { let boundActions = actionContext.get(action); if (!boundActions) { actionContext.set(action, (boundActions = new WeakSet())); } // avoid applying the same action to the same element multiple times if (!boundActions.has(element)) { boundActions.add(element); actions[action](element); } }); } /* istanbul ignore next */ const qM = typeof queueMicrotask === 'function' ? queueMicrotask : callback => Promise.resolve().then(callback); function createState (abortSignal) { let destroyed = false; let currentObserver; const propsToObservers = new Map(); const dirtyObservers = new Set(); let queued; const flush = () => { if (destroyed) { return } const observersToRun = [...dirtyObservers]; dirtyObservers.clear(); // clear before running to force any new updates to run in another tick of the loop try { for (const observer of observersToRun) { observer(); } } finally { queued = false; if (dirtyObservers.size) { // new updates, queue another one queued = true; qM(flush); } } }; const state = new Proxy({}, { get (target, prop) { if (currentObserver) { let observers = propsToObservers.get(prop); if (!observers) { observers = new Set(); propsToObservers.set(prop, observers); } observers.add(currentObserver); } return target[prop] }, set (target, prop, newValue) { if (target[prop] !== newValue) { target[prop] = newValue; const observers = propsToObservers.get(prop); if (observers) { for (const observer of observers) { dirtyObservers.add(observer); } if (!queued) { queued = true; qM(flush); } } } return true } }); const createEffect = (callback) => { const runnable = () => { const oldObserver = currentObserver; currentObserver = runnable; try { return callback() } finally { currentObserver = oldObserver; } }; return runnable() }; // destroy logic abortSignal.addEventListener('abort', () => { destroyed = true; }); return { state, createEffect } } // Compare two arrays, with a function called on each item in the two arrays that returns true if the items are equal function arraysAreEqualByFunction (left, right, areEqualFunc) { if (left.length !== right.length) { return false } for (let i = 0; i < left.length; i++) { if (!areEqualFunc(left[i], right[i])) { return false } } return true } const intersectionObserverCache = new WeakMap(); function intersectionObserverAction (node, abortSignal, listener) { /* istanbul ignore else */ { // The scroll root is always `.tabpanel` const root = node.closest('.tabpanel'); let observer = intersectionObserverCache.get(root); if (!observer) { // TODO: replace this with the contentvisibilityautostatechange event when all supported browsers support it. // For now we use IntersectionObserver because it has better cross-browser support, and it would be bad for // old Safari versions if they eagerly downloaded all custom emoji all at once. observer = new IntersectionObserver(listener, { root, // trigger if we are 1/2 scroll container height away so that the images load a bit quicker while scrolling rootMargin: '50% 0px 50% 0px', // trigger if any part of the emoji grid is intersecting threshold: 0 }); // avoid creating a new IntersectionObserver for every category; just use one for the whole root intersectionObserverCache.set(root, observer); // assume that the abortSignal is always the same for this root node; just add one event listener abortSignal.addEventListener('abort', () => { observer.disconnect(); }); } observer.observe(node); } } /* eslint-disable prefer-const,no-labels,no-inner-declarations */ // constants const EMPTY_ARRAY = []; const { assign } = Object; function createRoot (shadowRoot, props) { const refs = {}; const abortController = new AbortController(); const abortSignal = abortController.signal; const { state, createEffect } = createState(abortSignal); const actionContext = new Map(); // 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, currentGroupIndex: 0, groups: groups, 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, updateOnIntersection }; let firstRender = true; createEffect(() => { render(shadowRoot, state, helpers, events, actions, refs, abortSignal, actionContext, 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/Vitest/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) { updateCustomEmoji(); // re-run whenever customEmoji change } }); createEffect(() => { if (state.customEmoji && state.customEmoji.length) { if (state.groups !== allGroups) { // don't update unnecessarily state.groups = allGroups; } } else if (state.groups !== groups) { 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 = groups; } }); // // Set or update the preferred skin tone // createEffect(() => { async function updatePreferredSkinTone () { if (state.databaseLoaded) { state.currentSkinTone = await state.database.getPreferredSkinTone(); } } /* no await */ updatePreferredSkinTone(); }); createEffect(() => { state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i)); }); createEffect(() => { 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/Vitest 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. const { customEmoji, database } = state; const databaseCustomEmoji = customEmoji || EMPTY_ARRAY; if (database.customEmoji !== databaseCustomEmoji) { // Avoid setting this if the customEmoji have _not_ changed, because the setter triggers a re-computation of the // `customEmojiIndex`. Note we don't bother with deep object changes. database.customEmoji = databaseCustomEmoji; } } createEffect(() => { async function 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(); } }); // // Re-run whenever the emoji grid changes size, and re-calc style/layout-related state variables: // 1) Re-calculate the --num-columns var because it may have changed // 2) 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 layout-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) { resizeObserverAction(node, abortSignal, () => { /* istanbul ignore next */ { // 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'; // write to state variables state.numColumns = newNumColumns; state.isRtl = newIsRtl; } }); } // Re-run whenever the custom emoji in a category are shown/hidden. This is an optimization that simulates // what we'd get from `<img loading=lazy>` but without rendering an `<img>`. function updateOnIntersection (node) { intersectionObserverAction(node, abortSignal, (entries) => { for (const { target, isIntersecting } of entries) { target.classList.toggle('onscreen', isIntersecting); } }); } // // Set or update the currentEmojis. Check for invalid ZWJ renderings // (i.e. double emoji). // createEffect(() => { async function 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(); }); const resetScrollTopInRaf = () => { rAF(() => resetScrollTopIfPossible(refs.tabpanelElement)); }; // 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); rAF(() => checkZwjSupportAndUpdate(zwjEmojisToCheck)); } else { const newEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported); updateCurrentEmojis(newEmojis); // Reset scroll top to 0 when emojis change resetScrollTopInRaf(); } }); function checkZwjSupportAndUpdate (zwjEmojisToCheck) { const allSupported = checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode); if (allSupported) { // Even if all emoji are supported, we still need to reset the scroll top to 0 when emojis change resetScrollTopInRaf(); } else { // Force update. We only do this if there are any unsupported ZWJ characters since otherwise, // for browsers that support all emoji, it would be an unnecessary extra re-render. 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) { // -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(() => { }); // // 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(([cate