mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 19:45:25 +01:00 
			
		
		
		
	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  # After  --------- Signed-off-by: Yarden Shoham <git@yardenshoham.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
		
							parent
							
								
									ce9c1ddc4c
								
							
						
					
					
						commit
						3cc87370c3
					
				| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user