Skip to content

02 · 编辑器内核:实时预览怎么实现

这是整个项目最难、最有价值的部分。其他部分都有成熟方案,只有这里需要自己设计。

1. 问题陈述

用户看到的是渲染结果,但文件里是 Markdown 文本。要做到:

  1. **粗体** 平时显示为粗体、星号不可见;
  2. 光标进入这段文字时,星号就地出现,用户可以直接删掉一个 *
  3. 光标移出,星号重新隐藏;
  4. 全程只有一个编辑区、一套选区、一个撤销栈;
  5. 保存时写回的就是缓冲区里那串字符,一字不差。

2. 核心模型:装饰即渲染

缓冲区内容 = 文件内容(原则 P1)。渲染通过 CodeMirror 6 的 Decoration 实现, 分四类:

装饰类型用途例子
Decoration.mark给一段文本加样式粗体、斜体、行内代码、链接文字
Decoration.replace隐藏或替换一段文本隐藏 **# []() 的 URL 部分
Decoration.widget在某个位置插入 DOM行内公式 $x^2$ 渲染、图片
Decoration.replace({block})整块替换为自定义 DOM表格、代码块渲染、Mermaid 图、块级公式

关键点:装饰不改变文档内容,只改变呈现。文档长度、偏移量、撤销栈全都对应真实文本。

文档文本:   这是 **重点** 内容
            ─────┬┬────┬┬────
装饰:            │└ replace(隐藏)
                 └── mark(font-weight:bold) 覆盖 "重点"
呈现:       这是 重点 内容
                 ^^^^ 加粗

3. 「光标进入即显源码」的算法

一个 StateField<DecorationSet>,输入是 (语法树, 选区, 视口),输出是装饰集合。

ts
function buildDecorations(state: EditorState): DecorationSet {
  const tree = syntaxTree(state)
  const active = activeNodeRanges(state)   // 见下
  const b = new RangeSetBuilder<Decoration>()

  for (const { from, to } of state.visibleRanges) {
    tree.iterate({ from, to, enter(node) {
      const rule = RULES[node.name]
      if (!rule) return
      // 与选区相交的节点:只上样式,不隐藏标记
      const reveal = intersectsAny(active, node.from, node.to)
      rule.decorate(b, node, state, { reveal })
    }})
  }
  return b.finish()
}

activeNodeRanges 的定义决定了手感,需要仔细定:

  • 行内元素(强调、行内代码、链接):选区端点落在节点 [from, to] 闭区间内即视为激活。 闭区间很重要 —— 光标停在 **粗体** 右侧紧邻位置时也要显形,否则用户没法在结尾追加字符。
  • 块级元素(标题、引用、列表项标记):选区与该相交即激活。以行为粒度, 避免光标在标题中间时 # 忽隐忽现导致文本横跳。
  • 块级 widget(表格、代码块、块公式、图表):选区进入该块时, 按用户设置切换为「源码编辑」或「结构化编辑」(见 §6)。
  • 多光标:每个光标独立计算,取并集。

性能:只遍历 visibleRanges(视口),配合 Lezer 的增量解析。 装饰重算只在这些 transaction 上触发:文档变更、选区变更、视口变更、配置变更。 选区在同一节点内移动时结果不变,用一层「上次激活节点集」缓存直接跳过重建。

4. 隐藏标记带来的交互问题(以及对策)

这些是魔鬼细节,做不好整个体验就是假的。

4.1 方向键穿过隐藏文本

**粗体** 隐藏了两侧的 **,按左方向键从「粗」移到左边,物理上要跨 2 个字符。 用 EditorView.atomicRanges 把隐藏区间标记为原子,让光标一次跳过整段标记, 而不是「按一次没反应,按两次跳两格」。

实现之后发现的一件事(值得写下来,因为它反直觉): 横向方向键其实很少真正触发 atomicRanges。原因是 §3 的闭区间激活规则 —— 光标一走到 **粗体** 的边界,标记就已经显形了,此时它们是普通的可见字符, 光标进到两个星号之间完全正常(用户看得见自己在编辑什么)。

也就是说这两条规则是配合起作用的:闭区间显形负责横向移动atomicRanges 真正兜住的是另外几种「一步跨过去」的场景 —— 上下方向键落到某个恰好位于隐藏标记中间的列、点击定位、以及鼠标拖选。 少了它,光标会落进一段用户根本看不见的字符里。

端到端用例因此断言的是用户能观察到的那条保证: 光标走到加粗区边界时标记已经先一步显形,不会出现「在看不见的标记里打字」。

4.2 输入法(IME)

