乐于分享
好东西不私藏

ProseMirror踩坑实录:那些文档没写的坑,我帮你踩完了

ProseMirror踩坑实录:那些文档没写的坑,我帮你踩完了

上一篇聊了技术选型,这篇聊聊ProseMirror。

选型的时候我说ProseMirror是”最接近Typora思路”的方案。思路对不对?对。但这条路走起来,坑比我想的多得多。

ProseMirror的文档有个特点:它把每个概念都解释得很清楚,但从来不告诉你实际用的时候会遇到什么问题。就像一份完美的理论说明书,少了一章叫”常见翻车现场”。

这篇就是那个缺失的章节。

这是我踩的第一个坑,花了至少两个小时。

ProseMirror解析Markdown用的是prosemirror-markdown库。它的原理是:markdown-it把Markdown文本解析成一系列token,然后prosemirror-markdown根据你定义的token spec把这些token映射成ProseMirror的节点(Node)或标记(Mark)。

映射关系是通过token spec里的block、node、mark三个字段来定义的。比如:

// 这个是对的fence: {  block: 'code_block',  getAttrs(token) {    return { language: token.info || '' }  }}// 这个会炸fence: {  block: 'code_block_open',  getAttrs(token) {    return { language: token.info || '' }  }}

为什么?因为markdown-it在解析代码块的时候,会生成三个token:fence_openfencefence_close。而prosemirror-markdown的内部机制是自动帮你处理_open和_close后缀

如果你手动写了code_block_open作为key,prosemirror-markdown会再给它加一次,变成code_block_open_open。你猜对了,它永远匹配不上。

两个小时。就卡在一个后缀上。

教训:看prosemirror-markdown的源码,不要只看文档。

坑二:commonmark模式不支持GFM表格

这个坑更隐蔽。

markdown-it初始化的时候可以传参数。我一开始传了{ commonmark: true },觉得用标准模式比较安全。

结果GFM表格全部解析失败。打开浏览器调试一看,markdown-it把表格的那几行当成了普通文本,连token都没生成。

翻文档才搞明白:commonmark: true会启用严格的CommonMark解析模式,这个模式不支持任何GFM扩展。表格、任务列表、删除线,全废。

改成默认模式就行:

const md = markdownIt({ html: false, linkify: true })  .use(taskLists)      // - [ ] / - [x]  .use(footnote)       // [^1]

教训:Markdown解析器的”标准模式”未必是你想要的。

坑三:所见即所得的”源码模式”切换

Typora有一个很有意思的设计:光标在某个块级元素里的时候,这个元素会从渲染模式切换成源码模式。比如一个标题,你看到的是大号加粗的”标题文字”,但光标移进去之后,它会变成## 标题文字

我一开始以为这个功能很复杂,要用什么特殊的视图切换机制。后来想明白了:用Decoration就行了。

ProseMirror的Decoration系统可以在不修改文档模型的情况下改变DOM的渲染方式。具体做法是:

  1. 1. 创建一个Plugin,监听selection的变化
  2. 2. 找到当前光标所在的最外层块级节点
  3. 3. 给这个节点添加一个CSS class(比如wf-source-mode
  4. 4. 其他节点添加另一个class(比如wf-render-mode
  5. 5. 用CSS控制显示——源码模式下隐藏渲染内容,显示Markdown源文本;渲染模式下反过来

实际上,DOM里同时存在渲染结果和源文本,只是通过CSS来切换可见性。ProseMirror的文档模型完全不变。

核心代码大概是这样的:

// wysiwygPlugin的核心逻辑decorations(state) {  const { activePos } = this.getState(state)  state.doc.forEach((node, offset) => {    if (offset === activePos) {      // 当前编辑的块:源码模式      decos.push(Decoration.node(offset, offset + node.nodeSize,        { class: 'wf-source-mode' }))    } else {      // 其他块:渲染模式      decos.push(Decoration.node(offset, offset + node.nodeSize,        { class: 'wf-render-mode' }))    }  })  return DecorationSet.create(state.doc, decos)}

这个方案简洁得让我有点意外。没有复杂的视图切换,没有文档模型的修改,纯CSS + Decoration。

坑四:数学公式的自定义token

ProseMirror和markdown-it之间有一层”翻译”:markdown-it把$E=mc^2$解析成token,prosemirror-markdown把这个token映射成ProseMirror节点。

但markdown-it原生不支持数学公式。它不认识$...$$$...$$语法。

所以你得自己写markdown-it插件来生成自定义token。

markdown-it的插件系统允许你往inline ruler和block ruler里插入自定义的解析规则。行内公式($...$)插在inline ruler里,块级公式($$...$$)插在block ruler里:

// 行内公式:$E=mc^2$md.inline.ruler.after('escape', 'math_inline', (state, silent) => {  // 找到$符号,匹配到下一个$,生成token  const token = state.push('math_inline', 'math', 0)  token.content = state.src.slice(start + 1, pos)})// 块级公式:$$E=mc^2$$md.block.ruler.after('blockquote', 'math_block', (state, startLine) => {  // 找到$$,匹配到下一个$$,生成token})

然后在prosemirror-markdown的token spec里映射到自定义的math_inline和math_block节点。

这里也有一个细节:行内公式和块级公式的解析顺序很重要。 如果你先解析块级公式,$...$可能会被误当成块级公式的开始符号。所以行内公式的ruler一定要在遇到两个连续的$时提前返回。

坑五:任务列表的checkbox交互

Markdown的任务列表- [x] 已完成- [ ] 未完成,看起来简单,实现起来有个坑。

checkbox是一个HTML input元素,它天然会抢焦点。在ProseMirror里,一旦焦点离开编辑器,你就没法用键盘操作了。

解决方案:在checkbox的mousedown事件里阻止默认行为,阻止它抢焦点:

checkbox.addEventListener('mousedown', (e) => e.preventDefault())

但阻止了mousedown之后,click事件还是能触发的,所以checkbox的切换逻辑正常工作。

还有一个问题:checkbox的状态改变需要通过ProseMirror的事务(transaction)来更新文档模型,不能直接操作DOM。我在toDOM里创建checkbox的时候绑定了click事件,但具体的transaction逻辑要交给ProseMirror的状态管理来处理。

Schema就是编辑器的灵魂

踩完这些坑之后,我对ProseMirror的理解深了一层。

ProseMirror的核心不是EditorView,不是Plugin,而是Schema

Schema定义了你的文档能有什么节点、什么标记、它们的结构约束是什么。所有的解析、渲染、输入规则、键盘快捷键,都是围绕Schema来运转的。

在WriteFlow里,我定义了15种节点类型(段落、标题、引用、代码块、有序列表、无序列表、列表项、行内公式、块级公式、脚注、图片、分割线、表格等)和5种标记类型(加粗、斜体、行内代码、链接、删除线)。

每加一种节点类型,就要考虑三件事:解析(Markdown怎么变成这个节点)、渲染(这个节点在DOM里长什么样)、序列化(这个节点怎么变回Markdown)。

这三件事做对了,编辑器就能正确地读写Markdown文件。做错了任何一个,就会出现”打开文件后格式变了”或者”保存后Markdown丢了内容”的bug。

ProseMirror的学习曲线确实陡,但这个陡峭是值得的。搞明白Schema之后,后面加功能就是体力活了。

GitHub:

https://github.com/jaqen6688/WriteFlow

百度网盘(国内下载):https://pan.baidu.com/s/1m8Tad11Itl0Yw9D5kqBTpA?pwd=rlb2

下一篇聊聊Electron打包的那些白屏。

我在「全栈生涯」等你,关注就能找到。