GitHub.comにおける絵文字サジェストの仕組み

## はじめに

![CleanShot 2024-01-28 at 10.21.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3681749/d64b019b-cc81-f263-e43a-e0315b2239a6.png)


* GitHub.com では issue/PR のテキストエリアに `:a` のような `:emoji_shortcode:` と呼ばれる文字列を入力すると、入力補完の形でサジェストが表示されます。
* どのような仕組みで実現されているか気になったので、調べたことをまとめました。

## 仕組みを追う - ビュー

テキストエリア周辺のDOMを確認すると次のようになっていました。
（絵文字のサジェストに関係がある部分のみ抜粋しています。）

```html
<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>要素` とは

https://github.com/github/text-expander-element

これが`<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...

[^1]: https://developer.mozilla.org/ja/docs/Web/API/Web_components/Using_custom_elements を参照


### `<g-moji>要素` とは
https://github.com/github/g-emoji-element

絵文字非対応のブラウザでも絵文字を利用できるように、文字を代替画像に置き換えてくれるカスタム要素でした。


## 仕組みを追う - ロジック

`<text-expander>要素` が発生させているイベントを監視しているスクリプトが存在しているだろうと思い、開発者ツールからイベント名で検索してみると `emoji-suggester.ts` というファイルが見つかりました。

![CleanShot 2024-01-28 at 12.33.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3681749/245d8b4d-07b1-f84a-d300-e93c0a75aeb4.png)


<details>
<summary>emoji-suggester.tsの中身</summary>

```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)
```

</details>

中身を確認してみると、

* ページ表示時に読み込まれるスクリプトの一部。
* `text-expander-change` イベントが発生したら、絵文字の候補リストを作成して `<text-expander>要素` に通知する。
* `text-expander-value` イベントが発生したら、ユーザーが選択した項目に対応する絵文字を表示させる。
* `text-expander-activate` イベントが発生したら、絵文字候補のを表示させる。

ということが分かりました。

## まとめ

GitHub.com の絵文字サジェストは `<text-expander>要素` というカスタム要素（とその他のいくつかのカスタム要素の組み合わせ）と `emoji-suggestion.ts` の組み合わせで実現されていることが分かりました。


また、利用されているカスタム要素は公開されているので自分で似た仕組みを作ることはできそうです。

https://github.com/github/text-expander-element

https://github.com/github/g-emoji-element

https://github.com/github/combobox-nav

combobox-nav に関しては本文では触れていませんが、キーボードの"↑", "↓"で候補の選択を切り替えたり、"ESC" キーで候補を非表示する振る舞いを追加するために利用されています。