组合输入期间 DOM 被浏览器接管,此时重建装饰会打断组合、丢字或错位。对策:

  • view.composing === true 时,装饰字段冻结(返回上一次的 DecorationSet, 只做 map(tr.changes) 位移映射),组合结束后再重建;
  • 正在组合的范围绝不允许被 replace 装饰覆盖;
  • 中/日/韩三种输入法各写一条 Playwright 用例(通过 CDP Input.imeSetComposition 驱动), 进 CI 常跑。这是硬门槛(目标 G1),不是可选项。

4.3 点击定位

点击渲染后的粗体文字,光标应落到对应源码位置。CodeMirror 的 posAtCoordsmark 装饰天然正确;对 widget 需要在 widget 类里实现 coordsAt / 或让 widget 不可聚焦并在 mousedown 时手动 dispatch 选区。

4.4 选区跨越 widget

用鼠标从 widget 上方拖到下方,选区应包含 widget 对应的完整源码文本。 widget 设 ignoreEvent: false 但不 contenteditable,让 CM 自己处理跨越。

4.5 换行不跳动

标题行 ## 标题 隐藏 ## 后行宽变化会导致重排。对策:隐藏的前缀用 Decoration.replace 且替换成零宽 widget,同时用 CSS text-indent 补偿, 使行首视觉位置稳定。

5. 行内元素的处理规则表

