Monaco Editor 是什么呢,是一个网页端的文本编辑器,VSCode 里面写代码的那一部分。
众所周知 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 大概有以下优势:
<textarea>
,能更好地处理一些边界情况。最关键的还有一点,就是逼格,别人家都是文本框,咱的网站里有 VSCode,高下立判。
富文本编辑器可以分为两种:编辑源码的和编辑渲染结果的,前者就是本文要实现的,后者又称所见即所得(WYSIWYG)编辑器。
WYSIWYG 的原理跟 HTML 设计器类似,就是直接撸 HTML,然后序列化为 Markdown。但说实话,WYSIWYG 跟 Markdown 很不搭。
Markdown 作为一个轻量级的标记语言,轻量级指的是标记占比少,即使不渲染也很好读;同时为了保持语法的简洁,它舍弃了很多功能,像文字颜色,图片对齐等等。
用 WYSIWYG 编辑器来写 Markdown 不仅无法利用语法上的简洁性,还被 Markdown 制约了功能。所以我决定还是做传统的源码编辑器。
好的既然决定使用了,第一步就是安装和导入。Monaco Editor 的 NPM 包叫 monaco-editor
,直接装上就行。
Monaco Editor 是模块化的,有多个导出点,最简单的用法是直接导入主模块:
import * as monaco from "monaco-editor";
但这样会加载所有的模块,体积较大,还有另一种方法是仅导入核心,然后手动选择一些功能:
// 只导入核心部分。
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,上面挑了几个我常用的:
markdown.contribution.js
是 Markdown 语法解析模块。codiconStyles.js
是图标库,查找替换那个小框框要用。wordOperations.js
按 Ctrl + 左右箭头让光标跨词移动。linesOperations
按 Alt + 上下箭头移动整行。dnd.js
提供最基本的拖放功能,在拖动时显示虚线光标。multicursor.js
按 Ctrl + F2 选中全部相同的内容、Ctrl + Alt + 上下箭头多选等功能。那么它们有多大呢?未压缩 2.57 MB,Brotli 后 460 KB。看来即使省着用还是很大,建议做异步加载。
最后还要提一点的是关于 Worker 的问题,在官方示例里都还要加载一个 Worker,代码大概这样:
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 为基础的。
<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
,这里我设了一些参数,主要是写自然语言跟代码不一样,可以针对性的优化下:
fontSize
& lineHeight
CJK 的方块字需要大一号才容易看。wordWrap
启用自动换行。useShadows
不在顶部加阴影,更好看些。wordSeparators
很多编辑器都没注意 CJK 语言里的符号跟英文的不一样,在断句的时候会出问题,这里手动补上。左为默认,右边增加了 CJK 标点
我的博客使用 markdown-it 来转换 Markdown,通过监听onDidChangeModelContent
事件在修改之后更新预览即可,文本可用editor.getModel().getValue(1)
获取,参数 1 代表换行符使用\n
。
虽然主体已经搞定,但要成为完整的编辑器,通常还要加一些按钮和提示之类的。首先能显示写了多少字,以及选中的哪一段是编辑器必备的功能,通常放在底下的状态栏,像这样:
MonacoEditor 内部是按行存储的,也就是说光标的位置用的是几行几列这样的形式,要计算在整个文本里是第几个字,需要遍历每一行。MonacoEditor 也为此提供了一个getCharacterCountInRange
方法。
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 的整合。
<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
即可。当然还需注意一些边界情况。
<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,主要有两个方法:
editor.getModel().pushEditOperations()
editor.executeCommands()
它俩底层都是一样的,只是写法不同,第一个传俩参数分别修改内容和计算选区,第二个传一个 ICommand
实例,里面包含修改内容和计算选区的方法。
这里就演示下使用第二个 API,来给每行前头加上>
让它们变成引用块(第五个按钮)。
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 支持多选,所以要防止给同一行添加多次前缀。由于选区不能重叠,故可得出当两个选区包含同一行时,一定只有前一个选区的最后一行跟后一个选区的第一行在一起。
所以给选区排个序,然后两个一起检测下是否冲突,如果有则后一个选区跳过第一行即可。