博客集成 Monaco Editor 写 Markdown

发布时间:

最后更新:

Monaco Editor 是什么呢,是一个网页端的文本编辑器,VSCode 里面写代码的那一部分。

Monaco Editor 在哪Monaco Editor 在哪

众所周知 VSCode 是个 Chromium 套壳应用,里头的很多东西可以在浏览器上运行,这个编辑器部分就是如此。

这东西出来也有几年了,但是官方的文档却很少,很多地方得去它源码里看,总之想用好没那么简单。再把它集成到本站时也花了些功夫,特写本文总结一下经验。

Monaco Editor 的仓库是 https://github.com/microsoft/monaco-editor,但实际上该仓库只有少部分组件和示例,它的主体部分仍然在 VSCode 里 https://github.com/microsoft/vscode/tree/main/src/vs/editor

本文的完整代码见 https://github.com/kaciras-blog/markdown

文本框不行吗? #

曾在三年前,本站刚做好的时候我就想过把 VSCode 的编辑器给弄进来,但因为文本框也能用,这事就一直鸽着。直到某天不知道哪里更新之后光标总是乱飘,我才觉得与其在文本框上修修补补,不如直接搬个更高级的……

当然上面是本站的事情,你自己想用的话,最好先弄明白一个问题:为什么不用<textarea>

如果要在页面上写代码,那肯定不行。可是 Markdown 似乎文本框也凑合,这里我想了一想,Monaco Editor 大概有以下优势:

最关键的还有一点,就是逼格,别人家都是文本框,咱的网站里有 VSCode,高下立判。

所见即所得? #

富文本编辑器可以分为两种:编辑源码的和编辑渲染结果的,前者就是本文要实现的,后者又称所见即所得(WYSIWYG)编辑器。

WYSIWYG 的原理跟 HTML 设计器类似,就是直接撸 HTML,然后序列化为 Markdown。但说实话,WYSIWYG 跟 Markdown 很不搭。

Markdown 作为一个轻量级的标记语言,轻量级指的是标记占比少,即使不渲染也很好读;同时为了保持语法的简洁,它舍弃了很多功能,像文字颜色,图片对齐等等。

用 WYSIWYG 编辑器来写 Markdown 不仅无法利用语法上的简洁性,还被 Markdown 制约了功能。所以我决定还是做传统的源码编辑器。

包结构 #

好的既然决定使用了,第一步就是安装和导入。Monaco Editor 的 NPM 包叫 monaco-editor,直接装上就行。

Monaco Editor 是模块化的,有多个导出点,最简单的用法是直接导入主模块:

javascript
import * as monaco from "monaco-editor";

但这样会加载所有的模块,体积较大,还有另一种方法是仅导入核心,然后手动选择一些功能:

javascript
// 只导入核心部分。
import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";

// 挑选一些功能进行加载……
import "monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js";
import "monaco-editor/esm/vs/base/browser/ui/codicons/codiconStyles.js";
import "monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations.js";
import "monaco-editor/esm/vs/editor/contrib/linesOperations/browser/linesOperations.js";
import "monaco-editor/esm/vs/editor/contrib/dnd/browser/dnd.js";
import "monaco-editor/esm/vs/editor/contrib/multicursor/browser/multicursor.js";

可选的扩展功能很多,具体见 editor.main.ts,上面挑了几个我常用的:

那么它们有多大呢?未压缩 2.57 MB,Brotli 后 460 KB。看来即使省着用还是很大,建议做异步加载。

最后还要提一点的是关于 Worker 的问题,在官方示例里都还要加载一个 Worker,代码大概这样:

javascript
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
// 还导入了几个差不多的……

self.MonacoEnvironment = {
	getWorker(_: any, label: string) {
		// 几个 if 然后 return new xxxWorker()。
	}
};

因为一些功能需要大量的计算,比如语法解析、全文格式化等等,Monaco Editor 选择将它们放到单独的线程去处理。但并非所有的功能都需要这么做,上面挑选的那些模块都没有用到 Worker,所以可以跳过这一步。

如果带了 Worker,构建起来就复杂不少,特别是不同的工具对 Worker 的处理还不太一样。

创建编辑器 #

因为我的项目使用了 Vue,所以下面代码都是以集成 Monaco Editor 到 Vue 为基础的。

vue
<template>
	<div ref='editorEl' class='editor'/>
	<div class='preview' v-html='html'/>
</template>

<style>
body {
	display: flex;
	margin: 0;
}

/* 编辑器的容器需要确定大小 */
.editor {
	width: 50vw;
	height: 100vh;
}

.preview {
	width: 50vw;
	height: 100vh;
	overflow-y: auto;
}
</style>

<script setup lang='ts'>
import { onMounted, onUnmounted, shallowRef } from "vue";
import MarkdownIt from "markdown-it";
// Monaco editor 的导入省略了,见上面。

