import { EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_TYPE_UNICODE, EMOJI_TYPE_CUSTOM, } from './constants'; import { emojiToInversionClassName, unicodeHexToUrl } from './normalize'; import type { EmojiAppState, EmojiLoadedState, EmojiMode, EmojiState, EmojiStateCustom, EmojiStateUnicode, ExtraCustomEmojiMap, } from './types'; import { anyEmojiRegex, emojiLogger, emojiToUnicodeHex, isCustomEmoji, isUnicodeEmoji, stringHasUnicodeFlags, } from './utils'; const log = emojiLogger('render'); type TokenizedText = (string | EmojiState)[]; /** * Tokenizes text into strings and emoji states. * @param text Text to tokenize. * @returns Array of strings and emoji states. */ export function tokenizeText(text: string): TokenizedText { if (!text.trim()) { return [text]; } const tokens = []; let lastIndex = 0; for (const match of text.matchAll(anyEmojiRegex())) { if (match.index > lastIndex) { tokens.push(text.slice(lastIndex, match.index)); } const code = match[0]; if (code.startsWith(':') && code.endsWith(':')) { // Custom emoji tokens.push({ code, type: EMOJI_TYPE_CUSTOM, } satisfies EmojiStateCustom); } else { // Unicode emoji tokens.push({ code, type: EMOJI_TYPE_UNICODE, } satisfies EmojiStateUnicode); } lastIndex = match.index + code.length; } if (lastIndex < text.length) { tokens.push(text.slice(lastIndex)); } return tokens; } /** * Parses emoji string to extract emoji state. * @param code Hex code or custom shortcode. * @param customEmoji Extra custom emojis. */ export function stringToEmojiState( code: string, customEmoji: ExtraCustomEmojiMap = {}, ): EmojiStateUnicode | Required | null { if (isUnicodeEmoji(code)) { return { code: emojiToUnicodeHex(code), type: EMOJI_TYPE_UNICODE, }; } if (isCustomEmoji(code)) { const shortCode = code.slice(1, -1); if (customEmoji[shortCode]) { return { type: EMOJI_TYPE_CUSTOM, code: shortCode, data: customEmoji[shortCode], }; } } return null; } /** * Takes an element and emojifies all native emoji. */ export async function updateHtmlWithEmoji({ assetHost, darkTheme, element, mode, locale, }: { element: Element; locale: string; } & Omit) { if (mode === EMOJI_MODE_NATIVE) { return; } const tokens = tokenizeText(element.innerHTML); const newChildren: (string | Element)[] = []; for (const token of tokens) { if (typeof token === 'string') { newChildren.push(token); continue; } const state = await loadEmojiDataToState(token, locale); // Ignore custom emoji if we encounter them. if (!state || state.type === EMOJI_TYPE_CUSTOM) { newChildren.push(token.code); continue; } if (!shouldRenderImage(state, mode)) { newChildren.push(state.data.unicode); continue; } const img = document.createElement('img'); img.src = unicodeHexToUrl({ assetHost, darkTheme, unicodeHex: state.data.hexcode, }); img.alt = state.data.unicode; img.title = state.data.label; img.classList.add('emojione'); const inversionClass = emojiToInversionClassName(state.data.unicode); if (inversionClass) { img.classList.add(inversionClass); } newChildren.push(img); } element.innerHTML = newChildren.reduce( (prev, curr) => typeof curr === 'string' ? prev + curr : prev + curr.outerHTML, '', ); } /** * Loads emoji data into the given state if not already loaded. * @param state Emoji state to load data for. * @param locale Locale to load data for. Only for Unicode emoji. * @param retry Internal. Whether this is a retry after loading the locale. */ export async function loadEmojiDataToState( state: EmojiState, locale: string, retry = false, ): Promise { if (isStateLoaded(state)) { return state; } // Don't try to load data for custom emoji. if (state.type === EMOJI_TYPE_CUSTOM) { return null; } const { loadLegacyShortcodesByShortcode, loadEmojiByHexcode, LocaleNotLoadedError, } = await import('./database'); const code = isUnicodeEmoji(state.code) ? emojiToUnicodeHex(state.code) : state.code; // First, try to load the data from IndexedDB. try { const legacyCode = await loadLegacyShortcodesByShortcode(code); // This is duplicative, but that's because TS can't distinguish the state type easily. const data = await loadEmojiByHexcode(legacyCode?.hexcode ?? code, locale); if (data) { return { ...state, code, type: EMOJI_TYPE_UNICODE, data, // TODO: Use CLDR shortcodes when the picker supports them. shortcode: legacyCode?.shortcodes.at(0), }; } // If not found, assume it's not an emoji and return null. log('Could not find emoji %s for locale %s', code, locale); return null; } catch (err: unknown) { // If the locale is not loaded, load it and retry once. if (!retry && err instanceof LocaleNotLoadedError) { log( 'Error loading emoji %s for locale %s, loading locale and retrying.', code, locale, ); const { importEmojiData } = await import('./loader'); await importEmojiData(locale); // Use this from the loader file as it can be awaited. return loadEmojiDataToState(state, locale, true); } console.warn('Error loading emoji data, not retrying:', state, locale, err); return null; } } export function isStateLoaded(state: EmojiState): state is EmojiLoadedState { return !!state.data; } /** * Determines if the given token should be rendered as an image based on the emoji mode. * @param state Emoji state to parse. * @param mode Rendering mode. * @returns Whether to render as an image. */ export function shouldRenderImage(state: EmojiState, mode: EmojiMode): boolean { if (state.type === EMOJI_TYPE_UNICODE) { // If the mode is native or native with flags for non-flag emoji // we can just append the text node directly. if ( mode === EMOJI_MODE_NATIVE || (mode === EMOJI_MODE_NATIVE_WITH_FLAGS && !stringHasUnicodeFlags(state.code)) ) { return false; } } return true; }