From 6fcedc13b11ec911ab7482183708e24e426daef9 Mon Sep 17 00:00:00 2001 From: Aevann Date: Fri, 27 Oct 2023 13:38:55 +0300 Subject: [PATCH] split off emoji js code into 3 files for easier maintainability --- files/assets/js/core.js | 6 + files/assets/js/emoji_modal.js | 620 ------------------ files/assets/js/emoji_modal/emoji_modal.js | 276 ++++++++ .../js/emoji_modal/search_dictionary.js | 90 +++ .../js/emoji_modal/speed_emoji_modal.js | 226 +++++++ files/templates/modals/emoji.html | 4 +- 6 files changed, 601 insertions(+), 621 deletions(-) delete mode 100644 files/assets/js/emoji_modal.js create mode 100644 files/assets/js/emoji_modal/emoji_modal.js create mode 100644 files/assets/js/emoji_modal/search_dictionary.js create mode 100644 files/assets/js/emoji_modal/speed_emoji_modal.js diff --git a/files/assets/js/core.js b/files/assets/js/core.js index 9b533b5c3..39f763a30 100644 --- a/files/assets/js/core.js +++ b/files/assets/js/core.js @@ -693,6 +693,12 @@ if (screen_width < 768) { } } +document.addEventListener('show.bs.modal', () => { + if (typeof close_inline_speed_emoji_modal === "function") { + close_inline_speed_emoji_modal(); + } +}); + document.addEventListener('hide.bs.modal', () => { if (typeof close_inline_speed_emoji_modal === "function") { close_inline_speed_emoji_modal(); diff --git a/files/assets/js/emoji_modal.js b/files/assets/js/emoji_modal.js deleted file mode 100644 index b6c1e4f83..000000000 --- a/files/assets/js/emoji_modal.js +++ /dev/null @@ -1,620 +0,0 @@ -/* -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -Copyright (C) 2022 Dr Steven Transmisia, anti-evil engineer, - 2022 Nekobit, king autist -*/ - -// Status -/** - * inactive - user has not tried using an emoji - * loading - user has tried to use an emoji, and the engine is initializing itself - * ready - engine can handle all emoji usage - * @type {"inactive"|"loading"|"ready"} - */ -let emojiEngineState = "inactive"; -let emojiSpeedEngineState = "inactive"; - -// DOM stuff -const classesSelectorDOM = document.getElementById("emoji-modal-tabs"); -const emojiButtonTemplateDOM = document.getElementById("emoji-button-template"); -const emojiResultsDOM = document.getElementById("tab-content"); - -const emojiSelectSuffixDOMs = document.getElementsByClassName("emoji-suffix"); -const emojiSelectPostfixDOMs= document.getElementsByClassName("emoji-postfix"); - -const emojiNotFoundDOM = document.getElementById("no-emojis-found"); -const emojiWorkingDOM = document.getElementById("emojis-work"); - -const emojiSearchBarDOM = document.getElementById('emoji_search'); - -let emojiInputTargetDOM = undefined; - -// Emojis usage stats. I don't really like this format but I'll keep it for backward comp. -const favorite_emojis = JSON.parse(localStorage.getItem("favorite_emojis")) || {}; - -/** Associative array of all the emojis' DOM */ -let emojiDOMs = {}; - -let globalEmojis; - -const EMOIJ_SEARCH_ENGINE_MIN_INTERVAL = 350; -let emojiSearcher = { - working: false, - queries: [], - - addQuery: function(query) - { - this.queries.push(query); - if (!this.working) - this.work(); - }, - - work: async function work() { - this.working = true; - - while(this.queries.length > 0) - { - const startTime = Date.now(); - - // Get last input - const query = this.queries[this.queries.length - 1].toLowerCase(); - this.queries = []; - - // To improve perf we avoid showing all emojis at the same time. - if (query === "") - { - await classesSelectorDOM.children[0].children[0].click(); - classesSelectorDOM.children[0].children[0].classList.add("active"); - continue; - } - - // Search - const resultSet = emojisSearchDictionary.completeSearch(query); - - // update stuff - for(const [emojiName, emojiDOM] of Object.entries(emojiDOMs)) - emojiDOM.hidden = !resultSet.has(emojiName); - - emojiNotFoundDOM.hidden = resultSet.size !== 0; - - let sleepTime = EMOIJ_SEARCH_ENGINE_MIN_INTERVAL - (Date.now() - startTime); - if (sleepTime > 0) - await new Promise(r => setTimeout(r, sleepTime)); - } - - this.working = false; - } -}; - -// tags dictionary. KEEP IT SORT -class EmoijsDictNode -{ - constructor(tag, name) { - this.tag = tag; - this.emojiNames = [name]; - } -} -const emojisSearchDictionary = { - dict: [], - - updateTag: function(tag, emojiName) { - if (tag === undefined || emojiName === undefined) - return; - - let low = 0; - let high = this.dict.length; - - while (low < high) { - let mid = (low + high) >>> 1; - if (this.dict[mid].tag < tag) - low = mid + 1; - else - high = mid; - } - - let target = low; - if (this.dict[target] !== undefined && this.dict[target].tag === tag) - this.dict[target].emojiNames.push(emojiName); - else - this.dict.splice(target ,0,new EmoijsDictNode(tag, emojiName)); - }, - - /** - * We also check for substrings! (sigh) - * @param {String} tag - * @returns {Set} - */ - completeSearch: function(query) { - query = query.toLowerCase() - const result = new Set(); - - for(let i = 0; i < this.dict.length; i++) - if (this.dict[i].tag.startsWith('@')) - { - if (this.dict[i].tag == query) - for(let j = 0; j < this.dict[i].emojiNames.length; j++) - result.add(this.dict[i].emojiNames[j]) - } - else if(this.dict[i].tag.includes(query)) - for(let j = 0; j < this.dict[i].emojiNames.length; j++) - result.add(this.dict[i].emojiNames[j]) - - return result; - } -}; - -function makeEmojisSearchDictionary() { - // get public emojis list - const headers = new Headers({xhr: "xhr"}) - const emoji_num = document.getElementById('emoji_num').value - return fetch(`/emojis.csv?x=${emoji_num}`, { - headers, - }) - .then(res => res.json()) - .then(emojis => { - globalEmojis = emojis.map(({name, author_username, count}) => ({name, author_username, count})); - - for (let i = 0; i < emojis.length; i++) - { - const emoji = emojis[i]; - - emojisSearchDictionary.updateTag(emoji.name, emoji.name); - - if (!emoji.author_username.endsWith(' user')) { - emojisSearchDictionary.updateTag(`@${emoji.author_username.toLowerCase()}`, emoji.name); - if (emoji.author_original_username != emoji.author_username) - emojisSearchDictionary.updateTag(`@${emoji.author_original_username.toLowerCase()}`, emoji.name); - if (emoji.author_prelock_username !== undefined) - emojisSearchDictionary.updateTag(`@${emoji.author_prelock_username.toLowerCase()}`, emoji.name); - } - - if (emoji.tags instanceof Array) - for(let i = 0; i < emoji.tags.length; i++) - emojisSearchDictionary.updateTag(emoji.tags[i], emoji.name); - } - - emojiSpeedEngineState = "ready"; - }) -} - - -// get public emojis list -function fetchEmojis() { - const headers = new Headers({xhr: "xhr"}) - const emoji_num = document.getElementById('emoji_num').value - return fetch(`/emojis.csv?x=${emoji_num}`, { - headers, - }) - .then(res => res.json()) - .then(emojis => { - if (!(emojis instanceof Array )) - throw new TypeError("[EMOJI DIALOG] rDrama's server should have sent a JSON-coded Array!"); - - let classes = ["Marsey", "Platy", "Wolf", "Donkey Kong", "Tay", "Capy", "Carp", "Marsey Flags", "Marsey Alphabet", "Classic", "Rage", "Wojak", "Misc"] - - const bussyDOM = document.createElement("div"); - - for(let i = 0; i < emojis.length; i++) - { - const emoji = emojis[i]; - - // Create emoji DOM - const emojiDOM = document.importNode(emojiButtonTemplateDOM.content, true).children[0]; - - emojiDOM.title = emoji.name - emojiDOM.title += "\nauthor\t" + emoji.author_username - if (emoji.count !== undefined) - emojiDOM.title += "\nused\t" + emoji.count; - emojiDOM.dataset.className = emoji.kind; - emojiDOM.dataset.emojiName = emoji.name; - emojiDOM.onclick = emojiAddToInput; - emojiDOM.hidden = true; - - const emojiIMGDOM = emojiDOM.children[0]; - emojiIMGDOM.src = `${SITE_FULL_IMAGES}/e/${emoji.name}.webp` - emojiIMGDOM.alt = emoji.name; - /** Disableing lazy loading seems to reduce cpu usage somehow (?) - * idk it is difficult to benchmark */ - emojiIMGDOM.loading = "lazy"; - - // Save reference - emojiDOMs[emoji.name] = emojiDOM; - - // Add to the document! - bussyDOM.appendChild(emojiDOM); - } - - // Create header - for(let className of classes) - { - let classSelectorDOM = document.createElement("li"); - classSelectorDOM.classList.add("nav-item"); - - let classSelectorLinkDOM = document.createElement("button"); - classSelectorLinkDOM.type = "button"; - classSelectorLinkDOM.classList.add("nav-link", "emojitab"); - classSelectorLinkDOM.dataset.bsToggle = "tab"; - classSelectorLinkDOM.dataset.className = className; - classSelectorLinkDOM.textContent = className; - classSelectorLinkDOM.addEventListener('click', switchEmojiTab); - - classSelectorDOM.appendChild(classSelectorLinkDOM); - classesSelectorDOM.appendChild(classSelectorDOM); - } - - // Show favorite for start. - classesSelectorDOM.children[0].children[0].click(); - - // Send it to the render machine! - emojiResultsDOM.appendChild(bussyDOM); - - emojiResultsDOM.hidden = false; - emojiWorkingDOM.hidden = true; - emojiSearchBarDOM.disabled = false; - - emojiEngineState = "ready"; - }) -} - -/** -* -* @param {Event} e -*/ -function switchEmojiTab(e) -{ - const className = e.currentTarget.dataset.className; - - emojiSearchBarDOM.value = ""; - focusSearchBar(emojiSearchBarDOM); - emojiNotFoundDOM.hidden = true; - - // Special case: favorites - if (className === "favorite") - { - for(const emojiDOM of Object.values(emojiDOMs)) - emojiDOM.hidden = true; - - const favs = Object.keys(Object.fromEntries( - Object.entries(favorite_emojis).sort(([,a],[,b]) => b-a) - )).slice(0, 25); - - for (const emoji of favs) - if (emojiDOMs[emoji] instanceof HTMLElement) - emojiDOMs[emoji].hidden = false; - - return; - } - - for(const emojiDOM of Object.values(emojiDOMs)) - emojiDOM.hidden = emojiDOM.dataset.className !== className; - - document.getElementById('emoji-container').scrollTop = 0; -} - -for (const emojitab of document.getElementsByClassName('emojitab')) { - emojitab.addEventListener('click', (e)=>{switchEmojiTab(e)}) -} - -async function start_search() { - emojiSearcher.addQuery(emojiSearchBarDOM.value.trim()); - - // Remove any selected tab, now it is meaningless - for(let i = 0; i < classesSelectorDOM.children.length; i++) - classesSelectorDOM.children[i].children[0].classList.remove("active"); -} - -/** -* Add the selected emoji to the targeted text area -* @param {Event} event -*/ -function emojiAddToInput(event) -{ - // This should not happen if used properly but whatever - if (!(emojiInputTargetDOM instanceof HTMLTextAreaElement) && !(emojiInputTargetDOM instanceof HTMLInputElement)) - return; - - let strToInsert = event.currentTarget.dataset.emojiName; - - for(let i = 0; i < emojiSelectPostfixDOMs.length; i++) - if (emojiSelectPostfixDOMs[i].checked) - strToInsert = strToInsert + emojiSelectPostfixDOMs[i].value; - - for(let i = 0; i < emojiSelectSuffixDOMs.length; i++) - if (emojiSelectSuffixDOMs[i].checked) - strToInsert = emojiSelectSuffixDOMs[i].value + strToInsert; - - strToInsert = ":" + strToInsert + ":" - insertText(emojiInputTargetDOM, strToInsert) - - // kick-start the preview - emojiInputTargetDOM.dispatchEvent(new Event('input')); - - // Update favs. from old code - if (favorite_emojis[event.currentTarget.dataset.emojiName]) - favorite_emojis[event.currentTarget.dataset.emojiName] += 1; - else - favorite_emojis[event.currentTarget.dataset.emojiName] = 1; - localStorage.setItem("favorite_emojis", JSON.stringify(favorite_emojis)); -} - -let emoji_typing_state = false; - -function update_ghost_div_textarea(text) -{ - let ghostdiv - - if (location.pathname == '/chat') - ghostdiv = document.getElementById("ghostdiv-chat"); - else - ghostdiv = text.parentNode.getElementsByClassName("ghostdiv")[0]; - - if (!ghostdiv) return; - - ghostdiv.textContent = text.value.substring(0, text.selectionStart); - - ghostdiv.insertAdjacentHTML('beforeend', ""); - - // Now lets get coordinates - - ghostdiv.style.display = "block"; - let end = ghostdiv.querySelector("span"); - const carot_coords = end.getBoundingClientRect(); - const ghostdiv_coords = ghostdiv.getBoundingClientRect(); - ghostdiv.style.display = "none"; - return { pos: text.selectionStart, x: carot_coords.x, y: carot_coords.y - ghostdiv_coords.y }; -} - -// Used for anything where a user is typing, specifically for the emoji modal -// Just leave it global, I don't care -let speed_carot_modal = document.createElement("div"); -speed_carot_modal.id = "speed-carot-modal"; -speed_carot_modal.style.position = "absolute"; -speed_carot_modal.style.left = "0px"; -speed_carot_modal.style.top = "0px"; -speed_carot_modal.style.display = "none"; -document.body.appendChild(speed_carot_modal); - -let e - -let current_word = ""; -let selecting; -let emoji_index = 0; - -function curr_word_is_emoji() -{ - return current_word && current_word.charAt(0) == ":" && - current_word.charAt(current_word.length-1) != ":"; -} - -function close_inline_speed_emoji_modal() { - selecting = false; - speed_carot_modal.style.display = "none"; -} - -function populate_speed_emoji_modal(results, textbox) -{ - selecting = true; - - if (!results || results.size === 0) - { - speed_carot_modal.style.display = "none"; - return -1; - } - - emoji_index = 0; - speed_carot_modal.scrollTop = 0; - speed_carot_modal.innerHTML = ""; - const MAXXX = 50; - // Not sure why the results is a Set... but oh well - let i = 0; - for (let emoji of results) - { - let name = emoji.name - - if (i++ > MAXXX) return i; - let emoji_option = document.createElement("div"); - emoji_option.className = "speed-modal-option emoji-option " + (i === 1 ? "selected" : ""); - emoji_option.tabIndex = 0; - let emoji_option_img = document.createElement("img"); - emoji_option_img.className = "speed-modal-image emoji-option-image"; - // This is a bit - emoji_option_img.src = `${SITE_FULL_IMAGES}/e/${name}.webp` - let emoji_option_text = document.createElement("span"); - - emoji_option_text.title = name; - - emoji_option_text.title += "\nauthor\t" + emoji.author_username - - if (emoji.count !== undefined) - emoji_option_text.title += "\nused\t" + emoji.count; - - emoji_option_text.textContent = name; - - if (current_word.includes("#")) name = `#${name}` - if (current_word.includes("!")) name = `!${name}` - - emoji_option.addEventListener('click', () => { - close_inline_speed_emoji_modal() - textbox.value = textbox.value.replace(new RegExp(current_word+"(?=\\s|$)", "gi"), `:${name}: `) - textbox.focus() - if (typeof markdown === "function" && textbox.dataset.preview) { - markdown(textbox) - } - }); - // Pack - emoji_option.appendChild(emoji_option_img); - emoji_option.appendChild(emoji_option_text); - speed_carot_modal.appendChild(emoji_option); - } - if (i === 0) speed_carot_modal.style.display = "none"; - else speed_carot_modal.style.display = "initial"; - return i; -} - -function update_speed_emoji_modal(event) -{ - const box_coords = update_ghost_div_textarea(event.target); - - box_coords.x = Math.min(box_coords.x, screen_width - 150) - - let text = event.target.value; - - // Unused, but left incase anyone wants to use this more efficient method for emojis - switch (event.data) - { - case ':': - emoji_typing_state = true; - break; - case ' ': - emoji_typing_state = false; - break; - default: - break; - } - - // Get current word at string, such as ":marse" or "word" - let coords = text.indexOf(' ',box_coords.pos); - current_word = /:[!#a-zA-Z0-9_]+(?=\n|$)/.exec(text.slice(0, coords === -1 ? text.length : coords)); - if (current_word) current_word = current_word[0].toLowerCase(); - - /* We could also check emoji_typing_state here, which is less accurate but more efficient. I've - * kept it unless someone wants to provide an option to toggle it for performance */ - if (current_word && curr_word_is_emoji() && current_word != ":") - { - openSpeedModal().then( () => { - let modal_pos = event.target.getBoundingClientRect(); - modal_pos.x += window.scrollX; - modal_pos.y += window.scrollY; - - speed_carot_modal.style.display = "initial"; - speed_carot_modal.style.left = box_coords.x - 30 + "px"; - speed_carot_modal.style.top = modal_pos.y + box_coords.y + 14 + "px"; - - // Do the search (and do something with it) - const resultSet = emojisSearchDictionary.completeSearch(current_word.substring(1).replace(/[#!]/g, "")); - - const found = globalEmojis.filter(i => resultSet.has(i.name)); - - populate_speed_emoji_modal(found, event.target); - }); - } - else { - speed_carot_modal.style.display = "none"; - } -} - -function speed_carot_navigate(event) -{ - if (!selecting) return; - - let select_items = speed_carot_modal.querySelectorAll(".speed-modal-option"); - if (!select_items || !curr_word_is_emoji()) return; - - const modal_keybinds = { - // go up one, wrapping around to the bottom if pressed at the top - ArrowUp: () => emoji_index = ((emoji_index - 1) + select_items.length) % select_items.length, - // go down one, wrapping around to the top if pressed at the bottom - ArrowDown: () => emoji_index = ((emoji_index + 1) + select_items.length) % select_items.length, - // select the emoji - Enter: () => select_items[emoji_index].click(), - } - if (event.key in modal_keybinds) - { - select_items[emoji_index].classList.remove("selected"); - modal_keybinds[event.key](); - select_items[emoji_index].classList.add("selected"); - select_items[emoji_index].scrollIntoView({inline: "end", block: "nearest"}); - event.preventDefault(); - } -} - -function insertGhostDivs(element) { - let forms = element.querySelectorAll("textarea, .allow-emojis"); - forms.forEach(i => { - let ghostdiv - if (i.id == 'input-text-chat') { - ghostdiv = document.getElementsByClassName("ghostdiv")[0]; - } - else { - ghostdiv = document.createElement("div"); - ghostdiv.className = "ghostdiv"; - ghostdiv.style.display = "none"; - i.after(ghostdiv); - } - i.addEventListener('input', update_speed_emoji_modal, false); - i.addEventListener('keydown', speed_carot_navigate, false); - }); -} - -const emojiModal = document.getElementById('emojiModal') - -function openEmojiModal(t, inputTargetIDName) -{ - selecting = false; - speed_carot_modal.style.display = "none"; - - if (inputTargetIDName) { - emojiInputTargetDOM = document.getElementById(inputTargetIDName); - emojiModal.addEventListener('hide.bs.modal', () => { - setTimeout(() => { - emojiInputTargetDOM.focus(); - }, 200); - }, {once : true}); - } - - if (t && t.dataset.previousModal) { - emojiModal.addEventListener('hide.bs.modal', () => { - bootstrap.Modal.getOrCreateInstance(document.getElementById(t.dataset.previousModal)).show() - }, {once : true}); - } - - switch (emojiEngineState) { - case "inactive": - emojiEngineState = "loading" - makeEmojisSearchDictionary(); - return fetchEmojis(); - case "loading": - // this works because once the fetch completes, the first keystroke callback will fire and use the current value - return Promise.reject(); - case "ready": - return Promise.resolve(); - default: - throw Error("Unknown emoji engine state"); - } -} - -function openSpeedModal() -{ - switch (emojiSpeedEngineState) { - case "inactive": - emojiSpeedEngineState = "loading" - return makeEmojisSearchDictionary(); - case "loading": - // this works because once the fetch completes, the first keystroke callback will fire and use the current value - return Promise.reject(); - case "ready": - return Promise.resolve(); - default: - throw Error("Unknown emoji engine state"); - } -} - -document.getElementById('emojiModal').addEventListener('shown.bs.modal', function () { - focusSearchBar(emojiSearchBarDOM); - setTimeout(() => { - focusSearchBar(emojiSearchBarDOM); - }, 200); - setTimeout(() => { - focusSearchBar(emojiSearchBarDOM); - }, 1000); -}); diff --git a/files/assets/js/emoji_modal/emoji_modal.js b/files/assets/js/emoji_modal/emoji_modal.js new file mode 100644 index 000000000..d96a36c75 --- /dev/null +++ b/files/assets/js/emoji_modal/emoji_modal.js @@ -0,0 +1,276 @@ +let emojiEngineState = "inactive"; + +// DOM stuff +const classesSelectorDOM = document.getElementById("emoji-modal-tabs"); +const emojiButtonTemplateDOM = document.getElementById("emoji-button-template"); +const emojiResultsDOM = document.getElementById("tab-content"); + +const emojiSelectSuffixDOMs = document.getElementsByClassName("emoji-suffix"); +const emojiSelectPostfixDOMs= document.getElementsByClassName("emoji-postfix"); + +const emojiNotFoundDOM = document.getElementById("no-emojis-found"); +const emojiWorkingDOM = document.getElementById("emojis-work"); + +const emojiSearchBarDOM = document.getElementById('emoji_search'); + +let emojiInputTargetDOM = undefined; + +// Emojis usage stats. I don't really like this format but I'll keep it for backward comp. +const favorite_emojis = JSON.parse(localStorage.getItem("favorite_emojis")) || {}; + +/** Associative array of all the emojis' DOM */ +let emojiDOMs = {}; + +const EMOIJ_SEARCH_ENGINE_MIN_INTERVAL = 350; +let emojiSearcher = { + working: false, + queries: [], + + addQuery: function(query) + { + this.queries.push(query); + if (!this.working) + this.work(); + }, + + work: async function work() { + this.working = true; + + while(this.queries.length > 0) + { + const startTime = Date.now(); + + // Get last input + const query = this.queries[this.queries.length - 1].toLowerCase(); + this.queries = []; + + // To improve perf we avoid showing all emojis at the same time. + if (query === "") + { + await classesSelectorDOM.children[0].children[0].click(); + classesSelectorDOM.children[0].children[0].classList.add("active"); + continue; + } + + // Search + const resultSet = emojisSearchDictionary.completeSearch(query); + + // update stuff + for(const [emojiName, emojiDOM] of Object.entries(emojiDOMs)) + emojiDOM.hidden = !resultSet.has(emojiName); + + emojiNotFoundDOM.hidden = resultSet.size !== 0; + + let sleepTime = EMOIJ_SEARCH_ENGINE_MIN_INTERVAL - (Date.now() - startTime); + if (sleepTime > 0) + await new Promise(r => setTimeout(r, sleepTime)); + } + + this.working = false; + } +}; + +// get public emojis list +function fetchEmojis() { + const headers = new Headers({xhr: "xhr"}) + const emoji_num = document.getElementById('emoji_num').value + return fetch(`/emojis.csv?x=${emoji_num}`, { + headers, + }) + .then(res => res.json()) + .then(emojis => { + if (!(emojis instanceof Array )) + throw new TypeError("[EMOJI DIALOG] rDrama's server should have sent a JSON-coded Array!"); + + let classes = ["Marsey", "Platy", "Wolf", "Donkey Kong", "Tay", "Capy", "Carp", "Marsey Flags", "Marsey Alphabet", "Classic", "Rage", "Wojak", "Misc"] + + const bussyDOM = document.createElement("div"); + + for(let i = 0; i < emojis.length; i++) + { + const emoji = emojis[i]; + + // Create emoji DOM + const emojiDOM = document.importNode(emojiButtonTemplateDOM.content, true).children[0]; + + emojiDOM.title = emoji.name + emojiDOM.title += "\nauthor\t" + emoji.author_username + if (emoji.count !== undefined) + emojiDOM.title += "\nused\t" + emoji.count; + emojiDOM.dataset.className = emoji.kind; + emojiDOM.dataset.emojiName = emoji.name; + emojiDOM.onclick = emojiAddToInput; + emojiDOM.hidden = true; + + const emojiIMGDOM = emojiDOM.children[0]; + emojiIMGDOM.src = `${SITE_FULL_IMAGES}/e/${emoji.name}.webp` + emojiIMGDOM.alt = emoji.name; + /** Disableing lazy loading seems to reduce cpu usage somehow (?) + * idk it is difficult to benchmark */ + emojiIMGDOM.loading = "lazy"; + + // Save reference + emojiDOMs[emoji.name] = emojiDOM; + + // Add to the document! + bussyDOM.appendChild(emojiDOM); + } + + // Create header + for(let className of classes) + { + let classSelectorDOM = document.createElement("li"); + classSelectorDOM.classList.add("nav-item"); + + let classSelectorLinkDOM = document.createElement("button"); + classSelectorLinkDOM.type = "button"; + classSelectorLinkDOM.classList.add("nav-link", "emojitab"); + classSelectorLinkDOM.dataset.bsToggle = "tab"; + classSelectorLinkDOM.dataset.className = className; + classSelectorLinkDOM.textContent = className; + classSelectorLinkDOM.addEventListener('click', switchEmojiTab); + + classSelectorDOM.appendChild(classSelectorLinkDOM); + classesSelectorDOM.appendChild(classSelectorDOM); + } + + // Show favorite for start. + classesSelectorDOM.children[0].children[0].click(); + + // Send it to the render machine! + emojiResultsDOM.appendChild(bussyDOM); + + emojiResultsDOM.hidden = false; + emojiWorkingDOM.hidden = true; + emojiSearchBarDOM.disabled = false; + + emojiEngineState = "ready"; + }) +} + +/** +* +* @param {Event} e +*/ +function switchEmojiTab(e) +{ + const className = e.currentTarget.dataset.className; + + emojiSearchBarDOM.value = ""; + focusSearchBar(emojiSearchBarDOM); + emojiNotFoundDOM.hidden = true; + + // Special case: favorites + if (className === "favorite") + { + for(const emojiDOM of Object.values(emojiDOMs)) + emojiDOM.hidden = true; + + const favs = Object.keys(Object.fromEntries( + Object.entries(favorite_emojis).sort(([,a],[,b]) => b-a) + )).slice(0, 25); + + for (const emoji of favs) + if (emojiDOMs[emoji] instanceof HTMLElement) + emojiDOMs[emoji].hidden = false; + + return; + } + + for(const emojiDOM of Object.values(emojiDOMs)) + emojiDOM.hidden = emojiDOM.dataset.className !== className; + + document.getElementById('emoji-container').scrollTop = 0; +} + +for (const emojitab of document.getElementsByClassName('emojitab')) { + emojitab.addEventListener('click', (e)=>{switchEmojiTab(e)}) +} + +async function start_search() { + emojiSearcher.addQuery(emojiSearchBarDOM.value.trim()); + + // Remove any selected tab, now it is meaningless + for(let i = 0; i < classesSelectorDOM.children.length; i++) + classesSelectorDOM.children[i].children[0].classList.remove("active"); +} + +/** +* Add the selected emoji to the targeted text area +* @param {Event} event +*/ +function emojiAddToInput(event) +{ + // This should not happen if used properly but whatever + if (!(emojiInputTargetDOM instanceof HTMLTextAreaElement) && !(emojiInputTargetDOM instanceof HTMLInputElement)) + return; + + let strToInsert = event.currentTarget.dataset.emojiName; + + for(let i = 0; i < emojiSelectPostfixDOMs.length; i++) + if (emojiSelectPostfixDOMs[i].checked) + strToInsert = strToInsert + emojiSelectPostfixDOMs[i].value; + + for(let i = 0; i < emojiSelectSuffixDOMs.length; i++) + if (emojiSelectSuffixDOMs[i].checked) + strToInsert = emojiSelectSuffixDOMs[i].value + strToInsert; + + strToInsert = ":" + strToInsert + ":" + insertText(emojiInputTargetDOM, strToInsert) + + // kick-start the preview + emojiInputTargetDOM.dispatchEvent(new Event('input')); + + // Update favs. from old code + if (favorite_emojis[event.currentTarget.dataset.emojiName]) + favorite_emojis[event.currentTarget.dataset.emojiName] += 1; + else + favorite_emojis[event.currentTarget.dataset.emojiName] = 1; + localStorage.setItem("favorite_emojis", JSON.stringify(favorite_emojis)); +} + +const emojiModal = document.getElementById('emojiModal') + +function openEmojiModal(t, inputTargetIDName) +{ + selecting = false; + + if (inputTargetIDName) { + emojiInputTargetDOM = document.getElementById(inputTargetIDName); + emojiModal.addEventListener('hide.bs.modal', () => { + setTimeout(() => { + emojiInputTargetDOM.focus(); + }, 200); + }, {once : true}); + } + + if (t && t.dataset.previousModal) { + emojiModal.addEventListener('hide.bs.modal', () => { + bootstrap.Modal.getOrCreateInstance(document.getElementById(t.dataset.previousModal)).show() + }, {once : true}); + } + + switch (emojiEngineState) { + case "inactive": + emojiEngineState = "loading" + makeEmojisSearchDictionary(); + return fetchEmojis(); + case "loading": + // this works because once the fetch completes, the first keystroke callback will fire and use the current value + return Promise.reject(); + case "ready": + return Promise.resolve(); + default: + throw Error("Unknown emoji engine state"); + } +} + +document.getElementById('emojiModal').addEventListener('shown.bs.modal', function () { + focusSearchBar(emojiSearchBarDOM); + setTimeout(() => { + focusSearchBar(emojiSearchBarDOM); + }, 200); + setTimeout(() => { + focusSearchBar(emojiSearchBarDOM); + }, 1000); +}); diff --git a/files/assets/js/emoji_modal/search_dictionary.js b/files/assets/js/emoji_modal/search_dictionary.js new file mode 100644 index 000000000..ad7981c34 --- /dev/null +++ b/files/assets/js/emoji_modal/search_dictionary.js @@ -0,0 +1,90 @@ +// tags dictionary. KEEP IT SORT +class EmoijsDictNode +{ + constructor(tag, name) { + this.tag = tag; + this.emojiNames = [name]; + } +} +const emojisSearchDictionary = { + dict: [], + + updateTag: function(tag, emojiName) { + if (tag === undefined || emojiName === undefined) + return; + + let low = 0; + let high = this.dict.length; + + while (low < high) { + let mid = (low + high) >>> 1; + if (this.dict[mid].tag < tag) + low = mid + 1; + else + high = mid; + } + + let target = low; + if (this.dict[target] !== undefined && this.dict[target].tag === tag) + this.dict[target].emojiNames.push(emojiName); + else + this.dict.splice(target ,0,new EmoijsDictNode(tag, emojiName)); + }, + + /** + * We also check for substrings! (sigh) + * @param {String} tag + * @returns {Set} + */ + completeSearch: function(query) { + query = query.toLowerCase() + const result = new Set(); + + for(let i = 0; i < this.dict.length; i++) + if (this.dict[i].tag.startsWith('@')) + { + if (this.dict[i].tag == query) + for(let j = 0; j < this.dict[i].emojiNames.length; j++) + result.add(this.dict[i].emojiNames[j]) + } + else if(this.dict[i].tag.includes(query)) + for(let j = 0; j < this.dict[i].emojiNames.length; j++) + result.add(this.dict[i].emojiNames[j]) + + return result; + } +}; + +function makeEmojisSearchDictionary() { + // get public emojis list + const headers = new Headers({xhr: "xhr"}) + const emoji_num = document.getElementById('emoji_num').value + return fetch(`/emojis.csv?x=${emoji_num}`, { + headers, + }) + .then(res => res.json()) + .then(emojis => { + globalEmojis = emojis.map(({name, author_username, count}) => ({name, author_username, count})); + + for (let i = 0; i < emojis.length; i++) + { + const emoji = emojis[i]; + + emojisSearchDictionary.updateTag(emoji.name, emoji.name); + + if (!emoji.author_username.endsWith(' user')) { + emojisSearchDictionary.updateTag(`@${emoji.author_username.toLowerCase()}`, emoji.name); + if (emoji.author_original_username != emoji.author_username) + emojisSearchDictionary.updateTag(`@${emoji.author_original_username.toLowerCase()}`, emoji.name); + if (emoji.author_prelock_username !== undefined) + emojisSearchDictionary.updateTag(`@${emoji.author_prelock_username.toLowerCase()}`, emoji.name); + } + + if (emoji.tags instanceof Array) + for(let i = 0; i < emoji.tags.length; i++) + emojisSearchDictionary.updateTag(emoji.tags[i], emoji.name); + } + + emojiSpeedEngineState = "ready"; + }) +} diff --git a/files/assets/js/emoji_modal/speed_emoji_modal.js b/files/assets/js/emoji_modal/speed_emoji_modal.js new file mode 100644 index 000000000..24eb5077a --- /dev/null +++ b/files/assets/js/emoji_modal/speed_emoji_modal.js @@ -0,0 +1,226 @@ +let emojiSpeedEngineState = "inactive"; +let emoji_typing_state = false; +let globalEmojis; + +function update_ghost_div_textarea(text) +{ + let ghostdiv + + if (location.pathname == '/chat') + ghostdiv = document.getElementById("ghostdiv-chat"); + else + ghostdiv = text.parentNode.getElementsByClassName("ghostdiv")[0]; + + if (!ghostdiv) return; + + ghostdiv.textContent = text.value.substring(0, text.selectionStart); + + ghostdiv.insertAdjacentHTML('beforeend', ""); + + // Now lets get coordinates + + ghostdiv.style.display = "block"; + let end = ghostdiv.querySelector("span"); + const carot_coords = end.getBoundingClientRect(); + const ghostdiv_coords = ghostdiv.getBoundingClientRect(); + ghostdiv.style.display = "none"; + return { pos: text.selectionStart, x: carot_coords.x, y: carot_coords.y - ghostdiv_coords.y }; +} + +// Used for anything where a user is typing, specifically for the emoji modal +// Just leave it global, I don't care +let speed_carot_modal = document.createElement("div"); +speed_carot_modal.id = "speed-carot-modal"; +speed_carot_modal.style.position = "absolute"; +speed_carot_modal.style.left = "0px"; +speed_carot_modal.style.top = "0px"; +speed_carot_modal.style.display = "none"; +document.body.appendChild(speed_carot_modal); + +let e + +let current_word = ""; +let selecting; +let emoji_index = 0; + +function curr_word_is_emoji() +{ + return current_word && current_word.charAt(0) == ":" && + current_word.charAt(current_word.length-1) != ":"; +} + +function close_inline_speed_emoji_modal() { + selecting = false; + speed_carot_modal.style.display = "none"; +} + +function populate_speed_emoji_modal(results, textbox) +{ + selecting = true; + + if (!results || results.size === 0) + { + speed_carot_modal.style.display = "none"; + return -1; + } + + emoji_index = 0; + speed_carot_modal.scrollTop = 0; + speed_carot_modal.innerHTML = ""; + const MAXXX = 50; + // Not sure why the results is a Set... but oh well + let i = 0; + for (let emoji of results) + { + let name = emoji.name + + if (i++ > MAXXX) return i; + let emoji_option = document.createElement("div"); + emoji_option.className = "speed-modal-option emoji-option " + (i === 1 ? "selected" : ""); + emoji_option.tabIndex = 0; + let emoji_option_img = document.createElement("img"); + emoji_option_img.className = "speed-modal-image emoji-option-image"; + // This is a bit + emoji_option_img.src = `${SITE_FULL_IMAGES}/e/${name}.webp` + let emoji_option_text = document.createElement("span"); + + emoji_option_text.title = name; + + emoji_option_text.title += "\nauthor\t" + emoji.author_username + + if (emoji.count !== undefined) + emoji_option_text.title += "\nused\t" + emoji.count; + + emoji_option_text.textContent = name; + + if (current_word.includes("#")) name = `#${name}` + if (current_word.includes("!")) name = `!${name}` + + emoji_option.addEventListener('click', () => { + close_inline_speed_emoji_modal() + textbox.value = textbox.value.replace(new RegExp(current_word+"(?=\\s|$)", "gi"), `:${name}: `) + textbox.focus() + if (typeof markdown === "function" && textbox.dataset.preview) { + markdown(textbox) + } + }); + // Pack + emoji_option.appendChild(emoji_option_img); + emoji_option.appendChild(emoji_option_text); + speed_carot_modal.appendChild(emoji_option); + } + if (i === 0) speed_carot_modal.style.display = "none"; + else speed_carot_modal.style.display = "initial"; + return i; +} + +function update_speed_emoji_modal(event) +{ + const box_coords = update_ghost_div_textarea(event.target); + + box_coords.x = Math.min(box_coords.x, screen_width - 150) + + let text = event.target.value; + + // Unused, but left incase anyone wants to use this more efficient method for emojis + switch (event.data) + { + case ':': + emoji_typing_state = true; + break; + case ' ': + emoji_typing_state = false; + break; + default: + break; + } + + // Get current word at string, such as ":marse" or "word" + let coords = text.indexOf(' ',box_coords.pos); + current_word = /:[!#a-zA-Z0-9_]+(?=\n|$)/.exec(text.slice(0, coords === -1 ? text.length : coords)); + if (current_word) current_word = current_word[0].toLowerCase(); + + /* We could also check emoji_typing_state here, which is less accurate but more efficient. I've + * kept it unless someone wants to provide an option to toggle it for performance */ + if (current_word && curr_word_is_emoji() && current_word != ":") + { + openSpeedModal().then( () => { + let modal_pos = event.target.getBoundingClientRect(); + modal_pos.x += window.scrollX; + modal_pos.y += window.scrollY; + + speed_carot_modal.style.display = "initial"; + speed_carot_modal.style.left = box_coords.x - 30 + "px"; + speed_carot_modal.style.top = modal_pos.y + box_coords.y + 14 + "px"; + + // Do the search (and do something with it) + const resultSet = emojisSearchDictionary.completeSearch(current_word.substring(1).replace(/[#!]/g, "")); + + const found = globalEmojis.filter(i => resultSet.has(i.name)); + + populate_speed_emoji_modal(found, event.target); + }); + } + else { + speed_carot_modal.style.display = "none"; + } +} + +function speed_carot_navigate(event) +{ + if (!selecting) return; + + let select_items = speed_carot_modal.querySelectorAll(".speed-modal-option"); + if (!select_items || !curr_word_is_emoji()) return; + + const modal_keybinds = { + // go up one, wrapping around to the bottom if pressed at the top + ArrowUp: () => emoji_index = ((emoji_index - 1) + select_items.length) % select_items.length, + // go down one, wrapping around to the top if pressed at the bottom + ArrowDown: () => emoji_index = ((emoji_index + 1) + select_items.length) % select_items.length, + // select the emoji + Enter: () => select_items[emoji_index].click(), + } + if (event.key in modal_keybinds) + { + select_items[emoji_index].classList.remove("selected"); + modal_keybinds[event.key](); + select_items[emoji_index].classList.add("selected"); + select_items[emoji_index].scrollIntoView({inline: "end", block: "nearest"}); + event.preventDefault(); + } +} + +function insertGhostDivs(element) { + let forms = element.querySelectorAll("textarea, .allow-emojis"); + forms.forEach(i => { + let ghostdiv + if (i.id == 'input-text-chat') { + ghostdiv = document.getElementsByClassName("ghostdiv")[0]; + } + else { + ghostdiv = document.createElement("div"); + ghostdiv.className = "ghostdiv"; + ghostdiv.style.display = "none"; + i.after(ghostdiv); + } + i.addEventListener('input', update_speed_emoji_modal, false); + i.addEventListener('keydown', speed_carot_navigate, false); + }); +} + +function openSpeedModal() +{ + switch (emojiSpeedEngineState) { + case "inactive": + emojiSpeedEngineState = "loading" + return makeEmojisSearchDictionary(); + case "loading": + // this works because once the fetch completes, the first keystroke callback will fire and use the current value + return Promise.reject(); + case "ready": + return Promise.resolve(); + default: + throw Error("Unknown emoji engine state"); + } +} diff --git a/files/templates/modals/emoji.html b/files/templates/modals/emoji.html index e3065ccde..ae1dd7037 100644 --- a/files/templates/modals/emoji.html +++ b/files/templates/modals/emoji.html @@ -71,4 +71,6 @@ - + + +