MDL-65896 core: create emoji auto complete

This commit is contained in:
Ryan Wyllie 2019-10-03 15:02:48 +08:00
parent 8f80d18766
commit 75962db917
7 changed files with 416 additions and 0 deletions

View File

@ -0,0 +1,2 @@
define ("core/emoji/auto_complete",["exports","core/emoji/data","core/templates","core/utils","core/localstorage","core/key_codes"],function(a,b,c,d,e,f){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;b=function(a){if(a&&a.__esModule){return a}else{var b={};if(null!=a){for(var c in a){if(Object.prototype.hasOwnProperty.call(a,c)){var d=Object.defineProperty&&Object.getOwnPropertyDescriptor?Object.getOwnPropertyDescriptor(a,c):{};if(d.get||d.set){Object.defineProperty(b,c,d)}else{b[c]=a[c]}}}}b.default=a;return b}}(b);e=g(e);f=g(f);function g(a){return a&&a.__esModule?a:{default:a}}function h(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function i(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var i=a.apply(b,c);function f(a){h(i,d,e,f,g,"next",a)}function g(a){h(i,d,e,f,g,"throw",a)}f(void 0)})}}function j(a){return m(a)||l(a)||k()}function k(){throw new TypeError("Invalid attempt to spread non-iterable instance")}function l(a){if(Symbol.iterator in Object(a)||"[object Arguments]"===Object.prototype.toString.call(a))return Array.from(a)}function m(a){if(Array.isArray(a)){for(var b=0,c=Array(a.length);b<a.length;b++){c[b]=a[b]}return c}}var n="moodle-recent-emojis",o={EMOJI_BUTTON:"[data-region=\"emoji-button\"]",ACTIVE_EMOJI_BUTTON:"[data-region=\"emoji-button\"].active"},p=function(){var a=e.default.get(n);return a?JSON.parse(a):[]},q=function(a,b){var c={unified:a,shortnames:[b]},d=p(),f=[c].concat(j(d.filter(function(a){return a.unified!=c.unified})));f=f.slice(0,27);e.default.set(n,JSON.stringify(f))},r=function(a){var c=b.byShortName[a];if(c){var d=c.split("-").map(function(a){return"0x".concat(a)});return String.fromCodePoint.apply(null,d)}else{return null}},s=function(){var a=i(regeneratorRuntime.mark(function a(d,e){var f,g;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:f={emojis:e.map(function(a,c){return{active:0===c,emojitext:r(a),displayshortname:":".concat(a,":"),shortname:a,unified:b.byShortName[a]}})};a.next=3;return(0,c.render)("core/emoji/auto_complete",f);case 3:g=a.sent;d.innerHTML=g;case 5:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}(),t=function(a,c){if(""===a){return p().map(function(a){return a.shortnames[0]}).slice(0,c)}else{a=a.toLowerCase();return Object.keys(b.byShortName).filter(function(b){return b.includes(a)}).slice(0,c)}},u=function(a,b){var c=a.slice(0,b).match(/(\S*)$/),d=a.slice(b).match(/^(\S*)/),e="",f="";if(c){e=c[c.length-1]}if(d){f=d[d.length-1]}return"".concat(e).concat(f)},v=function(a){return /^:[^:\s]+:$/.test(a)},w=function(a){return /^:[^:\s]*$/.test(a)},x=function(a){return a.replace(/:/g,"")},y=function(a){return a.querySelector(o.ACTIVE_EMOJI_BUTTON)},z=function(a){var b=y(a),c=b.previousElementSibling;if(c){b.classList.remove("active");c.classList.add("active");c.scrollIntoView({behaviour:"smooth",inline:"center"})}},A=function(a){var b=y(a),c=b.nextElementSibling;if(c){b.classList.remove("active");c.classList.add("active");c.scrollIntoView({behaviour:"smooth",inline:"center"})}},B=function(a,b){var c=a.getAttribute("data-short-name"),d=a.getAttribute("data-unified");q(d,c);b(a.innerHTML.trim())};a.default=function(a,c,g,h){var i=!1,j="";c.addEventListener("keyup",(0,d.debounce)(function(){var d=c.value,e=c.selectionStart,f=u(d,e);if(f===j){return}else{j=f}if(v(f)){var k=x(f),l=r(k);i=!1;if(l){q(b.byShortName[k],k);h(l)}}else if(w(f)){var m=t(x(f),50);if(m.length){s(a,m);i=!0}else{i=!1}}else{i=!1}g(i)},200));c.addEventListener("keydown",function(b){if(i){var c=b.shiftKey||b.metaKey||b.altKey||b.ctrlKey;if(!c){switch(b.which){case f.default.escape:i=!1;g(!1);break;case f.default.arrowLeft:z(a);b.preventDefault();break;case f.default.arrowRight:A(a);b.preventDefault();break;case f.default.enter:B(y(a),h);b.preventDefault();b.stopPropagation();break;}}}});a.addEventListener("click",function(a){var b=a.target;if(b.matches(o.EMOJI_BUTTON)){B(b,h)}})};return a.default});
//# sourceMappingURL=auto_complete.min.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,326 @@
// 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.
*
* @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.
*
* @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);
}
});
};

