🖋️

Webでも動く小説用VS Code拡張機能を作る

2022/01/29に公開

小説のバージョン管理をGitで行うにあたり手頃なエディターが欲しくなったので、ブラウザでも編集できるようVS Codeの拡張機能として作ってみた。

https://marketplace.visualstudio.com/items?itemName=publictheta.vscode-japanese-novel

機能としては小説投稿サイトにあるような、

  • ルビや傍点を振る
  • 行頭の字下げを揃える
  • 字数のカウント
  • プレビュー

といったものを一通り備えている。

スクリーンショット

ルビや傍点の記法はカクヨムと同じものを採用し、傍点に対応していないサイト用にはルビによる代替表記に変換してコピーするコマンドを用意した。

ソースコードはGitHubで公開している:

https://github.com/publictheta/vscode-japanese-novel

なおNode.jsで動作する小説用拡張機能としては、より多機能なものとしてSF作家の藤井大洋氏による

https://github.com/ttrace/vscode-language-japanese-novel

が既にあり、今回の拡張機能を作る上でも参考にさせていただいた。

Web拡張機能

VS CodeはTypeScript製ということもあってブラウザでも動くようになってきている(Visual Studio Code for the Web = vscode.devgithub.devなど)。拡張機能についてはまだ日本語パックをはじめとして対応していないものも多いが、徐々に移行も進んでおり、Webでもデスクトップでも動くそのような拡張機能は「Web拡張機能」として既にドキュメンテーション化されている。

https://code.visualstudio.com/api/extension-guides/web-extensions

詳しくは上記ドキュメンテーションを参照してほしい。非公式な日本語訳も拙訳ではあるが公開してある。

https://zenn.dev/publictheta/articles/52089687bbaece

作り方はほぼ同じだが、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)として用いることにした。

https://github.com/microsoft/vscode-pull-request-github

ブラウザ用には"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を読み込むことができる。

https://code.visualstudio.com/api/extension-guides/webview

これはビルトインのMarkdown拡張機能のプレビューにも使われおり、設計を含めてとても参考になった。

https://github.com/microsoft/vscode/tree/main/extensions/markdown-language-features

Webviewと拡張機能はメッセージパッシングによってやりとりする。ただしメッセージは構造化複製アルゴリズムによって複製されて送られるので、VS Codeのクラスをそのまま送ることはできない。

拡張機能からはwebview.postMessageでメッセージを送り、webview.onDidReceiveMessageにリスナーを登録してメッセージを受け取る。

なおWebviewからもメッセージを送れるようにするには、Webviewパネルの作成時にenableScriptstrueにし、スクリプトから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.workspacevscode.window

vscode.TextDocumentvscode.TextEditorには、onDidReceiveMessageのようにリスナーを登録できるイベント(vscode.Event)はない。

変更を監視するとなるとvscode.workspacevscode.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(ウィンドウのフォーカスの変更)

メソッドについても同様で例えばドキュメントを開いて表示する際も、

  1. vscode.workspace.openTextDocument()でドキュメントを開く
  2. vscode.window.showTextDocument()でエディターに表示する

という順序を踏むことになる。

またvscode.workspaceからはファイルシステムへのアクセスも可能なほか、

vscode.windowからはメッセージや入力ボックスの表示などもできる。

  • vscode.window.showInformationMessage()
  • vscode.window.showWarningMessage()
  • vscode.window.showErrorMessage()
  • vscode.window.setStatusBarMessage()
  • vscode.window.showOpenDialog()
  • vscode.window.showQuickPick()

when節コンテキスト

コマンドパレットやコンテキストメニューなどの表示非表示を切り替えるには、when節コンテキストを使うことができる。

https://code.visualstudio.com/api/references/when-clause-contexts

{"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ジェネレーターで雛形を作るようになっている。プロジェクトのセットアップの手間を省きたい場合はこちらをおすすめする。

https://www.npmjs.com/package/generator-code

package.jsonについては、

https://code.visualstudio.com/api/references/extension-manifest

https://code.visualstudio.com/api/references/activation-events

https://code.visualstudio.com/api/references/contribution-points

を見ながら書いていくことになる。その際、設定やUIへの表示で使えるビルトインのアイコンがあることを知っておくと用意する手間が省け統一感も出る。

https://code.visualstudio.com/api/references/icons-in-labels

コードを書くときは、

https://code.visualstudio.com/api/references/vscode-api

を見ることになる。vscode.d.tsから生成されているものなので、エディタやIDEから直接参照しても構わない。

https://code.visualstudio.com/api/extension-guides/overview

の関係するガイドを読むと実現したい機能の作り方が分かる。

言語拡張機能を作る際は、

https://code.visualstudio.com/api/language-extensions/overview

を見てどのように作るかを決めることになる。

テスト、バンドル、公開の方法については、

https://code.visualstudio.com/api/working-with-extensions/testing-extension

https://code.visualstudio.com/api/working-with-extensions/bundling-extension

https://code.visualstudio.com/api/working-with-extensions/publishing-extension

ただしWeb拡張機能の場合は、

https://code.visualstudio.com/api/extension-guides/web-extensions

をまず見た方がよい。

サンポルコードは、

https://github.com/microsoft/vscode-extension-samples

にあるが、APIの使い方や拡張機能の構成などは、

https://github.com/microsoft/vscode/tree/main/extensions

にあるビルトインの拡張機能のソースコードや、

https://github.com/microsoft/vscode-pull-request-github

https://github.com/microsoft/vscode-python

https://github.com/microsoft/vscode-react-native

https://github.com/microsoft/vscode-wordcount

https://github.com/microsoft/vscode-anycode

などを読んだ方がよく分かる。

また、

https://code.visualstudio.com/api/references/extension-guidelines

を読んでおくと各種UIをどのように使えばいいか困らなくなる。

上記以外にも情報が載っているので、公式のドキュメンテーションは目次だけでも見ておくとよい。

https://code.visualstudio.com/api

最後に

はじめてで手間取ることも多かったが、動くものを作るのは楽しかった。ソースコードを見ると分かるようにVS Codeの標準機能の多くもビルトインの拡張機能として実装されており、拡張機能にできることは多い。リモート機能ライブシェア機能とVS Codeはどんどん便利になっていくと思うので、ぜひ拡張機能で自分好みのエディターに育てていってほしい。

Discussion