1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

vscode拡張機能開発~react,scssによるカスタムエディタを添えて~

Posted at

3行

全てはここに載っている

公式ドキュメント

公式サンプル集

まえがき

おはようございます、デブです。
最近はナイトレインで深度4に上がった瞬間に叩き落されて3と4を反復横とびしています。少しでも迷ったり慢心した瞬間、死ぬ。

それはそれとして少し前に実務でとある業務上の課題を達成するために色々と候補を考えている中、vscodeの拡張機能が候補に上がったので少しいじってました。
結局別の方法でやることになったので業務では終わりでしたが触ってて面白かったので、個人的にもう少し深堀りした内容をつらつらと垂れ流します。

さて今回はカスタム画面をメインにやっていきます。
カスタム画面を作ること自体が目的ということで作成する機能自体はなんでもよく、以下の要素があればええかという感じです。

  • ファイル操作
  • react,scssで構成されたカスタム画面

まずは簡単にテキストファイルを弄れれば良いので拡張機能の内容は「web小説用のテキスト変換」としました。
変換コマンドと、変換の設定ファイルをカスタム画面で編集する、ということで上記の要素を満たせます。
設定ファイルは面倒なので中身はjsonとします。

作成物をもとにどんな手順で作ったのかを以下に書きます。
なお、制作物ありきで記事を起こしているので実際に開発する場合は各段階を行ったり来たりになるかと思いますがその辺りはご容赦ください。
また、以下の要素も除きます

  • 環境構築(nodejs等)
  • 各操作及びAPIの詳細な説明
  • テストの作成及び実行
  • 作成した拡張機能のビルド及び配信、インストール方法

各種操作やAPIなどは冒頭に載せたガイドやリファレンスに記述されているのと、主要コードを併記するので適宜確認をお願いします。

また、そもそもnoedjsやweb関連は素人に毛が生えた程度の知識なので 「少なくともこのコードで動いてはいる」程度でベストプラクティスではない公算の方が大きいです 。鵜呑みにしないようにお願いします。
間違いやもっといい方法などあれば指摘やアドバイスなど頂ければ幸いです。
あと文体が変わります。

保険も十二分にかけられたので以下本編です。

react+scssを含んだvscode拡張機能の作成

作成物は以下で公開

1. ジェネレーターで雛形の作成

作業用ディレクトリを作成し、以下のコマンドで雛形を作成。

npx --package yo --package generator-code -- yo code

オプションは基本デフォルト値で、名前はよしなに命名。
重要なポイントはtypescriptとwebpackの明示的な指定。

? What type of extension do you want to create? 
❯ New Extension (TypeScript) 
(中略)
? Which bundler to use? 
  unbundled 
❯ webpack 
  esbuild 

2.各ライブラリの設定

大体は以下の記事を参考にした。

参考

各種インストール

カスタム画面を作成するためのライブラリとそのtypesやローダーをインストール。
主なものは以下の通り。

  • react
  • scss
  • vscode-webview(vscode apiをweb側から使うためのライブラリ)
npm i react react-dom vscode-webview
npm i -D style-loader css-loader sass-loader @types/react @types/react-dom @types/node sass @types/vscode-webview

webpack設定

上記ライブラリに合わせたwebpackの設定変更。
ベースとなる設定を作成し、拡張機能部分とwebview(react)部分の設定を分化。
(エントリーポイント及び出力ファイルが別々になるため)

特に重要なのは以下のローダーの追加

  • style-loader
  • css-loader(モジュールCSS指定)
  • sass-loader

改変したwebpack.configが以下の通り。

webpack.config.js
webpack.config.js
//@ts-check

"use strict";

const path = require("path");
const webpack = require("webpack");

//@ts-check
/** @typedef {import('webpack').Configuration} WebpackConfig **/

/** @type WebpackConfig */
const baseConfig = {
  target: "node", // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/
  mode: "none", // this leaves the source code as close as possible to the original (when packaging we set this to 'production')
  resolve: {
    // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
    extensions: [".ts", ".js", ".tsx"], 
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
    }),
  ],
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "ts-loader",
          },
        ],
      },
      {
        test:/\.scss$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "style-loader",
          },
          {
            loader: "css-loader",
            options: {
              modules: true,
            },
          },
          {
            loader: "sass-loader",
          },
        ],
        
      }
    ],
  },
  devtool: "nosources-source-map",
  infrastructureLogging: {
    level: "log", // enables logging required for problem matchers
  },
};

