lib/amd/src/emoji/picker.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 picker.
  17. *
  18. * @module core/emoji/picker
  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 LocalStorage from 'core/localstorage';
  23. import * as EmojiData from 'core/emoji/data';
  24. import {throttle, debounce} from 'core/utils';
  25. import {get_string as getString} from 'core/str';
  26. import {render as renderTemplate} from 'core/templates';
  27. const VISIBLE_ROW_COUNT = 10;
  28. const ROW_RENDER_BUFFER_COUNT = 5;
  29. const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis';
  30. const ROW_HEIGHT_RAW = 40;
  31. const EMOJIS_PER_ROW = 7;
  32. const MAX_RECENT_COUNT = EMOJIS_PER_ROW * 3;
  33. const ROW_TYPE = {
  34. EMOJI: 0,
  35. HEADER: 1
  36. };
  37. const SELECTORS = {
  38. CATEGORY_SELECTOR: '[data-action="show-category"]',
  39. EMOJIS_CONTAINER: '[data-region="emojis-container"]',
  40. EMOJI_PREVIEW: '[data-region="emoji-preview"]',
  41. EMOJI_SHORT_NAME: '[data-region="emoji-short-name"]',
  42. ROW_CONTAINER: '[data-region="row-container"]',
  43. SEARCH_INPUT: '[data-region="search-input"]',
  44. SEARCH_RESULTS_CONTAINER: '[data-region="search-results-container"]'
  45. };
  46. /**
  47. * Create the row data for a category.
  48. *
  49. * @method
  50. * @param {String} categoryName The category name
  51. * @param {String} categoryDisplayName The category display name
  52. * @param {Array} emojis The emoji data
  53. * @param {Number} totalRowCount The total number of rows generated so far
  54. * @return {Array}
  55. */
  56. const createRowDataForCategory = (categoryName, categoryDisplayName, emojis, totalRowCount) => {
  57. const rowData = [];
  58. rowData.push({
  59. index: totalRowCount + rowData.length,
  60. type: ROW_TYPE.HEADER,
  61. data: {
  62. name: categoryName,
  63. displayName: categoryDisplayName
  64. }
  65. });
  66. for (let i = 0; i < emojis.length; i += EMOJIS_PER_ROW) {
  67. const rowEmojis = emojis.slice(i, i + EMOJIS_PER_ROW);
  68. rowData.push({
  69. index: totalRowCount + rowData.length,
  70. type: ROW_TYPE.EMOJI,
  71. data: rowEmojis
  72. });
  73. }
  74. return rowData;
  75. };
  76. /**
  77. * Add each row's index to it's value in the row data.
  78. *
  79. * @method
  80. * @param {Array} rowData List of emoji row data
  81. * @return {Array}
  82. */
  83. const addIndexesToRowData = (rowData) => {
  84. return rowData.map((data, index) => {
  85. return {...data, index};
  86. });
  87. };
  88. /**
  89. * Calculate the scroll position for the beginning of each category from
  90. * the row data.
  91. *
  92. * @method
  93. * @param {Array} rowData List of emoji row data
  94. * @return {Object}
  95. */
  96. const getCategoryScrollPositionsFromRowData = (rowData) => {
  97. return rowData.reduce((carry, row, index) => {
  98. if (row.type === ROW_TYPE.HEADER) {
  99. carry[row.data.name] = index * ROW_HEIGHT_RAW;
  100. }
  101. return carry;
  102. }, {});
  103. };
  104. /**
  105. * Create a header row element for the category name.
  106. *
  107. * @method
  108. * @param {Number} rowIndex Index of the row in the row data
  109. * @param {String} name The category display name
  110. * @return {Element}
  111. */
  112. const createHeaderRow = async(rowIndex, name) => {
  113. const context = {
  114. index: rowIndex,
  115. text: name
  116. };
  117. const html = await renderTemplate('core/emoji/header_row', context);
  118. const temp = document.createElement('div');
  119. temp.innerHTML = html;
  120. return temp.firstChild;
  121. };
  122. /**
  123. * Create an emoji row element.
  124. *
  125. * @method
  126. * @param {Number} rowIndex Index of the row in the row data
  127. * @param {Array} emojis The list of emoji data for the row
  128. * @return {Element}
  129. */
  130. const createEmojiRow = async(rowIndex, emojis) => {
  131. const context = {
  132. index: rowIndex,
  133. emojis: emojis.map(emojiData => {
  134. const charCodes = emojiData.unified.split('-').map(code => `0x${code}`);
  135. const emojiText = String.fromCodePoint.apply(null, charCodes);
  136. return {
  137. shortnames: `:${emojiData.shortnames.join(': :')}:`,
  138. unified: emojiData.unified,
  139. text: emojiText,
  140. spacer: false
  141. };
  142. }),
  143. spacers: Array(EMOJIS_PER_ROW - emojis.length).fill(true)
  144. };
  145. const html = await renderTemplate('core/emoji/emoji_row', context);
  146. const temp = document.createElement('div');
  147. temp.innerHTML = html;
  148. return temp.firstChild;
  149. };
  150. /**
  151. * Check if the element is an emoji element.
  152. *
  153. * @method
  154. * @param {Element} element Element to check
  155. * @return {Bool}
  156. */
  157. const isEmojiElement = element => element.getAttribute('data-short-names') !== null;
  158. /**
  159. * Search from an element and up through it's ancestors to fine the category
  160. * selector element and return it.
  161. *
  162. * @method
  163. * @param {Element} element Element to begin searching from
  164. * @return {Element|null}
  165. */
  166. const findCategorySelectorFromElement = element => {
  167. if (!element) {
  168. return null;
  169. }
  170. if (element.getAttribute('data-action') === 'show-category') {
  171. return element;
  172. } else {
  173. return findCategorySelectorFromElement(element.parentElement);
  174. }
  175. };
  176. const getCategorySelectorByCategoryName = (root, name) => {
  177. return root.querySelector(`[data-category="${name}"]`);
  178. };
  179. /**
  180. * Sets the given category selector element as active.
  181. *
  182. * @method
  183. * @param {Element} root The root picker element
  184. * @param {Element} element The category selector element to make active
  185. */
  186. const setCategorySelectorActive = (root, element) => {
  187. const allCategorySelectors = root.querySelectorAll(SELECTORS.CATEGORY_SELECTOR);
  188. for (let i = 0; i < allCategorySelectors.length; i++) {
  189. const selector = allCategorySelectors[i];
  190. selector.classList.remove('selected');
  191. }
  192. element.classList.add('selected');
  193. };
  194. /**
  195. * Get the category selector element and the scroll positions for the previous and
  196. * next categories for the given scroll position.
  197. *
  198. * @method
  199. * @param {Element} root The picker root element
  200. * @param {Number} position The position to get the category for
  201. * @param {Object} categoryScrollPositions Set of scroll positions for all categories
  202. * @return {Array}
  203. */
  204. const getCategoryByScrollPosition = (root, position, categoryScrollPositions) => {
  205. let positions = [];
  206. if (position < 0) {
  207. position = 0;
  208. }
  209. // Get all of the category positions.
  210. for (const categoryName in categoryScrollPositions) {
  211. const categoryPosition = categoryScrollPositions[categoryName];
  212. positions.push([categoryPosition, categoryName]);
  213. }
  214. // Sort the positions in ascending order.
  215. positions.sort(([a], [b]) => {
  216. if (a < b) {
  217. return -1;
  218. } else if (a > b) {
  219. return 1;
  220. } else {
  221. return 0;
  222. }
  223. });
  224. // Get the current category name as well as the previous and next category
  225. // positions from the sorted list of positions.
  226. const {categoryName, previousPosition, nextPosition} = positions.reduce(
  227. (carry, candidate) => {
  228. const [categoryPosition, categoryName] = candidate;
  229. if (categoryPosition <= position) {
  230. carry.categoryName = categoryName;
  231. carry.previousPosition = carry.currentPosition;
  232. carry.currentPosition = position;
  233. } else if (carry.nextPosition === null) {
  234. carry.nextPosition = categoryPosition;
  235. }
  236. return carry;
  237. },
  238. {
  239. categoryName: null,
  240. currentPosition: null,
  241. previousPosition: null,
  242. nextPosition: null
  243. }
  244. );
  245. return [getCategorySelectorByCategoryName(root, categoryName), previousPosition, nextPosition];
  246. };
  247. /**
  248. * Get the list of recent emojis data from local storage.
  249. *
  250. * @method
  251. * @return {Array}
  252. */
  253. const getRecentEmojis = () => {
  254. const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY);
  255. return storedData ? JSON.parse(storedData) : [];
  256. };
  257. /**
  258. * Save the list of recent emojis in local storage.
  259. *
  260. * @method
  261. * @param {Array} recentEmojis List of emoji data to save
  262. */
  263. const saveRecentEmoji = (recentEmojis) => {
  264. LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(recentEmojis));
  265. };
  266. /**
  267. * Add an emoji data to the set of recent emojis. This function will update the row
  268. * data to ensure that the recent emoji rows are correct and all of the rows are
  269. * re-indexed.
  270. *
  271. * The new set of recent emojis are saved in local storage and the full set of updated
  272. * row data and new emoji row count are returned.
  273. *
  274. * @method
  275. * @param {Array} rowData The emoji rows data
  276. * @param {Number} recentEmojiRowCount Count of the recent emoji rows
  277. * @param {Object} newEmoji The emoji data for the emoji to add to the recent emoji list
  278. * @return {Array}
  279. */
  280. const addRecentEmoji = (rowData, recentEmojiRowCount, newEmoji) => {
  281. // The first set of rows is always the recent emojis.
  282. const categoryName = rowData[0].data.name;
  283. const categoryDisplayName = rowData[0].data.displayName;
  284. const recentEmojis = getRecentEmojis();
  285. // Add the new emoji to the start of the list of recent emojis.
  286. let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)];
  287. // Limit the number of recent emojis.
  288. newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT);
  289. const newRecentEmojiRowData = createRowDataForCategory(categoryName, categoryDisplayName, newRecentEmojis);
  290. // Save the new list in local storage.
  291. saveRecentEmoji(newRecentEmojis);
  292. return [
  293. // Return the new rowData and re-index it to make sure it's all correct.
  294. addIndexesToRowData(newRecentEmojiRowData.concat(rowData.slice(recentEmojiRowCount))),
  295. newRecentEmojiRowData.length
  296. ];
  297. };
  298. /**
  299. * Calculate which rows should be visible based on the given scroll position. Adds a
  300. * buffer to amount to either side of the total number of requested rows so that
  301. * scrolling the emoji rows container is smooth.
  302. *
  303. * @method
  304. * @param {Number} scrollPosition Scroll position within the emoji container
  305. * @param {Number} visibleRowCount How many rows should be visible
  306. * @param {Array} rowData The emoji rows data
  307. * @return {Array}
  308. */
  309. const getRowsToRender = (scrollPosition, visibleRowCount, rowData) => {
  310. const minVisibleRow = scrollPosition > ROW_HEIGHT_RAW ? Math.floor(scrollPosition / ROW_HEIGHT_RAW) : 0;
  311. const start = minVisibleRow >= ROW_RENDER_BUFFER_COUNT ? minVisibleRow - ROW_RENDER_BUFFER_COUNT : minVisibleRow;
  312. const end = minVisibleRow + visibleRowCount + ROW_RENDER_BUFFER_COUNT;
  313. const rows = rowData.slice(start, end);
  314. return rows;
  315. };
  316. /**
  317. * Create a row element from the row data.
  318. *
  319. * @method
  320. * @param {Object} rowData The emoji row data
  321. * @return {Element}
  322. */
  323. const createRowElement = async(rowData) => {
  324. let row = null;
  325. if (rowData.type === ROW_TYPE.HEADER) {
  326. row = await createHeaderRow(rowData.index, rowData.data.displayName);
  327. } else {
  328. row = await createEmojiRow(rowData.index, rowData.data);
  329. }
  330. row.style.position = 'absolute';
  331. row.style.left = 0;
  332. row.style.right = 0;
  333. row.style.top = `${rowData.index * ROW_HEIGHT_RAW}px`;
  334. return row;
  335. };
  336. /**
  337. * Check if the given rows match.
  338. *
  339. * @method
  340. * @param {Object} a The first row
  341. * @param {Object} b The second row
  342. * @return {Bool}
  343. */
  344. const doRowsMatch = (a, b) => {
  345. if (a.index !== b.index) {
  346. return false;
  347. }
  348. if (a.type !== b.type) {
  349. return false;
  350. }
  351. if (typeof a.data != typeof b.data) {
  352. return false;
  353. }
  354. if (a.type === ROW_TYPE.HEADER) {
  355. return a.data.name === b.data.name;
  356. } else {
  357. if (a.data.length !== b.data.length) {
  358. return false;
  359. }
  360. for (let i = 0; i < a.data.length; i++) {
  361. if (a.data[i].unified != b.data[i].unified) {
  362. return false;
  363. }
  364. }
  365. }
  366. return true;
  367. };
  368. /**
  369. * Update the visible rows. Deletes any row elements that should no longer
  370. * be visible and creates the newly visible row elements. Any rows that haven't
  371. * changed visibility will be left untouched.
  372. *
  373. * @method
  374. * @param {Element} rowContainer The container element for the emoji rows
  375. * @param {Array} currentRows List of row data that matches the currently visible rows
  376. * @param {Array} nextRows List of row data containing the new list of rows to be made visible
  377. */
  378. const renderRows = async(rowContainer, currentRows, nextRows) => {
  379. // We need to add any rows that are in nextRows but not in currentRows.
  380. const toAdd = nextRows.filter(nextRow => !currentRows.some(currentRow => doRowsMatch(currentRow, nextRow)));
  381. // Remember which rows will still be visible so that we can insert our element in the correct place in the DOM.
  382. let toKeep = currentRows.filter(currentRow => nextRows.some(nextRow => doRowsMatch(currentRow, nextRow)));
  383. // We need to remove any rows that are in currentRows but not in nextRows.
  384. const toRemove = currentRows.filter(currentRow => !nextRows.some(nextRow => doRowsMatch(currentRow, nextRow)));
  385. const toRemoveElements = toRemove.map(rowData => rowContainer.querySelectorAll(`[data-row="${rowData.index}"]`));
  386. // Render all of the templates first.
  387. const rows = await Promise.all(toAdd.map(rowData => createRowElement(rowData)));
  388. rows.forEach((row, index) => {
  389. const rowData = toAdd[index];
  390. let nextRowIndex = null;
  391. for (let i = 0; i < toKeep.length; i++) {
  392. const candidate = toKeep[i];
  393. if (candidate.index > rowData.index) {
  394. nextRowIndex = i;
  395. break;
  396. }
  397. }
  398. // Make sure the elements get added to the DOM in the correct order (ascending by row data index)
  399. // so that they appear naturally in the tab order.
  400. if (nextRowIndex !== null) {
  401. const nextRowData = toKeep[nextRowIndex];
  402. const nextRowNode = rowContainer.querySelector(`[data-row="${nextRowData.index}"]`);
  403. rowContainer.insertBefore(row, nextRowNode);
  404. toKeep.splice(nextRowIndex, 0, toKeep);
  405. } else {
  406. toKeep.push(rowData);
  407. rowContainer.appendChild(row);
  408. }
  409. });
  410. toRemoveElements.forEach(rows => {
  411. for (let i = 0; i < rows.length; i++) {
  412. const row = rows[i];
  413. rowContainer.removeChild(row);
  414. }
  415. });
  416. };
  417. /**
  418. * Build a function to render the visible emoji rows for a given scroll
  419. * position.
  420. *
  421. * @method
  422. * @param {Element} rowContainer The container element for the emoji rows
  423. * @return {Function}
  424. */
  425. const generateRenderRowsAtPositionFunction = (rowContainer) => {
  426. let currentRows = [];
  427. let nextRows = [];
  428. let rowCount = 0;
  429. let isRendering = false;
  430. const renderNextRows = async() => {
  431. if (!nextRows.length) {
  432. return;
  433. }
  434. if (isRendering) {
  435. return;
  436. }
  437. isRendering = true;
  438. const nextRowsToRender = nextRows.slice();
  439. nextRows = [];
  440. await renderRows(rowContainer, currentRows, nextRowsToRender);
  441. currentRows = nextRowsToRender;
  442. isRendering = false;
  443. renderNextRows();
  444. };
  445. return (scrollPosition, rowData, rowLimit = VISIBLE_ROW_COUNT) => {
  446. nextRows = getRowsToRender(scrollPosition, rowLimit, rowData);
  447. renderNextRows();
  448. if (rowCount !== rowData.length) {
  449. // Adjust the height of the container to match the number of rows.
  450. rowContainer.style.height = `${rowData.length * ROW_HEIGHT_RAW}px`;
  451. }
  452. rowCount = rowData.length;
  453. };
  454. };
  455. /**
  456. * Show the search results container and hide the emoji container.
  457. *
  458. * @method
  459. * @param {Element} emojiContainer The emojis container
  460. * @param {Element} searchResultsContainer The search results container
  461. */
  462. const showSearchResults = (emojiContainer, searchResultsContainer) => {
  463. searchResultsContainer.classList.remove('hidden');
  464. emojiContainer.classList.add('hidden');
  465. };
  466. /**
  467. * Hide the search result container and show the emojis container.
  468. *
  469. * @method
  470. * @param {Element} emojiContainer The emojis container
  471. * @param {Element} searchResultsContainer The search results container
  472. * @param {Element} searchInput The search input
  473. */
  474. const clearSearch = (emojiContainer, searchResultsContainer, searchInput) => {
  475. searchResultsContainer.classList.add('hidden');
  476. emojiContainer.classList.remove('hidden');
  477. searchInput.value = '';
  478. };
  479. /**
  480. * Build function to handle mouse hovering an emoji. Shows the preview.
  481. *
  482. * @method
  483. * @param {Element} emojiPreview The emoji preview element
  484. * @param {Element} emojiShortName The emoji short name element
  485. * @return {Function}
  486. */
  487. const getHandleMouseEnter = (emojiPreview, emojiShortName) => {
  488. return (e) => {
  489. const target = e.target;
  490. if (isEmojiElement(target)) {
  491. emojiShortName.textContent = target.getAttribute('data-short-names');
  492. emojiPreview.textContent = target.textContent;
  493. }
  494. };
  495. };
  496. /**
  497. * Build function to handle mouse leaving an emoji. Removes the preview.
  498. *
  499. * @method
  500. * @param {Element} emojiPreview The emoji preview element
  501. * @param {Element} emojiShortName The emoji short name element
  502. * @return {Function}
  503. */
  504. const getHandleMouseLeave = (emojiPreview, emojiShortName) => {
  505. return (e) => {
  506. const target = e.target;
  507. if (isEmojiElement(target)) {
  508. emojiShortName.textContent = '';
  509. emojiPreview.textContent = '';
  510. }
  511. };
  512. };
  513. /**
  514. * Build the function to handle a user clicking something in the picker.
  515. *
  516. * The function currently handles clicking on the category selector or selecting
  517. * a specific emoji.
  518. *
  519. * @method
  520. * @param {Number} recentEmojiRowCount Number of rows of recent emojis
  521. * @param {Element} emojiContainer Container element for the visible of emojis
  522. * @param {Element} searchResultsContainer Contaienr element for the search results
  523. * @param {Element} searchInput Search input element
  524. * @param {Function} selectCallback Callback function to execute when a user selects an emoji
  525. * @param {Function} renderAtPosition Render function to display current visible emojis
  526. * @return {Function}
  527. */
  528. const getHandleClick = (
  529. recentEmojiRowCount,
  530. emojiContainer,
  531. searchResultsContainer,
  532. searchInput,
  533. selectCallback,
  534. renderAtPosition
  535. ) => {
  536. return (e, rowData, categoryScrollPositions) => {
  537. const target = e.target;
  538. let newRowData = rowData;
  539. let newCategoryScrollPositions = categoryScrollPositions;
  540. // Hide the search results if they are visible.
  541. clearSearch(emojiContainer, searchResultsContainer, searchInput);
  542. if (isEmojiElement(target)) {
  543. // Emoji selected.
  544. const unified = target.getAttribute('data-unified');
  545. const shortnames = target.getAttribute('data-short-names').replace(/:/g, '').split(' ');
  546. // Build the emoji data from the selected element.
  547. const emojiData = {unified, shortnames};
  548. const currentScrollTop = emojiContainer.scrollTop;
  549. const isRecentEmojiRowVisible = emojiContainer.querySelector(`[data-row="${recentEmojiRowCount - 1}"]`) !== null;
  550. // Save the selected emoji in the recent emojis list.
  551. [newRowData, recentEmojiRowCount] = addRecentEmoji(rowData, recentEmojiRowCount, emojiData);
  552. // Re-index the category scroll positions because the additional recent emoji may have
  553. // changed their positions.
  554. newCategoryScrollPositions = getCategoryScrollPositionsFromRowData(newRowData);
  555. if (isRecentEmojiRowVisible) {
  556. // If the list of recent emojis is currently visible then we need to re-render the emojis
  557. // to update the display and show the newly selected recent emoji.
  558. renderAtPosition(currentScrollTop, newRowData);
  559. }
  560. // Call the client's callback function with the selected emoji.
  561. selectCallback(target.textContent);
  562. // Return the newly calculated row data and scroll positions.
  563. return [newRowData, newCategoryScrollPositions];
  564. }
  565. const categorySelector = findCategorySelectorFromElement(target);
  566. if (categorySelector) {
  567. // Category selector.
  568. const selectedCategory = categorySelector.getAttribute('data-category');
  569. const position = categoryScrollPositions[selectedCategory];
  570. // Scroll the container to the selected category. This will trigger the
  571. // on scroll handler to re-render the visibile emojis.
  572. emojiContainer.scrollTop = position;
  573. }
  574. return [newRowData, newCategoryScrollPositions];
  575. };
  576. };
  577. /**
  578. * Build the function that handles scrolling of the emoji container to display the
  579. * correct emojis.
  580. *
  581. * We render the emoji rows as they are needed rather than all up front so that we
  582. * can avoid adding tends of thousands of elements to the DOM unnecessarily which
  583. * would bog down performance.
  584. *
  585. * @method
  586. * @param {Element} root The picker root element
  587. * @param {Number} currentVisibleRowScrollPosition The current scroll position of the container
  588. * @param {Element} emojiContainer The emojis container element
  589. * @param {Object} initialCategoryScrollPositions Scroll positions for each category
  590. * @param {Function} renderAtPosition Function to render the appropriate emojis for a scroll position
  591. * @return {Function}
  592. */
  593. const getHandleScroll = (
  594. root,
  595. currentVisibleRowScrollPosition,
  596. emojiContainer,
  597. initialCategoryScrollPositions,
  598. renderAtPosition
  599. ) => {
  600. // Scope some local variables to track the scroll positions of the categories. We need to
  601. // recalculate these because adding recent emojis can change those positions by adding
  602. // additional rows.
  603. let [
  604. currentCategoryElement,
  605. previousCategoryPosition,
  606. nextCategoryPosition
  607. ] = getCategoryByScrollPosition(root, emojiContainer.scrollTop, initialCategoryScrollPositions);
  608. return (categoryScrollPositions, rowData) => {
  609. const newScrollPosition = emojiContainer.scrollTop;
  610. const upperScrollBound = currentVisibleRowScrollPosition + ROW_HEIGHT_RAW;
  611. const lowerScrollBound = currentVisibleRowScrollPosition - ROW_HEIGHT_RAW;
  612. // We only need to update the active category indicator if the user has scrolled into a
  613. // new category scroll position.
  614. const updateActiveCategory = (newScrollPosition >= nextCategoryPosition) ||
  615. (newScrollPosition < previousCategoryPosition);
  616. // We only need to render new emoji rows if the user has scrolled far enough that a new row
  617. // would be visible (i.e. they've scrolled up or down more than 40px - the height of a row).
  618. const updateRenderRows = (newScrollPosition < lowerScrollBound) || (newScrollPosition > upperScrollBound);
  619. if (updateActiveCategory) {
  620. // New category is visible so update the active category selector and re-index the
  621. // positions incase anything has changed.
  622. [
  623. currentCategoryElement,
  624. previousCategoryPosition,
  625. nextCategoryPosition
  626. ] = getCategoryByScrollPosition(root, newScrollPosition, categoryScrollPositions);
  627. setCategorySelectorActive(root, currentCategoryElement);
  628. }
  629. if (updateRenderRows) {
  630. // A new row should be visible so re-render the visible emojis at this new position.
  631. // We request an animation frame from the browser so that we're not blocking anything.
  632. // The animation only needs to occur as soon as the browser is ready not immediately.
  633. requestAnimationFrame(() => {
  634. renderAtPosition(newScrollPosition, rowData);
  635. // Remember the updated position.
  636. currentVisibleRowScrollPosition = newScrollPosition;
  637. });
  638. }
  639. };
  640. };
  641. /**
  642. * Build the function that handles search input from the user.
  643. *
  644. * @method
  645. * @param {Element} searchInput The search input element
  646. * @param {Element} searchResultsContainer Container element to display the search results
  647. * @param {Element} emojiContainer Container element for the emoji rows
  648. * @return {Function}
  649. */
  650. const getHandleSearch = (searchInput, searchResultsContainer, emojiContainer) => {
  651. const rowContainer = searchResultsContainer.querySelector(SELECTORS.ROW_CONTAINER);
  652. // Build a render function for the search results.
  653. const renderSearchResultsAtPosition = generateRenderRowsAtPositionFunction(rowContainer);
  654. searchResultsContainer.appendChild(rowContainer);
  655. return async() => {
  656. const searchTerm = searchInput.value.toLowerCase();
  657. if (searchTerm) {
  658. // Display the search results container and hide the emojis container.
  659. showSearchResults(emojiContainer, searchResultsContainer);
  660. // Find which emojis match the user's search input.
  661. const matchingEmojis = Object.keys(EmojiData.byShortName).reduce((carry, shortName) => {
  662. if (shortName.includes(searchTerm)) {
  663. carry.push({
  664. shortnames: [shortName],
  665. unified: EmojiData.byShortName[shortName]
  666. });
  667. }
  668. return carry;
  669. }, []);
  670. const searchResultsString = await getString('searchresults', 'core');
  671. const rowData = createRowDataForCategory(searchResultsString, searchResultsString, matchingEmojis, 0);
  672. // Show the emoji rows for the search results.
  673. renderSearchResultsAtPosition(0, rowData, rowData.length);
  674. } else {
  675. // Hide the search container and show the emojis container.
  676. clearSearch(emojiContainer, searchResultsContainer, searchInput);
  677. }
  678. };
  679. };
  680. /**
  681. * Register the emoji picker event listeners.
  682. *
  683. * @method
  684. * @param {Element} root The picker root element
  685. * @param {Element} emojiContainer Root element containing the list of visible emojis
  686. * @param {Function} renderAtPosition Function to render the visible emojis at a given scroll position
  687. * @param {Number} currentVisibleRowScrollPosition What is the current scroll position
  688. * @param {Function} selectCallback Function to execute when the user picks an emoji
  689. * @param {Object} categoryScrollPositions Scroll positions for where each of the emoji categories begin
  690. * @param {Array} rowData Data representing each of the display rows for hte emoji container
  691. * @param {Number} recentEmojiRowCount Number of rows of recent emojis
  692. */
  693. const registerEventListeners = (
  694. root,
  695. emojiContainer,
  696. renderAtPosition,
  697. currentVisibleRowScrollPosition,
  698. selectCallback,
  699. categoryScrollPositions,
  700. rowData,
  701. recentEmojiRowCount
  702. ) => {
  703. const searchInput = root.querySelector(SELECTORS.SEARCH_INPUT);
  704. const searchResultsContainer = root.querySelector(SELECTORS.SEARCH_RESULTS_CONTAINER);
  705. const emojiPreview = root.querySelector(SELECTORS.EMOJI_PREVIEW);
  706. const emojiShortName = root.querySelector(SELECTORS.EMOJI_SHORT_NAME);
  707. // Build the click handler function.
  708. const clickHandler = getHandleClick(
  709. recentEmojiRowCount,
  710. emojiContainer,
  711. searchResultsContainer,
  712. searchInput,
  713. selectCallback,
  714. renderAtPosition
  715. );
  716. // Build the scroll handler function.
  717. const scrollHandler = getHandleScroll(
  718. root,
  719. currentVisibleRowScrollPosition,
  720. emojiContainer,
  721. categoryScrollPositions,
  722. renderAtPosition
  723. );
  724. const searchHandler = getHandleSearch(searchInput, searchResultsContainer, emojiContainer);
  725. // Mouse enter/leave events to show the emoji preview on hover or focus.
  726. root.addEventListener('focus', getHandleMouseEnter(emojiPreview, emojiShortName), true);
  727. root.addEventListener('blur', getHandleMouseLeave(emojiPreview, emojiShortName), true);
  728. root.addEventListener('mouseenter', getHandleMouseEnter(emojiPreview, emojiShortName), true);
  729. root.addEventListener('mouseleave', getHandleMouseLeave(emojiPreview, emojiShortName), true);
  730. // User selects an emoji or clicks on one of the emoji category selectors.
  731. root.addEventListener('click', e => {
  732. // Update the row data and category scroll positions because they may have changes if the
  733. // user selects an emoji which updates the recent emojis list.
  734. [rowData, categoryScrollPositions] = clickHandler(e, rowData, categoryScrollPositions);
  735. });
  736. // Throttle the scroll event to only execute once every 50 milliseconds to prevent performance issues
  737. // in the browser when re-rendering the picker emojis. The scroll event fires a lot otherwise.
  738. emojiContainer.addEventListener('scroll', throttle(() => scrollHandler(categoryScrollPositions, rowData), 50));
  739. // Debounce the search input so that it only executes 200 milliseconds after the user has finished typing.
  740. searchInput.addEventListener('input', debounce(searchHandler, 200));
  741. };
  742. /**
  743. * Initialise the emoji picker.
  744. *
  745. * @method
  746. * @param {Element} root The root element for the picker
  747. * @param {Function} selectCallback Callback for when the user selects an emoji
  748. */
  749. export default (root, selectCallback) => {
  750. const emojiContainer = root.querySelector(SELECTORS.EMOJIS_CONTAINER);
  751. const rowContainer = emojiContainer.querySelector(SELECTORS.ROW_CONTAINER);
  752. const recentEmojis = getRecentEmojis();
  753. // Add the recent emojis category to the list of standard categories.
  754. const allData = [{
  755. name: 'Recent',
  756. emojis: recentEmojis
  757. }, ...EmojiData.byCategory];
  758. let rowData = [];
  759. let recentEmojiRowCount = 0;
  760. /**
  761. * Split categories data into rows which represent how they will be displayed in the
  762. * picker. Each category will add a row containing the display name for the category
  763. * and a row for every 9 emojis in the category. The row data will be used to calculate
  764. * which emojis should be visible in the picker at any given time.
  765. *
  766. * E.g.
  767. * input = [
  768. * {name: 'example1', emojis: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]},
  769. * {name: 'example2', emojis: [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]},
  770. * ]
  771. * output = [
  772. * {type: 'categoryName': data: 'Example 1'},
  773. * {type: 'emojiRow': data: [1, 2, 3, 4, 5, 6, 7, 8, 9]},
  774. * {type: 'emojiRow': data: [10, 11, 12]},
  775. * {type: 'categoryName': data: 'Example 2'},
  776. * {type: 'emojiRow': data: [13, 14, 15, 16, 17, 18, 19, 20, 21]},
  777. * {type: 'emojiRow': data: [22, 23]},
  778. * ]
  779. */
  780. allData.forEach(category => {
  781. const categorySelector = getCategorySelectorByCategoryName(root, category.name);
  782. // Get the display name from the category selector button so that we don't need to
  783. // send an ajax request for the string.
  784. const categoryDisplayName = categorySelector.title;
  785. const categoryRowData = createRowDataForCategory(category.name, categoryDisplayName, category.emojis, rowData.length);
  786. if (category.name === 'Recent') {
  787. // Remember how many recent emoji rows there are because it needs to be used to
  788. // re-index the row data later when we're adding more recent emojis.
  789. recentEmojiRowCount = categoryRowData.length;
  790. }
  791. rowData = rowData.concat(categoryRowData);
  792. });
  793. // Index the row data so that we can calculate which rows should be visible.
  794. rowData = addIndexesToRowData(rowData);
  795. // Calculate the scroll positions for each of the categories within the emoji container.
  796. // These are used to know where to jump to when the user selects a specific category.
  797. const categoryScrollPositions = getCategoryScrollPositionsFromRowData(rowData);
  798. const renderAtPosition = generateRenderRowsAtPositionFunction(rowContainer);
  799. // Display the initial set of emojis.
  800. renderAtPosition(0, rowData);
  801. registerEventListeners(
  802. root,
  803. emojiContainer,
  804. renderAtPosition,
  805. 0,
  806. selectCallback,
  807. categoryScrollPositions,
  808. rowData,
  809. recentEmojiRowCount
  810. );
  811. };