我开发了一款在 Markdown 文档编辑中快速插入反引号的 VS Code 插件

1、前言

👉 项目Github地址:github.com/XC0703/easy...

无论是 Prettier 插件,还是 Pangu-Markdown-VSCode 插件(注意,这款插件存在 bug,对于链接、粗体等语法可能会使得增加不必要的空格导致出错,需要使用可以基于源码改一个。仅仅需要对文件内中英文之间加空格的这个功能则使用基于开源库pangu.js 实现的 vscode-pangu 插件即可。),都一定程度地可以对我们编辑的 Markdown 文档进行格式化。

但是笔者在编辑 Markdown 文档时有一个习惯(本人编辑的都是中文文档),喜欢将英文单词用反引号包裹起来进行背景色特殊标识。如果每次都手动添加,则在编辑过程中不仅要按一次shift键切换英文输入法,还要按两次反引号键才能将单个单词包裹,然后又要按一次shift键切换文输入法。如果某个文档有 1000 个单词,则可能需要多按4000 次键,不仅麻烦,还增加了手指腱鞘炎的风险。如果选中某段内容,直接根据键绑定快速添加反引号,将会减少大量的按键操作,且这样的选中+按键操作远比反复切换按键输入心智负担低。

作为一个用 VSCode 编辑 Markdown 文档的重度"患者",迫切希望能有插件满足我的这个需求。本着不重复造轮子的原则,笔者找了一段时间,发现市面上的插件并不满足这个功能(可能这个习惯太小众了),因此决定自己实现一个,顺便手把手带大家实现一个简单的 VSCode 插件,同时水了这篇文章(狗头保命)。

注意:笔者原本想做类似 pangu 那样的效果,只不过空格变成反引号,可以对整个文档进行批量操作,后面发现压根不是一回事,且判断的情况远比我想象的多,遂改为只给选中的内容前后快速添加反引号或对选中的内容进行批量添加反引号,作为一个入门 VS Code 插件开发的小练习。(需要彻底实现该功能的读者可以去学习 pangu.js 的源码

2、插件介绍

插件作用:针对 Markdown 文档使用,给选中的内容前后快速添加反引号或对选中的内容进行批量处理:

  • 单个处理:鼠标选择某个英文/数字串,然后按下 shift+`(反引号键),即可实现对选中的内容前后快速添加反引号。
  • 批量处理:鼠标选择某段纯文本内容(不含代码块/链接/表格等),然后按下Ctrl+shift+p或者F1,输入并选择easy-backquote.batching,即可实现对选中的内容中所有的英文/数字串前后用反引号包裹。

3、开发过程

参考文档:【VS Code 插件开发中文文档

3.1 项目初始化

首先全局安装 YeomanVS Code Extension Generator

bash 复制代码
npm install -g yo generator-code

这个脚手架用于生成一个可以立马开发的项目,运行以下命令生成:

bash 复制代码
yo code

然后填好下列字段:

完成后进入 VS Code,按下F5或点击左侧的运行和调试按钮,会立即看到一个插件发开主机窗口,其中就运行着插件:

此时,在命令面板(Ctrl+Shift+P)中输入Hello World命令,可以看到Hello world弹窗。

可以删掉一些暂时不需要的冗余文件,方便我们进行开发:

其中,关于 Visual Studio Code 项目里的 .vscode 文件夹

  • .vscode\launch.json:插件加载和调试的配置。这个配置通过指定扩展开发路径、构建任务和输出文件路径,VS Code 能够正确地加载和运行开发者的扩展代码。
  • .vscode\tasks.json:用于定义和配置任务(Tasks)。任务是在 VS Code 中执行的命令或脚本,可以自动化一些常见的工作流程,如编译代码、运行测试、构建项目等。可以在这里定义自定义任务,并通过快捷键或命令面板执行它们。
  • 这两个文件的存在,使得能够调试时能够打开一个插件发开主机窗口。

同时删除掉一些冗余的依赖、脚本命令、注释和代码等:

typescript 复制代码
{
  "name": "easy-backquote",
  "description": "这款插件主要用于在 Markdown 文档编辑中快速插入反引号。",
  "version": "1.0.0",
  "engines": {
    "vscode": "^1.89.0"
  },
  "main": "./out/extension.js",
  "contributes": {
    "commands": [
      {
        "command": "easy-backquote.batching",
        "title": "easy-backquote.batching"
      }
    ]
  },
  "scripts": {
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
    "lint": "eslint src --ext ts"
  },
  "devDependencies": {
    "@types/node": "18.x",
    "@types/vscode": "^1.89.0",
    "@typescript-eslint/eslint-plugin": "^7.7.1",
    "@typescript-eslint/parser": "^7.7.1",
    "eslint": "^8.57.0",
    "typescript": "^5.4.5"
  }
}

此时,项目初始化完成。

3.2 插件主要流程

要实现对本文的加工处理,无非分成三步:获取文本、加反引号、替换原来的文本。毫无疑问,这些需要 VS Code API 来完成。(吐槽一下官方文档,把所有 API 都塞在一个页面里,且 API 的介绍也过于简洁。)

VS Code 自带一个清除多余的行尾空格的命令,按下 cmd + shift + p,在弹出的命令窗口中输入 trim,选中 Trim Trailing Whitespace 并回车执行:

我们想实现的这个插件和这个命令是类似的,按下 cmd + shift + p,在弹出的命令窗口中输入类似 add backquote 执行,只不过我们不是要清除多余空格,而是加反引号,但本质是一样的,即修改编辑器中的文本。

扩展的的执行命令以及提示文案在package.json文件中定义:

json 复制代码
// package.json
  "main": "./out/extension.js",
  "contributes": {
    "commands": [
      {
        "command": "easy-backquote.batching",
        "title": "easy-backquote.batching"
      }
    ]
  },

上面提到 VS Code 插件项目初始化时会自带一个 Hello World 范例,很庆幸的是,这个范例正好是我们所需要的。这个范例是这样工作的,在命令窗口中输入 Hello World,会弹出一个提示窗,代码是这样的:

typescript 复制代码
// src\extension.ts
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
	console.log('Congratulations, your extension "easy-backquote" is now active!');
	let disposable = vscode.commands.registerCommand('easy-backquote.helloWorld', () => {
		vscode.window.showInformationMessage('Hello World from easy-backquote!');
	});

	context.subscriptions.push(disposable);
}
export function deactivate() {}

其它的我们都可以不需要理解,只需要把 vscode.window.showInformationMessage('Hello World!'); 这行代码替换成我们自己的逻辑,即获取文本,加反引号,替换原来的文本,这个插件就基本完成了。

因此首先是要package.json里进行 插件发布内容对象的描述,相当于描述启动插件功能的一些操作:

json 复制代码
{
	"activationEvents": ["onLanguage: markdown"],
	"main": "./out/extension.js",
	"contributes": {
		"keybindings": [
			{
				"command": "easy-backquote.single",
				"key": "shift+`",
				"when": "editorTextFocus"
			}
		],
		"commands": [
			{
				"command": "easy-backquote.single",
				"title": "easy-backquote.single"
			},
			{
				"command": "easy-backquote.batching",
				"title": "easy-backquote.batching"
			}
		]
	}
}

