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 里被搁置、直到 M4.5 才动的原因。

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

  1. 一个字节的 HTML 都不进 DOM。 没有 innerHTML、没有 DOMParser。 渲染效果全部由 mark 装饰 + CSS 类名达成 —— 往 DOM 里写的只有类名, 而那是个封闭集合(cm-mosu-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 文本,用普通文本编辑(带该块语言的语法高亮)。

对表格额外设想过第三态:网格编辑器这一态最终没有实现,而且不是「还没排上」—— 是想清楚之后放弃了,换成了 §6.4 那套纯文本变换。难点正是下面那条铁律:widget 里的 列宽、选中单元格、拖拽中的列构成一份第二状态,它和文本缓冲区必须始终同调。 下面这段保留下来,因为那条铁律对所有 widget 都成立:

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

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

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

6.0 表格:连 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.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-scrollerscrollWidth 撑开,于是整个视口跟着横向 滚动 —— 标题和正文一起左移,列号大时整页内容被推出屏幕,只剩一小块悬空的高亮。

报告里的三个症状(行不滚 / 视口在滚 / 高亮跑到块外)是同一个原因:没有人把 行滚到该滚的位置。补上「光标移动时把它所在的代码行滚到插入符可见」之后, 另外两个自动消失 —— 不需要分别去治。

修好后同一场景: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 里的表格每行带 > , 列表项里的表格每行带缩进。

两处各自出错,合起来把表格彻底毁掉:

  1. 找单元格时用「首个非空白位置」判断有没有前导竖线 —— 而 > 里的 >是非空白字符,于是首个竖线被判成「不在行首」,"> " 成了第 0 个单元格。 三列表格变四列,> 出现在第一列里;
  2. 重新序列化时一律 '|' + …,不带任何前缀。而替换区间 ([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 打字机模式的两条克制规则

都是为了不跟用户抢滚动条:

  1. 只在光标是插入点(没有选区)时居中。 鼠标拖选必然产生选区,此时每动一下就 把视图拽回中间,用户根本选不到东西。全选同理。
  2. 只在行号变化、或本行内容变化时居中。 在同一行里左右移动光标不该引发滚动; 而同一行里打字要重新居中,因为折行会让这一行长高,中心跟着往下走。

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 层的打磨上。

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