语法平时呈现激活时
**x** / __x__加粗,标记隐藏显示原标记(保留用户用的是 * 还是 _
*x* / _x_斜体同上
~~x~~删除线显示
`x`等宽底色显示反引号(含多反引号情形)
[文字](url)只显示「文字」,带链接样式展开完整源码
![alt](url)图片 widget展开源码,图片仍显示在下方(不消失,避免布局塌陷)
$x$KaTeX 行内渲染展开源码
脚注 [^1]上标编号,hover 显示内容展开
行内 HTML <b>x</b>按标签的排版语义显示,标签隐藏(见 §5.1)显示标签本身
未识别语法原样显示为纯文本

最后一条是原则 P2 的落地:不认识就别动它。

5.1 行内 HTML:不解析 HTML 也能渲染 HTML

「渲染文档里的 HTML」是这套东西里唯一一条安全相关的路径:渲染进程手里 握着 fs.writeshell.openExternal,用户文档里的 <img onerror> 一旦真的 进了 DOM,XSS 当场升级成 RCE。通用做法是「解析 + 消毒 + 白名单」, 而消毒器漏一个就是全盘皆输 —— 这也是它在 M4 里被搁置的原因。

落地的做法绕开了那件事:

  1. 一个字节的 HTML 都不进 DOM。 没有 innerHTML、没有 DOMParser。 渲染效果全部由 mark 装饰 + CSS 类名达成 —— 往 DOM 里写的只有类名, 而那是个封闭集合(cm-typo-html- 加上一个白名单里的标签名)。
  2. 标签集合封闭b strong i em u s del ins sub supkbd mark br。入选标准只有一条 —— 纯排版语义、没有行为、 不带属性也有意义。落选的例子:<a>(离了 href 没意义)、<span>(同上)、 <code>(Markdown 已经有 `,两套写法渲染成同一个样子只会让人分不清 源码里到底写的哪一个)。
  3. 带属性的标签一律不认。 <b> 渲染,<b class="x"> 原样显示。 属性是绝大多数注入面的载体(on*stylehrefsrc), 而这几个标签的属性对排版毫无用处 —— 不解析属性,就没有属性可被利用。
  4. 认不出来的一律原样显示。 <div><script>、没闭合的 <b>、 交叉嵌套的那一层,全部按字面文本显示。

配对按容器算而不是按可见区算<b></b> 完全可能一个在视口上边、 一个在下边,只看可见区会把前者误判成没闭合。

块级 HTML(<div>…</div>)仍然原样显示。 它的意义几乎全在属性和布局上 (表格、iframe、带样式的容器),照第 3 条根本渲染不出有价值的东西, 而放开属性正好踩回那条安全路径。

设置里有「渲染行内 HTML」开关(默认开)。尽管实现上不存在注入面, 「我要看见我文件里到底写了什么」本身也是个正当诉求。

6. 块级元素:widget 与源码的双态

表格、代码块、公式块、Mermaid 图这类元素需要「结构化编辑」,但源码必须仍可直达。 统一模型:每个块 widget 有两个状态

        光标进入 / 点击 widget
渲染态 ──────────────────────────▶ 编辑态
       ◀──────────────────────────
        光标离开 / Esc
  • 渲染态:整块被 Decoration.replace({block, widget}) 替换为渲染结果。
  • 编辑态:装饰撤销,露出原始 Markdown 文本,用普通文本编辑(带该块语言的语法高亮)。

对表格额外提供第三态:网格编辑器(⏸ 已移入 M4.5,暂不实现 —— 难点正是下面那条铁律:widget 里的列宽、选中单元格、拖拽中的列构成一份第二状态, 它和文本缓冲区必须始终同调)。widget 内部渲染一个可聚焦的表格 UI, 在单元格里输入时:

  1. 网格编辑器计算出新的 GFM pipe table 文本(列宽对齐按用户偏好,默认保持原有对齐风格);
  2. 通过 一个 view.dispatch({changes: {from, to, insert: newText}}) 写回;
  3. 因为走的是正常 transaction,撤销、协同、脏标记全部自动正确。

铁律:widget 内部的任何编辑都必须转换成对主文档的 transaction,不允许 widget 持有独立状态。 违反这条就会出现「撤销撤不回表格里的改动」这类经典 bug。

代码块的编辑态用嵌套的语法高亮(Lezer 支持混合语言解析),不嵌套第二个 CodeMirror 实例 —— 嵌套实例会带来两套快捷键、两个撤销栈、焦点管理噩梦。

6.0 M2 的表格:连 widget 都没有

网格编辑器搁置之后,表格反而得到了一个更简单的呈现方案:它仍然是文本, 只是靠 CSS 摆成表格的样子。

  • 每个表格行的 .cm-linedisplay: table-row
  • 行内每个单元格挂 display: table-cell

.cm-content 并不是 display: table,但 CSS 2.1 规定:非表格父元素下连续的table-row 子元素会被自动包进一个匿名表格盒。这正好是想要的语义 —— 连续的表格行自成一张表,中间夹一行普通段落就自然断成两张, 不需要任何额外标记。跟代码块横向滚动同步靠 DOM 相邻关系是同一个路子。

收益:列宽由浏览器按内容算,跟真表格一致;没有第二状态,撤销/协同天然正确。

关键决定:每个单元格的范围包含它左边那根竖线。 不这么切的话,光标进表时显形的竖线会落在两个 table-cell 之间, 浏览器为它生成一个匿名单元格 —— 列数凭空多出来,整张表的列宽当场重排。 包进单元格内部之后,显形与否都不影响列结构。

显形粒度是整张表,不是单行。局部显形会让那一行脱出匿名表格盒, 把一张表劈成两张、列宽分家,比整体切换刺眼得多。同理,分隔行 (| --- | :---: |)平时由块级装饰藏起来,露出来时也必须被摆成表格行, 否则它会以块级行的身份把匿名表格盒截成两张。

这套方案押的是一个纯布局行为,单元测试验不了,因此配了 10 条 e2e 实测: 列宽跨行对齐、点击定位准确、格内打字不散架、显形前后列数不变、右对齐真右对齐。 任何一条不过,退路是 display: flex + flex: 1 1 0 的等宽列 —— 装饰结构完全一样,只需要改主题里的两条 CSS。

6.1 代码块不能折行(M1 遗留缺陷)

M1 的 EditorView.lineWrapping全局的:散文折行是对的,但代码块跟着一起折 就错了 —— 代码的缩进结构靠列对齐传达信息,一折行就读不出层级, 而且行号(将来有的话)与实际行不再一一对应。

这里有个 CodeMirror 的结构性约束要先说清楚:CM6 把每一行渲染成独立的 .cm-line 元素,没有「代码块」这一层 DOM 容器。所以「让整个代码块作为一个 整体横向滚动」并不是加一条 CSS 就能拿到的。三条路:

方案做法代价
A · 每行独立滚动代码行 white-space: pre; overflow-x: auto一行一个滚动条,块内各行滚动位置不同步,观感割裂
B · 整个文档横向滚动代码行 white-space: pre 并让 .cm-scroller 溢出横向滚动时散文段落跟着一起位移,很晕
C · 块级 widget 包一层容器用 §6 的 widget 机制把代码块整体替换编辑态要重建一套输入路径,成本等同表格

**决定:走 A,但补一个滚动同步。**代码行加 white-space: preoverflow-x: auto,同时用一个 ViewPlugin 监听滚动事件,把同一个代码块内所有行的 scrollLeft 对齐。视觉上等价于「整块一起滚」,成本却只有几十行, 也不用把代码块拖进 widget 的双态模型。

滚动条必须可见。 起初为了「每行一条太吵」把它藏了,这是个错误的取舍: 藏掉之后既没有可拖的东西,也没有「右边还有内容」的提示, 用不带横向滚轮的鼠标就彻底滚不动了。能用优先于好看。

遗留风险:滚动同步依赖 DOM 事件,行数极多的代码块(数百行) 监听器数量会上去。届时可以退化为「只同步视口内的行」。

6.2 语法高亮

M1 的代码块只有底色,没有按语言高亮 —— 因为 markdownLanguageSupport() 没有传 codeLanguages。补法见 03 §7: 把语言解析器按需注入,Lezer 会把围栏语言当作嵌套解析处理, 高亮出来的 token 直接走现有的 HighlightStyle,不需要额外的渲染路径。

6.3 围栏的隐藏 —— 以及一个只有实测才会发现的坑

围栏 ``` 平时藏起来、光标进块时显形,规则跟其他标记一致。但它是整行, 藏行必须连一个换行符一起盖,否则只会留下一个空行(Setext 标题就栽在这上面)。

盖哪一侧的换行,结果完全不同

  • 后面那个换行 → 这一行和下一行合并成一个视觉行, 而合并后的行锚定在被替换区间的起点上,于是下一行自己的行装饰 (代码块底色、语言角标)位置落进了被替换范围里,整个被丢弃。 表现是代码块第一行突然没了样式。
  • 前面那个换行 → 跟上一行合并,锚点还在上一行,谁的装饰都不受影响。

所以一律往前合并。文档以代码块开头时没有前一个换行可用 —— 这种情况索性不藏开围栏,也不走那条会破坏样式的路(原则 P2: 降级要优雅,不能为了藏一行标记把整行内容的呈现搞坏)。

围栏藏起来之后,语言名改由代码块首行右上角的角标呈现, 用 CSS 的 content: attr() 实现,不需要 widget。

6.4 表格编辑:每条命令都是一次文本变换

「表格网格编辑器」当初被列进 M4.5 的硬骨头,是因为通行做法是拿一个 widget 装整张表 —— 而 widget 里必然要存一份第二状态(选中的单元格、拖拽中的列、 列宽),它和文本缓冲区必须始终同调。撤销、外部改文件、将来的协同都会打破同调。 做浅了就是**「编辑完表格按 ⌘Z 文档烂掉」**。

这里绕开了整个问题:每一条命令都是一次纯文本变换。读出表格文本、算出新的 表格文本、发一个普通 transaction。撤销、脏标记、外部改文件、协同因此全都自动 正确 —— 跟代码块的语言选择器是同一个路子(03 §7.2),也跟 6.0 的渲染方案一脉 相承:表格始终只是文本

命令一览:Tab / Shift+Tab 在单元格间移动、回车去下一行、增删行列、设置列对齐、 整理表格、插入表格。

几条不那么显然的决定:

  • Tab 与 Shift+Tab 只移动光标,不改文档。 纯导航不该在撤销栈里留下一步。 只有结构性命令才重排竖线。例外是「最后一格按 Tab」—— 那时它长出新的一行, 这是搭表格最顺手的方式。
  • 移动过去是选中单元格内容,不是把光标放在末尾。Tab 过去接着敲就是替换, 这是表格导航的通行语义。结构性命令之后则相反(光标落在内容末尾), 因为那时用户是要接着填
  • 回车只在下一行已经存在时接管,表尾一律交还给默认行为。 这条边界是被真实 用法逼出来的:手敲一张表就是「敲完一行按回车、再敲下一行」。如果回车在表尾替 用户长出一个空行并把光标塞进第一格,接着敲的 | 1 | 2 | 就插进了那个格子里, 得到一堆嵌套竖线。想加行的用户有 Tab,手敲的用户则完全不受打扰。
  • 末行为空时回车 = 退出表格。 没有这条,用 Tab 长出空行的用户会被困在表里 只能靠方向键逃出去。这跟空列表项回车退出列表是同一个直觉。
  • 对齐按显示宽度算,中日韩文字占两格。 不这么做的话中文表格在源码模式下 是彻底歪的 —— 而这个项目的文档全是中文,那等于对齐功能根本不存在。 用的是经典的 wcwidth 近似,组合符号与部分 emoji 序列算不准, 差一格不影响正确性。
  • 列数取所有行里最多的那一个,而不是表头的列数。 GFM 渲染时会丢掉超出表头 的单元格,那些字在预览里看不见 —— 但它们确实在文件里。按表头归一等于 静默删掉用户的字(原则 P2),所以宁可把表头补宽。
  • 删表头时把第一条正文顶上来,因为 GFM 的表格必须有表头。 只剩一行 / 一列时拒绝执行:那等于删掉整张表,而那件事该由用户自己 选中删除,不该由一条「删除行」命令替他决定。

做不了的:拖拽调列宽。 它天生需要一份跨帧存活的拖拽状态,正是上面要避开的 东西。列宽仍由浏览器按内容算(6.0)。这是这一档缩范围之后剩下的唯一缺口, 不粉饰。

7. 输入行为

源码优先模型在这里有天然优势:用户敲 # 时缓冲区里本来就是 # , 不需要「输入规则 → 转换成标题节点」这种变换,标题自动就出现了。需要专门实现的只有:

行为说明
回车续列表在列表项内回车,自动插入同级标记;空列表项回车则退出列表
Tab / Shift-Tab列表项内改变缩进层级,并按 CommonMark 规则重算标记对齐
有序列表重编号按用户设置:保持原样 / 全部 1. / 递增(默认保持原样,最小 diff)
自动配对 / 选中包裹见 §7.1,两类字符规则不同
粘贴 HTML富文本剪贴板 → 用 rehype-remark 转 Markdown 插入;按住 Shift 粘贴纯文本。⏸ 已移入 M4.5 —— 转换质量是无底洞,跑通 80% 只要一天,剩下 20% 能吃掉一个月
粘贴图片写入附件目录(见 04),插入相对路径引用
智能标点可关;默认关(会破坏格式保真的直觉)
表格快捷键Tab 移到下一格、Enter 换行、快捷键增删行列。⏸ 随网格编辑器一起移入 M4.5

7.1 自动配对必须比代码编辑器克制

Markdown 的强调标记(* _ ~ `同时也是普通标点和列表标记, 所以不能像代码编辑器那样一视同仁地自动补全。按两类分开:

类别字符空选区时有选区时
括号类( [ { "自动补右半边包裹
强调类* _ ~ `原样插入包裹

强调类在空选区时绝不自动补全,理由都是真实的日常场景:行首敲 * 是在起一个 列表项,补成 ** 会让人当场想砸键盘;_ 出现在标识符里(some_var); ~ 在路径里(~/文档);反引号则是连着敲三个起围栏,自动配对会插出一堆多余的。

单引号从上游默认集里去掉了 —— 英文正文里它是撇号(don't、it's), 配对会把每一个缩写都变成 don''t。这是散文编辑器与代码编辑器最典型的一处分歧。 代码块内部由嵌套语言自己提供配置,那里 ' 该配对就配对,两不相干。

包裹保持选区在内容上,于是连敲两次 * 自然就是加粗(第二次作用在已经被 选中的 *x* 上)。不做「敲一次直接变粗体」的特殊处理 —— 那样就打不出斜体了。

EditorView.inputHandler 而不是 keymap:keymap 认的是按键, 而 * 在不同键盘布局上位置不同,输入法状态下更是对不上; inputHandler 认的是最终插入的字符,这才是真正关心的东西。

8. 视图模式

均为装饰层的组合,不改变文档:

  • 实时预览(默认)
  • 源码模式:关闭所有装饰扩展,纯文本 + 语法高亮
  • 打字机模式:当前行始终保持在视口垂直中央(scrollIntoView + 动态 padding)
  • 专注模式:非当前段落降低不透明度(一个额外的 mark 装饰)
  • 只读模式EditorState.readOnly

9. 大文档策略

文档规模策略
< 5k 行全功能
5k–50k 行视口渲染(默认已有);块 widget 仅在视口内实例化;图表渲染延迟到空闲帧
> 50k 行 或 > 5MB自动提示切换到源码模式;关闭图表与图片渲染;保留语法高亮

Lezer 的增量解析保证输入时只重解析受影响的片段;解析在超时后会让出主线程并在 空闲时继续(CodeMirror 内建行为),因此大文档不会卡死输入。

10. 为什么不用 ProseMirror

详见 ADR-0002,此处给结论对照:

维度CM6 源码优先(本方案)ProseMirror 富文本模型
往返保真天然零损耗,缓冲区即文件需在节点属性里存「源码提示」,仍难 100%
未知语法原样保留容易在序列化时丢失或被规范化
diff 友好只改动到的地方变保存时整篇重新序列化,易产生噪声 diff
排版观感需要靠装饰精修,嵌套结构稍逊更接近字处理器
表格等结构化编辑需自建 widget 层(工作量在这)内建更顺
协同编辑Y.Text 直接套在文本上,几乎白送需要 y-prosemirror,模型转换复杂
大文档视口渲染 + 增量解析,优势明显全量 DOM,压力大

我们的目标 G2(格式保真)优先级高于「排版观感」,所以选前者, 并把节省下来的复杂度投入到 widget 层的打磨上。

MIT 许可发布。与 Typora 无关联,是一个独立实现。