/** @type WebpackConfig */
const extensionConfig = {
  ...baseConfig,
  entry: "./src/extension.ts", // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/
  output: {
    // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/
    path: path.resolve(__dirname, "dist"),
    filename: "extension.js",
    libraryTarget: "commonjs2",
  },
  externals: {
    vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
    // modules added here also need to be added in the .vscodeignore file
  },
};

/** @type WebpackConfig */
const webviewConfig = {
  ...baseConfig,
  target: ["web", "es2020"],
  entry: "./src/index.tsx",
  experiments: { outputModule: true },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "webview.js",
    libraryTarget: "module",
    chunkFormat: "module",
  },
};

module.exports = [extensionConfig, webviewConfig];


tsconfig設定

同様にtsconfigの設定変更。
具体的には

  • トランスパイル対象にjsxの追加
  • moduleをES2015に変更
  • ライブラリにDOMを追加

改変したtsconfig.jsonが以下の通り

tsconfig.json
tsconfig.json
{
	"compilerOptions": {
		"jsx": "react",
		"module": "ES2015",
		"target": "ES2022",
		"lib": [
			"ES2022",
			"DOM"
		],
		"sourceMap": true,
		"moduleResolution":"node",
		"rootDir": "src",
		"strict": true,   /* enable all strict type-checking options */
		
		/* Additional Checks */
	// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
		// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
		// "noUnusedParameters": true,  /* Report errors on unused parameters. */
	},
}

マニフェスト設定

拡張機能のマニフェスト設定
(packege.jsonにcontributesを追加)

リファレンス

今回した設定はざっくりと以下の通り。

  • configuration
    • title(拡張機能名)
    • properties(拡張機能の設定)
      • 今回はファイル作成時のデフォルト値
  • customEditors
    • 表示名や適用する拡張子など
  • commands
    • コマンドパレットに表示するコマンド
  • menus
    • 各拡張子でエディタ内で右クリックで表示及び実行するコマンド
  • languages
    • 言語モードの追加
package.json パッケージなどは古いバージョンがある可能性あり。(というかある)
package.json

{
  "name": "simple-novel-setting",
  "displayName": "Simple Novel Setting",
  "description": "",
  "version": "0.0.1",
  "engines": {
    "vscode": "^1.97.0"
  },
  "categories": [
    "Other"
  ],
  "activationEvents": [],
  "main": "./dist/extension.js",
  "contributes": {
    "configuration":{
      "title": "Simple Novel Setting",
      "properties": {
        "Simple Novel Setting.Default Setting":{
          "type":"object",
          "default":{
            "enableInsertIndentation": true,
            "enableConvertRuby": true,
            "rubyFormat": "[[rb:書き > 読み]]",
            "rubyPairList": [
              {
                "write": "無限の剣製",
                "read": "Unlimited Blade Works"
              },
              {
                "write": "天の杯",
                "read": "Heaven's Feel"
              }
            ],
            "enableDeleteComment": true
          },
          "description": "デフォルトのコンバート設定"
        }
      }
    },
    "customEditors": [
      {
        "viewType": "simple-novel-setting.nsetting",
        "displayName": "Simple Novel Setting",
        "selector": [
          {
            "filenamePattern": "*.nsetting"
          }
        ]
      }
    ],
    "commands": [
      {
        "command": "simple-novel-setting.convertNovel",
        "title": "Convert Novel",
        "category": "Simple Novel Setting"
      },
      {
        "command": "simple-novel-setting.openNsetting",
        "title": "Open Simple Novel Setting",
        "category": "Simple Novel Setting"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "when": "editorLangId == plaintext || editorLangId == markdown",
          "command": "simple-novel-setting.convertNovel",
          "group": "extsns-group@1"
        },
        {
          "when": "editorLangId == plaintext || editorLangId == markdown",
          "command": "simple-novel-setting.openNsetting",
          "group": "extsns-group@2"
        }
      ]
    },
    "languages": [
      {
        "id": "nsetting",
        "extensions": [
          ".nsetting"
        ],
        "aliases": [
          "nsetting"
        ]
      }
    ]
  },
  "scripts": {
    "vscode:prepublish": "npm run package",
    "compile": "webpack",
    "watch": "webpack --watch",
    "package": "webpack --mode production --devtool hidden-source-map",
    "compile-tests": "tsc -p . --outDir out",
    "watch-tests": "tsc -p . -w --outDir out",
    "pretest": "npm run compile-tests && npm run compile && npm run lint",
    "lint": "eslint src",
    "test": "vscode-test"
  },
  "devDependencies": {
    "@types/mocha": "^10.0.10",
    "@types/node": "^20.17.23",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "@types/vscode": "^1.97.0",
    "@types/vscode-webview": "^1.57.5",
    "@typescript-eslint/eslint-plugin": "^8.22.0",
    "@typescript-eslint/parser": "^8.22.0",
    "@vscode/test-cli": "^0.0.10",
    "@vscode/test-electron": "^2.4.1",
    "css-loader": "^7.1.2",
    "eslint": "^9.19.0",
    "sass": "^1.85.1",
    "sass-loader": "^16.0.5",
    "style-loader": "^4.0.0",
    "ts-loader": "^9.5.2",
    "typescript": "^5.7.3",
    "webpack": "^5.97.1",
    "webpack-cli": "^6.0.1"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "vscode-webview": "^1.0.1-beta.1"
  }
}


