Markdown 所见即所得(WYSIWYG)编辑器的研究

前言

大约几个月前有想法折腾一下,想在自己的博客中集成一个类 Typora 的所见即所得的 Markdown 编辑器。

个人理解的所见即所得(WYSIWYG)是键入即时渲染的,而不包含占用视口位置的预览面板。

尝试过 VditorMilkdown 等开源 Markdown 编辑器,或多或少的存在一些问题,其中比较接近 Typora 体验的是 HyperMD,但项目过于老旧,难以扩展。

这些开源的 Markdown 所见即所得编辑器大致问题有:

  • 编辑器错误,键入复杂一些的 Markdown 语法时可能导致编辑器异常

  • Markdown 解析错误,对于 Markdown 的解析有误

  • 键入体验,键入体验并未达到或接近 Typora 的程度,大概有:

    1. 内联节点在光标移入时不展示源码,需要修改内容(图片、链接等)只能删除完整节点后重新键入

    2. 内联节点在光标移入时不展示源码,需要跳出当前内联节点时较为困难,容易使人困惑

    3. 块节点的渲染时机过早,比如键入 - - -,部分编辑器过早的渲染,导致呈现为三个嵌套的列表,而不是 主题分割

因此,我放弃了使用开源 Markdown 编辑器,同时着手尝试编写自己的 Markdown WYSIWYG 编辑器,虽然直至今日还没有结果,还是把这篇文章发出来供大家参考。

编辑器实现方式

经过这几个月的实验,总结出了两种实现 Markdown 所见即所得编辑器的方式:

  1. 以 Markdown 源码为主
  2. 以 DOM & 浏览器默认行为为主

这两种方式在实现难度、可扩展可定制性、兼容性上各有优缺点。

Markdown 源码为主

以 Markdown 源码为主,使用 Markdown 解析器,如 commonmark.js,将源码解析为 Markdown 文档树,然后依据文档树渲染 DOM 树。具体逻辑如下:

  1. 解析器解析 Markdown 源码为 Markdown 文档树

  2. 渲染器依据 Markdown 文档树渲染 DOM 树,并映射 DOM 节点与 Markdown 节点的关系

  3. 事件输入,根据 Input Events Level 2beforeinput 事件,阻止输入事件对 DOM 的默认修改行为,同时:

    1. 通过 beforeinput 事件对象的 getTargetRanges() 方法获取修改的 DOM 范围,以此找到修改的 Markdown 源码位置

    2. 解析更新后的 Markdown 源码为新的 Markdown 文档树

    3. 渲染器渲染新的 Markdown 文档树为 DOM 树

    4. 更新光标位置

这种方式的优点很明显:完全可控的输入,意味着多浏览器兼容性较高;缺点也很明显:实现非常困难,需要实现一套自己的输入逻辑。同时这种方式还有几个潜在的问题:

  1. Commonmark 规范与实际的编辑器行为存在冲突,大概有:

    • 对于段落头尾的空格,规范规定需要去除(想象一下你使用编辑器在键入空格时,虽然实际的源码变动了,但在 DOM 元素中没有反应出来,因为首尾空格被删除了)

    • 除部分块节点内的空行外(代码块、HTML 块等),规范规定空行将被忽略(同理,使用编辑器键入 Enter 键时,不在 DOM 中呈现为段落,看起来没有任何改变)

    • 规范规定,打开的 围栏代码块 在遇到结束标识前,会将块的后续内容都视为代码块内容(你在段落前键入 ``````````` 以打开围栏代码块,结果在键入过程中后续内容都被视为代码块内容并反应在 DOM 中,这种突变是很不友好的)

  2. 每次更新时重渲染块,会浪费性能,同时加载外部媒体资源的内联节点(图片)会再次加载

  3. 光标的处理次数过多,基本每次键入都需要更新光标

有困难也会有解决方法,针对这些问题,我总结的方式有:

  1. 通过编辑器,定义不同块的渲染时机,例如:

    • 列表在列表符号后键入空格,或光标离开当前行作用域时尝试进行渲染

    • 围栏代码块在离开当前行作用域时尝试进行渲染

  2. 对于 DOM 更改,使用诸如 React Diff 算法的方式,只对变更的部分打补丁,防止 DOM 节点和图片节点频繁加载

  3. 依赖 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>

浏览器对于富文本有天然的支持,上述代码中,在 pli 中键入 Enter 时,浏览器添加段落、列表项,这种行为与我们想要的行为一致,只需要稍加定制,很容易实现一个基本的 Markdown 所见即所得编辑器。

它的实现逻辑大概如下:

  1. 使用 MutationObserverinput 事件,侦听 DOM 变更

  2. 运行 Markdown 语法检查,对于符合要求的文本创建对应的 DOM 节点(或对于浏览器创建的 DOM 进行检查),同时将 Markdown 节点的信息(比如列表符号、代码围栏的长度、强调符号等)写入到 DOM 节点,方便后续检查;

  3. 补齐部分未兼顾的事件,比如 copypaste 事件等等

笔者没有太倾向于此方向上研究,因为这种方式注定不能很好的兼顾 CommonMark 规范;同时考虑到不同浏览器默认行为的差异,这种方式的兼容性可能较差。

从观察上看,Typora 的实现与此可能大差不差。

以源码为主的深入研究

主要以源码为主,进行编辑器的研究。

除了之前描述的问题外,解析器的选择也很重要,commonmark.js 虽然是符合规范的 Markdown 解析库,但无法进行扩展,所以笔者找到了一个 Java 版的 Markdown 解析库 commonmark-java,支持高度扩展,将其转译为 JS 版本后可用。

现在还有两个不得不解决的问题:段落首尾空格与空行。如果修改解析器以保留空格与空行,则与规范冲突,反之则与编辑器冲突,这是绕不开的。

目前已有的想法:

  • 关于空格

    • 扩展解析器,将段落首尾空格、内联元素的语法视为一个个 token,进入到行作用域、内联作用域时展示 token

    • 扩展解析器,在编辑器模式下,保留段落首尾空格,同时将内联元素的语法视为一个个 token,进入到内联作用域时展示 token

  • 关于空行,扩展解析器,将满足以下条件的空行视为段落:

    1. 空行前一行没有任何内容(另一个空行或块首),空行后一行没有任何内容

    2. 空行前一行是一个段落,空行后一行没有任何内容,缩进达到要求,则认为这是段落中的延续行

通过这几种方式,应该可以兼顾规范与编辑器行为,同时可被其他大部分 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% 的准确率,唯一的缺点是很多内部引用链接被去掉了。

相关推荐
爱分享的程序员28 分钟前
全栈架构设计图
前端
KenXu42 分钟前
YYEVA WebGPU 渲染实现技术解析
前端·webgl
~卷心菜~1 小时前
CSS基础-即学即用 -- 笔记1
前端·css·笔记
我最厉害。,。1 小时前
CSRF 请求伪造&Referer 同源&置空&配合 XSS&Token 值校验&复用删除
前端·xss·csrf
vvilkim1 小时前
深入解析React.lazy与Suspense:现代React应用的性能优化利器
前端·react.js·前端框架
野猪亨利2581 小时前
【JS 小白也能懂】回调函数:让代码学会「听话办事」的魔法
前端
dengzy3211 小时前
js 观察者模式和发布订阅模式详解
javascript
五号厂房1 小时前
前端接口编写的最佳实践总结:设计、实现、维护全流程
前端
Cutey9161 小时前
Vue 实现多语言国际化的完整指南
前端·javascript·面试
广龙宇1 小时前
【Web API系列】Web Shared Storage API 深度解析:WindowSharedStorage 接口实战指南
前端