0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

CleanShot 2024-01-28 at 10.21.38.png

  • 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 というファイルが見つかりました。

CleanShot 2024-01-28 at 12.33.50.png

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" キーで候補を非表示する振る舞いを追加するために利用されています。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?