SpecTest GUI ヘの道(2)
- 前回記事「SpecTest GUI ヘの道(1)」 の続きです。
- 今回は、同期スクロール とエディタの コマンド・ショートカット(ツールアイコン) を追加します。
誰向け?
- VSCode で使われている Monaco Editor に興味ある人
- Monaco Editor で Markdown Editor を Electron ベースで 作りたい人
- SpecTest を 応援してくれる 人
尚、今回の結果も以下にコミットしてあります。また、前回のには tag v0.1.0 をつけてあります。
はじめに
SpecTest は私が欲しいと思っていた BDD を実現するための汎用フレームワーク。
SpecTest GUI への道 と題して GUI 作っていきます。Kinx と両方並行して進めます。GUI 作りはサイド・プロジェクト。
前回の訂正
前回 の fontawesome の登録部分が足りてなかったので修正します。すみません。
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
というインタフェースがあるので、そこにハンドラを登録する。
mounted () {
var editor = this.$refs.editor.getEditor()
editor.onDidScrollChange(this.handleScroll)
},
ビューワ側は DOM オブジェクトなので、リスナに登録する。ここでは id
属性をつけておいて DOM オブジェクトを取得するようにしている。
mounted () {
document.getElementById("viewer").addEventListener('scroll', this.handleScroll);
},
beforeDestroy () {
document.getElementById("viewer").removeEventListener('scroll', this.handleScroll);
},
スクロール通知
スクロールの通知は親コンポーネントである MarkdownPane
を介して行う。それぞれからイベントを受け取り、相手側に値を転送させる。
尚、今回は 簡易的な 位置合わせベースのスクロール連動です。というのも、レイアウトがエディタとビューワで変わるので精密に連動させるのは結構手間がかかる。今回は、全体に対する位置を互いに転送しあってその位置まで動く、というだけのものになる。これでも大体のケースにおいてまあまあうまく機能するし、そもそも世の中の メジャーな Markdown Editor もそんな動きしかしていない ので良いでしょう。
MarkdownPane.vue
は以下のような感じ。@onScrollUpdatedViewer
と @onScrollUpdatedEditor
でそれぞれ相手側に位置情報を転送します。誤差があったので、1 以下に丸めている。
<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
の値は画面の上部になるので、一画面分上になるからです。ビューワ側も同様。
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 のプロパティにセットするだけ。
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)
})
}
}
},
},
やってみる。
静止画ではわからないとは思うが、無事、スクロールが同期した。
一番上に戻る
長い文章になると一番上に戻りたくなるよね。付けましょう。よくあるフローティング・ボタンを右下につけて、一番上まで戻る機能を。
フローティング・ボタンは全体で一番右下なので、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
を通知。
methods: {
gotoTop () {
this.$refs.pane.gotoTop()
},
},
MarkdownPane.vue
では、gotoTop
を受け取ったら ビューワのほうにだけ 通知。ビューワのほうが簡単なので。el.scrollTo
がいい具合にスムーズにアニメーションしてくれる。また、ビューワ側でスクロールするとさっきの同期処理が働いて自動的にエディタ側もスクロールしてくれる!素晴らしい。
MarkdownPane.vue
の実装は methods
にこれを追加するだけ。とりあえず、null
を渡すのをサインにした。
gotoTop () {
this.$refs.viewer.setScrollTop(null);
},
ビューワ側の実装はこんな感じ。setScrollTop
の先頭に null
の場合を追加。
setScrollTop (v) {
if (v == null) {
this.$refs.viewer.scrollTo({
top: 0,
behavior: "smooth"
})
return
}
...
実行。
さあ、ボタンを押してみよう。
(静止画ではわかりづらいが)いい感じにスムーズ・スクロールした! ...そして違和感なくスムーズ・スクロールのまま同期するのも気分がいい。ビューン。
編集機能
さて、ついでに Monaco Editor の編集機能を充実させます。基本キーボードで操作するのが楽なのだが、念のためツールバーもつけておきます。でもテキストエディタでツールバーって実は使いづらいよね。
ショートカットキー
まずはショートカットキーから。Monaco Editor の場合、アンドゥ機能を有効にするためには実質メソッドはこれしかない。
editor.getModel().pushEditOperations(...)
これで全てのことを実現する。replace
とか insert
とか気の利いた名前のメソッドは全くない。今回は、選択した文字列を特定の文字列で括る(例えば aaa
→ **aaa**
とか)とヘッダを付ける機能を用意する。ここで @editorWillMount
イベントで受け取った monaco
オブジェクトが必要になる。
文字列ラッピング・コマンド
まずは、汎用的なコマンド関数の生成関数(ジェネレータ)を作っておく。大体やること一緒なので。
ラッピング・コマンドの生成関数は次の通り。コマンド実行のクロージャ―を返す。
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]
に登録しているのは、後でツールバーからコマンド名で実行できるようにするため。
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
に指定した名前でコンテキストメニューにも追加されて一石二鳥だ。では、これでいくつかコマンドを登録してみよう。キーボードバインディングがこれで使いやすいかは別として、登録の仕方はわかると思う。
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
の前に設定されることになっている(実際はここでチェックする必要はない)。
mounted () {
var editor = this.$refs.editor.getEditor()
editor.onDidScrollChange(this.handleScroll)
if (this.monaco) {
setupShortcutKeys(this.monaco, editor)
}
},
では、ヘッダを追加するショートカット・コマンドも追加してみる。今度は行頭に ##
等をつけるというもの。元々ある場合は差し替える動作が自然だろう。同じように登録用関数を定義する。
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
が返る。ただし、スタート位置を指定できるが最後まで行くと先頭に戻る動作をする模様。そこで、見つかった場合は同じ行の先頭かどうかを確認する。同じ行の先頭だった場合は一旦削除する。今と同じなら何もしない。
これをショートカット・コマンドとして登録しよう。
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
というメソッドを用意しておく。
<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
は以下の通り。
action (name) {
if (actionCommand[name] != null) {
actionCommand[name]()
}
},
これだけ。さっき actionCommand
に関数登録しておいたおかげでそのまま使える。クロージャ―になっているので、monaco
とか editor
とかも内部でちゃんと使えて問題ない。
画面上はこんな感じになる。うん、マテリアル。
コマンド追加は同じ方法で可能なので、今後必要に応じて追加する。
おわりに
さて、Markdown Editor もいい感じにできた。というか、これベースにオリジナルで使いやすい Markdown Editor 作るのもアリじゃないか?というくらい個人的には出来がいいな。
次からは本当の意味で SpecTest に対応していこう。ただ、諸事情あってちょっとペースが落ちるかも。。。
ここまでの結果は、以下にコミットしてあります。v0.2.2 としてタグも打ってあります。
SpecTest そのものに関しては以下を参照してください。
ではまた次回。