feat(editor): add lazy emoji data loader and filter

This commit is contained in:
kolaente 2026-04-14 13:01:12 +02:00 committed by kolaente
parent f6ec5d8e96
commit 542cab5ef6
2 changed files with 133 additions and 0 deletions

View File

@ -0,0 +1,58 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import {filterEmojis, __resetEmojiCacheForTest, loadEmojis} from './emojiData'
const fixture = [
{shortcodes: ['grinning', 'grinning_face'], annotation: 'grinning face', tags: ['face', 'grin'], emoji: '😀'},
{shortcodes: ['eyes'], annotation: 'eyes', tags: ['look'], emoji: '👀'},
{shortcodes: ['eyeglasses'], annotation: 'glasses', tags: ['eye'], emoji: '👓'},
{shortcodes: ['smile'], annotation: 'grinning face with smiling eyes', tags: ['eye', 'smile'], emoji: '😄'},
]
describe('emojiData', () => {
beforeEach(() => {
__resetEmojiCacheForTest()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => fixture,
}))
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('flattens multi-shortcode entries and sorts alphabetically', async () => {
const idx = await loadEmojis()
const codes = idx.map(e => e.shortcode)
expect(codes).toEqual(['eyeglasses', 'eyes', 'grinning', 'grinning_face', 'smile'])
})
it('returns [] for empty query', () => {
expect(filterEmojis([{shortcode: 'eyes', emoji: '👀', annotation: '', tags: []}], '')).toEqual([])
})
it('prefers startsWith matches over substring matches', () => {
const loaded = [
{shortcode: 'eyeglasses', emoji: '👓', annotation: 'glasses', tags: ['eye']},
{shortcode: 'eyes', emoji: '👀', annotation: 'eyes', tags: []},
{shortcode: 'smile', emoji: '😄', annotation: 'grinning face with smiling eyes', tags: ['eye']},
]
const result = filterEmojis(loaded, 'eye')
expect(result[0].shortcode).toBe('eyeglasses')
expect(result[1].shortcode).toBe('eyes')
expect(result[2].shortcode).toBe('smile')
})
it('limits results to 15', () => {
const big = Array.from({length: 100}, (_, i) => ({
shortcode: `foo_${String(i).padStart(3, '0')}`, emoji: '✨', annotation: '', tags: [],
}))
expect(filterEmojis(big, 'foo')).toHaveLength(15)
})
it('caches the fetch promise across calls', async () => {
await loadEmojis()
await loadEmojis()
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1)
})
})

View File

@ -0,0 +1,75 @@
export interface EmojiEntry {
emoji: string
shortcode: string
annotation: string
tags: string[]
}
interface RawEmoji {
shortcodes: string[]
annotation: string
tags?: string[]
emoji: string
}
const MAX_RESULTS = 15
let cache: Promise<EmojiEntry[]> | null = null
export function __resetEmojiCacheForTest() {
cache = null
}
export function loadEmojis(): Promise<EmojiEntry[]> {
if (cache) return cache
cache = fetch('/emojis.json')
.then(res => {
if (!res.ok) throw new Error(`emojis.json HTTP ${res.status}`)
return res.json() as Promise<RawEmoji[]>
})
.then(raw => {
const flat: EmojiEntry[] = []
for (const entry of raw) {
for (const shortcode of entry.shortcodes) {
flat.push({
emoji: entry.emoji,
shortcode,
annotation: entry.annotation,
tags: entry.tags ?? [],
})
}
}
flat.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
return flat
})
.catch(err => {
cache = null
throw err
})
return cache
}
export function filterEmojis(index: EmojiEntry[], rawQuery: string): EmojiEntry[] {
const query = rawQuery.toLowerCase()
if (query === '') return []
const starts: EmojiEntry[] = []
const contains: EmojiEntry[] = []
for (const entry of index) {
if (entry.shortcode.startsWith(query)) {
starts.push(entry)
continue
}
if (
entry.shortcode.includes(query) ||
entry.annotation.toLowerCase().includes(query) ||
entry.tags.some(t => t.toLowerCase().includes(query))
) {
contains.push(entry)
}
if (starts.length >= MAX_RESULTS) break
}
return [...starts, ...contains].slice(0, MAX_RESULTS)
}