チェックボックスが押されたらどこが何番目のチェックボックスなのかを特定し、マークダウンを更新する

Qiita Teamでは、タスク管理なども行いやすいように、記事中のチェックボックスを押すとマークダウンも変更されるようになっています。



今回、元々この処理の一部がjQueryで書かれていたのをReactで書き直したので、整理も兼ねて記事に起こしています。

構成としてはすごく簡易的に書くと下記の図のような感じになっています（コンフリクトなどによるエラー処理などは説明の簡潔化のため省略）。

```mermaid
sequenceDiagram
    autonumber
    actor user as ユーザー
    participant browser  as ブラウザ
    participant server as サーバー
    user->>browser:　チェックボックスを押す
    browser->>browser: 押されたチェックボックスが何番目なのかと押された後の状態を確認する
    browser->>browser: 2の情報をもとにマークダウンを変換する
    browser->>server: 3で生成したマークダウンをサーバーに送信する
    server->>server: マークダウンを更新する
```

ブラウザ側でどうマークダウンを変換するのか？という部分まで担当してもらい、サーバーは更新するのみという構成です。
具体的な実装を見ていきましょう。

## ページを描画する

まずはページを描画しましょう。インポートは省略しています。

```jsx
interface Props {
  item: {
    rawBody: string
    body: string
    updatedAt: string // コンフリクト検知用
    id: string
  }
}

export const ItemBody = ({ item }: Props) => {
  const bodyRef = useRef(null)
  const [rawBody, setRawBody] = useState(item.rawBody)
  const [updatedAt, setUpdatedAt] = useState(item.updatedAt)

  useEffect(() => {
    const fragment = document.createRange().createContextualFragment(item.body)
    bodyRef.current.innerHTML = ''
    bodyRef.current.appendChild(fragment)
  }, [item.body])

  return (
    <div ref={bodyRef} />
  )
}

```

rawBodyとupdatedAtは更新するので、useStateを使って定義しています。


## チェックボックスが変更されることを検知できるようにする。
これは簡単にaddEventListerをチェックボックスに仕込めばいいです。

::: note
今回はチェックボックスに必ず`task-list-item-checkbox`というクラスが指定されているという過程で話を進めます。
:::

updatedAtを監視しているのは、後でupdatedAtとrawBodyを更新する時に、あとでupdatedAtを変更するのですがその時にだけ発火してほしいという都合です。
handleChangeに関しては後で記述します。

```jsx
  useEffect(() => {
    const elements = bodyRef.current.getElementsByClassName('task-list-item-checkbox')
    for (let i = 0; i < elements.length; i++) {
      elements[i].addEventListener('change', handleChange, elements[i])
    }
  }, [updatedAt]) // eslint-disable-line react-hooks/exhaustive-deps

```

## どのチェックボックスが押されたのかを特定する
forをぶん回して発火した要素が何番目かを特定し、また現在チェックされているかを確認しています。
もう少し良い方法はありそうですが・・・

```jsx
  const getIndexOfChangedCheckbox = (target: HTMLElement) => {
    let index
    let checked
    const elements = bodyRef.current.getElementsByClassName('task-list-item-checkbox')
    for (let i = 0; i < elements.length; i++) {
      if (target === elements[i]) {
        index = i
        checked = elements[i].checked
      }
    }

    return { index, checked }
  }
```

## 指定された番号のマークダウンのチェックボックスを書き換える
getIndexOfChangedCheckbox(target)で特定した位置とチェックの可否をもとにマークダウンを更新します。
tasklist.convertは https://github.com/increments/tasklist.js/blob/master/src/tasklist.js があるので、そちらを詳しく気になる人は読んでみてください。また、[こちらの記事](https://r7kamura.com/articles/2015-03-12-q)でも詳しく解説されているので読んでみてください。
行っていることは、正規表現でコードブロックなどは読み飛ばしつつ`- [ ]`や`- [x]`を数えて、該当番号になったら`- [ ]`を`- [x]`に（あるいはその逆）しています。本来、コメントも飛ばす必要がありますがそちらは現在カウントしてしまっているので改善予定です。（OSSなので、改善をしていただけると嬉しいです）
ポイントは`removeEventListener`であえて一度加えたaddEventListenerを剥がしています。
これは、古い状態のrawBodyやupdatedAtをもとにしたrequestが送信されてしまうためで、rawBodyを更新した後だとhandleChangeが更新されてしまって剥がせないので注意が必要です。

```jsx
  const handleChange = ({ target }: { target: HTMLElement }) => {
    const { index, checked } = getIndexOfChangedCheckbox(target)
    const convertedRawBody = tasklist.convert(rawBody, index + 1, checked)
    updateArticle({ convertedRawBody: convertedRawBody }) // サーバーにリクエストして更新している
    const elements = bodyRef.current.getElementsByClassName('task-list-item-checkbox')
    for (let i = 0; i < elements.length; i++) {
      elements[i].removeEventListener('change', handleChange, elements[i])
    }
  }
```

## 更新したマークダウンをサーバーに送る
今回はaxiosを使って`articles/:id`という仮のURLに送っています。
通信が成功した場合はrawBodyとupdatedAtを更新し、updatedAtが更新されると先述のuseEffectが発火し、addEventListenerが再度仕込まれるようになっています。
エラーに関しては、本来はメッセージを出したりするのですが今回は簡略化のためにconsole.logだけしています。
サーバー側の実装に関しては更新して、jsonを返すだけなので今回は割愛します。

```jsx
  const updateArticle = ({ convertedRawBody }: { convertedRawBody: string }) => {
    axios
      .patch(`/articles/${id}`, {
        raw_body: convertedRawBody,
        updated_at: updatedAt,
      })
      .then((res) => {
        setRawBody(convertedRawBody)
        setUpdatedAt(res.data.updatedAt) // コンフリクトしないようにいつの状態かをもっておく
      })
      .catch((err) => console.log(err))
  }
```

## まとめ
今回はReactとイベントリスナーを使って、チェックボックスを変更した時にマークダウンの変更をいい感じにする方法に関してまとめてみました。
removeEventListenerをどのタイミングで仕込むかなどは、Reactのライフサイクルをしっかり把握してないとつまる部分で書き換えようとした時にかなり苦戦をしました。
若干出力方法は違いますが、 https://github.com/github/task-lists-element でもどこのチェックボックスが押されたか特定できるライブラリはあったりするので、にたような仕組みを作ろうとしている人は是非参考にしてみてください！