在这其中,我们将插件的激活事件规定为打开 Markdown 文档,同时插件文件的路径为./out/extension.js

之后定义了两个command事件,一个是easy-backquote.single用于单个处理(只给选中的内容前后快速添加反引号),另一个是easy-backquote.batching用于批量处理(对选中的内容进行批量添加反引号)

然后要到src\extension.ts里进行事件注册:

typescript 复制代码
// src\extension.ts
export const activate = (context: vscode.ExtensionContext) => {
	const singleChange = vscode.commands.registerCommand('easy-backquote.single', () => {});
	const batchingChange = vscode.commands.registerCommand('easy-backquote.batching', () => {});
	context.subscriptions.push(singleChange, batchingChange);
};

此时剩下的工作就只剩补充功能逻辑了。

3.3 具体功能逻辑

3.3.2 获取文本

首先来看怎么获取文本。在 Hello World 范例 这篇文章中,我们能简单了解到以下几种对象:

  • Window 对象 - 表示当前 VS Code 的整个窗口,用 vscode.window 得到这个 Window 对象。
  • TextEditor 对象 - VS Code 的整个窗口中可能打开了多个 tab,每一个 tab 就是一个 TextEditor 对象,但我们只需要那个当前激活的 tab,我们用 window.activeTextEditor 属性来取得当前工作中的 tab,即 TextEditor 对象。
  • TextDocument 对象 - 每个 TextEditor 中都有一个文档,这个文档就是 TextDocument 对象,我们用 editor.document 属性来取得 TextEditor 对象中的 TextDocument 对象。TextDocument 对象有一个 getText() 方法来取得其中的所有文本。

最终,我们通过

typescript 复制代码
const originText = vscode.window.activeTextEditor.document.getText();

取得当前正在编辑的文档的所有文本,如果传入editor.selection参数,表示在 tab 中用光标选中的区域,则是获取选中的文本。

typescript 复制代码
// 如果当前有选中的文本,则只对选中的文本进行处理
const selection = editor.selection;
const selectedText = document.getText(selection);
if (selectedText) {
	// 进行处理并替换选中的文本
	const processedText = processText(selectedText);
	editor.edit(builder => {
		builder.replace(selection, processedText);
	});
	return;
}

