# 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` | 整行替换为 `
` 块 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 })`,**原子** | | 呈现 | `