# 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](https://github.com/typora/typora-issues/issues/6629))显示:
- 根容器 `#write`;块级元素带 `mdtype`(`heading` / `plain` / `table` / `list-item` / `fences`…)与稳定内容 id `cid`;
- 行内片段带 `md-inline`(`plain` / `code`…);语法的成对定界符是独立 span(`md-pair-s`);
- `md-expand` 标记「该片段已展开为源码」的状态。
**三条核心机制**(官方 [Markdown Reference](https://support.typora.io/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](https://github.com/typora/typora-issues/issues/6542)、[#6551](https://github.com/typora/typora-issues/issues/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](https://github.com/ueberdosis/tiptap/issues/8134)、行内 code 含反引号被破坏 [#8298](https://github.com/ueberdosis/tiptap/issues/8298)、表格内转义竖线丢失 [#8294](https://github.com/ueberdosis/tiptap/issues/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` | 整段替换为 `` widget(§5.4) |
| 引用 | `Blockquote` + `QuoteMark` | `>` → 竖线 widget(§5.3) |
| 列表 | `BulletList` / `OrderedList` + `ListMark` | 圆点 widget / 数字保留(§5.1、§5.2) |
| 代码围栏 | `FencedCode` + `CodeMark` / `CodeInfo` | 起围栏 → 语言标签 widget;闭围栏隐藏;块加 `.cm-md-fenced` |
| 水平线 | `HorizontalRule` | 整行替换为 `