LoginSignup
7

More than 1 year has passed since last update.

CodeMirror 6 で AsciiDoc エディタを実装する

Last updated at Posted at 2021-12-20

Qiita株式会社 アドベントカレンダー21日目は、SaaS 開発チームの @phigasui が担当します :santa:

Qiita, Qiita Team といえばマークダウンエディタですね。
今回は CodeMirror 6 + React + Asciidoctor.js を使ってブラウザで動作する簡易的な AsciiDoc エディタを実装してみます。

成果物はこんな感じです。
image.png

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} />
}

エディタを表示できました。
image.png

拡張機能を入れる

このままだとほとんど 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,
     })

これで少しエディタっぽくなります。

image.png

他にも キーマップ矩形選択 の拡張もあります。

エディタの内容をパースしてプレビューを表示する

エディタ内で変更があったときのコールバックを用意します。
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 エディタは概ね完成です。
image.png

よりエディタとして完成度を高めるために

エディタをエディタらしく使うにはやはりそのエディタで記述する言語のモードが必須です。
ハイライトや補完、自動インデントするためには記述されている言語を理解する必要があるからです。
CodeMirror 6 でもそれまでの CodeMirror の多くの言語モードをサポートしてくれていますが、 AsciiDoc は現状ありません。(旧 CodeMirror では Asciidoctor がモードを実装してくれていた。)

この言語モードの実装も拡張として自分で実装でき、テンプレートリポジトリも用意してくれています。

ちなみに、 Markdown はサポート済みなので、ハイライトや補完などもしてくれて用意されているモジュールの組み合わせだけ使える Markdown エディタが実装できます。

CodeMirror 6 はモジュラーな仕様のため、 それこそ Emacs の様なエディタ、あるいは自作キーボードやミニ四駆をいじっている様な楽しさがありますね。

明日の Qiita株式会社 Advent Calendar 2021 は、@kyntk が担当します。
お楽しみに!

Refs

CodeMirror 6 に関する情報

React + CodeMirror 6 の実装

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
7