【SpecTest GUI - 2】MonacoEditor + Vue.js/Electron

# SpecTest GUI ヘの道（2）

* [前回記事「SpecTest GUI ヘの道（1）」](https://qiita.com/Kray-G/items/445b5cfe8e9c746e2b81) の続きです。
* 今回は、**同期スクロール** とエディタの **コマンド・ショートカット（ツールアイコン）** を追加します。

## 誰向け?

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

尚、今回の結果も以下にコミットしてあります。また、前回のには tag v0.1.0 をつけてあります。

* [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) と両方並行して進めます。GUI 作りはサイド・プロジェクト。

## 前回の訂正

[前回](https://qiita.com/Kray-G/items/445b5cfe8e9c746e2b81) の fontawesome の登録部分が足りてなかったので修正します。すみません。

```javascript:main.js
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
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)
Vue.component('font-awesome-icon', FontAwesomeIcon)
```

`FontAwesomeIcon` の import と `Vue.component('font-awesome-icon', FontAwesomeIcon)` がなかった...

## 同期スクロール

基本、スクロールを検知したら相手側もスクロールさせる、ということを実装する。ただし、自動的にスクロールさせた結果も反対側で「スクロールした！」と反応して戻ってくるので何もしないと **ループしてしまう**。これを抑止しなければならない。

### スクロール・ハンドラの追加

エディタ側は Monaco Editor の機能を使う。Monaco Editor には `onDidScrollChange` というインタフェースがあるので、そこにハンドラを登録する。

```javascript:Editor.vue
  mounted () {
    var editor = this.$refs.editor.getEditor()
    editor.onDidScrollChange(this.handleScroll)
  },
```

ビューワ側は DOM オブジェクトなので、リスナに登録する。ここでは `id` 属性をつけておいて DOM オブジェクトを取得するようにしている。

```javascript:Viewer.vue
  mounted () {
    document.getElementById("viewer").addEventListener('scroll', this.handleScroll);
  },
  beforeDestroy () {
    document.getElementById("viewer").removeEventListener('scroll', this.handleScroll);
  },
```

### スクロール通知

スクロールの通知は親コンポーネントである `MarkdownPane` を介して行う。それぞれからイベントを受け取り、相手側に値を転送させる。

尚、今回は **簡易的な** 位置合わせベースのスクロール連動です。というのも、レイアウトがエディタとビューワで変わるので精密に連動させるのは結構手間がかかる。今回は、全体に対する位置を互いに転送しあってその位置まで動く、というだけのものになる。これでも大体のケースにおいてまあまあうまく機能するし、そもそも世の中の **メジャーな Markdown Editor もそんな動きしかしていない** ので良いでしょう。

`MarkdownPane.vue` は以下のような感じ。`@onScrollUpdatedViewer` と `@onScrollUpdatedEditor` でそれぞれ相手側に位置情報を転送します。誤差があったので、1 以下に丸めている。

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

<script>
...
  methods () {
    ...
    onScrollUpdatedEditor (value) {
      this.$refs.editor.setScrollTop(value > 1 ? 1 : value);
    },
    onScrollUpdatedViewer (value) {
      this.$refs.viewer.setScrollTop(value > 1 ? 1 : value);
    },
  },
...
</script>
```

設定するインタフェースは `setScrollTop` メソッドをそれぞれ用意しておく。次のループ抑制の中で一緒に説明する。

### ループ抑制

ループの抑制だが、単にフラグではうまくいかない。グリっとスクロールさせるといっぺんにいくつものスクロールイベントが発生するのでフラグの設定・解除漏れが発生する。ここでは相手からの通知があったら一定期間反対側からのスクロール・イベントはキャンセルするようにさせた。最後にイベントを受け取ってから 200 ミリに指定してある。

エディタ側は Monaco Editor の `setScrollTop()` メソッドを使う。`this.clientHeight` を引いているのは、`scrollTop` の値は画面の上部になるので、一画面分上になるからです。ビューワ側も同様。

```javascript:Editor.vue
  data: () => {
    isScrollReceived: false,
    ...
  },
  methods: {
    ...
    setTimeout (clearOnly) {
      if (this.timeoutId) {
        clearTimeout(this.timeoutId)
        this.timeoutId = null
      }
      if (!clearOnly) {
        this.timeoutId = setTimeout(() => {
          this.isScrollReceived = false
          this.timeoutId = null
        }, 200)
      }
    },
    setScrollTop (v) {
      this.isScrollReceived = true
      this.setTimeout(false)
      var el = this.$refs.editor;
      var editor = el.getEditor()
      var topEnd = editor.getScrollHeight() - this.clientHeight
      this.$nextTick(() => {
        editor.setScrollTop(topEnd * v);
      })
    },
    handleScroll () {
      if (this.isScrollReceived) {
        return
      }
      var editor = this.$refs.editor.getEditor()
      var scrollTop = editor.getScrollTop();
      var topEnd = editor.getScrollHeight() - this.clientHeight
      if (topEnd > 0) {
        this.$nextTick(() => {
          this.$emit('onScrollUpdatedViewer', scrollTop / topEnd)
        })
      }
    },
  },
```

ビューワ側は DOM のプロパティにセットするだけ。

```javascript:Viewer.vue
  data: () => {
    isScrollReceived: false,
    ...
  },
  methods: {
    ...
    setTimeout (clearOnly) {
      if (this.timeoutId) {
        clearTimeout(this.timeoutId)
        this.timeoutId = null
      }
      if (!clearOnly) {
        this.timeoutId = setTimeout(() => {
          this.isScrollReceived = false
          this.timeoutId = null
        }, 200)
      }
    },
    setScrollTop (v) {
      this.isScrollReceived = true
      this.setTimeout(false)
      var el = this.$refs.viewer;
      var topEnd = el.scrollHeight - el.clientHeight
      this.$nextTick(() => {
        el.scrollTop = topEnd * v;
      })
    },
    handleScroll (e) {
      if (this.isScrollReceived) {
        return
      }
      var el = e.target
      if (el && el.clientHeight && el.scrollHeight) {
        var topEnd = el.scrollHeight - el.clientHeight
        if (topEnd > 0) {
          this.$nextTick(() => {
            this.$emit('onScrollUpdatedEditor', el.scrollTop / topEnd)
          })
        }
      }
    },
  },
```

やってみる。

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

静止画ではわからないとは思うが、無事、スクロールが同期した。

### 一番上に戻る

長い文章になると一番上に戻りたくなるよね。付けましょう。よくあるフローティング・ボタンを右下につけて、一番上まで戻る機能を。

フローティング・ボタンは全体で一番右下なので、`App.vue` に追加。以下のように一番下に追加しておく。

```html: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-btn color="red" dark fixed right bottom fab @click="gotoTop"><font-awesome-icon icon="chevron-up" /></v-btn>
  </v-app>
</template>
```

`MarkdownPane` に ref を付けておき、ボタンが押されたら `gotoTop` を通知。

```javascript:App.vue
  methods: {
    gotoTop () {
      this.$refs.pane.gotoTop()
    },
  },
```

`MarkdownPane.vue` では、`gotoTop` を受け取ったら **ビューワのほうにだけ** 通知。ビューワのほうが簡単なので。`el.scrollTo` がいい具合にスムーズにアニメーションしてくれる。また、ビューワ側でスクロールするとさっきの同期処理が働いて自動的にエディタ側もスクロールしてくれる！素晴らしい。

`MarkdownPane.vue` の実装は `methods` にこれを追加するだけ。とりあえず、`null` を渡すのをサインにした。

```javascript:MarkdownPane.vue
    gotoTop () {
      this.$refs.viewer.setScrollTop(null);
    },
```

ビューワ側の実装はこんな感じ。`setScrollTop` の先頭に `null` の場合を追加。

```javascript:Vuewer.vue
    setScrollTop (v) {
      if (v == null) {
        this.$refs.viewer.scrollTo({
          top: 0,
          behavior: "smooth"
        })
        return
      }
      ...
```

実行。

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

さあ、ボタンを押してみよう。

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

（静止画ではわかりづらいが）いい感じにスムーズ・スクロールした！ ...そして違和感なくスムーズ・スクロールのまま同期するのも気分がいい。**ビューン**。

## 編集機能

さて、ついでに Monaco Editor の編集機能を充実させます。基本キーボードで操作するのが楽なのだが、念のためツールバーもつけておきます。でもテキストエディタでツールバーって実は使いづらいよね。

### ショートカットキー

まずはショートカットキーから。Monaco Editor の場合、アンドゥ機能を有効にするためには実質メソッドはこれしかない。

```javascript
editor.getModel().pushEditOperations(...)
```

これで全てのことを実現する。`replace` とか `insert` とか気の利いた名前のメソッドは全くない。今回は、選択した文字列を特定の文字列で括る（例えば `aaa` → `**aaa**` とか）とヘッダを付ける機能を用意する。ここで `@editorWillMount` イベントで受け取った `monaco` オブジェクトが必要になる。

#### 文字列ラッピング・コマンド

まずは、汎用的なコマンド関数の生成関数（ジェネレータ）を作っておく。大体やること一緒なので。
ラッピング・コマンドの生成関数は次の通り。コマンド実行のクロージャ―を返す。

```javascript:Editor.vue
function generateWrapperCommand(monaco, editor, startText, endText) {
  var len = startText.length + endText.length
  return () => {
    var sels = editor.getSelections()
    if (sels == null) {
      return
    }
    var ranges = []
    sels.forEach(selection => {
      ranges.push(new monaco.Selection(selection.startLineNumber, selection.startColumn, selection.endLineNumber, selection.endColumn+len))
      editor.getModel().pushEditOperations([], [
          {
              range: {
                  startLineNumber: selection.startLineNumber,
                  startColumn: selection.startColumn,
                  endLineNumber: selection.startLineNumber,
                  endColumn: selection.startColumn
              },
              text: startText
          },
          {
              range: {
                  startLineNumber: selection.endLineNumber,
                  startColumn: selection.endColumn,
                  endLineNumber: selection.endLineNumber,
                  endColumn: selection.endColumn
              },
              text: endText
          }
      ])
    })
    editor.setSelections(ranges)
    return null
  }
}
```

`pushEditOperations` の第二引数にオペレーションを登録していくが、start と end が一緒ならその位置に挿入、異なっているならその範囲を置換、と思えばよい。最後に挿入後の新しい範囲を指定するようにしておく。

これを使ったショートカット・コマンドを登録する汎用関数を作っておく。ちなみに、`actionCommand[label]` に登録しているのは、後でツールバーからコマンド名で実行できるようにするため。

```javascript:Editor.vue
var actionCommand = {}, cmdid = 0;

function addWrapperCommand(monaco, editor, context, label, keybindings, startText, endText) {
  actionCommand[label] = generateWrapperCommand(monaco, editor, startText, endText)
  editor.addAction({
    id: 'markdwon-'+(cmdid++),
    label: label,
    keybindings: keybindings,
    contextMenuGroupId: context && 'navigation',
    contextMenuOrder: context && 1.5,
    run: actionCommand[label]
  })
}
```

追加は `editor` の `addAction` を使う。`context...` を指定しておくことで、`label` に指定した名前でコンテキストメニューにも追加されて一石二鳥だ。では、これでいくつかコマンドを登録してみよう。キーボードバインディングがこれで使いやすいかは別として、登録の仕方はわかると思う。

```javascript:Editor.vue
function setupShortcutKeys(monaco, editor) {
  addWrapperCommand(monaco, editor, true , "Bold",           [ monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_B ],                        "**", "**")
  addWrapperCommand(monaco, editor, true , "Italic",         [ monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_I ],                        "*",  "*")
  addWrapperCommand(monaco, editor, true , "Underline",      [ monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_U ],                        "<u>",  "</u>")
  addWrapperCommand(monaco, editor, true , "Strikethrough",  [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_S ],  "~~", "~~")
  addWrapperCommand(monaco, editor, true , "Code",           [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_M ],  "`",  "`")
  addWrapperCommand(monaco, editor, false, "Link-1",         [ monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_L ],                        "[",  "]()")
  addWrapperCommand(monaco, editor, false, "Link-2",         [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_L ],  "[](",  ")")
}
```

この `setupShortcutKeys` は `mounted` のときに呼び出す。一応、`this.monaco` は `mouted` の前に設定されることになっている（実際はここでチェックする必要はない）。

```javascript:Editor.vue
  mounted () {
    var editor = this.$refs.editor.getEditor()
    editor.onDidScrollChange(this.handleScroll)
    if (this.monaco) {
      setupShortcutKeys(this.monaco, editor)
    }
  },
```

では、ヘッダを追加するショートカット・コマンドも追加してみる。今度は行頭に `## ` 等をつけるというもの。元々ある場合は差し替える動作が自然だろう。同じように登録用関数を定義する。

```javascript:Editor.vue
function generateHeaderCommand(monaco, editor, startText) {
  return () => {
    var sels = editor.getSelections()
    if (sels == null) {
      sels = [editor.getSelection()]
    }
    sels.forEach(selection => {      
      var m = editor.getModel().findNextMatch("^(#+ )", { lineNumber: selection.startLineNumber, column: 1 }, true, false, null, true)
      if (m != null) {
        if (m.range.startLineNumber == selection.startLineNumber && m.range.startColumn == 1) {
          if (m.matches[1] == startText) {
            return
          }
          editor.getModel().pushEditOperations([], [
              {
                  range: {
                      startLineNumber: selection.startLineNumber,
                      startColumn: 1,
                      endLineNumber: selection.startLineNumber,
                      endColumn: m.matches[1].length + 1
                  },
                  text: ''
              }
          ])
        }
      }
      editor.getModel().pushEditOperations([], [
          {
              range: {
                  startLineNumber: selection.startLineNumber,
                  startColumn: 1,
                  endLineNumber: selection.startLineNumber,
                  endColumn: 1
              },
              text: startText
          }
      ])
    })
    return null
  }
}

function addHeaderCommand(monaco, editor, context, label, keybindings, startText) {
  actionCommand[label] = generateHeaderCommand(monaco, editor, startText)
  editor.addAction({
    id: 'markdwon-'+(cmdid++),
    label: label,
    keybindings: keybindings,
    contextMenuGroupId: context && 'navigation',
    contextMenuOrder: context && 1.5,
    run: actionCommand[label]
  })
}
```

同じ文字列があるかどうかは `editor.getModel().findNextMatch(...)` を使う。正規表現も使えるので便利。見つからなかった場合、`null` が返る。ただし、スタート位置を指定できるが最後まで行くと先頭に戻る動作をする模様。そこで、見つかった場合は同じ行の先頭かどうかを確認する。同じ行の先頭だった場合は一旦削除する。今と同じなら何もしない。

これをショートカット・コマンドとして登録しよう。

```javascript:Editor.vue
function setupShortcutKeys(monaco, editor) {
  ...
  addHeaderCommand (monaco, editor, false, "Header 1", [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt   | monaco.KeyCode.KEY_1 ],  "# ")
  addHeaderCommand (monaco, editor, false, "Header 2", [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt   | monaco.KeyCode.KEY_2 ],  "## ")
  addHeaderCommand (monaco, editor, false, "Header 3", [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt   | monaco.KeyCode.KEY_3 ],  "### ")
  addHeaderCommand (monaco, editor, false, "Header 4", [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt   | monaco.KeyCode.KEY_4 ],  "#### ")
  addHeaderCommand (monaco, editor, false, "Header 5", [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt   | monaco.KeyCode.KEY_5 ],  "##### ")
  addHeaderCommand (monaco, editor, false, "Header 6", [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt   | monaco.KeyCode.KEY_6 ],  "###### ")
}
```

### ツールアイコン

#### アイコン

さて、せっかくなのでツールバーを作ってみる。イマイチ使いづらい気もするが、まぁ使わなければ後で消す。まずはアイコンから。長くないので全部載せる。以下のような修正。

* アイコン用の領域を作るが、高さを計算できるようにするため、高さを補正するコードを追加。
* アイコンが押されたときに、エディタに指示。エディタは `action` というメソッドを用意しておく。

```html:MarkdownPane.vue
<template>
  <splitpanes horizontal :style="{ height: height, overflow: 'hidden' }" @resized="resizedPane($event)">
    <pane :size="toobarSize">
      <div style="margin-left: 8px">
        <v-btn :style="{ marginTop: btnMargin }" class="btn-item" color="primary" fab x-small @click="click('Bold')"><font-awesome-icon icon="bold" /></v-btn>
        <v-btn :style="{ marginTop: btnMargin }" class="btn-item" color="primary" fab x-small @click="click('Italic')"><font-awesome-icon icon="italic" /></v-btn>
        <v-btn :style="{ marginTop: btnMargin }" class="btn-item" color="primary" fab x-small @click="click('Underline')"><font-awesome-icon icon="underline" /></v-btn>
        <v-btn :style="{ marginTop: btnMargin }" class="btn-item" color="primary" fab x-small @click="click('Strikethrough')"><font-awesome-icon icon="strikethrough" /></v-btn>
        <v-btn :style="{ marginTop: btnMargin }" class="btn-item" color="primary" fab x-small @click="click('Code')"><font-awesome-icon icon="code" /></v-btn>
      </div>
    </pane>
    <pane :size="editorSize">
      <splitpanes :style="{ overflow: 'hidden' }" @resized="resizedPane($event)">   
        <pane class="pane-editor" ref="epane" size="55">
          <MarkdownEditor ref="editor" @onScrollUpdatedViewer="onScrollUpdatedViewer" />
        </pane>
        <pane class="pane-view" ref="vpane">
          <MarkdownViewer ref="viewer" @onScrollUpdatedEditor="onScrollUpdatedEditor" />
        </pane>
      </splitpanes>
    </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,
  },

  data: () => ({
    toolbarPx: 60,
  }),

  methods: {
    click (name) {
      this.$refs.editor.action(name);
    },
    resizedPane () {
      this.$nextTick(() => {
        this.$refs.editor.resize(this.$refs.epane.$el, this.$store.state.windowSize.height - this.toolbarPx)
        this.$refs.viewer.resize(this.$refs.vpane.$el, this.$store.state.windowSize.height - this.toolbarPx)
      })
    },
    gotoTop () {
      this.$refs.viewer.setScrollTop(null);
    },
    onScrollUpdatedEditor (value) {
      this.$refs.editor.setScrollTop(value > 1 ? 1 : value);
    },
    onScrollUpdatedViewer (value) {
      this.$refs.viewer.setScrollTop(value > 1 ? 1 : value);
    },
  },

  computed: {
    height () {
      return (this.$store.state.windowSize.height - 1) + "px"
    },
    btnMargin () {
      var top = ((this.toolbarPx - 4 - 32) / 2 + 4) + "px"
      return top
    },
    toobarSize () {
      return this.toolbarPx * 100 / this.$store.state.windowSize.height
    },
    editorSize () {
      return (this.$store.state.windowSize.height - this.toolbarPx) * 100 / this.$store.state.windowSize.height
    },
  }
};
</script>

<style scoped>
.btn-item {
  margin-left: 2px;
  margin-right: 2px;
}
</style>
```

エディタの `action` は以下の通り。

```javascript:Editor.vue
    action (name) {
      if (actionCommand[name] != null) {
        actionCommand[name]()
      }
    },
```

これだけ。さっき `actionCommand` に関数登録しておいたおかげでそのまま使える。クロージャ―になっているので、`monaco` とか `editor` とかも内部でちゃんと使えて問題ない。

画面上はこんな感じになる。うん、マテリアル。

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

コマンド追加は同じ方法で可能なので、今後必要に応じて追加する。

## おわりに

さて、Markdown Editor もいい感じにできた。というか、これベースにオリジナルで使いやすい Markdown Editor 作るのもアリじゃないか？というくらい個人的には出来がいいな。

次からは本当の意味で SpecTest に対応していこう。ただ、諸事情あってちょっとペースが落ちるかも。。。
ここまでの結果は、以下にコミットしてあります。v0.2.2 としてタグも打ってあります。

* [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)

ではまた次回。