const WORD_SEPARATORS =
	'`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?'   // USUAL_WORD_SEPARATORS
	+ "·!¥…*()—【】:;‘’“”、《》,。?" // 中文符号。
	+ "「」{}<>・~@#$%^&*=『』"; // 日韩符号。

const md = new MarkdownIt();

let editor: monaco.editor.IStandaloneCodeEditor = undefined!;

const editorEl = shallowRef<HTMLElement>();
const html = shallowRef("初始内容");

onUnmounted(() => editor.dispose());

onMounted(() => {
	editor = monaco.editor.create(editorEl.value!, {
		value: "初始内容",
		language: "markdown",
		lineHeight: 22,
		fontSize: 16,
		scrollbar: {
			useShadows: false,
		},
		wordWrap: "on",
		wordSeparators: WORD_SEPARATORS,
	});

	editor.onDidChangeModelContent(() => {
		html.value = md.render(editor.getModel()!.getValue(1));
	});
});
</script>

核心就是调用 monaco.editor.create,这里我设了一些参数,主要是写自然语言跟代码不一样,可以针对性的优化下:

左为默认,右边增加了 CJK 标点

我的博客使用 markdown-it 来转换 Markdown,通过监听onDidChangeModelContent事件在修改之后更新预览即可,文本可用editor.getModel().getValue(1)获取,参数 1 代表换行符使用\n

其它功能 #

计算选择范围 #

虽然主体已经搞定,但要成为完整的编辑器,通常还要加一些按钮和提示之类的。首先能显示写了多少字,以及选中的哪一段是编辑器必备的功能,通常放在底下的状态栏,像这样:

字数统计字数统计

MonacoEditor 内部是按行存储的,也就是说光标的位置用的是几行几列这样的形式,要计算在整个文本里是第几个字,需要遍历每一行。MonacoEditor 也为此提供了一个getCharacterCountInRange方法。

typescript
import { Selection } from "monaco-editor/esm/vs/editor/editor.api.js";

const info = shallowRef({ start: 0, count: 0, sl: 0, sc: 0, el: 0, ec: 0 });

editor.onDidChangeCursorSelection(e => {
	const { startLineNumber, startColumn } = e.selection;
	const model = editor.getModel()!;

	// 选区之前的部分,注意行号和列号从 1 开始。
	const offset = Selection.createWithDirection(1, 1, startLineNumber, startColumn, 0);
	info.value = {
		sl: startLineNumber,                                // 起始行
		sc: startColumn,                                    // 起始列
		el: e.selection.endLineNumber,                      // 结束行
		ec: e.selection.endColumn,                          // 结束列
		start: model.getCharacterCountInRange(offset),      // 起始位置(第几个字)
		count: model.getCharacterCountInRange(e.selection), // 选中多少字
	};
});

选项开关 #

一些选项包括小地图,自动换行等等需要随时开关,这可以用editor.updateOptions()方法来实现。

通过创建响应对象,监听更改后应用到编辑器,实现跟 Vue 的整合。

vue
<template>
	<button @click='toggleWrap'>切换自动换行</button>
	<button @click='toggleMinimap'>切换小地图</button>
</template>

<script setup lang='ts'>
import { reactive, watch } from "vue";

const options = reactive<monaco.editor.IEditorOptions>({
	wordWrap: "on",
	minimap: { enabled: false },
});

function toggleWrap() {
	options.wordWrap = options.wordWrap === "on" ? "off" : "on";
}

function toggleMinimap() {
	const { enabled } = options.minimap!;
	options.minimap!.enabled = !enabled;
}

watch(options, o => editor.updateOptions(o), { deep: true });
</script>

同步滚动 #

由于篇幅原因,此处仅介绍百分比滚动如何实现,更精确的滚动方案会单独写一篇。

按百分比滚很简单,scrollTop是当前滚了多高,scrollHeight是总共多高,offsetHeight是元素能显示多高,一除一减就能算出百分比,然后设置scrollTop即可。当然还需注意一些边界情况。

vue
<template>
	<!-- 添加 ref 和 滚动事件 -->
	<div
		ref='previewEl' 
		class='preview' 
		v-html='html' 
		@scroll='scrollEditorToPreview'
	/>
	<!-- 其它元素省略了…… -->
</template>

<script setup lang='ts'>
const previewEl = shallowRef<HTMLElement>();

let ignoreScroll = false;

function runScrollAction(callback: () => void) {
	if (ignoreScroll) {
		return; // 防止循环触发。
	}
	ignoreScroll = true;

	// 延迟到下一帧,解决浏览器的平滑滚动问题。
	requestAnimationFrame(() => {
		callback();
		requestAnimationFrame(() => ignoreScroll = false);
	});
}