3. 拡張機能の実装

今回は上記のサンプル集の中から「custom-editor-sample」を参考にして進める。

ソース

ガイド

ガイドによるとカスタムエディターは以下の2種類。

  • CustomEditor
    • 独自のドキュメントモデルを使用
  • CustomTextEditor
    • VS Codeの標準的なTextDocumentをデータモデルとして使用

つまりどういうことかと言うと、ガイドでこのように書かれている。

When trying to decide which type of custom editor to use, the decision is usually simple: if you are working with a text based file format use CustomTextEditorProvider, for binary file formats use CustomEditorProvider.
(DeepL翻訳)カスタムエディタの種類を選択する際、判断は通常簡単です:テキストベースのファイル形式を扱う場合はCustomTextEditorProviderを、バイナリファイル形式を扱う場合はCustomEditorProviderを使用します。

今回は中身がjson形式のファイルを編集するのでCustomTextEditorを採用。
上記サンプルでいうとCatScratchEditorに該当。

以後、このCustomTextEditorをカスタムエディタと呼称。

基盤クラス

真っ先にカスタムエディタの紹介をして何だが、カスタムエディタだの拡張機能だの言う前にまずは実際の機能を実装。

今回はweb小説用のテキスト変換機能として(最終的に)以下を実装することに。

  • ルビ振り(特定の単語を指定したルビフォーマットに置き換える。ex."無限の剣製" => "[[rb:無限の剣製 > Unlimited Blade Works]])"
    • 単語と読みのペアリング
    • ルビフォーマットの指定
  • コメントの削除(//で始まる行、及び/**/で囲まれた範囲)
  • 行頭にスペースの挿入

そしてこれらの設定を管理するために設定クラスを実装する。
最終的なものが以下の通り。

基盤機能

utilクラスなどのインポートもあるがそちらは割愛。

nSetting.ts
export const Extension = ".nsetting";

export interface RubyPair{
	write:string,
	read:string,
}

export interface NSetting {
	enableInsertIndentation: boolean,
	enableConvertRuby: boolean,
	rubyFormat: string,
	rubyPairList:RubyPair[],
	enableDeleteComment:boolean,

}

const isRubyPair = (obj: any): obj is RubyPair =>
    typeof obj === 'object' &&
    typeof obj.write === 'string' &&
    typeof obj.read === 'string';

export const isNSetting = (obj: any): obj is NSetting =>
    typeof obj === 'object' &&
    typeof obj.enableInsertIndentation === 'boolean' &&
    typeof obj.enableConvertRuby === 'boolean' &&
    typeof obj.rubyFormat === 'string' &&
    Array.isArray(obj.rubyPairList) &&
    obj.rubyPairList.every(isRubyPair) &&
    typeof obj.enableDeleteComment === 'boolean';

export const defaultNSetting: NSetting = {
	enableInsertIndentation: true,
	enableConvertRuby: true,
	rubyFormat: "[[rb:書き > 読み]]",
	rubyPairList:[
		{write:"無限の剣製",read:"Unlimited Blade Works"},
		{write:"天の杯",read:"Heaven's Feel"},
	],
	enableDeleteComment:true,
}

export const snsConfig ={
	defaultNSetting : "Simple Novel Setting.Default Setting",
} as const;
novelConverter.ts
import { NSetting, RubyPair } from "./nSetting"
import {isNullOrEmpty} from "./util";


export const removeComments = (text: string): string => {
	const converted = text
		.replace(/\/\/.*$/gm, "")
		.replace(/\/\*[\s\S]*?\*\//g, "");
	return converted;
};

export const insertIndentation = (text: string, spaceCount: number = 2): string => {
	const spaces = " ".repeat(spaceCount);
	const converted = text.replace(/^(?=.*\S)/gm, spaces);
	return converted;
};

export const convertRuby = (
	text: string,
	rubyPairList: RubyPair[],
	rubyFormat: string,
): string => {
	let converted = text;
	for (const pair of rubyPairList.filter(r=>!(isNullOrEmpty(r.read) || isNullOrEmpty(r.write)))) {
		const format = rubyFormat
			.replace("書き", pair.write)
			.replace("読み", pair.read);
		converted = converted.replace(new RegExp(pair.write, "g"), format);
	}
	return converted;
};

export const convertNovel = (text:string,nSetting:NSetting)=>{
	let converted = text;
	if(nSetting.enableDeleteComment){
		converted = removeComments(converted);
	}
	if(nSetting.enableInsertIndentation){
		converted = insertIndentation(converted);
	}
	if(nSetting.enableConvertRuby){
		converted = convertRuby(converted,nSetting.rubyPairList,nSetting.rubyFormat);
	}
	return converted;
}

機能の登録

基盤が出来たらこれをコマンドとして拡張機能に登録。
また、ここで後述するカスタムエディタも同時に登録。

拡張機能のエントリーポイントであるextension.tsのactivate内で諸々登録。
前述のマニフェスト設定と名前の不一致が起きないように注意。(この不一致で1日溶かした)
(まあ実際の開発ではここで実装してからマニフェストの追記、という順番になるとは思うが)

機能登録
extension.ts
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import * as vscode from 'vscode';
import {NSettingEditorProvider,getActiveNSettingDocument} from "./nSettingEditorProvider"
import { defaultNSetting, isNSetting, NSetting, snsConfig ,Extension} from './nSetting';
import * as novelConverter from "./novelConverter"

const encoding = "utf-8"

// This method is called when your extension is activated
// Your extension is activated the very first time the command is executed
export function activate(context: vscode.ExtensionContext) {

	// Use the console to output diagnostic information (console.log) and errors (console.error)
	// This line of code will only be executed once when your extension is activated
	console.log('Congratulations, your extension "simple-novel-setting" is now active!');

	// The command has been defined in the package.json file
	// Now provide the implementation of the command with registerCommand
	// The commandId parameter must match the command field in package.json
	const disposableConvertNovel = vscode.commands.registerCommand('simple-novel-setting.convertNovel',convertNovel);
	const disposableOpenNsetting = vscode.commands.registerCommand('simple-novel-setting.openNsetting',openNSetting);

	context.subscriptions.push(disposableConvertNovel);
	context.subscriptions.push(disposableOpenNsetting);
	context.subscriptions.push(NSettingEditorProvider.register(context));

}

const getDocument=()=>{
	const document = vscode.window.activeTextEditor?.document;
	const isTxtOrMd = ["plaintext", "markdown"].some(s=>s===document?.languageId);

	if(!document|| !isTxtOrMd || document.isUntitled){
		vscode.window.showErrorMessage("not a proper file.");
		return undefined;
	}
	return document;
}

const openNSetting = async ()=>{

	const document = getDocument();
	if(!document){
		return;
	}

	const nSettingFile = vscode.Uri.file(document.fileName+".nsetting");
	//const nSettingDocument = await vscode.workspace.openTextDocument(nSettingFile.with({ scheme: 'untitled' }));

	const openTextDocument = async ()=>{
		try {
			return await vscode.workspace.openTextDocument(nSettingFile);
		} catch (e) {
			const doc = await vscode.workspace.openTextDocument(nSettingFile.with({ scheme: 'untitled' }));
			const nSetting =  vscode.workspace.getConfiguration().get<NSetting>(snsConfig.defaultNSetting,defaultNSetting);
			const blob = Buffer.from(JSON.stringify(nSetting));
			vscode.workspace.fs.writeFile(nSettingFile,blob);
			return doc;
		}
	}
	const _ = await openTextDocument();
	vscode.commands.executeCommand("vscode.openWith",nSettingFile,NSettingEditorProvider.viewType);
}

const readNSettingFile =async (uri:vscode.Uri)=>{
	try{
		const json =(await vscode.workspace.fs.readFile(uri)).toString();
		const n =  JSON.parse(json);
		if(isNSetting(n)){
			return n;
		}
	}catch{
		vscode.window.showErrorMessage(`.nsetting file is missing or improper`);
	}
}

const convertNovel=async ()=>{
	vscode.window.showInformationMessage('Hello World from Simple Novel Setting!');
	const activeNSettingDocument = getActiveNSettingDocument();
	const isNSetting = activeNSettingDocument !== undefined;
	

	let nSetting : NSetting|undefined;
	let content:string;
	let contentUri:vscode.Uri;

	if(isNSetting){
		const document = activeNSettingDocument;
		nSetting = await readNSettingFile(document.uri);
		contentUri =  vscode.Uri.file(document.uri.path.replace(Extension,""));
		const data = await vscode.workspace.fs.readFile(contentUri);
		content = new TextDecoder(encoding).decode(data);
	}else{
		const activeTextEditor = vscode.window.activeTextEditor;
		if (!activeTextEditor){
			vscode.window.showErrorMessage('unopened editor');
			return;
		}
		const document = activeTextEditor.document;
		contentUri = document.uri;
		content = document.getText();
		const nSettingFile = vscode.Uri.file(document.fileName+Extension);
		nSetting = await readNSettingFile(nSettingFile);
	}
	if(!nSetting){
		vscode.window.showErrorMessage('not found nsetting file');
		return;
	} 
	const writeFile = async (str:string,url:vscode.Uri)=>{
		const buf = new TextEncoder().encode(str);
		await vscode.workspace.fs.writeFile(url,buf);
	};

	const bkUri = vscode.Uri.file(`${contentUri.path}.bk`);
	await writeFile(content,bkUri);
	const convertedText = novelConverter.convertNovel(content,nSetting);
	await writeFile(convertedText,contentUri);
	await vscode.workspace.openTextDocument(contentUri);
	await vscode.window.showTextDocument(contentUri);
}

// This method is called when your extension is deactivated
export function deactivate() {}

カスタムエディタ

基盤クラスを実装したので次はカスタムエディタの実装、の前にカスタムエディタの仕組みを簡単に説明。
カスタムエディタによるファイル操作はMVCモデルで行われる。

  • Model => テキストドキュメント(テキストファイルの中身)
  • View => カスタムエディタ(web view panel)
  • Controller => 拡張機能(extension.tsで登録したコマンドやプロバイダ)

ゆえにコントローラー部分とビュー部分をそれぞれ実装する必要がある。
サンプルを参考にすると、コントローラーはvscode.CustomTextEditorProviderインタフェースを実装したプロバイダクラスを作成しているので同様に実装する。
このプロバイダとカスタムエディタとのやりとりはメッセージイベントを通して行われるので、メッセージの定義を行う。

メッセージ定義
snsUtil.ts

import { NSetting } from "./nSetting";

export const MessageType = {
	Update:"Update",
	Convert:"Convert",
	Save:"Save",
} as const;

export type MessageType = (typeof MessageType)[keyof typeof MessageType];

export interface ViewMessage {
	type:MessageType,
	arg: NSetting|undefined,
}

export const postMessageCurry = (post:(p:any)=>any)=>({
	postUpdate:(arg:NSetting|undefined)=>post({type:MessageType.Update,arg}),
	postConvert:(arg:string)=>post({type:MessageType.Convert,arg}),
	postSave:(arg:string)=>post({type:MessageType.Save,arg}),
});

プロバイダ

コントローラーとしてのプロバイダクラスにはざっくりと以下のような機能を実装。

  • カスタムエディタのセットアップ
    • webパネルの設定
    • テキスト内容を設定クラスに変換
    • ビューとのやりとりを含めた各種イベントの登録
  • web panelで表示するHTMLの中身
  • テキストドキュメントの更新(ファイル内容の更新)
  • その他必要な挙動
プロバイダ
nSettingEditorProvider.ts

import * as vscode from "vscode";
import { getNonce } from "./util";
import { defaultNSetting, isNSetting, NSetting, snsConfig } from "./nSetting";
import { MessageType, postMessageCurry, ViewMessage } from "./snsUtil";

let activeNSettingDocument: vscode.TextDocument | undefined;

export const getActiveNSettingDocument = () => activeNSettingDocument;

export class NSettingEditorProvider implements vscode.CustomTextEditorProvider {

	public static register(context: vscode.ExtensionContext): vscode.Disposable {
		const provider = new NSettingEditorProvider(context);
		const providerRegistration = vscode.window.registerCustomEditorProvider(NSettingEditorProvider.viewType, provider);
		return providerRegistration;
	}

	public static readonly viewType = 'simple-novel-setting.nsetting';

	constructor(
		private readonly context: vscode.ExtensionContext
	) { }

	public async resolveCustomTextEditor(
		document: vscode.TextDocument,
		webviewPanel: vscode.WebviewPanel,
		_token: vscode.CancellationToken
	): Promise<void> {
		activeNSettingDocument = document;

		// Setup initial content for the webview
		webviewPanel.webview.options = {
			enableScripts: true,
		};
		webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview);
		const currentNSeting = () => {
			try {
				const n = JSON.parse(document.getText());
				if (isNSetting(n)) {
					return n;
				}
				throw new Error();
			} catch {
				vscode.window.showErrorMessage("this setting is improperly.\nOverwrite with default values.");
				const n = this.getDefaultNSetting();
				this.updateTextDocument(document,n);
				return n;
			}
		};

		const { postUpdate } = postMessageCurry((p) => webviewPanel.webview.postMessage(p));

		const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => {
			if (e.document.uri.toString() === document.uri.toString()) {
				postUpdate(currentNSeting());
			}
		});

		webviewPanel.onDidChangeViewState(() => {
			if (webviewPanel.active) {
				activeNSettingDocument = document;
				activeNSettingDocument = document;
			} else if (activeNSettingDocument === document) {
				activeNSettingDocument = undefined;
			}
			});

		// Make sure we get rid of the listener when our editor is closed.
		webviewPanel.onDidDispose(() => {
			if (activeNSettingDocument === document) {
				activeNSettingDocument = undefined;
            }
			changeDocumentSubscription.dispose();
		});

		// Receive message from the webview.
		webviewPanel.webview.onDidReceiveMessage(e => {
			const message = e as ViewMessage;

			switch (message.type) {
				case MessageType.Update:
					if (message.arg === undefined) {
						postUpdate(currentNSeting());
					} else {
						this.updateTextDocument(document, message.arg);
					}
					break;
				case MessageType.Convert:
					break;
				case MessageType.Save:
					document.save();
					break;
			}
		});
	}

	public getDefaultNSetting = () => {

		const nSetting = vscode.workspace.getConfiguration().get<NSetting>(snsConfig.defaultNSetting, defaultNSetting);
		if (isNSetting(nSetting)) {
			return nSetting;
		}

		vscode.window.showErrorMessage(`"${snsConfig.defaultNSetting}" is improperly set.\n Overwrite with system default values.`);
		return defaultNSetting;
	}


	private getHtmlForWebview(webview: vscode.Webview): string {
		const webviewUri = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, "dist", "webview.js"));
		const nonce = getNonce();

		return `<!DOCTYPE html>
	  <html lang="jp">
	  <head>
		  <meta charset="UTF-8">
		  <meta name="viewport" content="width=device-width, initial-scale=1.0">
		  <title>Simple Novel Setting</title>
	  </head>
	  <body>
		<div id="app"></div>
		<script type="module" nonce="${nonce}" src="${webviewUri}"></script>
	  </body>
	  </html>`;
	}

	private updateTextDocument(document: vscode.TextDocument, json: any) {
		const edit = new vscode.WorkspaceEdit();

		// Just replace the entire document every time for this example extension.
		// A more complete extension should compute minimal edits instead.
		edit.replace(
			document.uri,
			new vscode.Range(0, 0, document.lineCount, 0),
			JSON.stringify(json, null, 2));

		return vscode.workspace.applyEdit(edit);
	}
}

