🗒️

【Monaco Editor】ブラウザで動く簡易メモ帳を1時間で作る【ファイル保存可】

2023/08/22に公開

成果物

コード:ihasq/monax
デモ:ihasq.com/monax

やること

マイクロソフト社が提供するOSSのテキストエディタ Monaco Editor を、ファイル編集機能とURLパラメータに対応させた自分用の簡易エディタを作ります。
ファイル操作には標準の FileSystem Access API を使用。URLパラメータは Monaco Editor の設定APIに直結しています。
以前まではurl欄にdata:text/html,<html contenteditable="plaintext-only", style="...と毎回打ち込んでは即席メモ帳を起動(?)していました。

開発環境

  • Node.js: 16.15.0
  • npm: 8.5.5
  • Vite: 4.2.1

作業

npm install monaco-editor から src/index.jsを記述開始。
MonacoのESモジュール版に関する記事がないので焦りましたが、esmフォルダ以降の構造がCJS版と同じで助かりました。

src/index.js
import * as monaco from "monaco-editor/esm/vs/editor/editor.main.js"

Monacoの設定に直結させるURLパラメータのパーサーを追記します。

src/index.js
// ...
const paramList = Object.create(null);
const paramBody = location.search.slice(1);
decodeURI(paramBody).split("&").forEach(index => {
	const pair = index.split("=");
	paramList[pair[0]] = (pair[1] === ("true" || "false"))? JSON.parse(pair[1].toLowerCase()) : (isNaN(Number(pair[1])))? pair[1] : Number(pair[1])
});

結論から言うと、HTMLファイルにはほとんど触れませんでした。Documentを参照してmonaco.editor.create(...)でハンドラを作成すると、そこからはハンドラ経由でAPIを叩くだけでコンテキストメニューの設定まで出来るのが便利です。ここではeditorSurfaceという名称にしました。

src/index.js
// ...
const editorSurface = monaco.editor.create(document.getElementById("container"), Object.assign({
	language: "plaintext",
	automaticLayout: true,
	fontSize: 15,
	fontFamily: "monospace",
	lineNumbersWidth: 12
}, paramList));

エントリのsrc/index.html を新たに作成し、 Monaco のコンテナとなる要素を書き込みます。タイトルは Monaco Extended(拡張されたMonaco)から Monax としました。いまいち。

src/index.html
<!DOCTYPE html>
<html>
	<head>
		<title>Monax</title>
		<style>
			html, body, div#container {
				box-sizing: border-box;
				height: 100%;
				padding: 0px;
				margin: 0px
			}
		</style>
	</head>
	<body>
		<div id="container"></div>
		<script type="module" src="./index.js"></script>
	</body>
</html>

コンテキストメニュー経由で実行されるファイル操作機能を記述します。Monaco には独自のコンテキストメニューが実装されており、CodeMirrorや通常の<textarea> にはない機能が多く用意されています。
オリジナルの処理はeditorSurface.addAction() を通して一個ずつ追加していく必要があります。
ここでは FileSystem Access API に関する説明は省略します。

src/index.js
// ...
const fileProperty = {
	fileHandle: null,
	file: null,
	fileData: null,
	fileWritable: null
};
const customFunction = {
	new() {
		fileProperty.fileHandle = null;
		editorSurface.getModel().setValue("");
		document.title = "Monax"
	},
	async open() {
		[fileProperty.fileHandle] = await window.showOpenFilePicker({
			types: [
				{
					description: "Text files",
					accept: {
						"text/*": [],
					},
				},
			],
			excludeAcceptAllOption: false,
			multiple: false,
		});
		fileProperty.file = await fileProperty.fileHandle.getFile();
		fileProperty.fileData = await fileProperty.file.text();
		const model = editorSurface.getModel();
		monaco.editor.setModelLanguage(model, fileProperty.file.type.slice(fileProperty.file.type.search("/") + 1))
		model.setValue(fileProperty.fileData);
		document.title = fileProperty.file.name + " - Monax";
	},
	async save() {
		if(!fileProperty.fileHandle) {
			fileProperty.fileHandle = await window.showSaveFilePicker();
		}
		fileProperty.fileWritable = await fileProperty.fileHandle.createWritable();
		const blob = new Blob([editorSurface.getValue()])
		await fileProperty.fileWritable.write(blob);
		await fileProperty.fileWritable.close();
	},
};

