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:
parent
ce9c1ddc4c
commit
3cc87370c3
4 changed files with 103 additions and 18 deletions
|
@ -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');
|
||||||
|
|
|
@ -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
43
web_src/js/utils/match.js
Normal 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);
|
||||||
|
}
|
47
web_src/js/utils/match.test.js
Normal file
47
web_src/js/utils/match.test.js
Normal 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]]);
|
||||||
|
});
|
Loading…
Reference in a new issue