Files
pure-note/docs/editor-live-rendering.md
wangairnan 9b0aff6f9f docs: 新增编辑器即时渲染方案、实施与 P3 加固记录
- editor-live-rendering.md(新):Typora 式单编辑面方案——路线选型、装饰层分层设计、
  widget 规格、安全与风险、测试方案、P0–P3 实施拆解;§12 为实施与加固记录:
  两个实测缺陷的根因与修复、3000 行性能实测数据、§12.6 真机输入法人工门槛(阻塞发布)、
  §12.7 编辑区高度
- design.md §8.2:编辑器段落由「分屏实时预览」改为即时渲染表述,链接本方案并标注发布门槛
- decisions.md:D37–D52(装饰器路线、单一 ViewPlugin、行级 reveal、长文档补解析、
  选区端点行采集、reveal 二分、@codemirror/commands 仅为 devDependency、人工门槛、编辑区高度)
- acceptance.md:新增 M5 里程碑与 M5-G 人工门槛行,补 v1.1/v1.2 浏览器复走记录与用例数

文档先行,代码实现见后续提交。
2026-09-11 01:02:36 +08:00

46 KiB
Raw Permalink Blame History

Pure Note — Markdown 即时渲染(Typora 式)设计方案

版本:v1.2 日期:2026-09-10(v1.0 方案)/ 2026-09-11(v1.1 实施、v1.2 P3 加固) 状态:P0–P3 代码与自动化验证全部完成(§12);唯一剩余项是 §12.6 的人工输入法门槛(阻塞发布) 关联:docs/design.md §8.2、§8.1;docs/decisions.md D5 / D26 / D37–D52 定位:本文档取代 docs/design.md §8.2「编辑器(编辑态)」中「分屏实时预览」的表述; 数据层、服务端渲染管线、安全防线均不在本次变更范围内。


1. 背景与目标

1.1 现状(改造前基线)

改造前编辑体验是「一块源码编辑区 + 一块渲染预览区」,两者物理分离:

层 位置 实现(改造前)
编辑态 web/src/components/Editor.tsx CodeMirror 6 源码 + 行号 + 等宽字体
预览触发 web/src/pages/AdminEdit.tsx showPreview 开关,预览 / 隐藏预览 按钮 + 条件渲染
浏览态 web/src/components/MarkdownViewer.tsx react-markdown → rehype-sanitize → rehype-highlight
排版契约 web/src/index.css .pn-note-body 正文排版
真相源 SQLite notes.content Markdown 原文,编辑与展示双向都不落地富文本中间态

上表是改造前的基线,便于对照回归面;改造后的实际落地见 §12。

1.2 问题

  1. 编辑区显示的是源码:#、**、>、| --- | 全部裸露,且用等宽字体,与阅读页视觉完全割裂;
  2. 想确认效果必须手动切换/分屏,写作—核对反复来回,长文与表格尤其痛苦;
  3. 分屏在窄屏下不可用(当前实现是「切换」而非真分屏),移动端等于没有预览。

1.3 目标 / 非目标

目标(IN SCOPE)

  • 单一编辑面:输入即渲染,语法标记默认隐藏、按渲染态显示,光标进入处展开为源码可编辑(Typora 核心手感);
  • 编辑态视觉 = 阅读态视觉:编辑区与公开笔记页共用同一套排版契约;
  • 数据层零改动:真相源仍是 Markdown 原文,公开页渲染管线(react-markdown + sanitize + highlight)与全部安全防线不动;
  • 一键源码模式作为逃生舱,装饰层出问题时用户永远可退回可用状态。

非目标(OUT OF SCOPE,后续方向见 §10)

  • 表格的 HTML 渲染与可视化编辑;
  • 数学公式(KaTeX/MathJax)与 Mermaid/流程图;
  • 引入 ProseMirror / Tiptap / Milkdown 等富文本模型;
  • 多光标协同、版本历史、移动端专门适配。

2. 调研:Typora 即时渲染的实现原理

2.1 Typora 的可观察实现(闭源)

Typora 不公开源码,以下为官方文档 + DOM 逆向 + 用户报告可确认的事实。