View File

@ -0,0 +1,52 @@
{{!
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/>.
}}
{{!
@template core/emoji/auto_complete
This template will render the emoji auto complete.
Classes required for JS:
* none
Data attributes required for JS:
*
Context variables required for this template:
*
Example context (json):
{}
}}
<div
data-region="emoji-auto-complete"
class="emoji-auto-complete bg-white d-flex align-items-center"
>
{{#emojis}}
<button
type="button"
class="btn btn-link btn-icon p-0 rounded-lg emoji-button {{#active}}active{{/active}}"
title="{{displayshortname}}"
data-region="emoji-button"
data-unified="{{unified}}"
data-short-name="{{shortname}}"
>
{{emojitext}}
</button>
{{/emojis}}
</div>

View File

@ -2355,3 +2355,18 @@ $picker-emojis-per-row: 7 !default;
width: $picker-width-xs;
}
}
.emoji-auto-complete {
height: $picker-row-height;
.btn.btn-link.btn-icon.emoji-button {
height: $picker-emoji-button-size;
width: $picker-emoji-button-size;
line-height: $picker-emoji-button-size;
font-size: $picker-emoji-button-font-size;
&.active {
background-color: $gray-200;
}
}
}

View File

@ -11520,6 +11520,16 @@ div.editor_atto_toolbar button .icon {
.emoji-picker {
width: 320px; } }
.emoji-auto-complete {
height: 40px; }
.emoji-auto-complete .btn.btn-link.btn-icon.emoji-button, .emoji-auto-complete .btn.btn-icon.emoji-button, .emoji-auto-complete #page-grade-grading-manage .actions .btn-icon.emoji-button.action, #page-grade-grading-manage .actions .emoji-auto-complete .btn-icon.emoji-button.action, .emoji-auto-complete #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-icon.emoji-button, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .emoji-auto-complete input.btn-icon.emoji-button, .emoji-auto-complete #rubric-rubric.gradingform_rubric .btn-icon.emoji-button.addcriterion, #rubric-rubric.gradingform_rubric .emoji-auto-complete .btn-icon.emoji-button.addcriterion {
height: 40px;
width: 40px;
line-height: 40px;
font-size: 24px; }
.emoji-auto-complete .btn.btn-link.btn-icon.emoji-button.active, .emoji-auto-complete .btn.btn-icon.emoji-button.active, .emoji-auto-complete #page-grade-grading-manage .actions .btn-icon.emoji-button.active.action, #page-grade-grading-manage .actions .emoji-auto-complete .btn-icon.emoji-button.active.action, .emoji-auto-complete #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-icon.emoji-button.active, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .emoji-auto-complete input.btn-icon.emoji-button.active, .emoji-auto-complete #rubric-rubric.gradingform_rubric .btn-icon.emoji-button.active.addcriterion, #rubric-rubric.gradingform_rubric .emoji-auto-complete .btn-icon.emoji-button.active.addcriterion {
background-color: #e9ecef; }
.icon {
font-size: 16px;
width: 16px;

View File

@ -11775,6 +11775,16 @@ div.editor_atto_toolbar button .icon {
.emoji-picker {
width: 320px; } }
.emoji-auto-complete {
height: 40px; }
.emoji-auto-complete .btn.btn-link.btn-icon.emoji-button, .emoji-auto-complete .btn.btn-icon.emoji-button, .emoji-auto-complete #page-grade-grading-manage .actions .btn-icon.emoji-button.action, #page-grade-grading-manage .actions .emoji-auto-complete .btn-icon.emoji-button.action, .emoji-auto-complete #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-icon.emoji-button, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .emoji-auto-complete input.btn-icon.emoji-button, .emoji-auto-complete #rubric-rubric.gradingform_rubric .btn-icon.emoji-button.addcriterion, #rubric-rubric.gradingform_rubric .emoji-auto-complete .btn-icon.emoji-button.addcriterion {
height: 40px;
width: 40px;
line-height: 40px;
font-size: 24px; }
.emoji-auto-complete .btn.btn-link.btn-icon.emoji-button.active, .emoji-auto-complete .btn.btn-icon.emoji-button.active, .emoji-auto-complete #page-grade-grading-manage .actions .btn-icon.emoji-button.active.action, #page-grade-grading-manage .actions .emoji-auto-complete .btn-icon.emoji-button.active.action, .emoji-auto-complete #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-icon.emoji-button.active, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .emoji-auto-complete input.btn-icon.emoji-button.active, .emoji-auto-complete #rubric-rubric.gradingform_rubric .btn-icon.emoji-button.active.addcriterion, #rubric-rubric.gradingform_rubric .emoji-auto-complete .btn-icon.emoji-button.active.addcriterion {
background-color: #e9ecef; }
.icon {
font-size: 16px;
width: 16px;