LoginSignup
2
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-29

SpecTest GUI ヘの道(2)

誰向け?

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

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

はじめに

SpecTest は私が欲しいと思っていた BDD を実現するための汎用フレームワーク。

  • SpecTest そのものについては ここ を参照してください。
  • リポジトリは ここ です。

SpecTest GUI への道 と題して GUI 作っていきます。Kinx と両方並行して進めます。GUI 作りはサイド・プロジェクト。

前回の訂正

前回 の fontawesome の登録部分が足りてなかったので修正します。すみません。

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 というインタフェースがあるので、そこにハンドラを登録する。

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

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

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 以下に丸めている。

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 の値は画面の上部になるので、一画面分上になるからです。ビューワ側も同様。

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 のプロパティにセットするだけ。

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

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

一番上に戻る

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

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

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 を通知。

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

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

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

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

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

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

実行。

GotoTop1

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

GotoTop2

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

編集機能

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

ショートカットキー

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

editor.getModel().pushEditOperations(...)

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

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

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

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] に登録しているのは、後でツールバーからコマンド名で実行できるようにするため。

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]
  })
}

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

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 ],  "[](",  ")")
}

この setupShortcutKeysmounted のときに呼び出す。一応、this.monacomouted の前に設定されることになっている(実際はここでチェックする必要はない)。

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

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

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 が返る。ただし、スタート位置を指定できるが最後まで行くと先頭に戻る動作をする模様。そこで、見つかった場合は同じ行の先頭かどうかを確認する。同じ行の先頭だった場合は一旦削除する。今と同じなら何もしない。

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

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 というメソッドを用意しておく。
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 は以下の通り。

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

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

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

Toolbar

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

おわりに

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

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

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

ではまた次回。

2
4
0

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
2
4