%PDF- %PDF-
Direktori : /home/vacivi36/ava/lib/amd/src/emoji/ |
Current File : /home/vacivi36/ava/lib/amd/src/emoji/auto_complete.js |
// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle 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 General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Emoji auto complete. * * @module core/emoji/auto_complete * @copyright 2019 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ import * as EmojiData from 'core/emoji/data'; import {render as renderTemplate} from 'core/templates'; import {debounce} from 'core/utils'; import LocalStorage from 'core/localstorage'; import KeyCodes from 'core/key_codes'; const INPUT_DEBOUNCE_TIMER = 200; const SUGGESTION_LIMIT = 50; const MAX_RECENT_COUNT = 27; const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis'; const SELECTORS = { EMOJI_BUTTON: '[data-region="emoji-button"]', ACTIVE_EMOJI_BUTTON: '[data-region="emoji-button"].active', }; /** * Get the list of recent emojis data from local storage. * * @return {Array} */ const getRecentEmojis = () => { const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY); return storedData ? JSON.parse(storedData) : []; }; /** * Add an emoji data to the set of recent emojis. The new set of recent emojis are * saved in local storage. * * @param {String} unified The char chodes for the emoji * @param {String} shortName The emoji short name */ const addRecentEmoji = (unified, shortName) => { const newEmoji = { unified, shortnames: [shortName] }; const recentEmojis = getRecentEmojis(); // Add the new emoji to the start of the list of recent emojis. let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)]; // Limit the number of recent emojis. newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT); LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(newRecentEmojis)); }; /** * Get the actual emoji string from the short name. * * @param {String} shortName Emoji short name * @return {String|null} */ const getEmojiTextFromShortName = (shortName) => { const unified = EmojiData.byShortName[shortName]; if (unified) { const charCodes = unified.split('-').map(code => `0x${code}`); return String.fromCodePoint.apply(null, charCodes); } else { return null; } }; /** * Render the auto complete list for the given short names. * * @param {Element} root The root container for the emoji auto complete * @param {Array} shortNames The list of short names for emoji suggestions to show */ const render = async(root, shortNames) => { const renderContext = { emojis: shortNames.map((shortName, index) => { return { active: index === 0, emojitext: getEmojiTextFromShortName(shortName), displayshortname: `:${shortName}:`, shortname: shortName, unified: EmojiData.byShortName[shortName] }; }) }; const html = await renderTemplate('core/emoji/auto_complete', renderContext); root.innerHTML = html; }; /** * Get the list of emoji short names that include the given search term. If * the search term is an empty string then the list of recently used emojis * will be returned. * * @param {String} searchTerm Text to match on * @param {Number} limit Maximum number of results to return * @return {Array} */ const searchEmojis = (searchTerm, limit) => { if (searchTerm === '') { return getRecentEmojis().map(data => data.shortnames[0]).slice(0, limit); } else { searchTerm = searchTerm.toLowerCase(); return Object.keys(EmojiData.byShortName) .filter(shortName => shortName.includes(searchTerm)) .slice(0, limit); } }; /** * Get the current word at the given position (index) within the text. * * @param {String} text The text to process * @param {Number} position The position (index) within the text to match the word * @return {String} */ const getWordFromPosition = (text, position) => { const startMatches = text.slice(0, position).match(/(\S*)$/); const endMatches = text.slice(position).match(/^(\S*)/); let startText = ''; let endText = ''; if (startMatches) { startText = startMatches[startMatches.length - 1]; } if (endMatches) { endText = endMatches[endMatches.length - 1]; } return `${startText}${endText}`; }; /** * Check if the given text is a full short name, i.e. has leading and trialing colon * characters. * * @param {String} text The text to process * @return {Bool} */ const isCompleteShortName = text => /^:[^:\s]+:$/.test(text); /** * Check if the given text is a partial short name, i.e. has a leading colon but no * trailing colon. * * @param {String} text The text to process * @return {Bool} */ const isPartialShortName = text => /^:[^:\s]*$/.test(text); /** * Remove the colon characters from the given text. * * @param {String} text The text to process * @return {String} */ const getShortNameFromText = text => text.replace(/:/g, ''); /** * Get the currently active emoji button element in the list of suggestions. * * @param {Element} root The emoji auto complete container element * @return {Element|null} */ const getActiveEmojiSuggestion = (root) => { return root.querySelector(SELECTORS.ACTIVE_EMOJI_BUTTON); }; /** * Make the previous sibling of the current active emoji active. * * @param {Element} root The emoji auto complete container element */ const selectPreviousEmojiSuggestion = (root) => { const activeEmojiSuggestion = getActiveEmojiSuggestion(root); const previousSuggestion = activeEmojiSuggestion.previousElementSibling; if (previousSuggestion) { activeEmojiSuggestion.classList.remove('active'); previousSuggestion.classList.add('active'); previousSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'}); } }; /** * Make the next sibling to the current active emoji active. * * @param {Element} root The emoji auto complete container element */ const selectNextEmojiSuggestion = (root) => { const activeEmojiSuggestion = getActiveEmojiSuggestion(root); const nextSuggestion = activeEmojiSuggestion.nextElementSibling; if (nextSuggestion) { activeEmojiSuggestion.classList.remove('active'); nextSuggestion.classList.add('active'); nextSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'}); } }; /** * Trigger the select callback for the given emoji button element. * * @param {Element} element The emoji button element * @param {Function} selectCallback The callback for when the user selects an emoji */ const selectEmojiElement = (element, selectCallback) => { const shortName = element.getAttribute('data-short-name'); const unified = element.getAttribute('data-unified'); addRecentEmoji(unified, shortName); selectCallback(element.innerHTML.trim()); }; /** * Initialise the emoji auto complete. * * @method * @param {Element} root The root container element for the auto complete * @param {Element} textArea The text area element to monitor for auto complete * @param {Function} hasSuggestionCallback Callback for when there are auto-complete suggestions * @param {Function} selectCallback Callback for when the user selects an emoji */ export default (root, textArea, hasSuggestionCallback, selectCallback) => { let hasSuggestions = false; let previousSearchText = ''; // Debounce the listener so that each keypress delays the execution of the handler. The // handler should only run 200 milliseconds after the last keypress. textArea.addEventListener('keyup', debounce(() => { // This is a "keyup" listener so that it only executes after the text area value // has been updated. const text = textArea.value; const cursorPos = textArea.selectionStart; const searchText = getWordFromPosition(text, cursorPos); if (searchText === previousSearchText) { // Nothing has changed so no need to take any action. return; } else { previousSearchText = searchText; } if (isCompleteShortName(searchText)) { // If the user has entered a full short name (with leading and trialing colons) // then see if we can find a match for it and auto complete it. const shortName = getShortNameFromText(searchText); const emojiText = getEmojiTextFromShortName(shortName); hasSuggestions = false; if (emojiText) { addRecentEmoji(EmojiData.byShortName[shortName], shortName); selectCallback(emojiText); } } else if (isPartialShortName(searchText)) { // If the user has entered a partial short name (leading colon but no trailing) then // search on the text to see if we can find some suggestions for them. const suggestions = searchEmojis(getShortNameFromText(searchText), SUGGESTION_LIMIT); if (suggestions.length) { render(root, suggestions); hasSuggestions = true; } else { hasSuggestions = false; } } else { hasSuggestions = false; } hasSuggestionCallback(hasSuggestions); }, INPUT_DEBOUNCE_TIMER)); textArea.addEventListener('keydown', (e) => { if (hasSuggestions) { const isModifierPressed = (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey); if (!isModifierPressed) { switch (e.which) { case KeyCodes.escape: // Escape key closes the auto complete. hasSuggestions = false; hasSuggestionCallback(false); break; case KeyCodes.arrowLeft: // Arrow keys navigate through the list of suggetions. selectPreviousEmojiSuggestion(root); e.preventDefault(); break; case KeyCodes.arrowRight: // Arrow keys navigate through the list of suggetions. selectNextEmojiSuggestion(root); e.preventDefault(); break; case KeyCodes.enter: // Enter key selects the current suggestion. selectEmojiElement(getActiveEmojiSuggestion(root), selectCallback); e.preventDefault(); e.stopPropagation(); break; } } } }); root.addEventListener('click', (e) => { const target = e.target; if (target.matches(SELECTORS.EMOJI_BUTTON)) { selectEmojiElement(target, selectCallback); } }); };