From 3cc87370c3b4495edf9f31a9f96e2b6f34cbd35d Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Sat, 22 Apr 2023 18:32:34 +0300 Subject: [PATCH] Improve emoji and mention matching (#24255) Prioritize matches that start with the given text, then matches that contain the given text. I wanted to add a heart emoji on a pull request comment so I started writing `:`, `h`, `e`, `a`, `r` (at this point I still couldn't find the heart), `t`... The heart was not on the list, that's weird - it feels like I made a typo or a mistake. This fixes that. This also feels more like GitHub's emoji auto-complete. # Before ![image](https://user-images.githubusercontent.com/20454870/233630750-bd0a1b76-33d0-41d4-9218-a37b670c42b0.png) # After ![image](https://user-images.githubusercontent.com/20454870/233775128-05e67fc1-e092-4025-b6f7-1fd8e5f71e87.png) --------- Signed-off-by: Yarden Shoham Co-authored-by: silverwind --- .../js/features/comp/ComboMarkdownEditor.js | 22 ++------- web_src/js/test/setup.js | 9 ++++ web_src/js/utils/match.js | 43 +++++++++++++++++ web_src/js/utils/match.test.js | 47 +++++++++++++++++++ 4 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 web_src/js/utils/match.js create mode 100644 web_src/js/utils/match.test.js diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index eb73b0914d..9995033e89 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -5,11 +5,11 @@ import {attachTribute} from '../tribute.js'; import {hideElem, showElem, autosize} from '../../utils/dom.js'; import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; -import {emojiKeys, emojiString} from '../emoji.js'; +import {emojiString} from '../emoji.js'; import {renderPreviewPanelContent} from '../repo-editor.js'; +import {matchEmoji, matchMention} from '../../utils/match.js'; let elementIdCounter = 0; -const maxExpanderMatches = 6; /** * validate if the given textarea is non-empty. @@ -106,14 +106,7 @@ class ComboMarkdownEditor { const expander = this.container.querySelector('text-expander'); expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { if (key === ':') { - const matches = []; - const textLowerCase = text.toLowerCase(); - for (const name of emojiKeys) { - if (name.toLowerCase().includes(textLowerCase)) { - matches.push(name); - if (matches.length >= maxExpanderMatches) break; - } - } + const matches = matchEmoji(text); if (!matches.length) return provide({matched: false}); const ul = document.createElement('ul'); @@ -129,14 +122,7 @@ class ComboMarkdownEditor { provide({matched: true, fragment: ul}); } else if (key === '@') { - const matches = []; - const textLowerCase = text.toLowerCase(); - for (const obj of window.config.tributeValues) { - if (obj.key.toLowerCase().includes(textLowerCase)) { - matches.push(obj); - if (matches.length >= maxExpanderMatches) break; - } - } + const matches = matchMention(text); if (!matches.length) return provide({matched: false}); const ul = document.createElement('ul'); diff --git a/web_src/js/test/setup.js b/web_src/js/test/setup.js index e0e2c71e29..d9f0b8b547 100644 --- a/web_src/js/test/setup.js +++ b/web_src/js/test/setup.js @@ -3,4 +3,13 @@ window.config = { pageData: {}, i18n: {}, appSubUrl: '', + tributeValues: [ + {key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'}, + {key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'}, + {key: 'user3 User 3', value: 'user3', name: 'user3', fullname: 'User 3', avatar: 'https://avatar3.com'}, + {key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'}, + {key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'}, + {key: 'user6 User 6', value: 'user6', name: 'user6', fullname: 'User 6', avatar: 'https://avatar6.com'}, + {key: 'user7 User 7', value: 'user7', name: 'user7', fullname: 'User 7', avatar: 'https://avatar7.com'}, + ], }; diff --git a/web_src/js/utils/match.js b/web_src/js/utils/match.js new file mode 100644 index 0000000000..0d20ca336f --- /dev/null +++ b/web_src/js/utils/match.js @@ -0,0 +1,43 @@ +import emojis from '../../../assets/emoji.json'; + +const maxMatches = 6; + +function sortAndReduce(map) { + const sortedMap = new Map([...map.entries()].sort((a, b) => a[1] - b[1])); + return Array.from(sortedMap.keys()).slice(0, maxMatches); +} + +export function matchEmoji(queryText) { + const query = queryText.toLowerCase().replaceAll('_', ' '); + if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]); + + // results is a map of weights, lower is better + const results = new Map(); + for (const {aliases} of emojis) { + const mainAlias = aliases[0]; + for (const [aliasIndex, alias] of aliases.entries()) { + const index = alias.replaceAll('_', ' ').indexOf(query); + if (index === -1) continue; + const existing = results.get(mainAlias); + const rankedIndex = index + aliasIndex; + results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex); + } + } + + return sortAndReduce(results); +} + +export function matchMention(queryText) { + const query = queryText.toLowerCase(); + + // results is a map of weights, lower is better + const results = new Map(); + for (const obj of window.config.tributeValues) { + const index = obj.key.toLowerCase().indexOf(query); + if (index === -1) continue; + const existing = results.get(obj); + results.set(obj, existing ? existing - index : index); + } + + return sortAndReduce(results); +} diff --git a/web_src/js/utils/match.test.js b/web_src/js/utils/match.test.js new file mode 100644 index 0000000000..78710f2a5f --- /dev/null +++ b/web_src/js/utils/match.test.js @@ -0,0 +1,47 @@ +import {test, expect} from 'vitest'; +import {matchEmoji, matchMention} from './match.js'; + +test('matchEmoji', () => { + expect(matchEmoji('')).toEqual([ + '+1', + '-1', + '100', + '1234', + '1st_place_medal', + '2nd_place_medal', + ]); + + expect(matchEmoji('hea')).toEqual([ + 'headphones', + 'headstone', + 'health_worker', + 'hear_no_evil', + 'heard_mcdonald_islands', + 'heart', + ]); + + expect(matchEmoji('hear')).toEqual([ + 'hear_no_evil', + 'heard_mcdonald_islands', + 'heart', + 'heart_decoration', + 'heart_eyes', + 'heart_eyes_cat', + ]); + + expect(matchEmoji('poo')).toEqual([ + 'poodle', + 'hankey', + 'spoon', + 'bowl_with_spoon', + ]); + + expect(matchEmoji('1st_')).toEqual([ + '1st_place_medal', + ]); +}); + +test('matchMention', () => { + expect(matchMention('')).toEqual(window.config.tributeValues.slice(0, 6)); + expect(matchMention('user4')).toEqual([window.config.tributeValues[3]]); +});