はじめに
- GitHub.com では issue/PR のテキストエリアに
:a
のような:emoji_shortcode:
と呼ばれる文字列を入力すると、入力補完の形でサジェストが表示されます。 - どのような仕組みで実現されているか気になったので、調べたことをまとめました。
仕組みを追う - ビュー
テキストエリア周辺のDOMを確認すると次のようになっていました。
(絵文字のサジェストに関係がある部分のみ抜粋しています。)
<text-expander
keys=": @ #"
data-issue-url="/sugesstions/issue/..."
data-mention-url="/suggestions/issue/..."
data-emoji-url="/autocomplete/emoji"
multiword="#"
>
<textarea></textarea>
<ul role="listbox">
<li role="option"><g-moji></g-moji></li>
<li role="option"><g-moji></g-moji></li>
<li role="option"><g-moji></g-moji></li>
<li role="option"><g-moji></g-moji></li>
<li role="option"><g-moji></g-moji></li>
</ul>
</text-expander>
<text-expander>要素
とは
これが<text-expander>要素
の実装になります。
実装を確認すると次のことが分かります。
- カスタム要素 1 である。
-
<text-expander>要素
の子孫関係にあるinput[type="text"]
かtextarea
を監視する。 -
keys
属性に登録されたキーが入力されると、text-expander-change
イベントを発生させる。-
text-expander-change
のイベントリスナーからmenu
となる要素を受け取り<text-expander>要素
にappendする。
-
-
menu
となる要素の項目が選択されると、text-expander-value
イベントを発生させる。 - テキストエリアフォーカスが外れると、
menu
となる要素を削除する。 - etc...
<g-moji>要素
とは
絵文字非対応のブラウザでも絵文字を利用できるように、文字を代替画像に置き換えてくれるカスタム要素でした。
仕組みを追う - ロジック
<text-expander>要素
が発生させているイベントを監視しているスクリプトが存在しているだろうと思い、開発者ツールからイベント名で検索してみると emoji-suggester.ts
というファイルが見つかりました。
emoji-suggester.tsの中身
import {compose, fromEvent} from '@github-ui/subscription'
import {compare} from '../../fuzzy-filter'
import {fetchSafeDocumentFragment} from '@github-ui/fetch-utils'
import {filterSort} from '../../filter-sort'
import memoize from '@github/memoize'
// eslint-disable-next-line no-restricted-imports
import {observe} from '@github/selector-observer'
function getValue(el: Element): string | undefined | null {
if (el.hasAttribute('data-use-colon-emoji')) {
return el.getAttribute('data-value')
}
const emojiEl = el.firstElementChild
if (emojiEl && 'G-EMOJI' === emojiEl.tagName && !emojiEl.firstElementChild) {
return emojiEl.textContent
} else {
return el.getAttribute('data-value')
}
}
function search(items: Element[], searchQuery: string): Element[] {
const query = ` ${searchQuery.toLowerCase().replace(/_/g, ' ')}`
const key = (item: Element) => {
const text = item.getAttribute('data-emoji-name')!
const score = emojiScore(emojiText(item), query)
return score > 0 ? {score, text} : null
}
return filterSort(items, key, compare)
}
function emojiText(item: Element): string {
const aliases = item.getAttribute('data-text')!.trim().toLowerCase().replace(/_/g, ' ')
return ` ${aliases}`
}
function emojiScore(aliases: string, query: string): number {
const score = aliases.indexOf(query)
return score > -1 ? 1000 - score : 0
}
observe('text-expander[data-emoji-url]', {
subscribe: el =>
compose(
fromEvent(el, 'text-expander-change', onchange),
fromEvent(el, 'text-expander-value', onvalue),
fromEvent(el, 'text-expander-activate', onactivate),
),
})
function onvalue(event: Event) {
const detail = (event as CustomEvent).detail
if (detail.key !== ':') return
detail.value = getValue(detail.item)
}
function onchange(event: Event) {
const {key, provide, text} = (event as CustomEvent).detail // 追記: key は `:`, textは `:` に続く文字列が入っている
if (key !== ':') return
const menu = event.target as Element
const url = menu.getAttribute('data-emoji-url')!
provide(emojiMenu(url, text))
}
function onactivate(event: Event) {
const expander = event.target as Element
const popover = expander.querySelector<HTMLElement>('.emoji-suggestions[popover]')
if (popover) popover.showPopover()
}
async function emojiMenu(url: string, query: string): Promise<{fragment: HTMLElement; matched: boolean}> {
const [list, children] = await cachedEmoji(url)
const results = search(children, query).slice(0, 5)
list.textContent = ''
for (const el of results) list.append(el)
return {fragment: list, matched: results.length > 0}
}
async function fetchEmoji(url: string): Promise<[HTMLElement, Element[]]> {
const fragment = await fetchSafeDocumentFragment(document, url)
const root = fragment.firstElementChild as HTMLElement
return [root, [...root.children]]
}
const cachedEmoji = memoize(fetchEmoji)
中身を確認してみると、
- ページ表示時に読み込まれるスクリプトの一部。
-
text-expander-change
イベントが発生したら、絵文字の候補リストを作成して<text-expander>要素
に通知する。 -
text-expander-value
イベントが発生したら、ユーザーが選択した項目に対応する絵文字を表示させる。 -
text-expander-activate
イベントが発生したら、絵文字候補のを表示させる。
ということが分かりました。
まとめ
GitHub.com の絵文字サジェストは <text-expander>要素
というカスタム要素(とその他のいくつかのカスタム要素の組み合わせ)と emoji-suggestion.ts
の組み合わせで実現されていることが分かりました。
また、利用されているカスタム要素は公開されているので自分で似た仕組みを作ることはできそうです。
combobox-nav に関しては本文では触れていませんが、キーボードの"↑", "↓"で候補の選択を切り替えたり、"ESC" キーで候補を非表示する振る舞いを追加するために利用されています。