Qiita Teamでは、タスク管理なども行いやすいように、記事中のチェックボックスを押すとマークダウンも変更されるようになっています。
今回、元々この処理の一部がjQueryで書かれていたのをReactで書き直したので、整理も兼ねて記事に起こしています。
構成としてはすごく簡易的に書くと下記の図のような感じになっています(コンフリクトなどによるエラー処理などは説明の簡潔化のため省略)。
ブラウザ側でどうマークダウンを変換するのか?という部分まで担当してもらい、サーバーは更新するのみという構成です。
具体的な実装を見ていきましょう。
ページを描画する
まずはページを描画しましょう。インポートは省略しています。
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をチェックボックスに仕込めばいいです。
今回はチェックボックスに必ずtask-list-item-checkbox
というクラスが指定されているという過程で話を進めます。
updatedAtを監視しているのは、後でupdatedAtとrawBodyを更新する時に、あとでupdatedAtを変更するのですがその時にだけ発火してほしいという都合です。
handleChangeに関しては後で記述します。
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をぶん回して発火した要素が何番目かを特定し、また現在チェックされているかを確認しています。
もう少し良い方法はありそうですが・・・
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 があるので、そちらを詳しく気になる人は読んでみてください。また、こちらの記事でも詳しく解説されているので読んでみてください。
行っていることは、正規表現でコードブロックなどは読み飛ばしつつ- [ ]
や- [x]
を数えて、該当番号になったら- [ ]
を- [x]
に(あるいはその逆)しています。本来、コメントも飛ばす必要がありますがそちらは現在カウントしてしまっているので改善予定です。(OSSなので、改善をしていただけると嬉しいです)
ポイントはremoveEventListener
であえて一度加えたaddEventListenerを剥がしています。
これは、古い状態のrawBodyやupdatedAtをもとにしたrequestが送信されてしまうためで、rawBodyを更新した後だとhandleChangeが更新されてしまって剥がせないので注意が必要です。
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を返すだけなので今回は割愛します。
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 でもどこのチェックボックスが押されたか特定できるライブラリはあったりするので、にたような仕組みを作ろうとしている人は是非参考にしてみてください!