富文本编辑器 ProseMirror 第一篇 の 初探
什么是 纯/富 文本?
纯文本
打开 notepad,写一段文字

这是纯文本 (pure text)。
富文本
打开 Google Docs,写一段文字

接着 select text "king of the world",然后按 ctrl + b, i, u。
效果

"king of the world" 不仅仅是文字,它还多了 styles -- bold, italic, underline。
这就是富文本 (rich text)。
"富" 指的是丰富,也就是说它不仅仅是单纯表达文字,它还带有 styles,甚至交互 (e.g. hyperlink)。
HTML + browser = 富文本
这是一段 HTML
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <p>I am the <b><i><u>king of the world</u></i></b></p> </body> </html>
用 browser 游览它,会看见

游览器对 <b>, <i>, <u> element 做了默认 styling,所以呈现出来的效果和 Google Docs 是一样的。
不准确的说,HTML 也是一种富文本。
什么是富文本编辑器 (rich text editor)
顾名思义,富文本编辑器就是一个用来制作 (编辑) 富文本的工具。
比如说 Microsoft Word 就是一款富文本编辑器,它是一个软件,可以创建出 .docx 文件 (.docx 是一种富文本文件格式)。
Google Docs 当然也是,它是在线工具,不需要安装,使用游览器就可以编辑,底层使用 canvas 实现。
Microsoft Word 也有 online 版本,和 Google Docs 大同小异,只不过它底层使用 DOM 实现而非 canvas。
除了它俩,以输出 HTML 作为富文本的编辑器还有一大堆,比如:ckeditor5, tinymce, quill, lexical, slate, prosemirror, tiptap 等 (这些我们下一 part 会介绍)。
本系列主要讲解的便是这类以 HTML 作为输出的富文本编辑器。
Rich text editor vs Markdown editor
Rich text editor
下图是博客园的 rich text editor (富文本编辑器),它用的是 TinyMCE。
它的交互体验是这样 -- 当我们把文本 (text) 从 "段落 (paragraph)" 设置成 "标题 1 (heading 1)",我们立马可以看见文字的 styles 改变了 (变大,变粗)。
Markdown editor
我们再看看 Github 的 markdown editor

它的交互体验是这样 -- 当我们把 text 从 paragraph 设置成 heading,它并没有修改文字的 styles,而是增加了文本内容,从 "Title" 变成 "### Title"。
这是 Markdown 语法,如果我们要看见最终 styling 的效果,我们需要切换到 Preview tab。
两者的区别
虽然两者最终都可以出效果,但显然 Markdown 受限于它的语法,不适合用来表达太过复杂的内容。
在 editor 的实现手法上,markdown 会比 rich text 简易很多,因为它把读写分离了。
写的部分是纯文本,读的部分是富文本,反观 rich text editor 则是将两者合一,直接在富文本上做编辑。
本系列主要讲解的是 rich text editor,不会谈及 markdown editor,另外 rich text editor 也是可以支持输入 markdown 语法的哦。
Browser default rich text editor
游览器 (或者说 HTML) 有自带的 rich text editor element 吗?
先试试 textarea
<textarea style="width: 360px; height: 128px; font-size: 20px; border: 1px solid black; padding: 16px;"></textarea>
效果

textarea 只能输入纯文本,无法添加 styles (e.g. bold, italic, underline) 到 "king of the world" 上。
我们把 textarea 换成 div + contenteditable
<div contenteditable style="width: 360px; height: 128px; font-size: 20px; border: 1px solid black; padding: 16px;" ></div>
效果

没错,当我们 select text "king of the world" 接着按 ctrl + b, i, u 后,styles 就出现了。
透过 Chrome DevTools 查看 elements