function scrollEditorToPreview() {
	runScrollAction(() => {
		const { offsetHeight } = editorEl.value!;
		const $el = previewEl.value!;
		const p = $el.scrollTop / ($el.scrollHeight - offsetHeight);

		// Monaco-editor 在末尾有一屏的空白,所以要多减去一个 offsetHeight。
		const max = editor.getScrollHeight() - offsetHeight * 2;

		/*
		 * 当右侧滚到底时,如果左侧位置超过了内容(即在空白区),就不滚动。
		 * 这样做是为了避免删除最后一页的行时,编辑区滚动位置抖动的问题。
		 */
		if (p < 0.999) {
			editor.setScrollTop(p * max);
		} else if (editor.getScrollTop() < max) {
			editor.setScrollTop(max);
		}
	});
}

function scrollPreviewToEditor() {
	runScrollAction(() => {
		const scrollHeight = editor.getScrollHeight();
		const { offsetHeight } = editorEl.value!;
		const $el = previewEl.value!;

		/*
		 * 没超过一屏就不滚动,因为结果可能长于 Markdown(反之好像不会),
		 * 此时换行会让 HTML 视图滚到顶而不是保持在当前位置。
		 */
		if (scrollHeight < offsetHeight * 2) {
			return;
		}
		const p = editor.getScrollTop() / (scrollHeight - offsetHeight * 2);
		$el.scrollTop = p * ($el.scrollHeight - offsetHeight);
	});
}

onMounted(() => {
	// 创建 Editor 部分省略了……
	editor.onDidScrollChange(scrollPreviewToEditor);
});
</script>

编辑按钮 #

工具栏左侧的按钮工具栏左侧的按钮

加一些按钮来处理常用的修改,让编辑器锦上添花!这里就涉及到如何在程序里去修改编辑器的内容和选区。

当然我们可以直接改字符串,但更好的方式是用 Monaco Editor 提供的 API,主要有两个方法:

它俩底层都是一样的,只是写法不同,第一个传俩参数分别修改内容和计算选区,第二个传一个 ICommand 实例,里面包含修改内容和计算选区的方法。

这里就演示下使用第二个 API,来给每行前头加上>让它们变成引用块(第五个按钮)。

typescript
import { Range, Selection, editor } from "monaco-editor/esm/vs/editor/editor.api.js";

class PrefixCommand implements editor.ICommand {

	private readonly range: Selection;
	private readonly prefix: string;

	overlap = false;

	constructor(selection: Selection, prefix: string) {
		this.range = selection;
		this.prefix = prefix;
	}

	computeCursorState() {
		const { range, prefix } = this;
		return new Selection(
			range.startLineNumber,
			range.startColumn + prefix.length,
			range.endLineNumber,
			range.endColumn + prefix.length,
		);
	}

	getEditOperations(_: unknown, builder: editor.IEditOperationBuilder) {
		const { range, prefix, overlap } = this;
		let i = range.startLineNumber;
		if (overlap) {
			i += 1; // 第一行跟其它选区重了,跳过。
		}
		for (; i <= range.endLineNumber; i++) {
			builder.addEditOperation(new Range(i, 0, i, 0), prefix);
		}
	}
}

// 使用`addPrefix("> ")`即可将选中的每行变成引用块。
function addPrefix(prefix: string) {
	const selections = editor.getSelections()!;

	selections.sort(Range.compareRangesUsingStarts);
	const commands = selections.map(s => new PrefixCommand(s, prefix));

	// 检查两个选区是否都包含了同一行,有则给后一个添加 overlap 标记。
	for (let i = 1; i < selections.length; i++) {
		const prev = selections[i - 1];
		const curr = selections[i];
		commands[i].overlap = curr.startLineNumber === prev.endLineNumber;
	}

	editor.focus();
	editor.executeCommands(null, commands);
}

ICommand包含两个方法,在执行时首先调用getEditOperations修改内容,然后再以computeCursorState的返回值作为修改后的选区。

如果选择了多段内容,那么就需要同时执行多个ICommand实例,完成后当前的所有选区将清除,然后选中它们返回的新选区。

好的来看需求,在行前加点字很简单,在 getEditOperations 里有个 builder 参数用于编辑内容,使用 builder.addEditOperation(range, text) 即表示将指定范围替换为指定的文本,所以只需要遍历选中的每一行,将最开头宽度为 0 的范围替换为> 即可。

然后处理选区,由于插入了两个字符,所以原文后移,只要把原选区的列 +2 即可保持选中原文。

还需要处理个去重问题,因为 Monaco Editor 支持多选,所以要防止给同一行添加多次前缀。由于选区不能重叠,故可得出当两个选区包含同一行时,一定只有前一个选区的最后一行跟后一个选区的第一行在一起。

所以给选区排个序,然后两个一起检测下是否冲突,如果有则后一个选区跳过第一行即可。

评论加载中