vue-codemirror を試す

CodeMirror っていう JavaScript 製のエディタコンポーネントの Vue.js 用コンポーネント vue-codemirror を触ってみたときの備忘録。

最終的なコードはここにある。

github.com

vue-codemirror をプロジェクトに読み込む

import Vue from 'vue'
import VueCodemirror from 'vue-codemirror'

import 'codemirror/lib/codemirror.css'

Vue.use(VueCodemirror)

こんな感じ。

これを plugins/vue-codemirror.js に切り出して、 main.js

import '@/plugins/vue-codemirror'

コンポーネントの利用

ドキュメント通りでできるけど、このあとのベースラインとして。

<template lang="pug">
  #app
    codemirror(v-model="code" :options="cmOptions")
</template>

<script>
export default {
  name: 'App',

  data() {
    return {
      code: '',

      cmOptions: {
      }
    }
  }
}
</script>

インデントまわりのカーソル移動をいい感じにする

これでだいたいエディタっぽいアプリをつくることはできるんだけど、インデントまわりに不満がある。個人的には以下のようにしたい。

  • インデントはタブではなく、ホワイトスペースを利用してほしい
  • インデントの先頭でバックスペースを押したとき、文字単位ではなくインデント単位で直前の空白を削除してほしい

CodeMirror のオプションを指定したらいい感じになるかなと思ったけど、うまくいかなかった。調べたら以下のような先達の tips があった。

Setting for using spaces instead of tabs · Issue #988 · codemirror/CodeMirror · GitHub

しかしこの通りにやってもうまくいかなかったので、ベースを利用しつつ、自分の好みになるように書いてみた。

Vue.use(VueCodemirror, {
  options: {
    // based on https://github.com/codemirror/CodeMirror/issues/988#issuecomment-549644684
    extraKeys: {
      Tab: (cm) => {
        if (cm.getMode().name === 'null') {
          cm.execCommand('insertTab');
        } else {
          if (cm.somethingSelected()) {
            cm.execCommand('indentMore');
          } else {
            cm.execCommand('insertSoftTab');
          }
        }
      },
      Backspace: (cm) => {
        if (!cm.somethingSelected()) {
          let cursorsPos = cm.listSelections().map((selection) => selection.anchor);
          let indentUnit = cm.options.indentUnit;
          let shouldDelChar = false;

          for (let cursorPos of cursorsPos) {
            const { start, end, string, type } = cm.getTokenAt(cursorPos)
            if (type !== 'indent' && type !== null
              || start !== 0 || end !== cursorPos.ch || end === 0
              || string.trimStart() !== ''
              || end % indentUnit > 0) {
              shouldDelChar = true
              break
            }
          }

          if (!shouldDelChar) {
            cm.execCommand('indentLess');
          } else {
            cm.execCommand('delCharBefore');
          }
        } else {
          cm.execCommand('delCharBefore');
        }
      },
      'Shift-Tab': (cm) => cm.execCommand('indentLess')
    }
  }
})

保存アクションで dirty flag を消す

CodeMirror には、 CMD-S (CTRL-S) で保存するキーバインドがデフォルトで定義されており、その際呼び出されるアクションとして save という (カラの) アクションが定義されている。

また、 CodeMirror コンポーネント自体にも save という (カラの) メソッドが定義されている (上記のアクションとは別物)。

これらをくみあわせて、保存アクションをしたときに dirty flag をリセットするようにしたい。

ちなみに CodeMirror には開発者が活用できる以下のような内部状況フラグがある。

まずは save action の上書きから。

// default save action の上書き
VueCodemirror.CodeMirror.commands.save = (cm) => {
  // 保存されるときに "clean" 扱いにする
  cm.markClean()

  // 現状ではなにも内容の定義されていない、コンポーネントの save メソッドを呼び出しておく
  cm.save(cm)
}

つぎはコンポーネント側。

    codemirror#editor(ref="cmComponent" v-model="code" :options="cmOptions" @input="cmChanged")
export default {
  name: 'App',

  data() {
    return {
      code: '',
      isClean: true,

      cmOptions: {
        // ...
      }
    }
  },

  computed: {
    cmComponent() {
      return this.$refs.cmComponent
    }
  },

  mounted() {
    this.cmComponent.codemirror.save = (cm) => {
      this.isClean = cm.isClean()
    }
  },

  methods: {
    cmChanged() {
      this.isClean = this.cmComponent.codemirror.isClean()
    }
  }
}

コンポーネントの save メソッド側で、 手元のコンポーネントの clean フラグに CodeMirror の isClean フラグの状況を反映させる。 また、 CodeMirror コンポーネントの change 時にもそのフラグの状況を反映させる。

そもそも CodeMirror の clean フラグを使わなくっても、 CMD-S を自前でハンドリングしたり、 change のときだけ not clean にしたりすればいいんじゃ?って思うかもだけど、実は CodeMirror 側では、 Undo 等して、編集差分が発生しなくなったときに clean になったりする。なので、 clean フラグ自体は CodeMirror 側に負わせるのがよい。

(とはいえ、 save action とコンポーネントの save method をどちらもオーバライドしているこのやりかたはすこしくどすぎるかも。 save action の上書きだけでよさそう。あと、 change イベントと save action をハンドリングして内部のフラグをもってきているから、いまいちリアクティブみがない。もっとよい方法ないのかな。)

集大成として Markdown editor もどき

以上のサンプルコードとして、 Markdown editor もどきをつくった。

再掲になるけど、

github.com

https://user-images.githubusercontent.com/42583/77849162-8715a600-7204-11ea-8501-943fa39cdea4.png

べつだんリアルタイムプレビューを出すのはそこまで難しくないんだけど、 clean フラグのとりまわしをやりたかったので、 あえて CMD-S を押さないとプレビューに反映されないようになっている。