Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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