多了 <b>,<i>,<u> elements。
虽然 div + contenteditable 可以让我们做到一些些 rich text 效果,但是它的能力十分有限。
比如说,我想要 hyperlink,img,table,ul > li 等等,这些通通无能为力。
结论:游览器没有自带完整的 rich text editor,我们需要自己透过 HTML + JS + CSS 才能做出一个功能完善的 rich text editor,这也是本系列要讲解的主题。
Rich text editor 实现思路
有两个思路,第一个是像 Google Docs 那样,使用 canvas。
基本上就是完全自定义,监听 mouse, keyboard,然后想要怎样画就怎样画。
第二个是扩展原生 rich text editor,也就是上一 part 提到的 div + contenteditable。
canvas 太复杂了,本系列只探讨如何扩展原生 rich text editor。
假设,我们想要实现一个支持 hyperlink 的 rich text editor。
首先是 div + contenteditable
<div class="editor" contenteditable style="width: 360px; height: 128px; font-size: 20px; border: 1px solid black; padding: 16px;" ></div>
接着是 JavaScript
const editor = document.querySelector<HTMLElement>('.editor')!;
// 监听 keydown
editor.addEventListener('keydown', e => {
// 拦截 ctrl + k
if (e.ctrlKey && e.key === 'k') {
// 阻止游览器默认行为
e.preventDefault();
// popup dialog 让 user 输入 URL
const url = window.prompt('Fill in URL');
if (url) {
// 获取 selected text 'my website'
const selection = document.getSelection()!;
const fullText = editor.textContent!;
const selectedText = fullText.substring(selection.anchorOffset, selection.focusOffset);
// 创建 hyperlink
const anchor = document.createElement('a');
anchor.href = url;
anchor.target = '_blank';
anchor.textContent = selectedText;
// 插入 hyperlink
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(anchor);
}
}
});
代码很简单,看注释理解就可以了。
效果是这样

交互方式是 -- 输入 text -> select text -> ctrl + k -> 输入 URL -> OK

my website 从普通的 text node 变成了 anchor element。
结论
大致思路是
-
监听交互事件 (mouse click / keydown 等等)
-
阻止原生事件效果 (preventDefault)
-
获取 selection info (通常用户会选择部分文本做编辑)
-
DOM manipulation,最后就是修改 DOM (add or remove 等等)
看上去好像没有那么难,但真正去实现的时候,你就会发现有一堆细节需要兼顾,比如说 undo redo。
contenteditable 原生是支持 undo (ctrl + z) 和 redo (ctrl + y) 的,
但假如我们像上面这样直接 DOM manipulation 插入 anchor hyperlink 的话,原生的 undo redo 逻辑就会被破坏掉。
像这样

逻辑全错了。
所以,若想实现一个完整的 rich text editor,我们还得维护一套 undo redo 机制,这些都不容易。
市面上的 rich text editor
上一 part 我们有提到,市场上有一堆的 rich text editor。
比如 ckeditor5, tinymce, quill, lexical, slate, prosemirror, tiptap
这里我们简单过一遍,稍微了解一下就好。
CKEditor 4
CKEditor 4 是最古老的 rich text editor,就是那种上古时代,一个 .js file 直接插进 .html 里,全局 variable 调用的 JS 库。
虽然说它已经彻底过时了,但要用还是能用的,而且是开箱即用,免费的 (GPL license 不可闭源)。
CKEditor 5
CKEditor 5 是 2018 年发布的,是 CKEditor 4 的完全重构版,采用 TypeScript,而且需要搭配 bundler。
刚推出的时候有很多功能缺失,近年算是补齐了,但有许多高级功能需要付费才能使用。
简而言之就是一个商业化的 JS 库,免费版有诸多限制,而且 license 是 GPLv2+ (要免费使用,项目就不可以闭源)。
TinyMCE
TinyMCE 和 CKEditor 是竞品。
TinyMCE 6.0 采用的是 MIT license (免费用,而且可以闭源),但很遗憾 v7.0 以后 license 改成了 GPLv2+ (免费,但不可以闭源)。
它和 CKEditor 5 都是商业化 JS 库。
想要免费 + 闭源的话,可以完全不用考虑它俩。
题外话:博客园使用的是 TinyMCE 5.0 版。
Quill
Quill 是一款开源,完全免费 (BSD license 可闭源) 的 rich text editor。
下载量远超 CKEditor 和 TinyMCE

Github 的 star 也远超 CKEditor 和 TinyMCE

