diff --git a/files/assets/js/emoji_modal.js b/files/assets/js/emoji_modal.js index 6bb4805b8..3639ee4f7 100644 --- a/files/assets/js/emoji_modal.js +++ b/files/assets/js/emoji_modal.js @@ -26,419 +26,419 @@ */ class EmojiEngine { - _res; - /** @type {Promise} */ - loaded = new Promise(res => this._res = res); - hasLoaded = false; + _res; + /** @type {Promise} */ + loaded = new Promise(res => this._res = res); + hasLoaded = false; - /** @type {EmojiTags} */ - tags = {}; + /** @type {EmojiTags} */ + tags = {}; - /** @type {EmojiKinds} */ - kinds = {}; + /** @type {EmojiKinds} */ + kinds = {}; - // Memoize this value so we don't have to recompute it. - _tag_entries; + // Memoize this value so we don't have to recompute it. + _tag_entries; - /** @type {{[index: string]: HTMLDivElement}} */ - emojiDom = {}; + /** @type {{[index: string]: HTMLDivElement}} */ + emojiDom = {}; - /** @type {{[index: string]: number}} */ - emojiNameCount = {}; + /** @type {{[index: string]: number}} */ + emojiNameCount = {}; - /** @type {(name: string) => void} */ - onInsert; + /** @type {(name: string) => void} */ + onInsert; - init = async () => { - if (this.hasLoaded) { - return; - } + init = async () => { + if (this.hasLoaded) { + return; + } - await Promise.all([ - this.loadTags(), - this.loadKinds(), - ]); + await Promise.all([ + this.loadTags(), + this.loadKinds(), + ]); - this._tag_entries = Object.entries(this.tags); + this._tag_entries = Object.entries(this.tags); - this._res(); - this.hasLoaded = true; - } + this._res(); + this.hasLoaded = true; + } - loadTags = async () => { - this.tags = await (await fetch('/emoji_tags.json')).json(); - } + loadTags = async () => { + this.tags = await (await fetch('/emoji_tags.json')).json(); + } - loadKinds = async () => { - this.kinds = await (await fetch('/emoji_kinds.json')).json(); - } + loadKinds = async () => { + this.kinds = await (await fetch('/emoji_kinds.json')).json(); + } - search = async (query, maxLength = Infinity) => { - await this.loaded; + search = async (query, maxLength = Infinity) => { + await this.loaded; - const resultsSet = new Set(); - const results = []; - for (const [tag, entries] of this._tag_entries) { - if (!tag.includes(query)) { - continue; - } + const resultsSet = new Set(); + const results = []; + for (const [tag, entries] of this._tag_entries) { + if (!tag.includes(query)) { + continue; + } - for (const [name, count] of entries) { - if (resultsSet.has(name)) { - continue; - } else if (count < results[maxLength - 1]?.[1]) { - // All the other emojis in this tag have less uses. We can stop here. - break; - } + for (const [name, count] of entries) { + if (resultsSet.has(name)) { + continue; + } else if (count < results[maxLength - 1]?.[1]) { + // All the other emojis in this tag have less uses. We can stop here. + break; + } - resultsSet.add(name); - // Insert into the array sorted. - let i = results.length; - while (i > 0 && count > results[i - 1][1]) { - i--; - } - results.splice(i, 0, [name, count]); - if (results.length >= maxLength) { - const [name] = results.pop(); - resultsSet.delete(name); - } - } - } + resultsSet.add(name); + // Insert into the array sorted. + let i = results.length; + while (i > 0 && count > results[i - 1][1]) { + i--; + } + results.splice(i, 0, [name, count]); + if (results.length >= maxLength) { + const [name] = results.pop(); + resultsSet.delete(name); + } + } + } - return results.map(([name]) => name); - } + return results.map(([name]) => name); + } - /** - * Get a dom element for a list of emojis in quick dropdown. - * @param {string[]} emojiNames - */ - getQuickDoms = (emojiNames) => { - return emojiNames.map(this.getQuickDom); - } + /** + * Get a dom element for a list of emojis in quick dropdown. + * @param {string[]} emojiNames + */ + getQuickDoms = (emojiNames) => { + return emojiNames.map(this.getQuickDom); + } - /** - * - * @param {*} emojiName - * @returns DOM element for an emoji quick dropdown. - */ - getQuickDom = (emojiName) => { - if (this.emojiDom[emojiName]) { - return this.emojiDom[emojiName]; - } + /** + * + * @param {*} emojiName + * @returns DOM element for an emoji quick dropdown. + */ + getQuickDom = (emojiName) => { + if (this.emojiDom[emojiName]) { + return this.emojiDom[emojiName]; + } - const emojiEl = document.createElement('button'); - emojiEl.classList.add('speed-modal-option', 'emoji-option'); - emojiEl.addEventListener('click', (e) => { - this.onInsert(emojiName); - }); + const emojiEl = document.createElement('button'); + emojiEl.classList.add('speed-modal-option', 'emoji-option'); + emojiEl.addEventListener('click', (e) => { + this.onInsert(emojiName); + }); - const emojiImgEl = document.createElement('img'); - emojiImgEl.classList.add('speed-modal-image', 'emoji-option-image'); - emojiImgEl.src = emojiEngine.src(emojiName); - emojiEl.appendChild(emojiImgEl); + const emojiImgEl = document.createElement('img'); + emojiImgEl.classList.add('speed-modal-image', 'emoji-option-image'); + emojiImgEl.src = emojiEngine.src(emojiName); + emojiEl.appendChild(emojiImgEl); - const emojiNameEl = document.createElement('span'); - emojiNameEl.textContent = emojiName; - emojiEl.appendChild(emojiNameEl); + const emojiNameEl = document.createElement('span'); + emojiNameEl.textContent = emojiName; + emojiEl.appendChild(emojiNameEl); - this.emojiDom[emojiName] = emojiEl; - return emojiEl; - } + this.emojiDom[emojiName] = emojiEl; + return emojiEl; + } - src = (name) => { - return `${SITE_FULL_IMAGES}/e/${name}.webp` - } + src = (name) => { + return `${SITE_FULL_IMAGES}/e/${name}.webp` + } } const emojiEngine = new EmojiEngine(); // Quick emoji dropdown & emoji insertion { - const emojiDropdownEl = document.createElement('div'); - emojiDropdownEl.classList.add('speed-carot-modal'); - /** @type {null | HTMLTextAreaElement} */ - let inputEl = null; - let visible = false; - let typingEmojiCanceled = false; - let firstDomEl = null; - let firstEmojiName = null; - let caretPos = 0; + const emojiDropdownEl = document.createElement('div'); + emojiDropdownEl.classList.add('speed-carot-modal'); + /** @type {null | HTMLTextAreaElement} */ + let inputEl = null; + let visible = false; + let typingEmojiCanceled = false; + let firstDomEl = null; + let firstEmojiName = null; + let caretPos = 0; - // Used by onclick attrib of the smile button - window.openEmojiModal = (id) => { - inputEl = document.getElementById(id); - initEmojiModal(); - } + // Used by onclick attrib of the smile button + window.openEmojiModal = (id) => { + inputEl = document.getElementById(id); + initEmojiModal(); + } - emojiEngine.onInsert = (name) => { - if (!inputEl) { - return; - } - const match = matchTypingEmoji(); - if (match) { - // We are inserting an emoji which we are typing. - inputEl.value = `${inputEl.value.slice(0, match.index)}:${name}:${inputEl.value.slice(match.index + name.length)} `; - // Draw the focus back to this element. - inputEl.focus(); - } else { - // We are inserting a new emoji. - const start = inputEl.value.slice(0, caretPos); - const end = inputEl.value.slice(caretPos); - const insert = `:${name}:${end.length === 0 ? ' ' : ''}`; - inputEl.value = `${start}${insert}${end}`; - caretPos += insert.length; - inputEl.setSelectionRange(caretPos, caretPos); - } + emojiEngine.onInsert = (name) => { + if (!inputEl) { + return; + } + const match = matchTypingEmoji(); + if (match) { + // We are inserting an emoji which we are typing. + inputEl.value = `${inputEl.value.slice(0, match.index)}:${name}:${inputEl.value.slice(match.index + name.length)} `; + // Draw the focus back to this element. + inputEl.focus(); + } else { + // We are inserting a new emoji. + const start = inputEl.value.slice(0, caretPos); + const end = inputEl.value.slice(caretPos); + const insert = `:${name}:${end.length === 0 ? ' ' : ''}`; + inputEl.value = `${start}${insert}${end}`; + caretPos += insert.length; + inputEl.setSelectionRange(caretPos, caretPos); + } - typingEmojiCanceled = false; - update(); + typingEmojiCanceled = false; + update(); - // This updates the preview. - inputEl.dispatchEvent(new Event('input', { bubbles: true })); + // This updates the preview. + inputEl.dispatchEvent(new Event('input', { bubbles: true })); - // Update the favorite count. - if (name in favoriteEmojis) { - favoriteEmojis[name]++; - } else { - favoriteEmojis[name] = 1; - } - localStorage.setItem("favorite_emojis", JSON.stringify(favoriteEmojis)); - } + // Update the favorite count. + if (name in favoriteEmojis) { + favoriteEmojis[name]++; + } else { + favoriteEmojis[name] = 1; + } + localStorage.setItem("favorite_emojis", JSON.stringify(favoriteEmojis)); + } - const inputCanTakeEmojis = (el = inputEl) => { - return el?.dataset && 'emojis' in el.dataset; - } + const inputCanTakeEmojis = (el = inputEl) => { + return el?.dataset && 'emojis' in el.dataset; + } - const matchTypingEmoji = () => { - return inputEl?.value.substring(0, inputEl.selectionEnd).match(/:([\w!#]+)$/); - } + const matchTypingEmoji = () => { + return inputEl?.value.substring(0, inputEl.selectionEnd).match(/:([\w!#]+)$/); + } - const getTypingEmoji = () => { - return matchTypingEmoji()?.[1] ?? null; - } + const getTypingEmoji = () => { + return matchTypingEmoji()?.[1] ?? null; + } - const isTypingEmoji = () => { - return inputCanTakeEmojis() && getTypingEmoji(); - } + const isTypingEmoji = () => { + return inputCanTakeEmojis() && getTypingEmoji(); + } - const endTypingEmoji = () => { - typingEmojiCanceled = false; - } + const endTypingEmoji = () => { + typingEmojiCanceled = false; + } - const update = async () => { - const typing = isTypingEmoji(); - visible = typing && !typingEmojiCanceled; - if (!visible) { - emojiDropdownEl.parentElement?.removeChild(emojiDropdownEl); - return; - } - const oldFirst = firstDomEl; - document.body.appendChild(emojiDropdownEl); - const search = await emojiEngine.search(getTypingEmoji(), 15); - firstEmojiName = search[0]; - const domEls = emojiEngine.getQuickDoms(search); - firstDomEl = domEls[0]; - if (oldFirst !== firstDomEl) { - oldFirst?.classList.remove('selected'); - firstDomEl.classList.add('selected'); - } - emojiDropdownEl.replaceChildren(...domEls); - const { left, bottom } = getCaretPos(inputEl); - // Using transform instead of top/left is faster. - emojiDropdownEl.style.transform = `translate(${left}px, ${bottom}px)`; - } + const update = async () => { + const typing = isTypingEmoji(); + visible = typing && !typingEmojiCanceled; + if (!visible) { + emojiDropdownEl.parentElement?.removeChild(emojiDropdownEl); + return; + } + const oldFirst = firstDomEl; + document.body.appendChild(emojiDropdownEl); + const search = await emojiEngine.search(getTypingEmoji(), 15); + firstEmojiName = search[0]; + const domEls = emojiEngine.getQuickDoms(search); + firstDomEl = domEls[0]; + if (oldFirst !== firstDomEl) { + oldFirst?.classList.remove('selected'); + firstDomEl.classList.add('selected'); + } + emojiDropdownEl.replaceChildren(...domEls); + const { left, bottom } = getCaretPos(inputEl); + // Using transform instead of top/left is faster. + emojiDropdownEl.style.transform = `translate(${left}px, ${bottom}px)`; + } - // Add a listener when we start typing. - /** - * @param {FocusEvent} e - */ - const onKeyStart = (e) => { - if (inputCanTakeEmojis(e.target)) { - inputEl = e.target; - emojiEngine.init(); - window.removeEventListener('keydown', onKeyStart); - } - } + // Add a listener when we start typing. + /** + * @param {FocusEvent} e + */ + const onKeyStart = (e) => { + if (inputCanTakeEmojis(e.target)) { + inputEl = e.target; + emojiEngine.init(); + window.removeEventListener('keydown', onKeyStart); + } + } - window.addEventListener('keydown', onKeyStart); - window.addEventListener('keydown', (e) => { - if (!visible) { - return; - } - const isFocused = document.activeElement === inputEl - if (e.key === 'Escape') { - typingEmojiCanceled = true; - update(); - } else if (e.key === 'Enter' && isFocused) { - emojiEngine.onInsert(firstEmojiName); - e.preventDefault(); - } else if (e.key === 'Tab' && isFocused) { - firstDomEl.focus(); - firstDomEl.classList.remove("selected"); - e.preventDefault(); - } - }); + window.addEventListener('keydown', onKeyStart); + window.addEventListener('keydown', (e) => { + if (!visible) { + return; + } + const isFocused = document.activeElement === inputEl + if (e.key === 'Escape') { + typingEmojiCanceled = true; + update(); + } else if (e.key === 'Enter' && isFocused) { + emojiEngine.onInsert(firstEmojiName); + e.preventDefault(); + } else if (e.key === 'Tab' && isFocused) { + firstDomEl.focus(); + firstDomEl.classList.remove("selected"); + e.preventDefault(); + } + }); - ['input', 'click', 'focus'].forEach((event) => { - window.addEventListener(event, (e) => { - if (inputCanTakeEmojis(e.target)) { - inputEl = e.target; - caretPos = inputEl.selectionEnd; - } - update(); - if (!isTypingEmoji()) { - endTypingEmoji(); - } - }); - }); + ['input', 'click', 'focus'].forEach((event) => { + window.addEventListener(event, (e) => { + if (inputCanTakeEmojis(e.target)) { + inputEl = e.target; + caretPos = inputEl.selectionEnd; + } + update(); + if (!isTypingEmoji()) { + endTypingEmoji(); + } + }); + }); } /** @type {{ [name: string]: number }} */ const favoriteEmojis = JSON.parse(localStorage.getItem("favorite_emojis")) || {}; const initEmojiModal = (() => { - let hasInit = false; - - return async () => { - if (hasInit) { - return; - } - hasInit = true; + let hasInit = false; + + return async () => { + if (hasInit) { + return; + } + hasInit = true; - await emojiEngine.init(); + await emojiEngine.init(); - document.getElementById('emojis-work').style.display = 'none'; + document.getElementById('emojis-work').style.display = 'none'; - /** @type {{ [tabName: string]: HTMLDivElement }} */ - const tabContentEls = {} + /** @type {{ [tabName: string]: HTMLDivElement }} */ + const tabContentEls = {} - /** @type {(kind: string, el: HTMLButtonElement) => void} */ - const addTabClickListener = (kind, el) => { - el.addEventListener('click', (e) => { - setTab(kind); - }); - } + /** @type {(kind: string, el: HTMLButtonElement) => void} */ + const addTabClickListener = (kind, el) => { + el.addEventListener('click', (e) => { + setTab(kind); + }); + } - const favorites = Object.entries(favoriteEmojis).sort((a, b) => b[1] - a[1]); - /** @type {{ [name: string]: HTMLButtonElement }} */ - const favoriteClones = {}; + const favorites = Object.entries(favoriteEmojis).sort((a, b) => b[1] - a[1]); + /** @type {{ [name: string]: HTMLButtonElement }} */ + const favoriteClones = {}; - const favoriteContentEl = (() => { - const content = document.createElement('div'); - tabContentEls['favorite'] = content; - return content; - })(); + const favoriteContentEl = (() => { + const content = document.createElement('div'); + tabContentEls['favorite'] = content; + return content; + })(); - let currentTab = 'favorite'; - const setTab = (kind) => { - currentTab = kind; - tabContent.replaceChildren(tabContentEls[kind]); - } + let currentTab = 'favorite'; + const setTab = (kind) => { + currentTab = kind; + tabContent.replaceChildren(tabContentEls[kind]); + } - const emojiModal = document.getElementById('emojiModal'); - const emojiTabsEl = document.getElementById('emoji-modal-tabs'); - const tabContent = document.getElementById('emoji-tab-content'); - /** @type {HTMLInputElement} */ - const searchInputEl = document.getElementById('emoji_search'); - searchInputEl.disabled = false; - const searchResultsContainerEl = document.createElement('div'); - let isSearching = false; - /** @type {{ [index: string ]: HTMLButtonElement }} */ - const searchResultsEl = {}; - const favoriteTabEl = document.getElementById('emoji-modal-tabs-favorite'); - addTabClickListener('favorite', favoriteTabEl); + const emojiModal = document.getElementById('emojiModal'); + const emojiTabsEl = document.getElementById('emoji-modal-tabs'); + const tabContent = document.getElementById('emoji-tab-content'); + /** @type {HTMLInputElement} */ + const searchInputEl = document.getElementById('emoji_search'); + searchInputEl.disabled = false; + const searchResultsContainerEl = document.createElement('div'); + let isSearching = false; + /** @type {{ [index: string ]: HTMLButtonElement }} */ + const searchResultsEl = {}; + const favoriteTabEl = document.getElementById('emoji-modal-tabs-favorite'); + addTabClickListener('favorite', favoriteTabEl); - window.emojiSearch = async () => { - if (searchInputEl.value.length === 0 && isSearching) { - isSearching = false; - setTab(currentTab); - } else if (searchInputEl.value.length > 0 && !isSearching) { - isSearching = true; - tabContent.replaceChildren(searchResultsContainerEl); - } + window.emojiSearch = async () => { + if (searchInputEl.value.length === 0 && isSearching) { + isSearching = false; + setTab(currentTab); + } else if (searchInputEl.value.length > 0 && !isSearching) { + isSearching = true; + tabContent.replaceChildren(searchResultsContainerEl); + } - if (isSearching) { - const query = searchInputEl.value; - requestIdleCallback(() => { - emojiEngine.search(query).then((results) => { - requestIdleCallback(() => { - searchResultsContainerEl.replaceChildren(...results.map((name) => searchResultsEl[name])); - }, { timeout: 100 }); - }); - }, { timeout: 100 }); - } - } + if (isSearching) { + const query = searchInputEl.value; + requestIdleCallback(() => { + emojiEngine.search(query).then((results) => { + requestIdleCallback(() => { + searchResultsContainerEl.replaceChildren(...results.map((name) => searchResultsEl[name])); + }, { timeout: 100 }); + }); + }, { timeout: 100 }); + } + } - const promises = Object.entries(emojiEngine.kinds).map(([kind, emojis]) => new Promise((res) => { - const tabEl = (() => { - const tab = document.createElement('li'); - const button = document.createElement('button'); - button.type = 'button'; - button.classList.add('nav-link', 'emojitab'); - button.dataset.bsToggle = 'tab'; - button.textContent = kind; - tab.appendChild(button); - emojiTabsEl.appendChild(tab); - addTabClickListener(kind, tab); - return tab; - })(); + const promises = Object.entries(emojiEngine.kinds).map(([kind, emojis]) => new Promise((res) => { + const tabEl = (() => { + const tab = document.createElement('li'); + const button = document.createElement('button'); + button.type = 'button'; + button.classList.add('nav-link', 'emojitab'); + button.dataset.bsToggle = 'tab'; + button.textContent = kind; + tab.appendChild(button); + emojiTabsEl.appendChild(tab); + addTabClickListener(kind, tab); + return tab; + })(); - const tabContentEl = (() => { - const tabContent = document.createElement('div'); - return tabContent; - })(); + const tabContentEl = (() => { + const tabContent = document.createElement('div'); + return tabContent; + })(); - tabContentEls[kind] = tabContentEl; + tabContentEls[kind] = tabContentEl; - const tick = () => { - for (const [name, count] of emojis) { - const buttonEl = document.createElement('button'); - buttonEl.type = 'button'; - buttonEl.classList.add('btn', 'm-1', 'px-0', 'emoji2'); - buttonEl.title = `${name} (${count})`; + const tick = () => { + for (const [name, count] of emojis) { + const buttonEl = document.createElement('button'); + buttonEl.type = 'button'; + buttonEl.classList.add('btn', 'm-1', 'px-0', 'emoji2'); + buttonEl.title = `${name} (${count})`; - const imgEl = document.createElement('img'); - imgEl.loading = 'lazy'; - imgEl.src = emojiEngine.src(name); - imgEl.alt = name; - buttonEl.appendChild(imgEl); + const imgEl = document.createElement('img'); + imgEl.loading = 'lazy'; + imgEl.src = emojiEngine.src(name); + imgEl.alt = name; + buttonEl.appendChild(imgEl); - const searchClone = buttonEl.cloneNode(true); - const els = [buttonEl, searchClone]; + const searchClone = buttonEl.cloneNode(true); + const els = [buttonEl, searchClone]; - if (name in favoriteEmojis) { - const favoriteClone = buttonEl.cloneNode(true); - favoriteClone.title = `${name} (${favoriteEmojis[name]})`; - els.push(favoriteClone); - favoriteClones[name] = favoriteClone; - } - - els.forEach((el) => { - el.addEventListener('click', (e) => { - emojiEngine.onInsert(name); - }); - }); + if (name in favoriteEmojis) { + const favoriteClone = buttonEl.cloneNode(true); + favoriteClone.title = `${name} (${favoriteEmojis[name]})`; + els.push(favoriteClone); + favoriteClones[name] = favoriteClone; + } + + els.forEach((el) => { + el.addEventListener('click', (e) => { + emojiEngine.onInsert(name); + }); + }); - tabContentEl.appendChild(buttonEl); - searchResultsEl[name] = searchClone; - } + tabContentEl.appendChild(buttonEl); + searchResultsEl[name] = searchClone; + } - res(); + res(); - } - requestIdleCallback(tick, { timeout: 250 }); - })); + } + requestIdleCallback(tick, { timeout: 250 }); + })); - Promise.all(promises).then(() => { - for (const [name] of favorites) { - if (!(name in favoriteClones)) { - continue; - } - - favoriteContentEl.appendChild(favoriteClones[name]); - } - }); + Promise.all(promises).then(() => { + for (const [name] of favorites) { + if (!(name in favoriteClones)) { + continue; + } + + favoriteContentEl.appendChild(favoriteClones[name]); + } + }); - setTab(currentTab); - } + setTab(currentTab); + } })(); \ No newline at end of file