3.3.3 处理文本

processText 方法主要用于将传入的文本进行我们需要的处理,即将里面的英文单词用反引号进行包裹,是本插件的核心方法之一。

但上面说到由于扫描整个文档的情况较为复杂,本插件暂不支持扫描整个文档进行处理,只对选择的内容进行简单处理,减少心智负担的同时还能极大减少工作复杂度。

typescript 复制代码
// src\processText.ts
const processText = (text: string, type: 'single' | 'batch' = 'batch') => {
	if (type === 'single') {
		return '`' + text + '`';
	}
	let newText = text;
	// 只考虑单词边界情况,同时忽略已有反引号包裹的内容或加粗显示的内容
	newText = newText.replace(/\b(?<!`)(?<!\*\*)([a-zA-Z0-9_\-.]+)\b(?!`)(?!\*\*)/g, '`$1`');
	return newText;
};

export default processText;

3.3.4 替换文本

3.2.4.1 TextEdit 对象

VS Code 插件编辑文本内容的核心思想体现在在 TextEdit 对象上 (注意,不是 TextEditor )。一个 TextEdit 对象就表示对文本的一次操作。

对文本的操作无外乎三种:增加,删除,替换,但其实归结起来,增加和删除,也算是替换操作。增加,用新的字符串,替换空字符串;删除,用空字符串替换原来的字符串。

对于要换替换的对象,既原来的字符串,我们要知道它在文档中所处的位置,这个位置包括起始位置和结束位置,每个位置都应该包括它所在的行号和所在行内的编号,这两个位置组成了一个区间。

VS CodePosition 对象来表征文档内一个字符所在的位置,它有两个属性:

  • line - 行号
  • character - 所在行内的编号

一个起始 Position 和一个结尾 Position,两个 Position 组成了 Range 对象,这个 Range 对象就代表了一串连续的字符。

这样,我们有了要替换的对象,又有新的字符串,我们就可以定义出一个 TextEdit 对象来表示这样一次替换操作。

typescript 复制代码
const aTextReplace = new vscode.TextEdit(range, newText);

比如,我们要把第 2 行第 3 个字符,到第 5 行第 6 个字符,删除掉,即用空字符串替换它,代码如下:

typescript 复制代码
const start = new vscode.Position(2, 3);
const end = new vscode.Position(5, 6);
const range = new vscode.Range(start, end);
const aTextDel = new vscode.TextEdit(range, '');

上面前三行代码可以简化成:

typescript 复制代码
const range = new vscode.Range(2, 3, 5, 6);

上述第四行代码生成的 TextEdit 对象等效于 TextEdit.delete(range) 静态方法生成的对象:

typescript 复制代码
const aTextDel = vscode.TextEdit.delete(range);

RangeTextEdit,我认为是操作文本的核心概念,理解它这两个对象,其它的也就没什么难的了。

3.2.4.2 WorkspaceEdit 对象

但是,到目前为止,TextEdit 还只是定义了一个将被应用的操作,但还没有真正地被应用到文本上,那怎么来把这个操作真正执行呢。

这里又涉及到一个新的对象 - WorkspaceEdit 对象。WorkspaceEdit 可以理解成 TextEdit 的容器。TextEdit 只是对文本的一次操作,如果我们需要对这个文本同时进行多次操作,比如全局替换,我们就要定义多个 TextEdit 对象,并把这些对象放到一个数组里,再把这个数组放到 WorkspaceEdit 对象中。

更强大的在于,WorkspaceEdit 支持对多个文档同时进行多次操作,因此,每个 TextEdit 数组必然需要对应一个文档对象,WorkspaceEdit 使用 uri 来表征一个文档,uri 可以从 document.uri 属性获得。

我们前面得到了 document 对象,我们又定义了一些 TextEdit 对象,我们把它放到 WorkspaceEdit 对象中:

typescript 复制代码
let textEdits = [];
textEdits.push(aTextDel);
// push more TextEdit
// textEdits.push(...)

let workspaceEdit = new vscode.WorkspaceEdit();
workspaceEdit.set(document.uri, textEdits);

最后,我们终于可以真正地执行这些操作了,使用 vscode.workspace.applyEdit() 方法来使这些操作生效:

typescript 复制代码
vscode.workspace.applyEdit(workspaceEdit);

此时替换文本的代码如下:

typescript 复制代码
/**
 * 使用 vscode.workspace.edit 方法对整个文档进行逐行处理(暂不支持)
 */
