-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix(frontend): 絵文字オートコンプリートの優先順位がおかしいのを修正 (#13423)
* 絵文字オートコンプリートの優先順位がおかしいのを修正 * update CHANGELOG.md * テストを追加 * lint fix
- Loading branch information
Showing
4 changed files
with
138 additions
and
94 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
export type EmojiDef = { | ||
emoji: string; | ||
name: string; | ||
url: string; | ||
aliasOf?: string; | ||
} | { | ||
emoji: string; | ||
name: string; | ||
aliasOf?: string; | ||
isCustomEmoji?: true; | ||
}; | ||
type EmojiScore = { emoji: EmojiDef, score: number }; | ||
|
||
export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] { | ||
if (!query) { | ||
return []; | ||
} | ||
|
||
const matched = new Map<string, EmojiScore>(); | ||
// 完全一致(エイリアスなし) | ||
emojiDb.some(x => { | ||
if (x.name === query && !x.aliasOf) { | ||
matched.set(x.name, { emoji: x, score: query.length + 3 }); | ||
} | ||
return matched.size === max; | ||
}); | ||
|
||
// 完全一致(エイリアス込み) | ||
if (matched.size < max) { | ||
emojiDb.some(x => { | ||
if (x.name === query && !matched.has(x.aliasOf ?? x.name)) { | ||
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 }); | ||
} | ||
return matched.size === max; | ||
}); | ||
} | ||
|
||
// 前方一致(エイリアスなし) | ||
if (matched.size < max) { | ||
emojiDb.some(x => { | ||
if (x.name.startsWith(query) && !x.aliasOf && !matched.has(x.name)) { | ||
matched.set(x.name, { emoji: x, score: query.length + 1 }); | ||
} | ||
return matched.size === max; | ||
}); | ||
} | ||
|
||
// 前方一致(エイリアス込み) | ||
if (matched.size < max) { | ||
emojiDb.some(x => { | ||
if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) { | ||
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length }); | ||
} | ||
return matched.size === max; | ||
}); | ||
} | ||
|
||
// 部分一致(エイリアス込み) | ||
if (matched.size < max) { | ||
emojiDb.some(x => { | ||
if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) { | ||
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 }); | ||
} | ||
return matched.size === max; | ||
}); | ||
} | ||
|
||
// 簡易あいまい検索(3文字以上) | ||
if (matched.size < max && query.length > 3) { | ||
const queryChars = [...query]; | ||
const hitEmojis = new Map<string, EmojiScore>(); | ||
|
||
for (const x of emojiDb) { | ||
// 文字列の位置を進めながら、クエリの文字を順番に探す | ||
|
||
let pos = 0; | ||
let hit = 0; | ||
for (const c of queryChars) { | ||
pos = x.name.indexOf(c, pos); | ||
if (pos <= -1) break; | ||
hit++; | ||
} | ||
|
||
// 半分以上の文字が含まれていればヒットとする | ||
if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) { | ||
hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 }); | ||
} | ||
} | ||
|
||
// ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分) | ||
[...hitEmojis.values()] | ||
.sort((x, y) => y.score - x.score) | ||
.slice(0, 6) | ||
.forEach(it => matched.set(it.emoji.name, it)); | ||
} | ||
|
||
return [...matched.values()] | ||
.sort((x, y) => y.score - x.score) | ||
.slice(0, max) | ||
.map(it => it.emoji); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
/* | ||
* SPDX-FileCopyrightText: syuilo and misskey-project | ||
* SPDX-License-Identifier: AGPL-3.0-only | ||
*/ | ||
|
||
import { assert, describe, test } from 'vitest'; | ||
import { searchEmoji } from '@/scripts/search-emoji.js'; | ||
|
||
describe('emoji autocomplete', () => { | ||
test('名前の完全一致は名前の前方一致より優先される', async () => { | ||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]); | ||
assert.equal(result[0].emoji, ':foooo:'); | ||
}); | ||
|
||
test('名前の前方一致は名前の部分一致より優先される', async () => { | ||
const result = searchEmoji('baaa', [{ emoji: ':baaar:', name: 'baaar' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]); | ||
assert.equal(result[0].emoji, ':baaar:'); | ||
}); | ||
|
||
test('名前の完全一致はタグの完全一致より優先される', async () => { | ||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]); | ||
assert.equal(result[0].emoji, ':foooo:'); | ||
}); | ||
|
||
test('名前の前方一致はタグの前方一致より優先される', async () => { | ||
const result = searchEmoji('foo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]); | ||
assert.equal(result[0].emoji, ':foooo:'); | ||
}); | ||
|
||
test('名前の部分一致はタグの部分一致より優先される', async () => { | ||
const result = searchEmoji('oooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]); | ||
assert.equal(result[0].emoji, ':foooo:'); | ||
}); | ||
}); |