ウェブ表示

次はビューとしてようやくウェブ表示(tsx及びscss)の実装。
と言っても前述の設定が面倒なだけで実装は通常の実装と大差ない。

言及すべきことはメッセージイベントの登録とstateの管理くらい。
前者はそのままなのでいいとして後者について、今回は前述の設定クラスをstateとした。
ただtsx内のstateとプロバイダで管理しているテキストドキュメントにズレが生じると面倒なことになりそうなので、以下のように実装。

  1. 内容の変更(ウェブ表示)
  2. 変更内容をプロバイダに通知(ウェブ表示)
  3. 変更内容をテキストドキュメントに反映(プロバイダ)
  4. テキストドキュメントの更新イベントでウェブ表示に変更内容を通知(プロバイダ)
  5. プロバイダから通知された内容をstateに反映(ウェブ表示)

これで動作に概ね問題はなかったが、一点だけ致命的な不具合があった。
IMEによる変換の最中に上記のサイクルによるstate更新が走り、入力途中で内容(プロバイダ及びstate)が確定してしまい正常な入力が出来なかった。
(例えば「まわり」と打ちたいのに「mあわrい」と入力されるような感じ)
なので変換中に限り、入力内容をウェブ表示内のstate更新に留めて変換が終了した場合に改めて上記のサイクルを走らせるようにした。
(この辺りもっとスマートな方法、あるいはそもそも違うイカしたstateとプロバイダの同期方法がありそう)

