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 <git@yardenshoham.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Yarden Shoham 2023-04-22 18:32:34 +03:00 committed by GitHub
parent ce9c1ddc4c
commit 3cc87370c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 103 additions and 18 deletions

View file

@ -5,11 +5,11 @@ import {attachTribute} from '../tribute.js';
import {hideElem, showElem, autosize} from '../../utils/dom.js'; import {hideElem, showElem, autosize} from '../../utils/dom.js';
import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js'; import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
import {emojiKeys, emojiString} from '../emoji.js'; import {emojiString} from '../emoji.js';
import {renderPreviewPanelContent} from '../repo-editor.js'; import {renderPreviewPanelContent} from '../repo-editor.js';
import {matchEmoji, matchMention} from '../../utils/match.js';
let elementIdCounter = 0; let elementIdCounter = 0;
const maxExpanderMatches = 6;
/** /**
* validate if the given textarea is non-empty. * validate if the given textarea is non-empty.
@ -106,14 +106,7 @@ class ComboMarkdownEditor {
const expander = this.container.querySelector('text-expander'); const expander = this.container.querySelector('text-expander');
expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
if (key === ':') { if (key === ':') {
const matches = []; const matches = matchEmoji(text);
const textLowerCase = text.toLowerCase();
for (const name of emojiKeys) {
if (name.toLowerCase().includes(textLowerCase)) {
matches.push(name);
if (matches.length >= maxExpanderMatches) break;
}
}
if (!matches.length) return provide({matched: false}); if (!matches.length) return provide({matched: false});
const ul = document.createElement('ul'); const ul = document.createElement('ul');
@ -129,14 +122,7 @@ class ComboMarkdownEditor {
provide({matched: true, fragment: ul}); provide({matched: true, fragment: ul});
} else if (key === '@') { } else if (key === '@') {
const matches = []; const matches = matchMention(text);
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;
}
}
if (!matches.length) return provide({matched: false}); if (!matches.length) return provide({matched: false});
const ul = document.createElement('ul'); const ul = document.createElement('ul');

View file

@ -3,4 +3,13 @@ window.config = {
pageData: {}, pageData: {},
i18n: {}, i18n: {},
appSubUrl: '', 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'},
],
}; };

43
web_src/js/utils/match.js Normal file
View file

@ -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);
}

View file

@ -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]]);
});