【SpecTest GUI】MonacoEditor + Vue.js/Electron

# SpecTest GUI ヘの道（1）

## 誰向け?

* VSCode で使われている **Monaco Editor** に興味ある人
* **Monaco Editor** で Markdown Editor を **Electron ベースで** 作りたい人
* SpecTest を **応援してくれる** 人

尚、今回の結果は以下にコミットしてあります。

* [GUI for SpecTest (spectest-gui)](https://github.com/Kray-G/spectest-gui)

## はじめに

[SpecTest](https://github.com/Kray-G/SpecTest) は私が欲しいと思っていた BDD を実現するための汎用フレームワーク。

* SpecTest そのものについては [**ここ**](https://qiita.com/Kray-G/items/ba7422568ab50fa18414) を参照してください。
* リポジトリは [**ここ**](https://github.com/Kray-G/SpecTest) です。

今回はいつもと趣向を変えて、**SpecTest GUI への道** と題して GUI 作っていきます。[Kinx](https://github.com/Kray-G/kinx) と両方並行して進めます。どっちかと言うとこっちがサイド・プロジェクト的な。

### Markdown Editor ベース

私は普段 [Virtual Studio Code](https://github.com/microsoft/vscode) にお世話になっているのだが、Markdown メモには [Joplin](https://joplinapp.org) を使っている。なので、Markdown エディタが別にあること自体に苦はないし、便利だと思う。色々 VSCode だけで行けるほうがいいとも思うが、ニッチに特化したツールはそれなりに存在意義があるし。

SpecTest も基本はマークダウンなので、Markdown Editor 的な何かがベースになると良いかなー。ということで、ちょっとした Markdown Editor ベースでいきます。結構長くなりそうなので、記事は分割します。今回は、簡易 Markdown Editor を作るところまで。最初は [MavonEditor](https://github.com/hinesboy/mavonEditor) が良さそうだと思ったが、エディタ部分のフォントが変えられず、融通の利かなさでパスすることに。

簡単な Markdown Editor のサンプルにはなると思う。とは言っても今回の目玉、前々から使ってみたかった [Monaco Editor](https://github.com/microsoft/monaco-editor) を使うので意味はある。

そう、アレです。[Virtual Studio Code](https://github.com/microsoft/vscode) で使われているアレです。実は、当初エディタは [Atom](https://github.com/atom/atom) を使おうと思っていたのだが、動作が重い...。そこで Visual Studio Code にしてみたところすこぶる軽快。しかし、どちらも Electron ベースだというではないか。 **この違いは一体何だ？**、と行きついたのが Monaco Editor。これは期待できる。

Monaco Editor の使い方や、Monaco Editor を使いたい人に参考になれば。

## 準備

### node.js インストール

node.js は大体の人がインストール済みですかね。[ここ](https://nodejs.org/ja/download/) からインストール。

### vuecli インストール、プロジェクトの作成

以前は electron-vue を使っていたのですが、最近は vuecli + electron-builder らしいですね。今回はそれでいきます。

vuecli をインストール。既にインストール済みなら飛ばしてください。

```
$ npm install -g @vue/cli
```

プロジェクトの作成。プロジェクト名は `spectest-gui` にします。

```
$ vue create spectest-gui
```

`Manually select features` を選んで、Router と Vuex を選択します。私はいつもこれです。Router は使わないか...。あとは、TypeScript を使うかどうか。今時は使うべきかもしれないが、時間もないので慣れている JavaScript にしてしまう。

```
Vue CLI v4.2.3
? Please pick a preset: Manually select features
? Check the features needed for your project:
 (*) Babel
 ( ) TypeScript
 ( ) Progressive Web App (PWA) Support
 (*) Router
>(*) Vuex
 ( ) CSS Pre-processors
 (*) Linter / Formatter
 ( ) Unit Testing
 ( ) E2E Testing
```

あとはデフォルト。

```
🎉  Successfully created project spectest-gui.
👉  Get started with the following commands:

 $ cd spectest-gui
 $ npm run serve
```

上記が出れば成功。

### electron-builder

プロジェクトが無事作成されたら、早速 electron-builder 入れてみましょう。

```
$ cd spectest-gui
$ vue add electron-builder
```

Choose Electron Version とか聞かれるので迷わず新しいのを。

```
? Choose Electron Version (Use arrow keys)
  ^4.0.0
  ^5.0.0
> ^6.0.0
```

```
✔  Successfully invoked generator for plugin: vue-cli-plugin-electron-builder
```

成功。

起動してみましょう。

```
$ npm run electron:serve
```

![Vue](https://raw.githubusercontent.com/Kray-G/spectest-gui/master/doc/screenshot/vue.png)

おぉ。

この辺で VSCode とかを立ち上げると、既に git リポジトリができていて、初版がコミットされており、既に変更があることが確認できます。必要に応じて git にコミットしておきましょう（たぶん git コマンドがないとできないと思うけど、git コマンドが無いと無視されるのかどうかとかは既に入っていたのでわかんない）。

```
$ git add .
$ git commit -m "added electron builder"
```

### Vuetify

やはり流行に乗ってマテリアル・デザインで Vuetify を使います。好きなので。色々揃ってて良いですねえ。

```
$ vue add vuetify
```

以下が聞かれますが、とりあえずデフォルトで進めます。

```
? Choose a preset: (Use arrow keys)
> Default (recommended)
  Prototype (rapid development)
  Configure (advanced)
```

```
✔  Successfully invoked generator for plugin: vue-cli-plugin-vuetify
 vuetify  Discord community: https://community.vuetifyjs.com
 vuetify  Github: https://github.com/vuetifyjs/vuetify
 vuetify  Support Vuetify: https://github.com/sponsors/johnleider
```

成功したようですね。起動してみます。

```
$ npm run electron:serve
```

![Vuetify](https://raw.githubusercontent.com/Kray-G/spectest-gui/master/doc/screenshot/vuetify.png)

おぉ、変わった。

### splitpanes / vue-monaco / marked / highlight.js

そしてお待ちかね、Monaco Editor の出番です。`vue-monaco` というパッケージがあります。ついでに今回、マルチペインで作業できるようにするつもりなので、`splitpanes` というライブラリを入れてしまいます。また、表示用に `marked` と `highlight.js` も入れます。さらに `github-markdown-css` も入れておきましょう。

```
$ npm install splitpanes
$ npm install vue-monaco
$ npm install marked
$ npm install highlight.js
$ npm install github-markdown-css
```

### fontawesome

間違いなく fontawesome のアイコンは使います。入れておきます。

```
$ npm install @fortawesome/fontawesome-svg-core
$ npm install @fortawesome/free-solid-svg-icons
$ npm install @fortawesome/vue-fontawesome
$ npm install @fortawesome/free-brands-svg-icons
$ npm install @fortawesome/free-regular-svg-icons
```

色々入ったので、また変更が通知されているはずです。コミットしてしまいましょう。

```
$ git add .
$ git commit -m "added vuetify and some modules"
```

## 簡易 Markdown Editor

さて、Markdown Editor つくりますよ。

### アイコンの設定

まず、fontawesome アイコンを使えるようにしてしまいましょう。`src/main.js` を開いて、`import` 文の最後の行の次あたりに以下の行を追加。

```javscript:main.js
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { fab } from '@fortawesome/free-brands-svg-icons'
import { far } from '@fortawesome/free-regular-svg-icons'
library.add(fas, far, fab)
```

### 初期画面の整理

ひとまず以下のような感じで構成してきます。

```
src/App.vue
   /components/MarkdownPane.vue
              /markdown/Editor.vue
              /markdown/VIewer.vue
```

### トップレベル（`src/App.vue`）

Vuetify の `<v-app>` に `<v-app-bar>`、`<v-content>` を配置し、`<v-content>` に `MarkdownPane` を配置します。全体を書くと以下の通り。基本、あったものをざっくり消して、`HelloWorld` を `MarkdownPane` に変える。

```html:App.vue
<template>
  <v-app>
    <v-app-bar app ref="appbar">
      <v-app-bar-nav-icon></v-app-bar-nav-icon>
      <v-toolbar-title>SpecTest GUI</v-toolbar-title>
    </v-app-bar>

    <v-content>
      <MarkdownPane />
    </v-content>
  </v-app>
</template>

<script>
import MarkdownPane from './components/MarkdownPane';

export default {
  name: 'App',

  components: {
    MarkdownPane,
  },

  data: () => ({
    //
  }),
};
</script>
```

`src/components/MarkdownPane.vue` が無いので、まだ起動できない。

### ペイン分割（`src/components/MarkdownPane.vue`）

中身のない `src/components/MarkdownPane.vue` を作ります。`splitpanes` で左右にペイン分割する。

```html:MarkdownPane.vue
<template>
  <splitpanes class="default-theme" :style="{ height: '100%', overflow: 'hidden' }">   
    <pane class="pane-editor" ref="epane" size="55">
    </pane>
    <pane class="pane-view" ref="vpane">
    </pane>
  </splitpanes>
</template>

<script>
import { Splitpanes, Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'

export default {
  name: 'MarkdownPane',

  components: {
    Splitpanes, Pane,
  },
};
</script>
```

これで一応動くようにはなる。まだエディタ組み込んでませんが、ペイン分割の動作を確認できます。

![Panes](https://raw.githubusercontent.com/Kray-G/spectest-gui/master/doc/screenshot/panes.png)

こんな感じ。真ん中のスプリッタでぐりぐりと動かせます。

### Window の大きさ調整

Electron を使っているとだいたいそうなのだが、ウィンドウのリサイズに追随してくれないコンポーネントが結構ある。なので、ウィンドウ・サイズを Vuex のストアに確保しておき、各コンポーネントで参照できるようにしておく。具体的には、App.vue にハンドラを設置。その際、上部のアプリケーションバーのサイズを含めないようにあらかじめ引き算しておく。軽く 3 引いているのは、誤差でスクロールバーが出たり変な感じになることがあったので気持ち少なめに程度の意味。

まず、store でウィンドウサイズを保存するように修正。

```javascript:src/store/index.js
export default new Vuex.Store({
  state: {
    windowSize: { width: 0, height: 0 },
  },
  mutations: {
    setWindowSize (state, appbar) {
      state.windowSize.width = window.innerWidth
      state.windowSize.height = window.innerHeight - appbar.clientHeight - 3
    },
  },
  ...
```

次に App.vue の `<v-app-bar>` タグに ref をつけ、バーの高さを渡せるようにした上で、

```html:App.vue
    <v-app-bar app ref="appbar">
```

`methods` に `handleResize` を追加し、ハンドラとして呼ばれるように `mounted` と `beforeDestroy` でリスナーに登録し、ウィンドウサイズを store にセットをする。

```javascript
  methods: {
    handleResize: function() {
      this.$store.commit('setWindowSize', this.$refs.appbar.$el)
    },
  },

  mounted: function () {
    window.addEventListener('resize', this.handleResize)
    this.$store.commit('setWindowSize', this.$refs.appbar.$el)
  },

  beforeDestroy: function () {
    window.removeEventListener('resize', this.handleResize)
  },
```

### テキストデータ共有

テキストエディタで編集したデータは、`marked` で変換されて表示される。なので、編集ドキュメント自体も store で管理。`state` に `code` として追加し、`upadteCode` でアップデートできるようにしておく。さっきのと合わせるとこんな感じ

```javascript:src/store/index.js
export default new Vuex.Store({
  state: {
    windowSize: { width: 0, height: 0 },
    code: "",
  },
  mutations: {
    setWindowSize (state, appbar) {
      state.windowSize.width = window.innerWidth
      state.windowSize.height = window.innerHeight - appbar.clientHeight - 3
    },
    updateCode (state, code) {
      state.code = code;
    }
  },
  ...
```

### Markdown エディタの配置

お待ちかねの MonacoEditor を組み込む時間です。

まず、`src/components/markdown/Editor.vue` を作ります。あらかじめ設定しているのは以下の点。

* MonacoEditor は自分でレイアウトに追随してくれないので、リサイズで再レイアウトさせる必要がある。そのための仕組みを入れておく。
* `resize` 自体は親コンポーネントから受け取る。
* `code` はローカルに編集するので `this` に持たせておき、変更をウォッチして store にコピーする。
* 縦横サイズはウィンドウサイズをもとに設定してやらないとうまくレイアウトできない。Pane で各ペインの高さをそろえて、その高さに Editor を合わせる。
* `@editorWillMount` イベントを受け取って、`monaco` インスタンスを確保しておく。これは後で使う。
* `fontSize` は一応設定値として持っておく。
* エディタが見やすいように、上下に少しマージンを入れておく。

全部入れると次のようになる。

```html:src/components/markdown/Editor.vue
<template>
  <MonacoEditor ref="editor" v-model="code" language="markdown" class="mdeditor" :style="{ width: width, height: height }"
    :options="{ scrollBeyondLastLine: false, wordWrap: 'on', fontSize: fontSize }"
    @editorWillMount="onEditorWillMount"
  />
</template>

<script>
import MonacoEditor from 'vue-monaco'

export default {
  name: 'MarkdownEditor',
  components: { MonacoEditor },

  data: () => ({
    code: '',
    monaco: null,
    fontSize: 12,
    clientWidth: 1,
    clientHeight: 1,
  }),

  methods: {
    onEditorWillMount: function(monaco) {
      this.monaco = monaco
    },
    resize: function(el) {
      this.clientWidth = el.clientWidth - 1;
      this.clientHeight = el.clientHeight - 3;
      this.$nextTick(() => {
        this.$refs.editor.getEditor().layout()
      })
    },
  },

  computed: {
    width() {
      return this.clientWidth + 'px'
    },
    height() {
      return this.clientHeight + 'px'
    },
  },

  watch: {
    code() {
      this.$store.commit('updateCode', this.code)
    },
  },
};
</script>

<style scoped>
.mdeditor {
  margin-top: 6px;
  margin-bottom: 8px;
}
</style>
```

また、`splitpanes` でペインサイズが変わったときも追随してくれない。なので、その仕組みを入れておく。修正すると次のようになる。`MarkdownEditor` のメソッドを呼ぶので `ref` をつけておく。

```html:src/components/MarkdownPane.vue
<template>
  <splitpanes class="default-theme" :style="{ height: height, overflow: 'hidden' }" @resized="resizedPane($event)">
    <pane class="pane-editor" ref="epane" size="55">
      <MarkdownEditor ref="editor" />
    </pane>
    <pane class="pane-view" ref="vpane">
    </pane>
  </splitpanes>
</template>

<script>
import MarkdownEditor from './markdown/Editor'
import { Splitpanes, Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'

export default {
  name: 'MarkdownPane',

  components: {
    MarkdownEditor, Splitpanes, Pane,
  },

  methods: {
    resizedPane: function() {
      this.$refs.editor.resize(this.$refs.epane.$el)
    }
  },

  computed: {
    height () {
      return (this.$store.state.windowSize.height - 1) + "px"
    },
  }
};
</script>
```

リサイズも通知されるように、`App.vue` の `handleResize` で通知するように修正。`mounted` のときも通知しておく。`App.vue` も全部載せると以下のようになる。

```html:src/App.vue
<template>
  <v-app>
    <v-app-bar app ref="appbar" height="56">
      <v-app-bar-nav-icon></v-app-bar-nav-icon>
      <v-toolbar-title>SpecTest GUI</v-toolbar-title>
    </v-app-bar>

    <v-content>
      <MarkdownPane ref="pane" />
    </v-content>
  </v-app>
</template>

<script>
import MarkdownPane from './components/MarkdownPane';

export default {
  name: 'App',

  components: {
    MarkdownPane,
  },

  data: () => ({
    //
  }),

  methods: {
    handleResize: function() {
      this.$store.commit('setWindowSize', this.$refs.appbar.$el)
      this.$refs.pane.resizedPane()
    },
  },

  mounted: function () {
    window.addEventListener('resize', this.handleResize)
    this.$store.commit('setWindowSize', this.$refs.appbar.$el)
    this.$nextTick(() => {
      this.$refs.pane.resizedPane()
    })
  },

  beforeDestroy: function () {
    window.removeEventListener('resize', this.handleResize)
  },
};
</script>
...
```

ここまでできると、エディタが使える。

![Editor](https://raw.githubusercontent.com/Kray-G/spectest-gui/master/doc/screenshot/editor.png)

やった！

VSCode と同じだ。検索も置換もできる。Minimap も付いてる。なんて素晴らしい。

### Markdown ビューワの配置

編集できるだけではイマイチですね。ちゃんと HTML に変換して表示しましょう。`src/components/markdown/Viewer.vue` を作ります。ここでは試行錯誤した結果のみ先に見せます。

```html:src/components/markdown/Viewer.vue
<template>
  <div id="viewer" class="mdviewer" :style="{ width: width, height: height, overflow: 'auto' }" ref="viewer">
    <div v-html="markdown" class="mdviewer-body markdown-body" />
  </div>
</template>

<script>
import marked from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-gist.css'
import 'github-markdown-css/github-markdown.css'

export default {
  name: 'MarkdownViewer',

  created: function () {
    marked.setOptions({
      langPrefix: '',
      highlight: function(code, lang) {
        var l = lang.split(':');
        return hljs.highlightAuto(code, [l[0]]).value
      }
    });
  },

  data: () => ({
    clientWidth: 1,
    clientHeight: 1,
  }),

  methods: {
    resize: function(el) {
      this.clientWidth = el.clientWidth - 1;
      this.clientHeight = el.clientHeight - 3;
    },
  },

  computed: {
    markdown: function () {
      return marked(this.$store.state.code) + '<br />'
    },
    width() {
      return this.clientWidth + 'px'
    },
    height() {
      return this.clientHeight + 'px'
    },
  }
}
</script>

<style>
.mdviewer {
  margin-top: 6px;
  margin-bottom: 8px;
}
.mdviewer-body {
  padding: 8px;
}

code {
  font-size: 85% !important;
  padding: 0px !important;
}
pre>code {
  width: 100%;
  padding: 0.5em;
  color: inherit !important;
  -webkit-box-shadow: inset 0 0px 0 #ffffff !important;
  box-shadow: inset 0 0px 0 #ffffff !important;
}
pre>code:after, pre>code:before {
  content: "" !important;
  letter-spacing: 0px !important;
}
</style>
```

`marked` で変換する際の `highlight.js` の設定を `created` で行い、エディタと同じようにサイズを調整できるようにして、どうも Vuetify が悪さしているっぽいスタイルを調整（グローバルスコープで `!important` とか使っているが、こうしないと回避できなかった...他に影響あったら考える）。

文書自体は store に変更があると自動的に `computed` して変換後の文書が表示される。賢いねー。

また、`src/component/MarkdownPane.vue` に登録してリサイズされるようにメソッドを追加。`splitpanes` も `default-theme` が邪魔な感じなので消してしまう。全体は以下の通り。

```html:src/component/MarkdownPane.vue
<template>
  <splitpanes :style="{ height: height, overflow: 'hidden' }" @resized="resizedPane($event)">
    <pane class="pane-editor" ref="epane" size="55">
      <MarkdownEditor ref="editor" />
    </pane>
    <pane class="pane-view" ref="vpane">
      <MarkdownViewer ref="viewer" />
    </pane>
  </splitpanes>
</template>

<script>
import MarkdownEditor from './markdown/Editor'
import MarkdownViewer from './markdown/Viewer'
import { Splitpanes, Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'

export default {
  name: 'MarkdownPane',

  components: {
    MarkdownEditor, MarkdownViewer, Splitpanes, Pane,
  },

  methods: {
    resizedPane: function() {
      this.$nextTick(() => {
        this.$refs.editor.resize(this.$refs.epane.$el)
        this.$refs.viewer.resize(this.$refs.vpane.$el)
      })
    }
  },

  computed: {
    height () {
      return (this.$store.state.windowSize.height - 1) + "px"
    },
  }
};
</script>
```

![Viewer](https://raw.githubusercontent.com/Kray-G/spectest-gui/master/doc/screenshot/viewer.png)

おぉー。エディタだ。

## おわりに

ここまでで簡単な Markdown Editor ができました。保存とかできないけど。あと、やはりスクロールは連動してほしい。

ということで、次回は **スクロール連動** を実現させます。

ここまでの結果は、以下にコミットしてあります。

* [GUI for SpecTest (spectest-gui)](https://github.com/Kray-G/spectest-gui)

SpecTest そのものに関しては以下を参照してください。


* [SpecTest - ビヘイビア駆動開発(BDD)用テスティング・フレームワーク](https://qiita.com/Kray-G/items/ba7422568ab50fa18414)
* [リポジトリ(Github)](https://github.com/Kray-G/SpecTest)

ではまた次回。