在 2019 年,Quill 是当时最火,最被看好,未来一片光明的 rich text editor。
它虽然没有 TinyMCE 那么开箱即用,学习成本也略高一点点,除此之外,其它方面都吊打 TinyMCE。
但是...非常可惜。在 2019 年 9 月 9 日 发布 v1.3.7 版本后,Quill 就陷入了 4 年半的停更。
一直到 2024 年 4 月 17 日 才发布了 v2.0 版本。
即便如此,v2.0 也只是一个大重构版本 (之前是 JS 写的,现在改成了 TS),并没有补齐原来缺失的功能。
而且,就目前的情况看,贡献者也没有想要继续推动这个项目,只是做做维护而已。
我个人的看法是,如果你正在找一款免费,学习成本不太高,功能还不错,扩展性勉强的 rich text editor,Quill 会是首选。
Lexical
Lexical 是 Meta (a.k.a facebook) 出品的 rich text editor。
早年 facebook 还有一款叫 draft.js 的 rich text editor,但现在已经被 Lexical 取代了。
虽然 Lexical 不限于 React,但毕竟是 facebook 内部自己在用的,算是 React 1st 吧。
如果你是 React 生态,首选 Lexical,不是问题。
Slate
Slate 也是 React 生态的 rich text editor。
比起 Lexical,它可以更 "自定义",你可以控制/实现任何你想要的交互体验。
总之,React 生态真繁荣啊,好多轮子,哪像 Angular... 啥也没有。
ProseMirror 和 TipTap
TinyMCE,Quill 属于开箱即用的 rich text editor,你可以扩展它的功能,但很难 "自定义"。
而 ProseMirror 则是搭建 rich text editor 的框架。
非常底层,和 Slate 一样可以高度 "自定义"。
TipTap 则是在 ProseMirror 之上又 wrap 了一层,简化了 ProseMirror 的使用难度,同时又保留了它的高度 "自定义" 能力。
简而言之,如果你需要一款高度自定义化的 rich text editor,那你可以考虑花大量时间来学习 ProseMirror,接着再学习 TipTap,然后用 TipTap 制作一个你喜欢的 rich text editor。
本系列会探索 ProseMirror 和 TipTap,最后用 Angular Material 制作出一款适合 CMS 使用的 rich text editor。
ProseMirror Quick Start
ProseMirror 是框架,非常底层。
但是,它有一些 example 代码,可以快速搭建一个 rich text editor 来做 demo。
我们就先跑个 demo,看看它长啥样。
安装 packages
yarn add prosemirror-model yarn add prosemirror-state yarn add prosemirror-view yarn add prosemirror-schema-basic yarn add prosemirror-example-setup
ProseMirror 是散装的,它有很多独立的 packages,但使用的时候必须把它们合起来。
需求是上一 part 我们自己实现的 hyperlink

注意看,我们实现的版本,无法支持 undo redo。
在最后的 undo 操作,它不是把 hyperlink 退回,而是把前一个 action "输入 this is my website" 给撤回。
但由于 hyperlink action 修改了 "my website" 这个段字,所以 "my website" 没有因为撤回而被删除,只有 "this is" 被删除。
而当 redo 时,它又重新输入 "this is my website",于是最终效果就出现了 2 个 "my website" 😂,简直是乱七八糟。
结论:由于我们做了 DOM manipulation,这导致了原生的 undo redo 没法达到预期的效果。
解决方法是自定义一套 undo redo 机制,不要使用原生的。
好,现在来看看 ProseMirror 的表现
HTML
<div class="editor"></div>
Styles
.editor { width: 500px; border: 1px solid black; }
Scripts
import { exampleSetup } from 'prosemirror-example-setup';
import { DOMParser } from 'prosemirror-model';
import { schema } from 'prosemirror-schema-basic';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import 'prosemirror-menu/style/menu.css';
const editorElement = document.querySelector('.editor')!;
const state = EditorState.create({
doc: DOMParser.fromSchema(schema).parse(editorElement),
plugins: exampleSetup({ schema }),
});
new EditorView(editorElement, { state });
我们先不理会它代码如何运作,直接看效果就好。

注意看,最后的 undo redo 是正确的。
好,演示就到这里,下一篇我们才正式进入 ProseMirror 教学。
总结
本篇对富文本编辑器做一个简要的概述。
介绍了什么是富文本,如何实现富文本编辑器,以及市场上有哪些现成的 library 可用。
最后,还演示了一把本系列的主角 ProseMirror。
下一篇我们将正式进入 ProseMirror 教学,敬请期待。
主要参考


浙公网安备 33010602011771号