最後の記述です。バッファ初期化処理のnew()、展開処理のopen()、そしてファイルセーブ処理のsave()customFunctionに書き込んだので、editorSurfaceに適用します。
keybindingsでショートカットキーの設定と表示まで出来ます。

処理を見る(長い割に繰り返しが続くので畳みました)
src/index.js
// ...
[
	// files
	{
		id: "custom-fn-new",
		label: "New",
		keybindings: [
			monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyN,
			monaco.KeyMod.chord(
				monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyN,
			),
		],

		precondition: null,
		keybindingContext: null,
		contextMenuGroupId: "file",
		contextMenuOrder: 1.5,
		run: customFunction.new
	},
	{
		id: "custom-fn-open",
		label: "Open",
		keybindings: [
			monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyO,
			monaco.KeyMod.chord(
				monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyO,
			),
		],

		precondition: null,
		keybindingContext: null,
		contextMenuGroupId: "file",
		contextMenuOrder: 1.5,
		run: customFunction.open
	},
	{
		id: "custom-fn-save",
		label: "Save",
		keybindings: [
			monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
			monaco.KeyMod.chord(
				monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
			),
		],

		precondition: null,
		keybindingContext: null,
		contextMenuGroupId: "file",
		contextMenuOrder: 1.5,

		run: customFunction.save
	},
	{
		id: "custom-fn-saveAs",
		label: "Save As",
		keybindings: [
			monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyA,
			monaco.KeyMod.chord(
				monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyA,
			),
		],

		precondition: null,
		keybindingContext: null,
		contextMenuGroupId: "file",
		contextMenuOrder: 1.5,

		run: async () => {
			fileProperty.fileHandle = await window.showSaveFilePicker();
			await customFunction.save()
		}
	},
	// tweet
	{
		id: "custom-fn-tweet",
		label: "Share on X",

		precondition: null,
		keybindingContext: null,
		contextMenuGroupId: "tweet",
		contextMenuOrder: 1.5,

		run: () => open(encodeURI(("https://twitter.com/intent/tweet?text=" + editorSurface.getValue())))
	},
	// about
	{
		id: "custom-fn-about",
		label: "About Monax",

		precondition: null,
		keybindingContext: null,
		contextMenuGroupId: "about",
		contextMenuOrder: 1.5,

		run: () => open(encodeURI(("./?" + decodeURI(paramBody) + `&value=		


			MONAX - MONAco eXtended

					version 0.0.1
		by ihasq and microsoft monaco team
	Monax is open source and freely distributable



		`.replace(/\t/g, ""))))
	}

].forEach(index => editorSurface.addAction(index));

いかがでしたか?

Monaco の API があまりにも充実していてビビりました。Tab入力のUndo/Redo操作が完璧だったりと、CodeMirrorにはない圧倒的な強みをあらゆる部分に感じました。ただ一つ問題を挙げるとすれば、この簡易メモ帳のビルドサイズが3MBもあることです。この点CodeMirrorはRollupによるバンドルで700KBとコンパクトに収まっています。実用レベルのテキストエディタでの埋め込みを前提に開発されている Monaco と、コードのビューワーとしての使用が前提の CodeMirror のそれぞれのスタンスが何となく分かったような気がしました。

参考

vscode-textbuffer: Monaco Editor 内部で使用されるTypeScript製のテキストバッファ。文字列の参照の組み合わせでバッファ全体を表現するピーステーブル方式に、高速な編集機能を提供する赤黒木構造を組み合わせたピースツリーという独自のデータ構造を持っており、他のテキストバッファと比較して大量の文字データを保持できる点が特徴です。

Discussion