lib/amd/src/emoji/auto_complete.js

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * Emoji auto complete.
  17. *
  18. * @module core/emoji/auto_complete
  19. * @copyright 2019 Ryan Wyllie <ryan@moodle.com>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import * as EmojiData from 'core/emoji/data';
  23. import {render as renderTemplate} from 'core/templates';
  24. import {debounce} from 'core/utils';
  25. import LocalStorage from 'core/localstorage';
  26. import KeyCodes from 'core/key_codes';
  27. const INPUT_DEBOUNCE_TIMER = 200;
  28. const SUGGESTION_LIMIT = 50;
  29. const MAX_RECENT_COUNT = 27;
  30. const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis';
  31. const SELECTORS = {
  32. EMOJI_BUTTON: '[data-region="emoji-button"]',
  33. ACTIVE_EMOJI_BUTTON: '[data-region="emoji-button"].active',
  34. };
  35. /**
  36. * Get the list of recent emojis data from local storage.
  37. *
  38. * @return {Array}
  39. */
  40. const getRecentEmojis = () => {
  41. const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY);
  42. return storedData ? JSON.parse(storedData) : [];
  43. };
  44. /**
  45. * Add an emoji data to the set of recent emojis. The new set of recent emojis are
  46. * saved in local storage.
  47. *
  48. * @param {String} unified The char chodes for the emoji
  49. * @param {String} shortName The emoji short name
  50. */
  51. const addRecentEmoji = (unified, shortName) => {
  52. const newEmoji = {
  53. unified,
  54. shortnames: [shortName]
  55. };
  56. const recentEmojis = getRecentEmojis();
  57. // Add the new emoji to the start of the list of recent emojis.
  58. let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)];
  59. // Limit the number of recent emojis.
  60. newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT);
  61. LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(newRecentEmojis));
  62. };
  63. /**
  64. * Get the actual emoji string from the short name.
  65. *
  66. * @param {String} shortName Emoji short name
  67. * @return {String|null}
  68. */
  69. const getEmojiTextFromShortName = (shortName) => {
  70. const unified = EmojiData.byShortName[shortName];
  71. if (unified) {
  72. const charCodes = unified.split('-').map(code => `0x${code}`);
  73. return String.fromCodePoint.apply(null, charCodes);
  74. } else {
  75. return null;
  76. }
  77. };
  78. /**
  79. * Render the auto complete list for the given short names.
  80. *
  81. * @param {Element} root The root container for the emoji auto complete
  82. * @param {Array} shortNames The list of short names for emoji suggestions to show
  83. */
  84. const render = async(root, shortNames) => {
  85. const renderContext = {
  86. emojis: shortNames.map((shortName, index) => {
  87. return {
  88. active: index === 0,
  89. emojitext: getEmojiTextFromShortName(shortName),
  90. displayshortname: `:${shortName}:`,
  91. shortname: shortName,
  92. unified: EmojiData.byShortName[shortName]
  93. };
  94. })
  95. };
  96. const html = await renderTemplate('core/emoji/auto_complete', renderContext);
  97. root.innerHTML = html;
  98. };
  99. /**
  100. * Get the list of emoji short names that include the given search term. If
  101. * the search term is an empty string then the list of recently used emojis
  102. * will be returned.
  103. *
  104. * @param {String} searchTerm Text to match on
  105. * @param {Number} limit Maximum number of results to return
  106. * @return {Array}
  107. */
  108. const searchEmojis = (searchTerm, limit) => {
  109. if (searchTerm === '') {
  110. return getRecentEmojis().map(data => data.shortnames[0]).slice(0, limit);
  111. } else {
  112. searchTerm = searchTerm.toLowerCase();
  113. return Object.keys(EmojiData.byShortName)
  114. .filter(shortName => shortName.includes(searchTerm))
  115. .slice(0, limit);
  116. }
  117. };
  118. /**
  119. * Get the current word at the given position (index) within the text.
  120. *
  121. * @param {String} text The text to process
  122. * @param {Number} position The position (index) within the text to match the word
  123. * @return {String}
  124. */
  125. const getWordFromPosition = (text, position) => {
  126. const startMatches = text.slice(0, position).match(/(\S*)$/);
  127. const endMatches = text.slice(position).match(/^(\S*)/);
  128. let startText = '';
  129. let endText = '';
  130. if (startMatches) {
  131. startText = startMatches[startMatches.length - 1];
  132. }
  133. if (endMatches) {
  134. endText = endMatches[endMatches.length - 1];
  135. }
  136. return `${startText}${endText}`;
  137. };
  138. /**
  139. * Check if the given text is a full short name, i.e. has leading and trialing colon
  140. * characters.
  141. *
  142. * @param {String} text The text to process
  143. * @return {Bool}
  144. */
  145. const isCompleteShortName = text => /^:[^:\s]+:$/.test(text);
  146. /**
  147. * Check if the given text is a partial short name, i.e. has a leading colon but no
  148. * trailing colon.
  149. *
  150. * @param {String} text The text to process
  151. * @return {Bool}
  152. */
  153. const isPartialShortName = text => /^:[^:\s]*$/.test(text);
  154. /**
  155. * Remove the colon characters from the given text.
  156. *
  157. * @param {String} text The text to process
  158. * @return {String}
  159. */
  160. const getShortNameFromText = text => text.replace(/:/g, '');
  161. /**
  162. * Get the currently active emoji button element in the list of suggestions.
  163. *
  164. * @param {Element} root The emoji auto complete container element
  165. * @return {Element|null}
  166. */
  167. const getActiveEmojiSuggestion = (root) => {
  168. return root.querySelector(SELECTORS.ACTIVE_EMOJI_BUTTON);
  169. };
  170. /**
  171. * Make the previous sibling of the current active emoji active.
  172. *
  173. * @param {Element} root The emoji auto complete container element
  174. */
  175. const selectPreviousEmojiSuggestion = (root) => {
  176. const activeEmojiSuggestion = getActiveEmojiSuggestion(root);
  177. const previousSuggestion = activeEmojiSuggestion.previousElementSibling;
  178. if (previousSuggestion) {
  179. activeEmojiSuggestion.classList.remove('active');
  180. previousSuggestion.classList.add('active');
  181. previousSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'});
  182. }
  183. };
  184. /**
  185. * Make the next sibling to the current active emoji active.
  186. *
  187. * @param {Element} root The emoji auto complete container element
  188. */
  189. const selectNextEmojiSuggestion = (root) => {
  190. const activeEmojiSuggestion = getActiveEmojiSuggestion(root);
  191. const nextSuggestion = activeEmojiSuggestion.nextElementSibling;
  192. if (nextSuggestion) {
  193. activeEmojiSuggestion.classList.remove('active');
  194. nextSuggestion.classList.add('active');
  195. nextSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'});
  196. }
  197. };
  198. /**
  199. * Trigger the select callback for the given emoji button element.
  200. *
  201. * @param {Element} element The emoji button element
  202. * @param {Function} selectCallback The callback for when the user selects an emoji
  203. */
  204. const selectEmojiElement = (element, selectCallback) => {
  205. const shortName = element.getAttribute('data-short-name');
  206. const unified = element.getAttribute('data-unified');
  207. addRecentEmoji(unified, shortName);
  208. selectCallback(element.innerHTML.trim());
  209. };
  210. /**
  211. * Initialise the emoji auto complete.
  212. *
  213. * @method
  214. * @param {Element} root The root container element for the auto complete
  215. * @param {Element} textArea The text area element to monitor for auto complete
  216. * @param {Function} hasSuggestionCallback Callback for when there are auto-complete suggestions
  217. * @param {Function} selectCallback Callback for when the user selects an emoji
  218. */
  219. export default (root, textArea, hasSuggestionCallback, selectCallback) => {
  220. let hasSuggestions = false;
  221. let previousSearchText = '';
  222. // Debounce the listener so that each keypress delays the execution of the handler. The
  223. // handler should only run 200 milliseconds after the last keypress.
  224. textArea.addEventListener('keyup', debounce(() => {
  225. // This is a "keyup" listener so that it only executes after the text area value
  226. // has been updated.
  227. const text = textArea.value;
  228. const cursorPos = textArea.selectionStart;
  229. const searchText = getWordFromPosition(text, cursorPos);
  230. if (searchText === previousSearchText) {
  231. // Nothing has changed so no need to take any action.
  232. return;
  233. } else {
  234. previousSearchText = searchText;
  235. }
  236. if (isCompleteShortName(searchText)) {
  237. // If the user has entered a full short name (with leading and trialing colons)
  238. // then see if we can find a match for it and auto complete it.
  239. const shortName = getShortNameFromText(searchText);
  240. const emojiText = getEmojiTextFromShortName(shortName);
  241. hasSuggestions = false;
  242. if (emojiText) {
  243. addRecentEmoji(EmojiData.byShortName[shortName], shortName);
  244. selectCallback(emojiText);
  245. }
  246. } else if (isPartialShortName(searchText)) {
  247. // If the user has entered a partial short name (leading colon but no trailing) then
  248. // search on the text to see if we can find some suggestions for them.
  249. const suggestions = searchEmojis(getShortNameFromText(searchText), SUGGESTION_LIMIT);
  250. if (suggestions.length) {
  251. render(root, suggestions);
  252. hasSuggestions = true;
  253. } else {
  254. hasSuggestions = false;
  255. }
  256. } else {
  257. hasSuggestions = false;
  258. }
  259. hasSuggestionCallback(hasSuggestions);
  260. }, INPUT_DEBOUNCE_TIMER));
  261. textArea.addEventListener('keydown', (e) => {
  262. if (hasSuggestions) {
  263. const isModifierPressed = (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
  264. if (!isModifierPressed) {
  265. switch (e.which) {
  266. case KeyCodes.escape:
  267. // Escape key closes the auto complete.
  268. hasSuggestions = false;
  269. hasSuggestionCallback(false);
  270. break;
  271. case KeyCodes.arrowLeft:
  272. // Arrow keys navigate through the list of suggetions.
  273. selectPreviousEmojiSuggestion(root);
  274. e.preventDefault();
  275. break;
  276. case KeyCodes.arrowRight:
  277. // Arrow keys navigate through the list of suggetions.
  278. selectNextEmojiSuggestion(root);
  279. e.preventDefault();
  280. break;
  281. case KeyCodes.enter:
  282. // Enter key selects the current suggestion.
  283. selectEmojiElement(getActiveEmojiSuggestion(root), selectCallback);
  284. e.preventDefault();
  285. e.stopPropagation();
  286. break;
  287. }
  288. }
  289. }
  290. });
  291. root.addEventListener('click', (e) => {
  292. const target = e.target;
  293. if (target.matches(SELECTORS.EMOJI_BUTTON)) {
  294. selectEmojiElement(target, selectCallback);
  295. }
  296. });
  297. };