ウェブ表示
nSettingEditor.tsx
import "vscode-webview";
import { ReactNode, useState, useEffect } from "react";
import { MessageType, postMessageCurry, ViewMessage } from "./snsUtil";
import { NSetting, RubyPair, defaultNSetting } from "./nSetting";
import * as React from "react";
import * as styles from './scss/SnsEditor.module.scss';

const vscode = acquireVsCodeApi();

export const NSettingEditor = () => {

	const { postConvert, postUpdate ,postSave} = postMessageCurry(vscode.postMessage);
	const [nSetting, setNSetting] = useState<NSetting>(defaultNSetting);
	const [isComposing, setIsComposing] = useState(false);
	
	useEffect(() => {
		window.addEventListener("message", e => {
			const mesasge = e.data as ViewMessage;
			switch (mesasge.type) {
				case MessageType.Update:
					if (mesasge.arg !== undefined) {
						const ns = mesasge.arg;
						console.log(ns);
						setNSetting(ns);
					}
					break;
			}
		});
		postUpdate(undefined);
	}, []);

	const updateNSetting = (ns:NSetting,force = false)=>{
		if(isComposing&&!force){
			setNSetting(ns);
			return;
		}
		postUpdate(ns);
	}

	const getValueString = (name: keyof NSetting) => typeof nSetting[name] === "string" ? nSetting[name] : undefined;
	const getChecked = (name: keyof NSetting) => typeof nSetting[name] === "boolean" ? nSetting[name] : undefined;
	
	const createUpdatingTextInput=(name:keyof NSetting|undefined,value:string|undefined,onChange:(e:React.SyntheticEvent<HTMLInputElement>,force?:boolean)=>void)=>
		<input type="text" name={name} value={value}
			onChange={onChange}
			onCompositionStart={() => setIsComposing(true)}
			onCompositionEnd={(e) => {
				setIsComposing(false);
				onChange(e,true);
			}}
		/>

	const onChangeInputElement = (element: "checked" | "value") => (e: React.SyntheticEvent<HTMLInputElement>,force?:boolean) => {
		const target = e.target as HTMLInputElement;
		const name = target.name;
		const value = target[element];
		//console.log(`${name}:${value}`);
		const n = {
			...nSetting,
			[name]: value
		};
		updateNSetting(n,force);
	}

	const createInputElement = (type: "text" | "checkbox") => (label: string, name: keyof NSetting) =>{
		const isText =  type === "text";
		const onChange = onChangeInputElement(isText ? "value":"checked");
		const input = isText ? 
			createUpdatingTextInput(name,getValueString(name),onChange) : 
			<input
				type={type} name={name} onChange={onChangeInputElement("checked")}
				value={getValueString(name)}
				checked={getChecked(name)} 
			/>;


		return (<p className={styles.inputElement}>
			<label>
				{label} : {input}
			</label>
		</p>);
	}
		

	const itemChecknox = createInputElement("checkbox");
	const itemText = createInputElement("text");

	const updateRubyPairList = (rpList: RubyPair[],force=false) => {
		const n = {
			...nSetting,
			rubyPairList: rpList,
		}
		updateNSetting(n,force);
	}

	

	const pair = (p: RubyPair, i: number) =>{
		
		const inputRead = createUpdatingTextInput(undefined,p.read,
			(e,force)=>updateRubyPairList(nSetting.rubyPairList.map((r, j) => ({
				read: i === j ? (e.target as HTMLInputElement).value : r.read,
				write: r.write,
			})),force))
		
		const inputWrite = createUpdatingTextInput(undefined,p.write,
			(e,force)=>updateRubyPairList(nSetting.rubyPairList.map((r, j) => ({
				read: r.read,
				write: i === j ? (e.target as HTMLInputElement).value : r.write,
			})),force))

		return (<div className={styles.rubyPair} key={i}>
			◯ 読み 
			<br />
			{inputRead}
			<br />
			◯ 書き  
			<br />
			{inputWrite}
			<br />
			<p>
				<button onClick={e =>updateRubyPairList(nSetting.rubyPairList.filter((r, j) => i !== j))}>
					削除
				</button>
			</p>
		</div>);
	}
	
	const addRubyPair =()=>updateRubyPairList([...nSetting.rubyPairList,{read:"",write:""}]);

	return (
		<div className={styles.ohoho}>
			<p>n settting!</p>
			<textarea name="" id="" value={JSON.stringify(nSetting)} readOnly></textarea>
			<div>
				<h1>Sinple Nobel Setting</h1>
				{itemChecknox("コメントを削除する", "enableDeleteComment")}
				{itemChecknox("行頭に空白を入れる", "enableInsertIndentation")}
				{itemChecknox("ルビを振る", "enableConvertRuby")}
				{itemText("ルビのフォーマット", "rubyFormat")}
			</div>
			<div className={styles.rubyList}>
				<h2>ルビリスト</h2>
				{nSetting.rubyPairList.map((p, i) => pair(p, i))}
				<p><button onClick={addRubyPair}>追加</button></p>
			</div>
			<div>
				<p className={styles.button}>
				<button onClick={()=>postSave("")}>保存</button>
			</p>
			</div>
		</div>
	);
}

