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 里被搁置、直到 M4.5 才动的原因。
落地的做法绕开了那件事:
- 一个字节的 HTML 都不进 DOM。 没有
innerHTML、没有DOMParser。 渲染效果全部由 mark 装饰 + CSS 类名达成 —— 往 DOM 里写的只有类名, 而那是个封闭集合(cm-mosu-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 文本,用普通文本编辑(带该块语言的语法高亮)。
对表格额外设想过第三态:网格编辑器。这一态最终没有实现,而且不是「还没排上」—— 是想清楚之后放弃了,换成了 §6.4 那套纯文本变换。难点正是下面那条铁律:widget 里的 列宽、选中单元格、拖拽中的列构成一份第二状态,它和文本缓冲区必须始终同调。 下面这段保留下来,因为那条铁律对所有 widget 都成立:
- 网格编辑器计算出新的 GFM pipe table 文本(列宽对齐按用户偏好,默认保持原有对齐风格);
- 通过 一个
view.dispatch({changes: {from, to, insert: newText}})写回; - 因为走的是正常 transaction,撤销、协同、脏标记全部自动正确。
铁律:widget 内部的任何编辑都必须转换成对主文档的 transaction,不允许 widget 持有独立状态。 违反这条就会出现「撤销撤不回表格里的改动」这类经典 bug。
代码块的编辑态用嵌套的语法高亮(Lezer 支持混合语言解析),不嵌套第二个 CodeMirror 实例 —— 嵌套实例会带来两套快捷键、两个撤销栈、焦点管理噩梦。
6.0 表格:连 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.3.1 代码块的横向滚动:光标驱动的那一半一开始漏了(issue #2)
代码块没有「块」这层 DOM 容器(CodeMirror 每行一个 .cm-line),所以整块横向 滚动拿不到现成的。做法是每行各自 overflow-x: auto,再把同一块内各行的 scrollLeft 对齐。
第一版只监听 scroll 事件 —— 于是只有鼠标滚轮和拖滚动条才滚。用方向键或 查找跳到长行深处时,行一动不动。实测(1100px 窗口,光标跳到第 61 列):
代码行 scrollLeft = [0, 0, 0] ← 一次都没变
插入符画在 x=957,而代码块可视范围是 x=238..862
第二个数字是关键。插入符被画到块右边界外 95px 的空白上;窗口再窄一点,这个块外 的幽灵插入符就会把 .cm-scroller 的 scrollWidth 撑开,于是整个视口跟着横向 滚动 —— 标题和正文一起左移,列号大时整页内容被推出屏幕,只剩一小块悬空的高亮。
报告里的三个症状(行不滚 / 视口在滚 / 高亮跑到块外)是同一个原因:没有人把 行滚到该滚的位置。补上「光标移动时把它所在的代码行滚到插入符可见」之后, 另外两个自动消失 —— 不需要分别去治。
修好后同一场景:scrollLeft = [0, 0, 119, 0, 0],插入符 x=838(块内),标题不动。
排查时错怪过 coordsAtPos。 中途量到「插入符图层在 837、而选区起点在 742」, 以为 CM 的坐标模型不认 line.scrollLeft,还据此把实现换成了手写 DOM Range。 95px 其实是 MARKER_DEEP 这 11 个字符在 0.9em 等宽下的宽度 —— 查找跳转后光标在 匹配末尾,而我量的是选区起点,两者本来就差一个匹配文本的宽度。 coordsAtPos 从头到尾都是对的,已改回去。
教训跟 06 §3.3 那个 PDF、07 §2.2 那个按键延迟是同一条:先确认这个数字量的是不是 你以为的那件事。这次多绕了一圈,因为「95」看起来太像某个滚动量。
短行不跟着滚,这是浏览器行为不是缺陷:没有滚动余量的元素,scrollLeft 会被夹回 0。短行在那个偏移上本来也没有内容可显示,停在原地反而让人保得住阅读位置。
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)。这是这一档缩范围之后剩下的唯一缺口, 不粉饰。
表格不一定从第 0 列开始(issue #8)
「每条命令都是一次文本变换」这个模型有一个前提没写下来,于是它被违反了很久: 表格所在的那几行,行首不一定就是表格。blockquote 里的表格每行带 > , 列表项里的表格每行带缩进。
两处各自出错,合起来把表格彻底毁掉:
- 找单元格时用「首个非空白位置」判断有没有前导竖线 —— 而
>里的>是非空白字符,于是首个竖线被判成「不在行首」,"> "成了第 0 个单元格。 三列表格变四列,>出现在第一列里; - 重新序列化时一律
'|' + …,不带任何前缀。而替换区间 ([table.from, table.to])只跳过了首行的前缀,却横跨了后续各行的 —— 于是第二行往后当场掉出 blockquote / 列表项。
用户看到的是:按一下 Tab,引用竖条消失、表格散架。而 Tab 是表格里最常用的键, 「在无序列表下面放一张表」在文档里非常常见。
修法是把块前缀变成模型的一部分(TableModel.prefix):找单元格时从行节点 的起点算起(解析器给出的那个位置本来就在 QuoteMark 之后),序列化时把前缀 加回第二行往后的每一行。
教训不在这两行代码上,而在测试上:table-edit.test.ts 里当时没有一张表格 不是从第 0 列开始的。一整类输入没有任何一条用例,缺陷就住在那里。
退出表格要空一行,不是换一行(issue #10)
「末行为空时回车 = 退出表格」原来只补一个 \n。而 GFM 里表格行之后紧邻的 非空行仍然是表格行 —— 用户刚被送出表格,敲的第一个字又把自己送了回去, 连刚删掉的那一空行也一并回来。补两个换行才是真的落在新段落里。
7. 输入行为
源码优先模型在这里有天然优势:用户敲 # 时缓冲区里本来就是 # , 不需要「输入规则 → 转换成标题节点」这种变换,标题自动就出现了。需要专门实现的只有:
| 行为 | 说明 |
|---|---|
| 回车续列表 | 在列表项内回车,自动插入同级标记;空列表项回车则退出列表 |
| Tab / Shift-Tab | 列表项内改变缩进层级,并按 CommonMark 规则重算标记对齐 |
| 有序列表重编号 | 按用户设置:保持原样 / 全部 1. / 递增(默认保持原样,最小 diff) |
| 自动配对 / 选中包裹 | 见 §7.1,两类字符规则不同 |
| 粘贴 HTML | ✅ 富文本剪贴板 → 用 rehype-remark 转 Markdown 插入;按住 Shift 粘贴纯文本。当初担心「转换质量是无底洞」,落地时的解法是把降级写成清单而不是追求完美 —— 合并单元格摊平、嵌套表格摊平、单元格里的段落压平,全部列在 03 §8.7 里 |
| 粘贴图片 | 写入附件目录(见 04),插入相对路径引用 |
| 智能标点 | 可关;默认关(会破坏格式保真的直觉) |
| 表格快捷键 | ✅ Tab 移到下一格、增删行列、设置列对齐、整理。没有随网格编辑器一起做 —— 每条命令都是一次纯文本变换,见 §6.4 |
7.1 自动配对必须比代码编辑器克制
Markdown 的强调标记(* _ ~ `)同时也是普通标点和列表标记, 所以不能像代码编辑器那样一视同仁地自动补全。按两类分开:
| 类别 | 字符 | 空选区时 | 有选区时 |
|---|---|---|---|
| 括号类 | ( [ { " | 自动补右半边 | 包裹 |
| 强调类 | * _ ~ ` | 原样插入 | 包裹 |
强调类在空选区时绝不自动补全,理由都是真实的日常场景:行首敲 * 是在起一个 列表项,补成 ** 会让人当场想砸键盘;_ 出现在标识符里(some_var); ~ 在路径里(~/文档);反引号则是连着敲三个起围栏,自动配对会插出一堆多余的。
单引号从上游默认集里去掉了 —— 英文正文里它是撇号(don't、it's), 配对会把每一个缩写都变成 don''t。这是散文编辑器与代码编辑器最典型的一处分歧。 代码块内部由嵌套语言自己提供配置,那里 ' 该配对就配对,两不相干。
包裹保持选区在内容上,于是连敲两次 * 自然就是加粗(第二次作用在已经被 选中的 *x* 上)。不做「敲一次直接变粗体」的特殊处理 —— 那样就打不出斜体了。
走 EditorView.inputHandler 而不是 keymap:keymap 认的是按键, 而 * 在不同键盘布局上位置不同,输入法状态下更是对不上; inputHandler 认的是最终插入的字符,这才是真正关心的东西。
7.2 加粗 / 斜体命令要按 CommonMark 的规则来(issue #9)
toggleWrap(⌘B / ⌘I / ⌘E / 删除线共用)原来是「在选区两头各插一个 marker」加「两侧各看一个字符就解包」。三个缺陷都产出语义变了的 Markdown, 而不只是难看:
空白必须挤出选区。 CommonMark 的 flanking 规则不允许强调标记贴着空白 (spec 例 379、391:** foo bar** 和 **foo bar ** 都按字面渲染)。所以 「选中 def 按 ⌘B」产出的 abc **def **ghi 根本没有加粗,用户文件里 凭空多了四个星号。跨行选区是同一件事更常见的形态:「选中一整行再 ⌘B」时选区 末尾带着换行符,闭合标记落到了下一行行首。
行内代码的围栏要够长。 CommonMark 要求围栏的反引号数多于内容里最长的那串。 一律用一个反引号的话,内容里的反引号会提前把 code span 闭掉 —— 只有半截成了 代码,还剩一个游离的反引号。内容以反引号开头或结尾时另需各垫一个空格。
解包不能只看一个字符。 对 这是**重点**内容 里的「重点」按 ⌘I 时, * 的左右各看一个字符都是 *,看起来正像一对斜体标记 —— 于是把粗体的内侧 星号拆走了。用户按下「斜体」,得到的是粗体没了。
判据得按 marker 串的总长、照 CommonMark 的叠加规则拆:
| 串长 | 含义 | 按斜体 | 按加粗 |
|---|---|---|---|
* | 斜体 | 脱掉 | 再包成 *** |
** | 粗体 | 再包成 *** | 脱掉 |
*** | 粗体 + 斜体 | 脱一层 → ** | 脱两个 → * |
修完之后快捷键和直接敲 * 的结果也对上了 —— §7.1 的 wrapSelection 没有 解包分支,一直给的就是 ***重点***。两条路对同一个操作给出两个答案, 本身就是缺陷的信号。
8. 视图模式
均为装饰层或滚动位置的组合,不改变文档一个字节:
- 实时预览(默认)
- 源码模式:关闭所有装饰扩展,纯文本 + 语法高亮
- 打字机模式:当前行始终保持在视口垂直中央
- 专注模式:当前块以外的行降低不透明度
- 只读模式:
EditorState.readOnly
实现都在 packages/editor/src/writing-modes.ts。下面几条是落地时被现实修正过的 设计,值得单独记下来。
8.1 专注的单位是「块」,不是「行」
按行高亮在中文里几乎必错:一个自然段折行后有五六行,光标在第三行时另外五行变暗, 读起来像文字被劈开了。按「块」算才对应人心里的「我正在写的这一段」。
块的判定从语法树来:从光标处向上走到 Document 的直接子节点,途中遇到容器块 (Blockquote / BulletList / OrderedList)要继续往里走。所以:
| 光标在 | 点亮的范围 |
|---|---|
| 段落 | 整段(含所有折行) |
| 标题 | 那一行 |
| 代码块 | 整个块 —— 中间的空行不该把它劈开 |
| 列表第二项 | 只有第二项,含项目符号(再往里走一层到 Paragraph,符号就落进暗区,看着像列表少了一项) |
| 引用里的第二段 | 只有那一段 |
| 块之间的空行 | 只有那一行 |
最后一条容易漏:空行上 resolveInner 只能给到 Document,若照直用就变成「整篇都是 当前块」—— 表现是「光标一进空行,全文突然全亮」,像功能坏了。这里退回当前行。
判定范围时还要注意,列表项这类节点的 to 会盖过行尾的换行符、落在下一行的行首上。 按闭区间判断的话下一行会跟着亮 —— 而且这多出来的一行随节点类型时有时无, 很难看出规律。所以终点按半开区间算。
8.2 变暗用 opacity,不调前景色
文档里有代码块底色、表格边框、图片、公式。逐个调色是永远补不完的清单, opacity 一次盖住整层。不透明度走 --mosu-focus-dim,高对比主题必须调高 (0.7 而不是 0.35)—— 否则刚保证的对比度又被这个功能拿掉了。
8.3 打字机模式不走 dispatch,也不走 scrollIntoView
直觉写法是在 updateListener 里 dispatch(scrollIntoView(...))。两处都不成立:
- CodeMirror 禁止在 update 期间再发 transaction;
scrollIntoView只保证「可见」,到不了「居中」—— 目标行已经在视口里时它什么都 不做,而那正是打字机模式最需要动的时候。
所以走 view.requestMeasure:读阶段算出目标 scrollTop,写阶段直接赋值。 一次读、一次写,不产生任何 transaction,也就不会跟撤销栈、协作扩展互相干扰。
读阶段全部用视口坐标算,不去猜 .cm-content 的 padding 怎么计入块坐标 —— 打字机模式恰恰要往 content 上加 50vh 的 padding(没有它,第一行和最后一行永远 到不了中央,滚动条已经到头了),猜错的表现是「越靠近文档开头偏得越多」。
那条 padding 必须靠根元素上的类名提高优先级,不能直接写 .cm-content { padding-top }:基础主题里那条是 padding 简写,同优先级时谁后进样式表谁赢, 而顺序取决于扩展的注册次序。这个坑真的踩到了,症状是「模式看着开了但第一行怎么 也到不了中间」,且换个扩展顺序就会自己好。
8.4 打字机模式的两条克制规则
都是为了不跟用户抢滚动条:
- 只在光标是插入点(没有选区)时居中。 鼠标拖选必然产生选区,此时每动一下就 把视图拽回中间,用户根本选不到东西。全选同理。
- 只在行号变化、或本行内容变化时居中。 在同一行里左右移动光标不该引发滚动; 而同一行里打字要重新居中,因为折行会让这一行长高,中心跟着往下走。
8.5 专注 / 打字机是全局状态,源码模式是每个标签的状态
这不是实现细节,是产品判断:源码模式回答「这一篇怎么看」,所以改设置只影响新标签, 把已开的标签全切一遍很吓人;而专注 / 打字机回答「我现在处在什么工作状态」, 它不该因为切了个标签就消失。所以后两者存在偏好里、立刻作用到所有标签、 新标签直接带着开。
9. 大文档策略
| 文档规模 | 策略 | 现状 |
|---|---|---|
| < 5k 行 | 全功能 | ✅ |
| 5k–50k 行 | 视口渲染;块 widget 仅在视口内实例化;图表渲染延迟到空闲帧 | ✅ 白拿的 |
| > 50k 行 或 > 5MB | 自动提示切换到源码模式;关闭图表与图片渲染;保留语法高亮 | ❌ 没做 |
中间那一档是 CodeMirror 白送的:装饰只对可见范围计算(computeDecorations 收 一组范围,专注模式那层也是同样的签名),widget 只在视口内实例化。我们没有为 它写任何代码,所以它也不会因为「忘了维护」而退化。
最后一档没有做。LivePreviewConfig 上有一个 renderImages 开关是为它留的, 但没有任何地方按文档大小去关它 —— 现在恒为 true。真要做,缺的不是那个 开关,是「多大算大、怎么提示用户、用户拒绝之后记不记住」这一整套决策。 在有人真的拿 5 万行文档来抱怨之前,那些决策没有依据可定。
open10k 基准实测 36ms(07 §2),离 300ms 的预算还很远,这也是它一直没被推上 日程的原因。
Lezer 的增量解析保证输入时只重解析受影响的片段;解析在超时后会让出主线程并在 空闲时继续(CodeMirror 内建行为),因此大文档不会卡死输入。
10. 为什么不用 ProseMirror
详见 ADR-0002,此处给结论对照:
| 维度 | CM6 源码优先(本方案) | ProseMirror 富文本模型 |
|---|---|---|
| 往返保真 | 天然零损耗,缓冲区即文件 | 需在节点属性里存「源码提示」,仍难 100% |
| 未知语法 | 原样保留 | 容易在序列化时丢失或被规范化 |
| diff 友好 | 只改动到的地方变 | 保存时整篇重新序列化,易产生噪声 diff |
| 排版观感 | 需要靠装饰精修,嵌套结构稍逊 | 更接近字处理器 |
| 表格等结构化编辑 | 需自建 widget 层(工作量在这) | 内建更顺 |
| 协同编辑 | Y.Text 直接套在文本上,几乎白送 | 需要 y-prosemirror,模型转换复杂 |
| 大文档 | 视口渲染 + 增量解析,优势明显 | 全量 DOM,压力大 |
我们的目标 G2(格式保真)优先级高于「排版观感」,所以选前者, 并把节省下来的复杂度投入到 widget 层的打磨上。