Webでも動く小説用VS Code拡張機能を作る
小説のバージョン管理をGitで行うにあたり手頃なエディターが欲しくなったので、ブラウザでも編集できるようVS Codeの拡張機能として作ってみた。
機能としては小説投稿サイトにあるような、
- ルビや傍点を振る
- 行頭の字下げを揃える
- 字数のカウント
- プレビュー
といったものを一通り備えている。
ルビや傍点の記法はカクヨムと同じものを採用し、傍点に対応していないサイト用にはルビによる代替表記に変換してコピーするコマンドを用意した。
ソースコードはGitHubで公開している:
なおNode.jsで動作する小説用拡張機能としては、より多機能なものとしてSF作家の藤井大洋氏による
が既にあり、今回の拡張機能を作る上でも参考にさせていただいた。
Web拡張機能
VS CodeはTypeScript製ということもあってブラウザでも動くようになってきている(Visual Studio Code for the Web = vscode.dev
、github.dev
など)。拡張機能についてはまだ日本語パックをはじめとして対応していないものも多いが、徐々に移行も進んでおり、Webでもデスクトップでも動くそのような拡張機能は「Web拡張機能」として既にドキュメンテーション化されている。
詳しくは上記ドキュメンテーションを参照してほしい。非公式な日本語訳も拙訳ではあるが公開してある。
作り方はほぼ同じだが、package.json
でエントリーポイントを指定するときにmain
プロパティではなくbrowser
プロパティに指定するようになっており、これによってNode.jsでしか動かないコードとブラウザでしか動かないコードを分けられるようになっている。
{
"main": "./dist/node/extension.js",
"browser": "./dist/browser/extension.js"
}
またWeb Workerで実行されることになるブラウザ用のコードにはいくつか制限があり、Node.jsのAPIにアクセスできないのはもちろん、他のモジュールのインポートもサポートされていない。そのためコードは単一のファイルにバンドルされている必要がある。
webpackの設定
バンドルにはesbuild
をそのまま使ってもよいが、後述のWebviewやテストなどの設定とまとめてしまうために、今回は「GitHub Pull Requests and Issues」を参考にwebpack
のローダー(esbuild-loader
)として用いることにした。
ブラウザ用には"webworker"
、Node.js用には"node"
をターゲットに指定する。ライブラリのタイプには"commonjs"
を指定し、VS Codeによって提供されるrequire("vscode")
に対応するためexternals
を設定しておく。
const extensionConfig = {
target: isWeb ? "webworker" : "node",
output: {
library: {
type: "commonjs",
},
// ...
},
externals: {
vscode: "commonjs vscode",
},
// ...
}
また必要に応じて、Node.jsのモジュールのフォールバックや、グローバルの定義を追加する。ブラウザで実行するテストもバンドルされている必要があったので、以下の設定を今回実際に用いた。
const testConfig = {
resolve: {
fallback: {
assert: require.resolve("assert"),
},
// ...
},
plugins: [
new webpack.ProvidePlugin({
process: "process/browser",
}),
// ...
],
// ...
}
デバッグ構成
拡張機能をWeb拡張機能としてテスト実行するには、launch.json
の構成で、
-
"type"
を"pwa-extensionHost"
に指定 -
"debugWebWorkerHost"
をtrue
に指定 -
"args"
に"--extensionDevelopmentKind=web"
を追加
する。
{
"version": "0.2.0",
"configurations": [
{
"name": "拡張機能(Web)を実行する",
"type": "pwa-extensionHost",
"request": "launch",
"debugWebWorkerHost": true,
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionDevelopmentKind=web"
],
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "npm: start"
},
{
"name": "拡張機能(Node.js)を実行する",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "npm: start"
}
]
}
これで拡張機能が実際に動くかどうかを確認できる。
実装
設定さえしてしまえばWeb拡張機能も従来の拡張機能と同じように開発することができる。ただ拡張機能の開発自体が初めてだったので、以下知ったことをまとめておく。
アクティベーションとDisposeパターン
拡張機能はVS Codeから呼び出されるactivate
関数によって初期化される。
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {}
export function deactivate() {}
終了時の処理はdeactivate
関数の中に書くことができるが、VS Code全体で使われているdispose
パターンに従って実装すれば多くの場合書く必要はない。
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push({
dispose() {
console.log("拡張機能を終了します。")
}
})
}
Webviewによるプレビュー
VS Codeの拡張機能はWebviewを使ってiframe
にHTML/CSS/JSを読み込むことができる。
これはビルトインのMarkdown拡張機能のプレビューにも使われおり、設計を含めてとても参考になった。
Webviewと拡張機能はメッセージパッシングによってやりとりする。ただしメッセージは構造化複製アルゴリズムによって複製されて送られるので、VS Codeのクラスをそのまま送ることはできない。
拡張機能からはwebview.postMessage
でメッセージを送り、webview.onDidReceiveMessage
にリスナーを登録してメッセージを受け取る。
なおWebviewからもメッセージを送れるようにするには、Webviewパネルの作成時にenableScripts
をtrue
にし、スクリプトからacquireVsCodeApi()
を呼べるようにしておく必要がある。
extension.ts
import * as vscode from "vscode"
// 拡張機能のコンテキストは`activate`時に得られる
export function activate(context: vscode.ExtensionContext) {
// パネルを作成する
const panel = vscode.window.createWebviewPanel(
"my-extension-view-type",
"タイトル",
vscode.ViewColumn.Beside,
// Webviewからメッセージを送るには`enableScripts`を`true`にしておく
{ enableScripts: true }
)
// ローカルリソースのURIは`webview.asWebviewUri`で変換する
const src = panel.webview.asWebviewUri(
vscode.Uri.joinPath(context.extensionUri, "dist/webview.js")
)
// HTMLを読み込む
panel.webview.html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Webview</title>
<script defer src="${src}"></script>
</head>
<body></body>
</html>`
// メッセージを受け取る
panel.webview.onDidReceiveMessage((message) => {
vscode.window.showInformationMessage(
`Hello ${meesage.hello}.`
)
}, undefined, context.subscriptions)
// メッセージを送る
panel.webview.postMessage({
hello: "from my extension"
})
}
Webview側ではwindow
の"message"
イベントにリスナーを登録してメッセージを受け取り、acquireVsCodeApi()
(呼び出しは一回のみ可能)を呼んで得られるVS Code APIからpostMessage()
を呼んでメッセージを送る。
webview.ts
window.addEventListener("DOMContentLoaded", () => {
// メッセージを受け取る
window.addEventListener("message", message => {
document.body.innerText = `Hello ${message.hello}.`
})
// `acquireVsCodeApi`は一回のみ呼び出し可能
const vscode = acquireVsCodeApi()
// メッセージを送る
vscode.postMessage({
hello: "from my webview"
})
})
今回作った拡張機能では、vscode.workspace.onDidChangeTextDocument
のイベントで得られる変更などを逐一Webviewに伝えることで、プレビューを同期させている。実装ミスによるバグは生みやすくなるものの編集時の更新は最小限で済むので、執筆体験は多少良くなっているかもしれない。
Webviewの永続化
Webviewパネルを作成するだけでは、次回の起動時に復元されない。永続化するには、必要に応じてWebviewで状態を保存しておき、
webview.ts
// const vscode = acquireVsCodeApi()
vscode.setState({foo: "Webviewの状態"})
拡張機能の起動時にvscode.WebviewPanelSerializer
を実装したオブジェクトを、vscode.window.registerWebviewPanelSerializer
で登録する。
ただし復元時に拡張機能がアクティベートされるように、package.json
の"activationEvents"
にonWebviewPanel:作成時に設定したビュータイプ
を追加するのも忘れないようにしておく。
package.json
{
"activationEvents": [
"onWebviewPanel:my-extension-view-type"
]
}
extension.ts
import * as vscode from "vscode"
export function activate(context: vscode.ExtensionContext) {
vscode.window.registerWebviewPanelSerializer(
"my-extension-view-type",
async deserializeWebviewPanel(
panel: vscode.WebviewPanel,
state: {foo: string}
): Promise<void> {
panel.webview.html = /* HTMLの復元する */ ""
}
)
}
setState()
で保存しておく状態は復元に必要な情報だけでよく、今回作った拡張機能ではURIのみを保存する仕様にしている。またgetState()
を使ってWebviewのJavaScriptから以前の状態を取得することもできる。
webview.ts
// const vscode = acquireVsCodeApi()
const state = vscode.getState()
// {foo: "Webviewの状態"}
なおWebviewパネルはバックグラウンドに移動した際にも状態が破棄され、復帰するたびに再読み込みされる。こちらはHTMLを再設定する必要はないが、状態の復元は必要となる。
それが困難な場合retainContextWhenHidden
を追加することでその挙動をOFFにすることも可能だが、パフォーマンスの観点から推奨されていないので今回は採用しなかった。
const panel = vscode.window.createWebviewPanel(
"my-extension-view-type",
"タイトル",
vscode.ViewColumn.Beside,
{ enableScripts: true,
/* 追加 */
retainContextWhenHidden: true,}
);
vscode.workspace
とvscode.window
vscode.TextDocument
やvscode.TextEditor
には、onDidReceiveMessage
のようにリスナーを登録できるイベント(vscode.Event
)はない。
変更を監視するとなるとvscode.workspace
やvscode.window
にリスナーを登録することになる。
おおまかにvscode.TextDocument
のようなワークスペースにあるモデルへはvscode.workspace
から、vscode.TextEditor
のようなウィンドウに表示されているビューへはvscode.window
からアクセスできる。
vscode.workspace
にあるイベントの例:
-
vscode.workspace.onDidChangeTextDocument
(テキストの変更) -
vscode.workspace.onDidRenameFiles
(ファイル名の変更) -
vscode.workspace.onDidChangeConfiguration
(設定の変更)
vscode.window
にあるイベントの例:
-
vscode.window.onDidChangeTextEditorSelection
(エディターの選択範囲の変更) -
vscode.window.onDidChangeTextEditorVisibleRanges
(エディターの表示範囲の変更) -
vscode.window.onDidChangeVisibleTextEditors
(表示されてるエディターの変更) -
vscode.window.onDidChangeWindowState
(ウィンドウのフォーカスの変更)
メソッドについても同様で例えばドキュメントを開いて表示する際も、
-
vscode.workspace.openTextDocument()
でドキュメントを開く -
vscode.window.showTextDocument()
でエディターに表示する
という順序を踏むことになる。
またvscode.workspace
からはファイルシステムへのアクセスも可能なほか、
vscode.workspace.fs
vscode.workspace.createFileSystemWatcher
vscode.window
からはメッセージや入力ボックスの表示などもできる。
vscode.window.showInformationMessage()
vscode.window.showWarningMessage()
vscode.window.showErrorMessage()
vscode.window.setStatusBarMessage()
vscode.window.showOpenDialog()
vscode.window.showQuickPick()
when節コンテキスト
コマンドパレットやコンテキストメニューなどの表示非表示を切り替えるには、when節コンテキストを使うことができる。
{"contributes": {"menus": {"commandPalette": [
{
"command": "my-extension.markdown-command",
"when": "editorLangId == markdown"
}
]}}}
これによって特定の条件によってのみ操作可能にしてメニューが繁雑になることを避けることができる。
「開発者: コンテキスト キーの検査(Developer: Inspect Context Keys)」コマンドで確認できるように、既にさまざまなコンテキストがwhen
で参照できるようセットされているが、これを"setContext"
コマンドを実行することで拡張機能からセットすることもできる。
await vscode.commands.executeCommand(
"setContext",
"my-extension", /* `.`を含んでもよい */
{
foo: ["hello", "extension"],
bar: {
data: [2, 3, 5, 7]
},
} /* オブジェクト以外の値でもよい */
)
このように配列やオブジェクトをセットすることも可能で、通常の論理演算や比較演算に加えて、正規表現によるマッチや、次のように配列が要素を含んでいるかやオブジェクトにキーがあるかもチェックできる。
- 論理演算(
!
、==
、!=
、&&
、||
)"!editorIsOpen"
"resourceExtname == .md"
"isMac && isWeb"
- 比較演算(
>
、>=
、<
、<=
)"workspaceFolderCount > 1"
-
=~
(正規表現、左辺にキーで右辺に正規表現の順)"resourceFilename =~ /^tsconfig.*\\.json$/"
-
in
- 要素の存在:
"hello in my-extension.foo"
(foo
は配列) - キーの存在:
"data in my-extension.bar"
(bar
はオブジェクト)
- 要素の存在:
開発のための情報源
今回は使わなかったが公式のチュートリアルではYeomanジェネレーターで雛形を作るようになっている。プロジェクトのセットアップの手間を省きたい場合はこちらをおすすめする。
package.json
については、
を見ながら書いていくことになる。その際、設定やUIへの表示で使えるビルトインのアイコンがあることを知っておくと用意する手間が省け統一感も出る。
コードを書くときは、
を見ることになる。vscode.d.ts
から生成されているものなので、エディタやIDEから直接参照しても構わない。
の関係するガイドを読むと実現したい機能の作り方が分かる。
言語拡張機能を作る際は、
を見てどのように作るかを決めることになる。
テスト、バンドル、公開の方法については、
ただしWeb拡張機能の場合は、
をまず見た方がよい。
サンポルコードは、
にあるが、APIの使い方や拡張機能の構成などは、
にあるビルトインの拡張機能のソースコードや、
などを読んだ方がよく分かる。
また、
を読んでおくと各種UIをどのように使えばいいか困らなくなる。
上記以外にも情報が載っているので、公式のドキュメンテーションは目次だけでも見ておくとよい。
最後に
はじめてで手間取ることも多かったが、動くものを作るのは楽しかった。ソースコードを見ると分かるようにVS Codeの標準機能の多くもビルトインの拡張機能として実装されており、拡張機能にできることは多い。リモート機能にライブシェア機能とVS Codeはどんどん便利になっていくと思うので、ぜひ拡張機能で自分好みのエディターに育てていってほしい。
Discussion