4. デバッグ

ここまで実装したらデバッグ。
実際にはデバッグしながら実装するとは思うが、記事の構成が完成物ありきなので容赦頂きたい。

と言ってもF5を押すだけ。以上。
開発中の拡張機能が有効になったデバッグ用ウィンドウが表示されるので任意の操作を行う。

たまに「タスクの問題マッチャーを構成してください」のようなエラーウィンドウが出るが気にせず「このままデバッグ」でデバッグ可能。
(なんかとある拡張機能を入れれば良いっぽいが別に困ったことになってもいないので放置)

あと言うことがあるとすれば、ウェブ表示でデバッグする際に開発者ツールの表示方法。
ツールバーの「ヘルプ」=>「開発者ツールの切り替え」で開発者ツールが表示される。
tsx及びscssの更新であればタブの切り替えだけで再表示されるので変更の確認は簡単のはず。
(プロバイダやextension.tsを変更した場合には開発ウィンドウでデバッグツールから再起動が必要の場合もある)

あとがき

ちょっと駆け足気味であまり親切ではない書き方ですが、こんな感じで独自画面のカスタム画面が作成出来ました。
ささっと書いてますが、躓いた部分は結構多かったです。

  • トランスパイル対象が複数なので別設定が必要なこと
  • web側からのAPIの呼び出しするためのtypesのインポート
  • テキストドキュメントの管理やvscode.Uriの扱いや対応するファイルの操作
  • プロバイダで必要なイベントの種類
  • マニフェストの設定
  • CSSの調整

あと拡張機能とは直接関係ないですけれどmodule cssも初めてやったので何やかんやで結構時間食いましたね。

そんな感じでreact,scssによるカスタム画面込の拡張機能の作成でした。
作成した機能自体の紹介もしたほうがいいような気もしますが、まあ本筋ではないしコード読めば分かるからいいかな、と。
見どころは対象ファイルに対応する設定ファイルを開くないし新規作成する機能くらいですしそもそも実用性が低いですし。一太郎を買え

なお他記事との温度感の違いについては察してください。心理的リアクタンスとだけ言っておきます

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?