最初に
現在プライベートで開発中のテキストエディタ、CMeDitorではaceエディタというライブラリを使っている。aceは非常に便利で高機能。Delphi時代のCMeDitorよりも理想のエディタを作れる!と満足していたが、ふと調べてみると、なんとMicrosoftも同様のエディタライブラリを作成していることがわかった。ものすごく今更だろうが、Visual Studio Codeでも用いられている、 Monaco Editor である。
すでに多くの方が試されているが、自分も試してみることにした。
構造
Aceに最近触ったばかりなので、せっかくなのでオブジェクト周りの対比をしてみる。次のように捉えた。
Ace | Monaco |
---|---|
Editor | ICodeEditor |
EditSession | ITextModel |
Document | 対応なし |
なので、用語は若干違えどプロパティやメソッド周りも似ている部分はある。
使い方
組み込み
基本的にはサンプルをそのまま見れば済む話だが、個人的なまとめと復習としてざっと掲載。サンプルとの違いは、2つエディタを生成したことと、生成するモデルが一つの点。私の前の記事、aceエディタでの分割表示のやり方に通ずるものがあったり。
<h2>Monaco Editor Sample</h2>
<dir class="row">
<div class="col s6">
<div id="container1" style="width:100%;height:500px;border:1px solid grey"></div>
</div>
<div class="col s6">
<div id="container2" style="width:100%;height:500px;border:1px solid grey"></div>
</div>
</div>
<!-- OR ANY OTHER AMD LOADER HERE INSTEAD OF loader.js -->
<script src="../node_modules/monaco-editor/min/vs/loader.js"></script>
<script>
var editor1;
var editor2;
var session;
var text = [
'function x() {',
'\tconsole.log("Hello world!");',
'}'
].join('\n');
require.config({
paths: { 'vs': '../node_modules/monaco-editor/min/vs' },
'vs/nls' : {
availableLanguages : {
"*" : "ja"
}
}
});
require(['vs/editor/editor.main'], function() {
session = monaco.editor.createModel(text,"javascript");
editor1 = monaco.editor.create(document.getElementById('container1'), {});
editor2 = monaco.editor.create(document.getElementById('container2'), {});
editor1.setModel(session);
editor2.setModel(session);
});
</script>
※レイアウトを整えるのに個人的な趣味でmaterialize.jsを使っています。(css classのrowやcolなど)
個人的にはjavascriptのrequireの仕組みが苦手なのでscriptタグで済ませられる方がいいのだが、Monaco Editorはガッツリとrequireを使うらしい。しかし使わないことももちろん可能。そのあたりサンプル等にはもはや書いていないが。
<!-- これ以前は省略 -->
<!-- OR ANY OTHER AMD LOADER HERE INSTEAD OF loader.js -->
<script>
var require = {
paths: { 'vs': '../node_modules/monaco-editor/dev/vs' },
'vs/nls' : {
availableLanguages: {
'*': 'ja'
}
}
};
</script>
<script src="../node_modules/monaco-editor/dev/vs/loader.js"></script>
<script src="../node_modules/monaco-editor/dev/vs/editor/editor.main.js"></script>
<script src="../node_modules/monaco-editor/dev/vs/editor/editor.main.nls.js"></script>
<script src="../node_modules/monaco-editor/dev/vs/editor/editor.main.nls.ja.js"></script>
<script>
var editor1;
var editor2;
var session;
var text = [
'function x() {',
'\tconsole.log("Hello world!");',
'}'
].join('\n');
/* require.config({
paths: { 'vs': '../node_modules/monaco-editor/dev/vs' },
'vs/nls' : {
availableLanguages: {
'*': 'ja'
}
}
});
*/
session = monaco.editor.createModel(text,'javascript');
editor1 = monaco.editor.create(document.getElementById('container1'), {});
editor2 = monaco.editor.create(document.getElementById('container2'), {});
editor1.setModel(session);
editor2.setModel(session);
</script>
ポイントは、requireの定義の後にloader.jsやeditor.main.jsなどを記述すること。その他はほとんど書き方は変わらず。ちなみにscriptタグの順序を誤ると、devtoolのコンソールで大量エラーで叱られてげんなりする羽目になる。
Aceとの違いはデフォルトだけでもいくつかある。右側のサムネイルのようなスクロールバー、F1を押すと出てくるコマンドパレット、そしてコンテキストメニュー。これらは見栄えもいい。しかしデフォルトは英語になっているため日本語化したい。そのためにはrequire.configに"vs/nls"を定義し、その中に"availableLanguages"を指定する。
ここで"*":"ja"としているが、これは全部日本語という意味になる。"*"の部分はmonaco-editor\dev\vs\editor配下にあるeditor.main.nls.ja.jsなどを見ると、メッセージやメニューなど部分ごとに言語を変えることができるようだ。
とはいえ部分的に別の言語とする機会はないため、全て日本語でOK。
基本的なプロパティ・メソッド
大体の紹介記事はここらへんで終わっているが、もうちょっと踏み込んでみたい。というのも、Monaco Editorを発見して試した数日、Aceにはあるのにこちらにはない!と個人的に嘆いて憤って悲しみ、そして対応する機能を発見して歓喜した苦労があるからだ。同じ苦労を他の方にはしてほしくない。
よく使いそうなものを、Aceと対比しながら紹介する。
エディタの生成
//---Ace
var editor = ace.edit("container1");
//---Monaco Editor
var editor = monaco.editor.create(document.getElementById("container1"), {
value : "hogehoge\nfoobaa",
languages : "plaintext"
});
//生成したエディタは内部的に全て取得可能
console.log(editor._codeEditorService.listCodeEditors());
Aceはedit()の引数にHTML要素のIDも指定できる。対してMonacoは要素自体のみだ。それからMonacoはエディタの生成時に同時にオプションを指定することができる。かなり細かくオプション指定が可能なので、いじるだけでも楽しい。
また、Monaco~が優れていると思ったのは、生成したエディタをまとめて取得可能な点。非公開扱いなのだろうICodeEditor._codeEditorServiceを使うと、listCodeEditors()やlistDiffEditors()で今まで生成したエディタが取得可能なのだ。ただ内部的にしか使われないものかもしれないので、今後名称が変わるかもしれない点は注意。
セッション(モデル)の生成
Ace.EditSessionクラスはMonaco~ではmonaco.editor.ITextModelというインターフェースに相当する。
//---Ace
var session = ace.createEditSession("hogehgoe","ace/mode/text");
//または
var document = new ace.require("ace/document").Document("hogehoge");
var session = ace.createEditSession(document,"ace/mode/text");
//---Monaco Editor
var model = monaco.editor.createModel("hogehgoe","plaintext");
//生成したモデルはまとめて参照可能
monaco.editor.getModels();
どちらも物理的なエディタに対する論理的なエディタの存在となる。基本的な考え方では、このEditSession(ITextModel)を作って文章と背景での操作を管理し、実際の表示はEditorに任せるという使い方になるはず。
生成にはどちらも文字列と言語名を渡せばOKだ。しかしAceの方はさらに下層にDocumentオブジェクトが存在する。それを別途生成し、受け渡すことができる。AceとMonaco~の思想の違いが伺える部分だ。AceはEditSessionが論理的なエディタ、Documentが文章(ファイル)。対してMonaco~はITextModelが論理的なエディタと文章(ファイル)両方を担当すると言ってもよい。
また、Editorのときと同じく、Monaco~では生成したモデルをまとめて取得できる。monaco.editorモジュールのgetModels()メソッドだ。こちらはEditorの方と違ってしっかりAPI Docにも記載があり、公開されているので堂々と使える。個々のアプリでモデルを別に管理しておく必要がないので便利だ。
ドキュメントの存在
これがAceとMonaco~の基本的な部分での大きな違いだ。EditorにEditSession(ITextModel)を割り当てるという使い方はどちらも同じだが、AceにはEditSessionの配下にDocumentがある。Aceでは実質こいつが実際の文章の管理を司っている。
そのため、文字列(の配列)をあれやこれやするメソッドが結構備わっているのがAceのDocumentだ。編集ならこいつに任せれば万事解決。(EditorやEditSessionにもいくつか備わっているのでどちらを使っても良い)
一方でMonaco~にはDocumentに相当するものがない。そのため、この事実を知ったときは文章の編集はどうやってやるんだ!?Monaco Editorダメすぎるだろ!?と嘆いた。
しかし海外の掲示板まで見てやっと発見したのが次に紹介する機能である。
Monaco Editorの編集機能
Monaco EditorにはAceや他のエディタライブラリでよく見るような、文章の特定の位置に文字列を挿入するinsert()、置き換えるreplace()、はては特定の行に文字列をセットするsetLine()やsetText(0,"hogehoge")などの便利でわかりやすい一般的な名称のメソッドが一切ない。
エディタで編集する
ICodeEditor.executeEdits | Monaco Editor API v0.13.1
var editor = /* 省略 */
editor.executeEdits("", [
{ range: new monaco.Range(1,1,1,3), text: "hoge" }
]);
ICodeEditorにある executeEdits() メソッドを使う。
第一引数 source ・・・ stringとなっている。何に使うかわかっていないが""でも問題なし。
第二引数 edits ・・・ IIdentifiedSingleEditOperation[]という 非常に禍々しい オブジェクト名・クラス名が指定されているが、インターフェース名だ。monaco.editor等からは一切作成する機能が提供されていないが、何のことはない。
ただのObjectの配列だった。
これはrangeとtextという2つのプロパティを持っている必要がある。rangeはその名が示すとおり、範囲を示すもの。Aceにも同名のクラスがある。似た機能だからこれは特に使い方に困ることもないだろう。コンストラクタ引数は 開始行、開始桁、終了行、終了桁 という順番。
textはそのとおりセットしたい文字列だ。
上記コードは、「1行目の1桁目~3桁目に、hogeという文字列をセットする」という動きになる。すでに文字列がある場合は該当桁範囲を上書きする。溢れた分は4桁目を置き換えることなく、挿入という動きになる。
このIIdentifiedSingleEditOperation、配列になっているのがポイントだ。つまり、一回の呼び出しで多数の箇所の文字列を挿入・上書き・削除できるのだ。
モデルで編集する
ITextModelにも同様のメソッドがある。こちらは2つ用意されている。
ITextModel.applyEdits | Monaco Editor API v0.13.1
ITextModel.editPushOperations | Monaco Editor API v0.13.1
//方法1
editor1.model.applyEdits([{ range: new monaco.Range(1,1,1,3), text: "hoge" }]);
//方法2
editor1.model.pushEditOperations([editor1.getSelection()],[{range:new monaco.Range(4,1,4,1), text : "\ntest"}],null);
この2つの違いは、Undoの管理下にあるかどうかだ。
applyEdits()を使うと、Undo/Redoでは変更を取り消せない。一方でpushEditOperations()は変更を取り消すことができる。つまり使い分けが可能なのだ。
どちらも引数に IIdentifiedSingleEditOperation[] というこまっしゃくれた名前のただのJSON風情を指定することになる。
pushEditOperations()ではさらに2つ引数が必要だ。
第一引数 beforeCursorState ・・・ Selection[]とある。本操作前のカーソルの状態を指定という意味だ。
第二引数 editOperations ・・・ こまっしゃくれた名前の(ry
第三引数 cursorStateComputer ・・・ ICursorStateComputerというまた小洒落た名前の、ただの コールバック関数 だ。
大事なのは第二引数で、第一と第三は例の通りで問題ない(editor1.getSelection()は現在の選択範囲を取得する)。カーソル位置を決められるのは、面白い機能かもしれない。
ここまで紹介したメソッドだが、Aceなら次のとおりだ。
Ace | Monaco Editor |
---|---|
Editor.insert, Editor.remove系, Editor.replace系、Editor.setValue | ICodeEditor.executeEdits |
EditSession.insert, EditSession.remove, EditSession.replace, Document.insert系, Document.remove系, Document.replace | ITextModel.applyEdits, ITextModel.editPushOperations |
Aceは細かくメソッドが分かれており、引数も同名ながら違う場合がある。しかし名称が一般的なので、後でソースを見返しても理解と思い出しが早くて済むだろう。
Monaco~は今回紹介した3つであらゆる動きを再現可能。非常に便利だがパラメータ次第で動きが変化するだけに、きちんと自分で機能を管理しておかないと、後でソースを見返したときに苦労するかもしれない。そして名称や引数の型が個人的にはうっとおしくて仕方ない。直感的ではないのだ。
メソッドがバラけても直感的でわかりやすい汎用性を取るか、直感的ではなくわかりづらいが使うメソッドが少なくて済む合理性を取るか、悩ましい。
検索機能
2018/05/26追記
検索周りも見ていく。どちらも検索機能の起点はセッション(モデル)だ。大体似たメソッドと動きをしている。
Search - Ace
ITextModel.findMatches | Monaco Editor API v0.13.1
※findNextMatchとfindPreviousMatchはスクロールするとすぐ見える
//Ace
//エディタで検索
var startpos = new Range(0,0,0,0); //ace.Rangeクラス
editor1.find("hoge|hage",{regExp:true,start:startpos});
//セッションで検索
var search = new Search({needle:"hoge|hage",regExp:true}); //ace.Searchクラス
search.set({start:startpos}); //検索の開始位置を指定
search.set({backwards:true}); //前の行に検索を戻す場合
var ret = search.find(editor1.session); //戻り値の型はRangeクラス
//結果を全て取得する場合(Rangeの配列)
var ret = search.findAll(editor1.session);
//Monaco Editor
var startpos = {lineNumber:1, column:1}; //検索の開始位置を指定
var is_regexp = true;
//戻り値の型はFindMatch(rangeプロパティを持つ)
//次の行に検索を進める場合
var ret = editor1.model.findNextMatch("hoge|hage",startpos,is_regexp,false,null,false);
//前の行に検索を戻す場合
var ret = editor1.model.findPreviousMatch("hoge|hage",startpos,is_regexp,false,null,false);
//検索を全て取得する場合(FindMatchの配列)
var ret = editor1.model.findMatches("hoge|hage",startpos,is_regexp,false,null,false);
Aceの場合、findが二箇所にある。
Editor.findは第一引数に検索文字列、第二引数に検索オプションを必要とする。実行すると、エディタ中で文字列がヒットした箇所にカーソルと選択範囲が移動する。
EditSessionの場合、findメソッドはないのでace.Searchクラスを使う。検索文字列は検索オプションの"needle"にセットすることになる。それ以外はオプションは同じだ。検索の実行にはfindメソッドを呼び出す。
対してMonaco Editorの場合、ICodeEditorにはそんなメソッドはなく、ITextModelに存在する。オプションはAceのようにJSON形式で指定するわけではなく、それぞれの引数に指定する。
第一引数 searchString ・・・ 検索文字列
第二引数 searchStart ・・・ 検索開始位置。Ipositionインターフェースという名の、lineNumberとcolumnを持つただのObject
第三引数 isRegex ・・・ 正規表現を使うかどうか
第四引数 matchCase ・・・ 大文字小文字を区別するかどうか
第五引数 wordSeparator ・・・ 単語全体にマッチするかどうか(stringかnull)→なお、これはBooleanな模様。公式Docの記述が間違っている可能性あり。
第六引数 captureMatches ・・・ 結果のFindMatchにmatchesという、各位置でヒットした実際の文字列を含めるかどうか
AceのEditor.findはカーソル位置をその位置に移動し、てくれるのに対し、Search.find()もITextModel.findNextMatchも、ただヒットした位置の範囲を返すのみだ。カーソルの移動は別途する必要がある。しかし挙動はどちらのライブラリも同じため、アプリで使う場合の検索周りのテクニックとしてはほぼ使い回せるはず。
検索結果をまとめて取得することもできる。Aceの場合はSearchクラスのfindAll()を呼び出す。EditorにもfindAll()はあるが、戻り値が単なるヒットした件数だけなので注意。
Monaco Editorの場合はfindMatches()だ。
検索周りのAPIは、Monaco Editorのほうが一極集中していてわかりやすいかもしれない。
エディタの設定
2018/05/27追記
InternalEditorOptions | Monaco Editor API v0.13.1
IEditorOptions | Monaco Editor API v0.13.1
※2020/03/09 一部追記(0.19.0以降のメソッド追加)
//Ace
//---全部取得
editor1.getOptions();
//---一部のみ取得
editor1.getOption("mode");
//---設定
editor1.setOption("tabSize",4);
editor1.setOptions({"tabSize":4}); //こちらでも可
//Monaco Editor
//---全部取得
//var config = editor1.getConfiguration(); //オプション名が厳密には異なる?
var config = editor1.getRawOptions(); //0.19.0からこっちに改名。
var config = editor1._configuration._validatedOptions; //またはこちら
//---一部のみ取得
config["useTabStops"];
//---設定
editor1.updateOptions({"useTabStop":false});
//---Modelの設定
editor1.getModel().getOptions(); //0.19.0以降でもこっちは変わらず同じ。
Aceは特に問題ない。これ以上でも以下でもないので理解はスムーズだ。オプション名も設定時と取得時で全く同じなのは当たり前過ぎて言うまでもない。
問題はMonaco Editorの方だ。取得と設定でまず名称が異なる。updateOptionsなのに、取得ではgetConfigurationとなっている。OptionかConfigurationかどちらかに統一してほしい。
そしてさらに問題なのは、取得したオプション名が全てが全てそのままでupdateOptionsで使えるわけではないという点。getConfiguration()で返ってくるのはInternalEditorOptionsクラス。updateOptions()で必要とされるのはIEditorOptionsというインターフェース。中身はほぼ同じように見えるが若干異なる。おそらくgetConfiguration()は、設定後計算されて最終的な形としてのオプション名と値なのだろうと推測される。
updateOptionsで必要とされるIEditorOptionsにより近いのは、外部的には非公開扱いと思われる、editor1._configuration._validatedOptionsのようだ。
API DocのIEditorOptionsとInternalEditorOptions、そしてブラウザのdevtoolなどで上記を試し、そのまま使えるオプション名かどうかを逐一確認する必要があるだろう。
イベント関連
イベント周りも大半が似た発火条件が揃っている。が、イベント名は結構異なる。詳細は各ライブラリのドキュメントを見てほしい。今回は個人的に使うものを対比して紹介する。
//Ace
editor.on("focus",function(e){ /*...*/ }); //フォーカスが当たった時
editor.on("change",function(e) { /*...*/ }); //内容に変更があった時
editor.selection.on("changeCursor",function(e,sel) { /*...*/ }); //カーソル位置が変更になった
editor.selection.on("changeSelection",function(e,sel) { /*...*/ }); //選択範囲が変更になった
//Monaco Editor
editor.onDidChangeModelContent(function(e){ /*...*/ }); //フォーカスが当たった時
editor.onDidFocusEditor(function(){ /*...*/ }); //内容に変更があった時
//---ITextModelの場合
editor.model.onDidChangeContent(...);
editor.onDidChangeCursorPosition(function(e){ /*...*/ }); //カーソル位置が変更になった
editor.onDidChangeCursorSelection(function(e){ /*...*/ }); //選択範囲が変更になった
AceもMonaco~も名前で対応具合が容易に見て取れる。カーソルや選択範囲はAceのほうはEditor配下のSelectionオブジェクトのイベントから呼び出す必要がある。そしてさらに気をつけたいのは、コールバック関数に渡ってくる引数。変更系はRangeやSelectionがもれなく含まれて渡ってくるので問題ない。
問題なのはフォーカスだ。onDidFocusEditorは、引数に何も渡ってこない。AceのほうはFocusEventが渡ってくるので、どの要素にフォーカスが当たったのかすぐに取得できる。Monaco~の方は仕方ないので、生成したエディタを別に保持しておき、コールバック関数の中でエディタにフォーカスが当たっているかどうかを逐一判定するのがベターかもしれない。
if (editor.isFocused()) {
/* 処理 */
}
どのエディタにフォーカスが当たったのかは最低限の情報だろうに、驚きの仕様だ。
その他
(プログラミング)言語の定義周り
AceではMode、Monaco~ではLanguageという、つまりソースコードのシンタックスの定義だ。
//---全言語定義を取得する
//Ace
var mode = ace.require("ace/ext/modelist").modesByName;
console.log(mode["javascript"]);
//Monaco Editor
var lang = monaco.languages.getLanguages();
console.log(lang[29]);
//エディタ(セッション)に言語を適用する
//Ace
editor1.session.setMode("ace/mode/python");
editor1.session.setMode(mode["python"].mode); //上記Modeオブジェクトを使う場合
//Monaco Editor
monaco.editor.setModelLanguage(editor1.model, "python");
//エディタ(セッション)の現在の言語を取得する
//Ace
var curmode = editor1.session.getMode();
//Monaco Editor
var curmode = editor1.model.getModeId();
Aceは方針が一貫している。EditSessionでModeをセット・取得するという管理の集中ができる。対してMonaco~は、セットはなぜかmonaco.editorモジュールにあるsetModelLanguageで設定し、取得はITextModelのgetModeId()だ。名前もなぜかModeとなっていて設計にばらつきがある印象を受ける。
テーマ周り
//全テーマを取得する
//Ace
var themes = ace.require("ace/ext/themelist").themesByName;
console.log(themes["chaos"]);
//Monaco Editor
// そんなものはない! → あった(隠し?公式には未実装扱い?)
var allThemes = editor1._themeService._knownThemes;
//---個別はこれ
console.log(editor1._themeService._knownThemes.get("vs"));
//テーマをセットする
//Ace
editor1.setTheme("ace/theme/chaos");
//Monaco Editor
monaco.editor.setTheme("vs-dark");
//現在のテーマを取得する
//Ace
editor1.getTheme();
//Monaco Editor
// そんなものはない! → あった(隠し?公式には未実装扱い?)
editor1._themeService.getTheme();
テーマ周りはハッキリ言ってMonaco~は弱い。弱すぎる。てか、対応している初期状態の全テーマを取得できないってどういうことだ?何を指定すればいいんだ? MSよ、そこのところ、もうちょっと開発者に優しく設計しようぜ。甘すぎるよ・・・。
テーマの管理にも違いがある。AceはEditorごとに設定できるのに対し、Monaco~は 生成したICodeEditor全て に設定される。この仕様は個人的には痛すぎる。個々のエディタでテーマも個別に適用させてくれよ・・・。
テーマ数はAceの37個に対し、どうやらMonaco~は4つ程度と風の噂で耳にした。その分新しいテーマの定義機能は結構揃っているみたいだが、 違う。根本的な問題はそこじゃない。
まずMSの開発チームは、テーマ機能をAceを見習って設計し直すべきだと思う。現在の仕様はハッキリいって論外。
終わりに
なかなかこれだ!という紹介記事がなかったので、自分でいじくり回して基本的にこれがわかればあとはどうとでもなるだろうという部分をざっと紹介した。試してみて分かった機能については随時追加するか、記事を分けて投稿したい。
何分先にAceエディタライブラリを知ってガッツリ使っているため、どうしても比較して一喜一憂してしまう。しかし、Monaco Editorは素晴らしいのだ。コードスニペットや自動補完周りは、Aceをすでに通り越して爆走している印象を受けた。ただのサンプルでも、自動補完が効き、型の提案を見ることができ、エラーを見やすく表示してくれるのだ。
コマンドパレットも便利。しかしこれは自作エディタのCMeDitorの目玉機能と完全に被るため、もしMonaco Editorを採用したあかつきには同機能を封印するか、既存コマンドを全部消して自作コマンド用にするだろう。つまるところ、ライバル・・・。
API Docを眺めていると、どうやらtypescriptの実行機能もあるように見受けられる。なんつう高機能さだ。
言語やテーマも比較的備わっているが、Aceに比べるとまだまだだ。言語はAceの145個はいくらなんでも多すぎるので、Monaco~の45個は必要十分と思った。
そして問題のテーマ。こいつは上でも書いたが、論外。
Aceをとうに追い越している部分もあるだけに、Aceに至っていない部分が非常にもったいない。編集機能は一度発見して便利さを実感すればいいが、見つけづらさ・分かりづらさで損しているだろうMonaco Editorに出会ってからのこの数日、編集機能がなかなか見つけられなくて本当に辛かった。なんとかCMeDitorで採用してみたいという思いがあったので、これでなんとかAceから置き換えて組み込むことも夢ではなくなった。
Monaco Editor、組み込むだけなら簡単だ。きっとMonaco Editorに出会った他の方はすでに自分のウェブアプリ等に組み込んで、デフォルトで備わっている便利機能を享受していることだろう。しかしプログラム的に編集操作をするところまで行ってる人がいらっしゃるのか・・・。今回なんとか確認できたexecuteEdits,applyEdits,pushEditOperationsに関するブログや記事、海外ばかりだ。
私の本投稿が、国内でのMonaco Editorの利用促進に一役買えたら幸いだ。