前言
大约几个月前有想法折腾一下,想在自己的博客中集成一个类 Typora 的所见即所得的 Markdown 编辑器。
个人理解的所见即所得(WYSIWYG)是键入即时渲染的,而不包含占用视口位置的预览面板。
尝试过 Vditor、Milkdown 等开源 Markdown 编辑器,或多或少的存在一些问题,其中比较接近 Typora 体验的是 HyperMD,但项目过于老旧,难以扩展。
这些开源的 Markdown 所见即所得编辑器大致问题有:
-
编辑器错误,键入复杂一些的 Markdown 语法时可能导致编辑器异常
-
Markdown 解析错误,对于 Markdown 的解析有误
-
键入体验,键入体验并未达到或接近 Typora 的程度,大概有:
-
内联节点在光标移入时不展示源码,需要修改内容(图片、链接等)只能删除完整节点后重新键入
-
内联节点在光标移入时不展示源码,需要跳出当前内联节点时较为困难,容易使人困惑
-
块节点的渲染时机过早,比如键入
- - -
,部分编辑器过早的渲染,导致呈现为三个嵌套的列表,而不是 主题分割
-
因此,我放弃了使用开源 Markdown 编辑器,同时着手尝试编写自己的 Markdown WYSIWYG 编辑器,虽然直至今日还没有结果,还是把这篇文章发出来供大家参考。
编辑器实现方式
经过这几个月的实验,总结出了两种实现 Markdown 所见即所得编辑器的方式:
- 以 Markdown 源码为主
- 以 DOM & 浏览器默认行为为主
这两种方式在实现难度、可扩展可定制性、兼容性上各有优缺点。
Markdown 源码为主
以 Markdown 源码为主,使用 Markdown 解析器,如 commonmark.js,将源码解析为 Markdown 文档树,然后依据文档树渲染 DOM 树。具体逻辑如下:
-
解析器解析 Markdown 源码为 Markdown 文档树
-
渲染器依据 Markdown 文档树渲染 DOM 树,并映射 DOM 节点与 Markdown 节点的关系
-
事件输入,根据 Input Events Level 2 的
beforeinput
事件,阻止输入事件对 DOM 的默认修改行为,同时:-
通过
beforeinput
事件对象的getTargetRanges()
方法获取修改的 DOM 范围,以此找到修改的 Markdown 源码位置 -
解析更新后的 Markdown 源码为新的 Markdown 文档树
-
渲染器渲染新的 Markdown 文档树为 DOM 树
-
更新光标位置
-
这种方式的优点很明显:完全可控的输入,意味着多浏览器兼容性较高;缺点也很明显:实现非常困难,需要实现一套自己的输入逻辑。同时这种方式还有几个潜在的问题:
-
Commonmark 规范与实际的编辑器行为存在冲突,大概有:
-
对于段落头尾的空格,规范规定需要去除(想象一下你使用编辑器在键入空格时,虽然实际的源码变动了,但在 DOM 元素中没有反应出来,因为首尾空格被删除了)
-
除部分块节点内的空行外(代码块、HTML 块等),规范规定空行将被忽略(同理,使用编辑器键入 Enter 键时,不在 DOM 中呈现为段落,看起来没有任何改变)
-
规范规定,打开的 围栏代码块 在遇到结束标识前,会将块的后续内容都视为代码块内容(你在段落前键入 ``````````` 以打开围栏代码块,结果在键入过程中后续内容都被视为代码块内容并反应在 DOM 中,这种突变是很不友好的)
-
-
每次更新时重渲染块,会浪费性能,同时加载外部媒体资源的内联节点(图片)会再次加载
-
光标的处理次数过多,基本每次键入都需要更新光标
有困难也会有解决方法,针对这些问题,我总结的方式有:
-
通过编辑器,定义不同块的渲染时机,例如:
-
列表在列表符号后键入空格,或光标离开当前行作用域时尝试进行渲染
-
围栏代码块在离开当前行作用域时尝试进行渲染
-
-
对于 DOM 更改,使用诸如 React Diff 算法的方式,只对变更的部分打补丁,防止 DOM 节点和图片节点频繁加载
-
依赖 Diff 算法,如果 Markdown 节点的结构未变,说明没有新增或删除 Markdown 节点,只包含了普通文本节点的变更,这种行为可以交由浏览器处理,不必手动控制光标。
DOM & 浏览器默认行为为主
这种方式依托浏览器行为,加以定制修改,能够较为快速的实现目标,以下列代码举例:
html
<style>
.editor {
padding: 20px;
width: 100%;
height: 90vh;
border: 1px solid #ccc;
}
:where(p, li):empty::after {
content: '\200B';
}
</style>
<div class="editor" contenteditable="true">
<p></p>
<ul>
<li></li>
</ul>
</div>
浏览器对于富文本有天然的支持,上述代码中,在 p
、li
中键入 Enter 时,浏览器添加段落、列表项,这种行为与我们想要的行为一致,只需要稍加定制,很容易实现一个基本的 Markdown 所见即所得编辑器。
它的实现逻辑大概如下:
-
使用 MutationObserver 或
input
事件,侦听 DOM 变更 -
运行 Markdown 语法检查,对于符合要求的文本创建对应的 DOM 节点(或对于浏览器创建的 DOM 进行检查),同时将 Markdown 节点的信息(比如列表符号、代码围栏的长度、强调符号等)写入到 DOM 节点,方便后续检查;
-
补齐部分未兼顾的事件,比如
copy
、paste
事件等等
笔者没有太倾向于此方向上研究,因为这种方式注定不能很好的兼顾 CommonMark 规范;同时考虑到不同浏览器默认行为的差异,这种方式的兼容性可能较差。
从观察上看,Typora 的实现与此可能大差不差。
以源码为主的深入研究
主要以源码为主,进行编辑器的研究。
除了之前描述的问题外,解析器的选择也很重要,commonmark.js 虽然是符合规范的 Markdown 解析库,但无法进行扩展,所以笔者找到了一个 Java 版的 Markdown 解析库 commonmark-java,支持高度扩展,将其转译为 JS 版本后可用。
现在还有两个不得不解决的问题:段落首尾空格与空行。如果修改解析器以保留空格与空行,则与规范冲突,反之则与编辑器冲突,这是绕不开的。
目前已有的想法:
-
关于空格
-
扩展解析器,将段落首尾空格、内联元素的语法视为一个个 token,进入到行作用域、内联作用域时展示 token
-
扩展解析器,在编辑器模式下,保留段落首尾空格,同时将内联元素的语法视为一个个 token,进入到内联作用域时展示 token
-
-
关于空行,扩展解析器,将满足以下条件的空行视为段落:
-
空行前一行没有任何内容(另一个空行或块首),空行后一行没有任何内容
-
空行前一行是一个段落,空行后一行没有任何内容,缩进达到要求,则认为这是段落中的延续行
-
通过这几种方式,应该可以兼顾规范与编辑器行为,同时可被其他大部分 Markdown 编辑器识别。
这里再提一下在调试 Typora 时发现的小技巧,在 contenteditable="true"
的元素中,使用一个 contenteditable="false"
元素,可以隔绝编辑器的行为,举例:
html
<style>
.editor {
padding: 20px;
width: 100%;
height: 90vh;
border: 1px solid #ccc;
}
:where(p, li):empty::after {
content: '\200B';
}
</style>
<div class="editor" contenteditable="true">
<p></p>
<div contenteditable="false">
<pre class="code-block" contenteditable="true">
<code></code>
</pre>
</div>
</div>
此时 .code-block
内部的编辑行为是独立的,这用来渲染一些块很有用,比如代码块、HTML 块、GFM 中的 table 块。这种方式可以创建一个内部编辑器,比如使用 codemirror 进行代码块的扩展,以支持语法高亮等。
结语
花了几个月(应该快大半年了),写写停停还是新建文件夹也挺让人无语的。但好歹还算有一些成果,希望能帮助有相同想法的人。
自己也花了一点时间阅读并翻译了 CommonMark 目前最新的 0.31.2 版规范,放在 这里。机翻,手动对照修改过,大概能达到 90% 的准确率,唯一的缺点是很多内部引用链接被去掉了。