02 · 编辑器内核:实时预览怎么实现
这是整个项目最难、最有价值的部分。其他部分都有成熟方案,只有这里需要自己设计。
1. 问题陈述
用户看到的是渲染结果,但文件里是 Markdown 文本。要做到:
**粗体**平时显示为粗体、星号不可见;- 光标进入这段文字时,星号就地出现,用户可以直接删掉一个
*; - 光标移出,星号重新隐藏;
- 全程只有一个编辑区、一套选区、一个撤销栈;
- 保存时写回的就是缓冲区里那串字符,一字不差。
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>,输入是 (语法树, 选区, 视口),输出是装饰集合。
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 的 posAtCoords 对 mark 装饰天然正确;对 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) | 只显示「文字」,带链接样式 | 展开完整源码 |
 | 图片 widget | 展开源码,图片仍显示在下方(不消失,避免布局塌陷) |
$x$ | KaTeX 行内渲染 | 展开源码 |
脚注 [^1] | 上标编号,hover 显示内容 | 展开 |
行内 HTML <b>x</b> | 按标签的排版语义显示,标签隐藏(见 §5.1) | 显示标签本身 |
| 未识别语法 | 原样显示为纯文本 | — |
最后一条是原则 P2 的落地:不认识就别动它。
5.1 行内 HTML:不解析 HTML 也能渲染 HTML
「渲染文档里的 HTML」是这套东西里唯一一条安全相关的路径:渲染进程手里 握着 fs.write 与 shell.openExternal,用户文档里的 <img onerror> 一旦真的 进了 DOM,XSS 当场升级成 RCE。通用做法是「解析 + 消毒 + 白名单」, 而消毒器漏一个就是全盘皆输 —— 这也是它在 M4 里被搁置的原因。
落地的做法绕开了那件事:
- 一个字节的 HTML 都不进 DOM。 没有
innerHTML、没有DOMParser。 渲染效果全部由 mark 装饰 + CSS 类名达成 —— 往 DOM 里写的只有类名, 而那是个封闭集合(cm-typo-html-加上一个白名单里的标签名)。 - 标签集合封闭:
bstrongiemusdelinssubsupkbdmarkbr。入选标准只有一条 —— 纯排版语义、没有行为、 不带属性也有意义。落选的例子:<a>(离了 href 没意义)、<span>(同上)、<code>(Markdown 已经有`,两套写法渲染成同一个样子只会让人分不清 源码里到底写的哪一个)。 - 带属性的标签一律不认。
<b>渲染,<b class="x">原样显示。 属性是绝大多数注入面的载体(on*、style、href、src), 而这几个标签的属性对排版毫无用处 —— 不解析属性,就没有属性可被利用。 - 认不出来的一律原样显示。
<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, 在单元格里输入时:
- 网格编辑器计算出新的 GFM pipe table 文本(列宽对齐按用户偏好,默认保持原有对齐风格);
- 通过 一个
view.dispatch({changes: {from, to, insert: newText}})写回; - 因为走的是正常 transaction,撤销、协同、脏标记全部自动正确。
铁律:widget 内部的任何编辑都必须转换成对主文档的 transaction,不允许 widget 持有独立状态。 违反这条就会出现「撤销撤不回表格里的改动」这类经典 bug。
代码块的编辑态用嵌套的语法高亮(Lezer 支持混合语言解析),不嵌套第二个 CodeMirror 实例 —— 嵌套实例会带来两套快捷键、两个撤销栈、焦点管理噩梦。
6.0 M2 的表格:连 widget 都没有
网格编辑器搁置之后,表格反而得到了一个更简单的呈现方案:它仍然是文本, 只是靠 CSS 摆成表格的样子。
- 每个表格行的
.cm-line挂display: 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: pre 与 overflow-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 层的打磨上。