IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    博客集成 Monaco Editor 写 Markdown

    Kaciras 的博客发表于 2024-05-30 08:08:59
    love 0

    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

    1. 文本框不行吗?
    2. 所见即所得?
    3. 包结构
    4. 创建编辑器
    5. 其它功能
      1. 计算选择范围
      2. 选项开关
      3. 同步滚动
      4. 编辑按钮

    文本框不行吗? #

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

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

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

    • 基本操作支持:插入 TAB,多段选区,光标的处理等等。
    • 小地图(minimap)利于长文跳转。
    • 语法高亮。
    • 查找替换功能。
    • Ctrl + X/C 剪切/复制整行。
    • 提供了一套编辑内容的 API,比起<textarea>,能更好地处理一些边界情况。

    最关键的还有一点,就是逼格,别人家都是文本框,咱的网站里有 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,上面挑了几个我常用的:

    • 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,代码大概这样:

    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,这里我设了一些参数,主要是写自然语言跟代码不一样,可以针对性的优化下:

    • fontSize & lineHeight CJK 的方块字需要大一号才容易看。
    • wordWrap 启用自动换行。
    • useShadows 不在顶部加阴影,更好看些。
    • wordSeparators 很多编辑器都没注意 CJK 语言里的符号跟英文的不一样,在断句的时候会出问题,这里手动补上。

    左为默认,右边增加了 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,主要有两个方法:

    • editor.getModel().pushEditOperations()
    • editor.executeCommands()

    它俩底层都是一样的,只是写法不同,第一个传俩参数分别修改内容和计算选区,第二个传一个 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 支持多选,所以要防止给同一行添加多次前缀。由于选区不能重叠,故可得出当两个选区包含同一行时,一定只有前一个选区的最后一行跟后一个选区的第一行在一起。

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



沪ICP备19023445号-2号
友情链接