const lineCount = document.lineCount;
const textEdits = [];
for (let i = 0; i < lineCount; i++) {
	// 获取当前行的文本
	const textLine = document.lineAt(i);
	const oriTrimText = textLine.text.trimEnd();
	// 分情况进行处理
	if (oriTrimText.length === 0) {
		textEdits.push(new vscode.TextEdit(textLine.range, ''));
	} else {
		// 进行处理并替换当前行
		const processedText = processText(oriTrimText);
		textEdits.push(new vscode.TextEdit(textLine.range, processedText));
	}
}
// 应用编辑
const workspaceEdit = new vscode.WorkspaceEdit();
workspaceEdit.set(document.uri, textEdits);
vscode.workspace.applyEdit(workspaceEdit);

3.2.4.3 edit() 方法

但是,WorkspaceEdit 的设计目标是同时对多个文档进行多次操作,如果我们只是想对当前文档进行编辑,用 WorkspaceEdit 有点杀鸡用牛刀的感觉。

如果只对当前 tabTextEditor 对象进行文本编辑,我们可以使用 TextEditor 对象的 edit() 方法,代码是类似的,只不过不用显式的生成 TextEdit 对象。看代码就明白了:

typescript 复制代码
/**
 * 使用 vscode.workspace.applyEdit 方法对整个文档进行逐行处理(为每行文本建立一个 TextEdit 编辑对象)(暂不支持)
 */
const lineCount = document.lineCount;
editor.edit(builder => {
	for (let i = 0; i < lineCount; i++) {
		// 获取当前行的文本
		const textLine = document.lineAt(i);
		const oriTrimText = textLine.text.trimEnd();
		// 分情况进行处理
		if (oriTrimText.length === 0) {
			builder.replace(textLine.range, '');
		} else {
			// 进行处理并替换当前行
			const processedText = processText(oriTrimText);
			builder.replace(textLine.range, processedText);
		}
	}
});

builder.repalce(textLine.range, processedText) 就相当于执行了一个 TextEdit(textLine.range, processedText) 对象。相比之下,代码比上面简洁了一些。(注意:不要把循环写在 editor.edit() 外面

4、打包并发布插件

4.1 插件打包

使用vsce工具来打包插件。首先,全局安装vsce

bash 复制代码
npm install -g @vscode/vsce

(打包前会检查 package.json 文件是否配置 publisher 属性)

然后,在插件项目根目录下运行以下命令来生成.vsix文件:

bash 复制代码
vsce package

可以从生成的.vsix文件安装该插件:

4.2 插件发布

4.2.1 注册 Azure 开发者账号

Azure 文档

先得有一个微软账号 ,然后打开 azure 开发者中心 ,新建一个azure 开发组织

4.2.2 新建个人令牌

新建个人令牌

注意选择 Full access 和 过期时间,如果令牌过期,需要回到这个页面再新建令牌:

这步之后,一定要复制并保存好你的令牌字符串哦,因为之后只能新建,是找不到的。

4.2.3 注册插件市场发行账户

接着我们 注册插件市场发行账号,也在这可以管理所有市场中自己发布的插件:

此时可以直接上传我们插件打包后的.vsix文件,也可以在项目根目录下执行以下命令来上传(令牌填我们上面申请的Token):

bash 复制代码
vsce publish

这个过程里还会检查一些package.json的必填项,一切无误的话,等个 5-10 分钟就能在扩展商店搜到我们上传的扩展:

注意事项:

  • README.md 是插件主页的详情介绍。
  • 记得更新 package.json 里的版本号。
  • 如果 package.json 中填写了 repository 字段,在发布时会要求你先提交仓库。
  • README.md 中的图片资源必须全部是 HTTPS 的。(推荐免费图床:catbox.moe)
  • CHANGELOG.md 是插件主页的变更选项卡。
相关推荐
Mapmost几秒前
你的3DGS数据为何难以用在项目里?Web端开发实战指南
前端
举个栗子dhy3 分钟前
第一章、React + TypeScript + Webpack项目构建
前端·javascript·react.js
大杯咖啡7 分钟前
localStorage与sessionStorage的区别
前端·javascript
RaidenLiu19 分钟前
告别陷阱:精通Flutter Signals的生命周期、高级API与调试之道
前端·flutter·前端框架
非凡ghost19 分钟前
HWiNFO(专业系统信息检测工具)
前端·javascript·后端
非凡ghost21 分钟前
FireAlpaca(免费数字绘图软件)
前端·javascript·后端
非凡ghost28 分钟前
Sucrose Wallpaper Engine(动态壁纸管理工具)
前端·javascript·后端
拉不动的猪29 分钟前
为什么不建议项目里用延时器作为规定时间内的业务操作
前端·javascript·vue.js
该用户已不存在36 分钟前
Gemini CLI 扩展,把Nano Banana 搬到终端
前端·后端·ai编程
地方地方38 分钟前
前端踩坑记:解决图片与 Div 换行间隙的隐藏元凶
前端·javascript