不是「文本区 + 预览区」,而是单一 contenteditable 编辑面,整篇文档即一棵渲染后的 DOM 树。公开的 DOM dump(typora-issues#6629)显示:

  • 根容器 #write;块级元素带 mdtype(heading / plain / table / list-item / fences…)与稳定内容 id cid;
  • 行内片段带 md-inline(plain / code…);语法的成对定界符是独立 span(md-pair-s);
  • md-expand 标记「该片段已展开为源码」的状态。

三条核心机制(官方 Markdown Reference):

  1. 输入即渲染:"Span elements will be parsed and rendered right after typing."
  2. 光标进入即展开源码:"Moving the cursor to the middle of a span element will expand that element into the Markdown source." —— 平时 **粗体** 显示为粗体,光标移入后 ** 才现身可编辑;
  3. 块级元素整体渲染:代码围栏、表格、公式、图表、图片各有专门呈现;另有全局源码模式(Cmd/Ctrl+/)。

内部算法(AST ↔ DOM 映射、脏块增量重解析)无公开文档。但从用户报告看并非高度增量:>5000 行、>10000 词即明显卡顿(#6542、#6551)。结论:即时渲染的交互模型不难复刻,难点在大文档性能、IME 与选区边界。

2.2 开源对照

编辑器 技术栈 真相源 保真度 备注
Obsidian Live Preview CodeMirror 6(闭源) Markdown 原文 逐字节 与本方案同路线,最接近的生产级范例
Vditor IR 自有 Lute 引擎 Markdown 原文 逐字节 三模式 sv/wysiwyg/ir
HyperMD CodeMirror 5 Markdown 原文 逐字节 插件式隐藏,CM5 时代产物
Milkdown / Crepe ProseMirror + remark ProseMirror 文档 序列化规范化 markdown 仅为导入导出格式
Tiptap + @tiptap/markdown ProseMirror + MarkedJS Tiptap JSON 有损 官方标注 early release,往返缺陷多
Toast UI Editor ProseMirror Markdown 字符串 API 规范化 富模型驱动
ByteMD CodeMirror + remark Markdown 原文 编辑侧精确 预览为单向 HTML

2.3 路线选型

路线 A:CM6 装饰器即时渲染 路线 B:富文本模型 WYSIWYG
真相源 Markdown 原文(不变) ProseMirror/Tiptap JSON
改动面 仅 Editor.tsx 内部 + 新增装饰模块 编辑器 + 序列化 + 预览管线全部替换
数据/安全 不动 需重新论证
保真度 原文逐字节不变 表格/转义/代码围栏/脚注有损
依赖 零新增(已装 CM6 全部够用,见附录 A) 新增 prosemirror 全家桶(bundle 显著增大)
主要风险 光标/选区/IME 的边角情况 富模型 ↔ Markdown 往返缺陷

选路线 A。 理由:

  1. 与项目定位一致 —— design.md:118 明确选 CM6 就是为了「不需要维护富文本中间态」;换富模型等于把这条好处还回去;
  2. 命中既有预留 —— design.md:684 已声明「MarkdownViewer 组件边界独立;若日后要 WYSIWYG,仅替换 Editor,数据层不变」;
  3. 零新增依赖、公开页渲染管线与安全防线完全不动,回归面最小;
  4. 路线 B 的往返有损已被大量实证(Tiptap 转义块丢失 #8134、行内 code 含反引号被破坏 #8298、表格内转义竖线丢失 #8294),对「极简健壮、可预期」是倒退。

3. 总体设计

3.1 架构分层

Editor.tsx(React 外壳,接口:value / onChange / onUploadStart / onUploadEnd / sourceMode)
│  职责:拖拽/粘贴上传、主题、源码模式开关、拖拽遮罩
└── web/src/editor/livePreview.ts   扩展集入口(ViewPlugin + atomicRanges + Compartment)
    ├── syntax.ts       基于 lezer syntaxTree 识别语法区间 → 装饰描述 Spec(纯函数,可单测)
    ├── reveal.ts       光标处展开(行粒度):装饰集是 selection 的函数(纯函数,可单测)
    ├── decorations.ts  Spec → DecorationSet / 原子区间;图片 src 白名单
    └── widgets.ts      BulletWidget / QuoteBarWidget / HrWidget / ImageWidget /
                        FenceLangWidget / TaskWidget

实施时把方案里的 markers.ts / inline.ts / blocks.ts 合并为 syntax.ts + decorations.ts (三个文件职责都是「节点 → 装饰」,拆分只增加跳转成本),详见 §12.2 偏差 D2。

3.2 数据流(不变)

DB(notes.content = Markdown 原文)
   ├── 编辑态:CodeMirror 文档 = 原文;装饰器只改「视图」,不碰文档
   └── 浏览态:MarkdownViewer = react-markdown 管线(原样保留)

不变量:装饰层永不修改文档内容,只影响呈现。 onChange 拿到的一直是用户键入后的 Markdown 原文。

3.3 一致性契约

编辑态渲染结果必须与公开页一致,覆盖:字号/行高、标题层级字号与字体族(--font-serif)、引用左边框、代码块底色与等宽、列表间距、链接样式、图片圆角、表格边框。实现方式见 §4.6。


4. 详细设计

4.1 语法识别

用 lezer 语法树,不自己写正则。 syntaxTree(state)(@codemirror/language 6.12.4)由 @codemirror/lang-markdown 6.5.2 增量维护;当前 Editor.tsx:49 配置为 markdown({ base: markdownLanguage }),markdownLanguage 为 GFM 语言,Table / Task / Strikethrough 节点可用(已核实)。

按节点取区间:

语法 lezer 节点 处理
标题 ATXHeading1..6 + HeaderMark 隐藏 #;行加 .cm-md-h{1..6}
粗/斜/删除线 StrongEmphasis / Emphasis / Strikethrough + *Mark 隐藏定界符;内容加 mark
行内代码 InlineCode + CodeMark 隐藏反引号;内容加 .cm-md-code
链接 Link + LinkMark 隐藏 [、](url);文本加 .cm-md-link
图片 Image 整段替换为 <img> widget(§5.4)
引用 Blockquote + QuoteMark > → 竖线 widget(§5.3)
列表 BulletList / OrderedList + ListMark 圆点 widget / 数字保留(§5.1、§5.2)
代码围栏 FencedCode + CodeMark / CodeInfo 起围栏 → 语言标签 widget;闭围栏隐藏;块加 .cm-md-fenced
水平线 HorizontalRule 整行替换为 <hr> 块 widget
任务项 Task + TaskMarker [ ]/[x] → 可点 checkbox widget(§5.4)
表格 Table 全族 v1 仅行级等宽对齐 + 定界符淡化,不渲染 HTML(§10.1)

4.2 隐藏与原子化

  • 纯定界符区间用 Decoration.replace({}) 隐藏,并纳入 EditorView.atomicRanges:方向键整段跳过、退格一次删净;
  • 仅对「纯标记区间」原子化,绝不对含可编辑内容的区间原子化 —— 例如链接只把 [ 与 ](url) 两个子区间原子化,中间文本仍可正常进入编辑;
  • 破坏行高/换行的块级装饰(围栏折叠、水平线、图片)必须用 StateField 直接 provide: EditorView.decorations;行内隐藏用 ViewPlugin + view.visibleRanges(CM6 硬性约束:间接装饰在视口计算后取,块级 widget 会错位)。

4.3 行内渲染

定界符隐藏后,内容区间用 Decoration.mark({ class }) 上样式,类名对齐阅读态:.cm-md-strong(font-weight 600)、.cm-md-em(italic)、.cm-md-del(删除线 + --muted-foreground)、.cm-md-code(等宽 + --muted 底 + 圆角,对齐 index.css:852-857)、.cm-md-link(下划线 + --border-strong,对齐 index.css:919-927)。

4.4 块级渲染

  • 标题:隐藏 # 后该行加 .cm-md-h1..h6,字号/字体族/间距对齐 index.css:817-838;
  • 引用:整行加 .cm-md-quote(左内边距 + 左边框,对齐 index.css:840-847),> 由竖线 widget 承接(§5.3);
  • 代码块:首行围栏替换为语言标签 widget,块内各行加 .cm-md-fenced(--muted 底 + 圆角),代码文本保持 --font-mono;
  • 表格:整表各行加 .cm-md-table(等宽 + 13.5px,对齐 index.css:903-917),| 与分隔行 --- 用 .cm-md-table-mark 淡化(不隐藏 —— 隐藏竖线会破坏列对齐的可读性)。

4.5 光标处展开(reveal)

Typora 的核心手感,实现为装饰集是 state.selection 的函数:

activeLine = state.doc.lineAt(state.selection.main.head)
对每个候选隐藏区间:
    若与 activeLine 相交  → 撤销该处 replace(显示源码),保留样式 mark
    否则                  → 隐藏
  • v1 用行粒度(光标所在行的语法标记整体展开),实现简单、行为可预期、对 IME 最安全;
  • 未聚焦时整篇按渲染态呈现(决策 E11):编辑器失焦 = 只读观感,避免「首行因光标停在行首而永远显示 #」;
  • 代码围栏处于活动状态时同样展开围栏,便于改语言标识;
  • 细化到 Typora 的 span 级展开列入 §10.2,不在 v1 承诺。

4.6 排版一致性(共享 typography 层)

现有正文排版硬编码在 .pn-note-body(index.css:805-933),装饰态用的是 .cm-line / span,无法直接复用选择器。做法:

  1. 把排版参数抽为 CSS 变量(--md-body-size: 15px、--md-body-lh: 1.8、--md-h1-size: 26px …);
  2. .pn-note-body 的规则与装饰态类(.cm-md-*)共同引用同一组变量,杜绝两份样式漂移;
  3. 编辑区正文族与字号对齐阅读态(--font-sans / 15px / 1.8),仅 .cm-md-code 与 .cm-md-fenced 用 --font-mono;当前 CM 全局等宽字号 14.5px(index.css:1944-1948)需调整;
  4. 即时渲染模式下关闭行号与活动行高亮(Typora 无行号),Editor.tsx:109 的 basicSetup 相应调整。

4.7 源码模式(逃生舱)

  • 用 Compartment(@codemirror/state)包裹 livePreview 扩展集,开关即整体挂载/卸载,O(1) 切换、无残留装饰;
  • AdminEdit.tsx 现有「预览」按钮(AdminEdit.tsx:204-209)语义改为**「源码模式」开关**,showPreview 分屏块(AdminEdit.tsx:282-286)删除;
  • 进入源码模式时恢复行号与等宽,与现状一致。

4.8 与既有功能的衔接

功能 变化
图片粘贴/拖拽上传 不变。仍插入 ![alt](/api/images/{id}) 文本,装饰器随即把它渲染为图片
自动保存(防抖 2s) 不变。onChange 语义与频率不变
工具栏插入 不变。插入的仍是 Markdown 片段,插入后立即渲染
主题(明/暗/跟随) 不变。装饰类全部走 CSS 变量,自动随主题
拖拽遮罩 不变(Editor.tsx:124-128)

5. Widget 规格

5.1 无序列表圆点(决策 E2)

项 规格
触发 ListMark ∈ BulletList(源文本 - / * / +)
装饰 Decoration.replace({ widget: BulletWidget }),原子
呈现 <span class="cm-md-bullet" aria-hidden="true">,字符按嵌套深度取 •(深 1)/ ◦(深 2)/ ▪(深 3+)
深度判定 遍历语法树祖先中 BulletList / OrderedList 的个数(可靠,不依赖缩进列数)
对齐 widget 宽度 = 原标记宽度,保证列表内容左边界不跳动
交互 v1 不可点击(改标记类型请切源码模式)

5.2 有序列表(决策 E3)

项 规格
触发 ListMark ∈ OrderedList(源文本 1. / 2) …)
装饰 仅 Decoration.mark({ class: 'cm-md-ol-mark' }),不替换、不原子
理由 数字携带序号语义(起始序号、3. 这类非常规起点),替换为 widget 会让用户无法编辑;CM 亦不自动重排列表号
呈现 数字加粗 + --muted-foreground,与圆点视觉重量接近

5.3 引用竖线(决策 E4)

项 规格
触发 QuoteMark(源文本 >,嵌套时每层各一个)
装饰 Decoration.replace({ widget: QuoteBarWidget }),原子
呈现 <span class="cm-md-quote-bar" aria-hidden="true">,CSS 画 2px var(--border-strong) 竖线,宽度 = 原标记宽度,保证后续内容对齐
嵌套 每层 QuoteMark 各出一个 widget,自然叠加为多道竖线(接近 Typora)
行样式 同行加 Decoration.line({ class: 'cm-md-quote' }) 提供左内边距,视觉对齐 index.css:840-847

5.4 其余 widget

widget 触发 呈现与行为
图片 Image 全节点 Decoration.replace 成 <img>(max-width:100%、圆角,对齐 index.css:893-897);src 白名单见 §6;原子
水平线 HorizontalRule 整行 <hr> 块 widget(StateField 直接 provide)
任务框 TaskMarker <input type="checkbox">;点击 dispatch 改写源文本 [ ] ↔ [x](真相源仍是文本);原子
围栏语言标签 CodeInfo + 起围栏 语言名小标签;点击可切源码模式改语言
链接 URL LinkMark 的 ](url) 段 隐藏;v1 不做悬浮预览,光标进入该行即展开可见

5.5 Widget 通用约束

  1. 构造 DOM 一律用 document.createElement / textContent / setAttribute,禁止 innerHTML;
  2. 所有 widget 视为装饰层私有产物,不接受用户 HTML;
  3. toDOM 返回值带 aria-hidden="true"(视觉替代物,不参与无障碍朗读;复选框除外,需带 aria-label);
  4. 块级 widget 必须走 StateField 直接 provide(§4.2);
  5. widget 不得改变文档内容与选区语义。

6. 安全设计

装饰层不引入新的 XSS 面,但必须显式加固以下两条:

面 风险 措施
图片 src 用户可写 ![x](javascript:…) / 外链 仅允许 ^/api/images/ 与 ^data:image/;其余不发 <img>,降级为源码文本
链接 URL 用户可写 [x](javascript:…) v1 链接只做文本样式,不渲染 <a>,无点击行为 → 无协议执行面;日后若加「点击打开」,必须复用 sanitize.ts 的 http/https/mailto 白名单
widget DOM 注入执行 全程 createElement/textContent,零 innerHTML(§5.5)
CSP 现有策略 不变。style-src 'unsafe-inline' 的既有豁免仍由 CodeMirror style-mod 需要(见 review-round1.md 第 18 项);装饰类走静态 CSS,不增加 CSP 面

公开页渲染管线(MarkdownViewer + rehype-sanitize)与 tests/markdown.test.tsx 的断言全部保持有效,本次不改动该链路。


7. 风险与缓解

7.1 中文输入法(IME)—— 最高风险

替换装饰会在组合输入过程中改动 DOM,CodeMirror 官方 issue 中此类缺陷集中:

issue 现象
dev#1654 装饰与 IME 组合输入冲突
dev#1650 语法高亮边界处组字乱码
dev#1684 Chrome 上中文 IME 删掉前文
dev#1688 括号内组字后文本视觉消失

缓解:

  1. view.composing === true 期间冻结装饰更新(view.composing 为 CM6 公开 API,已核实),绝不替换组合范围内的 DOM; —— 已实现(livePreview.ts 中组合期间只做 decorations.map(changes),不重建);
  2. reveal 用行粒度 —— 光标所在行本就是展开态,组字中途不会被替换;
  3. 原子区间只作用于已隐藏的纯标记范围(不变量有单测守护,§12.5.3);
  4. 上线前必须用拼音 / 搜狗 / 系统输入法真机回归,这是自动化测不出来的硬门槛 —— 已形式化为 §12.6 的人工验收门槛(阻塞发布),含 8 项用例与签字要求;当前状态:未执行。

7.2 选区与光标

已知边角:多行 replace 破坏光标定位(#1658)、块 widget 致选区渲染异常(#1406)、相邻 widget 间移动异常(#979)。缓解:避免多行 replace(逐行处理);对替换区间原子化。

实测结论(P3,2026-09-11):读 CM6 源码可知 EditorView.atomicRanges 的消费点有两处 —— view 包内 skipAtomsForSelection(鼠标指针选区,view/dist:4317)与 @codemirror/commands 的 skipAtomic(键盘移动/删除)。因为 reveal 是行粒度,光标所在行永远是展开态,所以原子区间在 键盘输入路径上几乎不起作用(退格只会命中「当前行之前」的换行符,而非本行标记);它的真实作用面是 鼠标点击/拖拽:锚点落在已隐藏的标记内部时被吸附到标记边界。这不是缺陷(行粒度下「本行永远可编辑」 本就是设计目标,决策 E6),而是需要如实记录的作用边界;若日后细化到 span 级 reveal(§10.2), 原子区间才会在键盘路径上变成关键机制。

边界回归见 §12.5:文首/文末构造、全选删除、跨标记选区替换、原子区间四项不变量(不跨行 / 有序不重叠 / 不含可编辑内容 / 隐藏标记必被原子化)。

7.3 块 widget 与布局

"#575 视口外行不渲染"、"#625 文末块 widget 消失":本实现不使用 block widget(决策 E10),水平线以整行 inline-block 承接、围栏闭标记只隐藏内容,因此这两类缺陷的结构性成因不存在。仍单独回归了「文首水平线/列表/围栏/任务」 「文末水平线/图片/围栏/任务」「整篇只有一条水平线」「文末追加内容」等边界(§12.5),全部通过。

7.4 性能

装饰计算只遍历 view.visibleRanges(外加选区端点行),不整篇扫描;语法树由 lezer 增量维护。 目标:3000 行文档单帧 < 16ms。P3 已实测,结论见 §12.5,摘要:

  • 视口窗口(约 100 行)装饰采集中位数 0.26ms;全篇作为单一区间的极端情况中位数 5.0ms(< 16ms);
  • 真实编辑器输入(3000 行文档、文末处连打 40 字)同步耗时中位数 0.1ms、最大 0.7ms;
  • 冷启动从文档开头直跳文末:帧间隔最大 7.1ms、零 long task(无卡顿);
  • 整篇 3000 行(15.6 万字符)全量解析 15.4ms,因此 §12.5 的「补解析预算」常态下不会真正被耗尽。

长文档的真实风险不是构建成本,而是 lezer 的惰性解析:syntaxTree(state) 首次只覆盖前约 3000 字符, 若不做处理,未解析区间会显示裸 Markdown(P3 修复,见 §12.5 与决策 E13)。

7.5 兼容与降级

风险兜底为源码模式(§4.7)。若某类文档装饰层出现无法及时修复的问题,可只对受影响语法节点关闭隐藏(配置项 + 单测),用户无感降级。


8. 测试方案

层 用例 落地
纯函数单测(vitest,无 DOM) syntax.ts 区间识别(嵌套强调、代码块内转义、中文标点、表格分隔行);reveal.ts 行粒度展开与二分/线性等价 tests/editor-live-preview.test.ts
安全单测 widget toDOM 输出无 <script>、无 innerHTML;图片 src 非白名单时降级为文本;链接不产出 <a> 同上
组件冒烟(happy-dom 真实 EditorView) 非活动行标记已隐藏、活动行标记可见、原子区间生效;文首/文末构造;全选删除;不变量 tests/editor-boundaries.test.ts
性能基准 3000 行视口/全篇装饰计算中位数、全量解析成本、最坏选区(Cmd+A)构建成本 同上「性能基准」组
真机回归(人工门槛,阻塞发布) 拼音/搜狗/系统输入法连续输入中文 + 标点;复制粘贴;撤销重做;文首/文末 §12.6 清单,未通过不得发布
既有回归 tests/markdown.test.tsx 全绿(公开页管线不受影响);make smoke 全绿 已通过

实测中发现 happy-dom 能真实创建 EditorView(含 view.focus()、装饰 DOM、atomicRanges facet), 因此边界与原子化行为用真实 view 断言,不再只测纯函数。


9. 实施拆解

阶段 内容 风险 状态
P0 骨架 syntax.ts + decorations.ts + reveal.ts + livePreview.ts;覆盖标题/粗体/斜体/行内代码/链接;行粒度 reveal;关闭行号;接入 Compartment 源码开关 低,手感提升最明显 ✅ 已完成
P1 块级 widget 图片、水平线、任务列表(可点)、围栏语言标签 中(块 widget 布局) ✅ 已完成
P2 列表与引用 + 排版一致性 圆点(§5.1)、引用竖线(§5.3)、代码块底色;抽出共享 typography 变量层,编辑态对齐 .pn-note-body 中 ✅ 已完成
P3 加固 IME 冻结、选区边界、性能基准、文首/文末边界;删除分屏预览 中高(IME 必须真机) ✅ 代码与自动化部分完成(IME 冻结、分屏删除、选区/文首文末边界回归、3000 行基准实测);真机 IME 回归是人工门槛,见 §12.6,未通过不得发布

每阶段交付:实现 + 对应单测 + 该阶段回归清单。P0 完成后即可替换现有分屏默认路径。 实际交付与验证证据见 §12。


10. 后续方向(明确记录,不在 v1 范围)

  1. 表格(v1 只做源码对齐):后续可做 HTML 表格渲染 + 行列增删的可视化编辑。技术要点:块级 Decoration.replace 为 <table> widget(必须 StateField 直接 provide)+ 单元格编辑回写 Markdown 文本 + GFM 对齐语法(:---:)保真;这是复杂度黑洞,需单独立项与完整回归。
  2. span 级 reveal:把 §4.5 从行粒度细化到 Typora 的「光标所在行内节点」粒度,需先解决 7.1/7.2 的 IME 与光标问题。
  3. 代码块增强:围栏内复用 highlight.js(公开页已用 rehype-highlight)做与阅读态一致的高亮;语言选择器。
  4. 数学与图表:KaTeX / Mermaid 需编辑态与公开页同步引入,否则「所见即所得」不成立;且要评估 bundle 与 CSP(KaTeX 需字体、Mermaid 需 unsafe-eval 类风险),单独评审。
  5. 脚注 / 定义列表 / 目录:lezer 支持后按 §4.1 表扩展。
  6. 无障碍:widget 的 aria-label 与键盘可操作性补全(任务框、语言标签)。
  7. 移动端/触屏:选中与原子区间的触屏行为回归。
  8. 大文档:3000 行以上基准与必要时的装饰节流。→ P3 已完成 3000 行基准(§12.5);更大规模(1 万行+)与装饰节流仍未做。

11. 本文档决策记录

# 决策 依据
E1 采用 CM6 装饰器路线(路线 A),不引入富文本模型 §2.3;零新增依赖、真相源不变、回归面最小;符合 design.md:118/684
E2 无序列表标记替换为圆点 widget(原子,按嵌套深度 •/◦/▪) 用户决策;更接近 Typora;宽度对齐避免内容跳动
E3 有序列表标记保留数字,仅上样式,不替换不原子 数字携带序号语义且 CM 不自动重排,替换会导致无法编辑起始序号
E4 引用 > 替换为竖线 widget(宽度=原标记宽度,原子),嵌套自然叠加多道竖线 用户决策;视觉对齐 index.css:840 的 blockquote
E5 表格 v1 只做源码等宽对齐 + 定界符淡化,不渲染 HTML 按用户确认;表格可视化编辑复杂度高,列 §10.1 后续方向
E6 reveal 粒度 v1 取行级,span 级列 §10.2 行级对 IME 最安全、行为可预期;先保正确性再求细腻
E7 保留源码模式开关(Compartment 整体挂载/卸载)作为逃生舱 装饰层出 bug 时用户可退回;对应 Typora Cmd/Ctrl+/
E8 不修改公开页渲染管线与 sanitize 管线 安全防线不动;tests/markdown.test.tsx 持续守护
E9 编辑区正文改用 --font-sans / 15px / 1.8,仅代码用等宽 与 .pn-note-body 视觉一致,否则「所见即所得」不成立
E10 全部装饰由单一 ViewPlugin 产出,不引入 StateField 实现中不存在真正的块级装饰(水平线用整行 inline-block 承接、闭围栏只隐藏内容),因此无需 StateField 的全文档遍历,也规避块 widget 的布局/选区风险;代价见 §12.2 D1
E11 reveal 以「编辑器聚焦」为前提:未聚焦时整篇按渲染态呈现 否则载入即因光标在行首而显示 #,WYSIWYG 首屏观感受损;失焦=只读观感,与 Typora 一致
E12 图片与任务复选框不参与 reveal,恒为渲染/可点状态 图片:源码模式作为改 URL 的出口,避免段落内行内图片在编辑该行时永远显示源码;复选框:参与 reveal 会导致光标落到任务行后无法点击
E13 装饰前用 ensureSyntaxTree 在 20ms 预算内补齐可见区间,并以「语法树对象换代」触发重建 P3 实测 syntaxTree(state) 对 3000 行文档首次只覆盖 3061 字符(惰性解析),不补解析则未解析区间显示裸 Markdown;预算有界避免卡顿,补不动时自动等后台解析推进,见 §12.5
E14 装饰采集范围 = 可见区间 + 选区端点行(不是整个选区跨越的行) CM 只把选区端点块渲染到视口之外;全量取会让 Cmd+A 在长文档上退化为全文档装饰构建,见 §12.5
E15 reveal 判定改为「区间合并 + 二分」 整篇选区会产生数千个展开区间,原线性扫描 O(装饰数×区间数) 在长文档下失控;二分后最坏选区构建中位数 0.98ms,见 §12.5
E16 编辑区默认吃满视口剩余高度,长文在编辑器内部滚动(页面不再随内容增长) 用户要求(2026-09-11)。此前 .pn-ae-body-wrap 只有 min-height: 420px 且高度随内容增长:短文编辑区偏小,3000 行长文把页面撑到 8.4 万像素、只能靠页面滚动。落地与实测见 §12.7

12. 实施记录(2026-09-11)

12.1 交付物

文件 职责
web/src/editor/syntax.ts lezer 语法树 → 装饰描述(Spec)纯函数;覆盖标题 / 强调 / 行内代码 / 链接 / 图片 / 引用 / 列表 / 任务 / 围栏 / 水平线 / 表格
web/src/editor/reveal.ts 行粒度展开:activeLineRanges / selectionEndpointLines / applyReveal(合并区间 + 二分)
web/src/editor/widgets.ts BulletWidget / QuoteBarWidget / HrWidget / ImageWidget / FenceLangWidget / TaskWidget
web/src/editor/decorations.ts Spec → DecorationSet + 原子区间;图片 src 白名单;可见区间去重;visibleSyntaxTree(补解析)
web/src/editor/livePreview.ts ViewPlugin(可见区间 + doc/selection/viewport/focus/语法树换代 驱动)、EditorView.atomicRanges、livePreviewCompartment、组合输入冻结
web/src/components/Editor.tsx 新增 sourceMode prop;.pn-cm-live 包裹类;compartment 热切换
web/src/pages/AdminEdit.tsx 删除分屏预览块与 MarkdownViewer import;「预览」按钮改为「源码模式 / 退出源码」
web/src/index.css --md-* 排版契约变量(阅读态与编辑态共用)+ .cm-md-* 装饰样式;移除 .pn-ae-preview
web/tests/editor-live-preview.test.ts 29 条单测:区间识别 / reveal 策略(含二分等价) / 构建与安全
web/tests/editor-boundaries.test.ts 32 条回归:文首文末边界 / 跨原子区间选区 / 装饰层不变量 / 长文档惰性解析 / 性能基准

验证结果:前端 71 条测试全绿(新增 29 + 32,既有 10),tsc --noEmit 与 npm run build 通过, make test(go vet + go test + vitest)与 make smoke(构建→起服务→SPA/meta/安全头/可见性抽查)全绿。 运行时零新增 npm 依赖;仅新增 @codemirror/commands 为 devDependency(它本就在依赖树中,用于边界回归里 真实执行退格等编辑命令,不进入产物)。

12.2 与方案的偏差(均经实机验证后确定)

# 偏差 原因与代价
D1 不引入 StateField,全部装饰由一个 ViewPlugin 产出 方案 §4.2/§5.4 要求块级装饰走 StateField;但实现里不存在真正的块级装饰:水平线用「整行内容的 inline-block(width:100%)」承接,围栏闭标记只隐藏内容并用行类延续底色。收益是免去 StateField 的全文档遍历与块 widget 的布局/选区风险(§7.3)。代价:闭围栏行留下一行空白,成为代码块底部内边距
D2 模块合并:markers.ts / inline.ts / blocks.ts → syntax.ts(识别)+ decorations.ts(构建) 三个文件职责都是「节点 → 装饰」,拆分只增加跨文件跳转;单测边界改为「识别」与「构建/展开」两段,覆盖不减
D3 图片 widget 不参与 reveal,恒渲染 与 Typora 一致(点图片不展开为源码);改 URL/alt 走源码模式(§4.7)。同时避免「段落中的行内图片在编辑该行时永远显示源码」
D4 任务复选框 不参与 reveal,恒可点 否则光标一落到任务行,复选框就变回 [ ] 文本,反而无法点击
D5 行号 / 活动行高亮用 CSS 隐藏(.pn-cm-live .cm-gutters{display:none}),不改 basicSetup 避免依赖 @uiw/react-codemirror 对 basicSetup 的运行时重配置行为
D6 表格定界符产出单个 tableMark spec(带 delimiter 标记):普通竖线 55% 透明、分隔行 38% 与 §4.4「淡化」一致,实现更简单;TableDelimiter 节点在 lezer 里对分隔行是整段而非单竖线
D7 列表圆点覆盖「标记 + 其后空格」,行内样式类名定为 cm-md-strong/em/del/code/link 圆点宽度固定 1.15em,与「宽度=原标记宽度」的方案意图一致但更可控(原标记在比例字体下宽度不定)

12.3 实机验证(内置浏览器,pn start --dev + vite dev)

  • 渲染:标题 / 粗体 / 斜体 / 删除线 / 行内代码 / 链接 / 引用(嵌套两道竖线)/ 无序列表(•/◦/▪ 三级)/ 有序列表(保留 1. 2. 3.)/ 任务框 / 图片(480×220 原尺寸 + 12px 圆角 + 边框)/ 水平线 / 围栏(js 标签 + 底色圆角)/ 表格(等宽 + 竖线淡化)全部按预期呈现;
  • 展开:点击段落行 → 该行显示 **粗体** 等源码、其他行标记保持隐藏;聚焦标题行显示 # ,失焦后恢复整篇渲染(E11);
  • 交互:点击复选框 → 源文本 - [ ] 改写为 - [x],2s 防抖自动保存后服务端确实收到改写(读回 API 确认);
  • 源码模式:切换后行号恢复、装饰全部卸载(无 cm-md-* 残留),连续切换 4 次无异常、无 console 错误;
  • 中文输入:insertText 通路追加中文,以及合成 compositionstart → 输入 → compositionend 组合输入, 文本无重复/乱序、无 console 错误,其他行装饰保持;
  • 零回归:公开阅读页 .pn-note-body 计算样式未变(正文 15px / 行高 27px、标题衬线、段落下边距 24px); tests/markdown.test.tsx 全绿。

12.4 未完成 / 待办

  1. 真机输入法回归 —— 人工验收门槛,阻塞发布(§12.6):自动化只能合成组合事件;拼音 / 搜狗 / 系统输入法的连续中文 + 标点输入必须人工真机走查。
  2. 表格 HTML 渲染与可视化编辑(§10.1)、span 级 reveal(§10.2)、代码块高亮(§10.3)仍按方案留作后续。
  3. 图片 widget 目前按原尺寸显示;超大图依赖 max-width:100% 收敛,未做高度上限与点击放大(§10.7 范畴)。
  4. 1 万行以上文档与装饰节流未做(§10.8);当前实测覆盖到 3000 行。

12.5 P3 加固记录(2026-09-11)

12.5.1 修掉的两个真实缺陷

# 现象 根因 修复
P3-1 长文档中未解析区间显示裸 Markdown(3000 行文档实测:文档开头以外全部是 **粗体**、## 标题 原文) lezer 是惰性增量解析:EditorState 建好后 syntaxTree(state) 只覆盖前 3061 字符(15.5 万字符文档),越界区间采集不到任何 spec;且后台解析推进时事务没有 doc/selection 变化,ViewPlugin 不会重算 ① 新增 visibleSyntaxTree():用 ensureSyntaxTree(state, upto, 20ms) 在有限预算内补齐可见区间;② ViewPlugin 增加「语法树对象换代」触发条件(syntaxTree(startState) !== syntaxTree(state) → 重建),后台解析推进后装饰自动补齐
P3-2 Cmd+A 全选在长文档下会构建全文档装饰;reveal 判定是 O(装饰数×区间数) 线性扫描 ① 采集范围把「选区跨越的每一行」都算进去了;② applyReveal 对每个 spec 线性扫全部展开区间 ① 采集范围改为「可见区间 + 选区端点行」(CM 也只渲染端点块);② applyReveal 先合并区间再二分(E14/E15)

两项均补了回归:visibleSyntaxTree 补解析断言、越界不越界断言、光标行装饰断言、二分/线性等价断言、 最坏选区(Cmd+A)构建耗时基准。

12.5.2 性能实测结果

环境:本机 macOS arm64 / Chromium(内置浏览器,~144Hz 刷新);vitest 组运行于 happy-dom(Node 22)。 负载:3000 行 / 3004 行、15.5–15.6 万字符的混合文档(每 7 行一个 h2、每 5 行一个含粗体/行内代码/链接的列表项、 每 11 行一个引用,其余为含强调/斜体/行内代码/链接的正文)。

指标 方法 结果 判定
视口窗口(100 行)装饰采集 collectSpecs ×40 取中位数(vitest) 中位数 0.26ms,p95 0.83ms ✅ ≪ 16ms(§7.4 目标)
全篇作为单一可见区间(最坏采集) collectSpecs 全文档 ×10 取中位数 中位数 5.0ms,最大 12.1ms ✅ < 16ms
最坏选区(Cmd+A 整篇选中 + 聚焦) buildDecorations ×10 取中位数 中位数 0.98ms,最大 1.73ms ✅ < 16ms
整篇全量解析(冷启动一次性) visibleSyntaxTree(tree=null → 全篇) 15.4ms(覆盖 155327 字符) 一次性成本,20ms 预算内
真实编辑器输入(文末普通段落,连打 60 字) execCommand 同步返回耗时 ×60 取中位数 中位数 1.5ms,p95 1.8ms,最大 2.0ms ✅
真实编辑器输入(中段列表项,装饰更密) 同上 ×45 中位数 2.2ms,p95 2.5ms,最大 6.1ms ✅
输入到下一帧(含浏览器排版/绘制/帧调度) 同上一循环内 measure 到 rAF 中位数 13.9–18.3ms、p95 22.8–28.5ms(≈1–3 帧 @60Hz 口径) ✅ 无可感卡顿;该指标受帧调度与绘制影响,装饰层自身成本即上面的同步耗时
冷启动直跳文末(编辑器内部滚动,跳过未解析区) rAF 帧间隔 + longtask 观察器 帧间隔最大 8.6ms(≈1 帧),long task 数 0 ✅ 无掉帧
跳转后文末渲染完整性 断言可见行装饰数与末行文本 34 行渲染 / 32 行带装饰,末行 文末正文 粗 结束。 无标记泄漏 ✅

结论:§7.4 的「3000 行单帧 < 16ms」目标达成(装饰采集 0.26–5.0ms,真实输入同步耗时中位数 1.5–2.2ms)。 ensureSyntaxTree 的 20ms 预算在实测中甚至未被真正耗尽(整篇解析仅 15.4ms),因此不构成卡顿来源。

输入类指标为编辑区吃满视口 + 内部滚动落地后重测(见 §12.7);滚动类指标同样在内部滚动下重测。

12.5.3 边界与选区回归(tests/editor-boundaries.test.ts,32 条)

组 覆盖
文首边界(5) 标题 / 任务项 / 围栏 / 水平线开头的渲染与原子化;光标在文档起点退格为无操作且不损坏内容
文末边界(7) 水平线 / 图片 / 围栏 / 任务结尾的 widget 渲染;整篇只有一条水平线;文末追加内容后装饰正确重建;全选清空不抛错
跨原子区间选区(5) 全选替换、跨隐藏标记的选区替换、只选中可见内容时标记完整保留、连标记整体删除、跨行删除后装饰重建
装饰层不变量(6) 原子区间不跨行(CM 硬性约束)/ 有序不重叠 / 隐藏标记必在原子区间内 / 强调·行内代码·链接的内容区间绝不被原子化 / reveal 契约(进入行→原子消失,离开→恢复)/ 失焦整篇渲染
长文档惰性解析(5) syntaxTree 首覆盖仅 3061 字符的事实、visibleSyntaxTree 补齐、预算受限时越界保持源码态、装饰不越过已解析边界、光标行在视口外也被装饰
性能基准(6) 上表前五项 + 最坏选区(Cmd+A)构建成本

交互面(真实浏览器)另验证:文首连续退格不损坏内容;Cmd+A + 退格清空 3000 行文档后 Cmd+Z 完整恢复; 远距离滚动后光标所在块仍以渲染态出现(P3-2 修复点)。

12.6 人工验收门槛:真机输入法回归(未通过不得发布)

自动化无法产生真实 IME 合成事件,冻结逻辑(view.composing 为真时仅映射、不重建装饰)虽已实现且 通过了合成 compositionstart/…/compositionend 冒烟,但未经过真实输入法验证,因此列为 阻塞发布的人工验收项,与 docs/acceptance.md 的 M5 门槛一致。

执行要求:在真实浏览器 + 真实输入法(至少覆盖「系统拼音」与「搜狗拼音」各一遍)中逐项走查:

# 用例 期望
I1 在空行用拼音连续输入中文(含候选词上下屏、翻页) 文字正确上屏,无重复/丢字/乱序,装饰不闪断
I2 在已有 **粗体** 行内、光标位于标记中间开始组字 该行保持展开态,组字过程中 DOM 不被替换,无「视觉消失」
I3 在行首/行尾紧邻隐藏标记处组字 不吞前文、不把标记一起吃掉(对照 CM issue #1684/#1688)
I4 中文标点(,。、;:「」()——)连续输入 标点正确、不触发误解析
I5 组字过程中移动光标 / 点击其他行 不出现错位、重复上屏或崩溃
I6 列表项、引用、标题行内组字 行级 reveal 生效,标记不被误删
I7 中文输入后撤销/重做(Cmd+Z / Shift+Cmd+Z) 按「一次组字一次撤销」的直觉工作
I8 3000 行文档中组字 无卡顿(输入同步耗时已在 §12.5 实测为亚毫秒级)

签字:执行人 / 日期 / 浏览器与输入法版本 / 结果(通过 / 问题单链接)—— 未填写即视为未通过。 当前状态:未执行(2026-09-11,开发环境无法进行真实 IME 走查)。

12.7 编辑区高度(2026-09-11,用户要求)

改动:编辑卡默认吃满视口剩余高度,多余的滚动交给编辑器自身(.cm-scroller), 页面不再随正文长度增长。此前 .pn-ae-body-wrap 只有 min-height: 420px、高度随内容撑开, 导致两个极端:新建空文档时编辑区只有 420px;3000 行长文时页面高达 8.4 万像素、只能整页滚动。

位置 改动
index.css .pn-ae-grid height: calc(100vh - 110px)(topbar 62 + content padding 24×2)、min-height: 520px;≤1023px 用 calc(100vh - 102px);≤900px(堆叠布局)回落 height: auto
index.css .pn-ae-main / .ant-card-body 拉成纵向 flex,把剩余高度让给编辑区
index.css .pn-ae-body-wrap flex: 1 1 auto; min-height: 280px; overflow: hidden
Editor.tsx CodeMirror 内联 minHeight 420 → 240(允许在矮视口下收缩,不再撑破容器)

实测(1280×720 视口):

场景 结果
新建空文档 编辑卡 610px(= 100vh−110),编辑区 379px;页面 scrollHeight 721 ≈ 一屏,页面不滚动
3000 行长文 编辑卡仍 610px,.cm-scroller clientHeight 379 / scrollHeight 85087(overflow: auto)→ 编辑器内部滚动;页面 scrollHeight 保持 721,window.scrollY 恒为 0
内部滚动到中段 渲染行(约 1520–1532)全部按渲染态呈现;光标所在块(文档第 1 行)在滚动 4.2 万像素后仍带 cm-md-h2 装饰(P3-2 修复点在内部滚动布局下依然成立)
窄屏 480×800(≤900px 断点) 单列堆叠、height: auto,编辑区 280px(min-height 兜底),页面正常滚动

未纳入本次改动:编辑区最大宽度(当前随卡片宽度全宽)、粘性工具栏、全屏/专注模式。


附录 A 依赖与 API 核实(2026-09-10,实测已装版本)

依赖 版本 用到的 API 状态
@codemirror/view 6.43.11 Decoration.replace/mark/widget/line、EditorView.atomicRanges、view.composing、EditorView.decorations 已核实
@codemirror/state 6.7.4 StateField、Compartment 已核实
@codemirror/language 6.12.4 syntaxTree、ensureSyntaxTree、foldable 已核实
@codemirror/lang-markdown 6.5.2 markdownLanguage(GFM 语言)、GFM 已核实
@lezer/markdown — Table / Task / Strikethrough 节点 已核实

结论:本次改造零新增 npm 依赖。

附录 B 参考资料