Qiita株式会社 アドベントカレンダー21日目は、SaaS 開発チームの @phigasui が担当します
Qiita, Qiita Team といえばマークダウンエディタですね。
今回は CodeMirror 6 + React + Asciidoctor.js を使ってブラウザで動作する簡易的な AsciiDoc エディタを実装してみます。
CodeMirror とは
CodeMirror は JavaScript で実装されたブラウザ向けのエディタです。
プログラマブルで拡張性に優れているため、 CodeMirror を使ってブラウザで動作する独自のエディタを実装できます。
また、様々な言語のモードが用意されており、ハイライトや、補完などもしてくれます。
CodeMirror 6
CodeMirror 6 はそれまでの CodeMirror を書き直しアーキテクチャや従来のインタフェースが大きく変更され、より利便性、拡張性が高まっています。
モジュラー性に優れているため、必要な機能のみを選択したり、部分的にカスタムした実装に置き換えたりしやすいです。
その分、高機能のエディタを実装するには多くのモジュールを組み合わせる必要があり、その都度パッケージをインストールするのが最初は煩わしさもあります。
また、CSS-in-JS を採用しており、別途 css ファイルを読み込む必要がなくなったのも嬉しい点です。
AsciiDoc とは
AsciiDoc は軽量マークアップ言語のひとつで、可読性が高く表現力も豊かです。
Markdown の様に可読性が高く、HTMLよりも表現力はないですが、Markdown より表現力が高いです。
Qiita 社内で AsciiDoc をそんなに活用しているわけではないですが、AsciiDoc では変数の定義をして使いまわせるため、GitHub にリポジトリを作って AsciiDoc を使ってユビキタス言語の定義をしています。(GitHub は AsciiDoc のパースをしてくれる。)
Asciidoctor.js
Asciidoctor.js は AsciiDoc をパースし、HTMLへ変換してくれる JavaScript 製のライブラリです。
これを使ってエディタに入力されたテキストを変換してリアルタイムにプレビュー表示します。
CodeMirror + React でエディタを実装 する
基本のパッケージ
- @codemirror/view
- @codemirror/state
$ yarn add @codemirror/state @codemirror/view
もしくは
@codemirror/basic-setup
を使うと、view と state と基本的な拡張のセットが使えます。
$ yarn add @codemirror/basic-setup
試しに使ってみるにはこちらが楽ですが、後々必要な機能に絞ったり一部カスタムしたモジュールに変えたりしたくなったりすると思うので、basicSetup
に含まれる拡張を見て、個別入れるのがおすすめです。
basic-setup/basic-setup.ts at main · codemirror/basic-setup
CodeMirror のエディタのコンポーネント実装
import { useEffect, useRef } from 'react'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
const AsciiDocEditor = ({ defaultValue }: { defaultValue: string }) => {
const editorParentRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (editorParentRef.current === null) return
const editorView = new EditorView({
state: EditorState.create({
doc: defaultValue,
}),
parent: editorParentRef.current,
})
return () => {
editorView.destroy()
}
}, [editorParentRef])
return <div ref={editorParentRef} />
}
拡張機能を入れる
このままだとほとんど textarea
と同じなため、ある程度エディタっぽくするために幾つかの拡張を追加します。
$ yarn add @codemirror/closebrackets @codemirror/commands @codemirror/gutter
+import { closeBrackets } from '@codemirror/closebrackets'
+import { indentWithTab } from '@codemirror/commands'
+import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter'
import { EditorState } from '@codemirror/state'
+import { highlightActiveLine, EditorView, keymap } from '@codemirror/view'
...
const editorView = new EditorView({
state: EditorState.create({
doc: defaultValue,
extensions: [
+ closeBrackets(), // 閉じ brackets 補完
+ lineNumbers(), // 行数表示
+ highlightActiveLine(), // カーソル行ハイライト
+ highlightActiveLineGutter(), // カーソル行の gutter ハイライト
+ keymap.of([indentWithTab]), // タブでインデント
],
}),
parent: editorParentRef.current,
})
これで少しエディタっぽくなります。
エディタの内容をパースしてプレビューを表示する
エディタ内で変更があったときのコールバックを用意します。
view に updateListner
というファセットが用意されており、これを使って拡張として用意できます。
CodeMirror におけるファセットとは拡張ポイントを表しており、各ファセットに値を設定して拡張として定義できます。
今回の場合、view がアップデートされるタイミングでドキュメントの変更があれば、任意の関数を実行する拡張を用意します。
+const AsciiDocEditor = ({ defaultValue, onChange }: { defaultValue: string, onChange: (value: string) => void }) => {
const editorParentRef = useRef<HTMLDivElement | null>(null)
+ const updateCallback = EditorView.updateListener.of((update) => update.docChanged && onChange(update.state.doc.toString()))
useEffect(() => {
if (editorParentRef.current === null) return
const editorView = new EditorView({
state: EditorState.create({
doc: defaultValue,
extensions: [
closeBrackets(),
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
keymap.of([indentWithTab]),
+ updateCallback,
],
}),
parent: editorParentRef.current,
})
Asciidoctor.js でパースしてプレビュー表示
あとは更新された時のエディタの値を受け取って Asciidoctor.js でパースして表示するだけです。
$ yarn add @asciidoctor/core
import Asciidoctor from '@asciidoctor/core'
const parse = (rawBody: string) => {
const asciidoctor = Asciidoctor()
return asciidoctor.convert(rawBody) as string
}
const Preview = ({ html }: { html: string }) => {
return (
<div className={styles.preview} dangerouslySetInnerHTML={{
__html: html
}} />
)
}
const Page = () => {
const defaultDoc = ``
const [text, setText] = useState<string>(defaultDoc)
return (
<div className={styles.editorWrapper}>
<AsciiDocEditor defaultValue={text} onChange={(value) => setText(value)} />
<Preview html={parse(text)} />
</div>
)
}
これでプレビュー付きの AsciiDoc エディタは概ね完成です。
よりエディタとして完成度を高めるために
エディタをエディタらしく使うにはやはりそのエディタで記述する言語のモードが必須です。
ハイライトや補完、自動インデントするためには記述されている言語を理解する必要があるからです。
CodeMirror 6 でもそれまでの CodeMirror の多くの言語モードをサポートしてくれていますが、 AsciiDoc は現状ありません。(旧 CodeMirror では Asciidoctor がモードを実装してくれていた。)
この言語モードの実装も拡張として自分で実装でき、テンプレートリポジトリも用意してくれています。
ちなみに、 Markdown はサポート済みなので、ハイライトや補完などもしてくれて用意されているモジュールの組み合わせだけ使える Markdown エディタが実装できます。
CodeMirror 6 はモジュラーな仕様のため、 それこそ Emacs の様なエディタ、あるいは自作キーボードやミニ四駆をいじっている様な楽しさがありますね。
明日の Qiita株式会社 Advent Calendar 2021 は、@kyntk が担当します。
お楽しみに!