Files
pure-note/docs/editor-live-rendering.md
T
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

587 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` | 整段替换为 `<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](https://github.com/codemirror/dev/issues/1654) | 装饰与 IME 组合输入冲突 |
| [dev#1650](https://github.com/codemirror/dev/issues/1650) | 语法高亮边界处组字乱码 |
| [dev#1684](https://github.com/codemirror/dev/issues/1684) | Chrome 上中文 IME 删掉前文 |
| [dev#1688](https://github.com/codemirror/dev/issues/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](https://github.com/codemirror/dev/issues/1658))、块 widget 致选区渲染异常([#1406](https://github.com/codemirror/dev/issues/1406))、相邻 widget 间移动异常([#979](https://github.com/codemirror/dev/issues/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](https://github.com/codemirror/dev/issues/575) 视口外行不渲染"、"[#625](https://github.com/codemirror/dev/issues/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 参考资料
- Typora:[Markdown Reference](https://support.typora.io/Markdown-Reference/)("expand around cursor" 官方描述)、[DOM 证据 typora-issues#6629](https://github.com/typora/typora-issues/issues/6629)、性能 [#6542](https://github.com/typora/typora-issues/issues/6542) / [#6551](https://github.com/typora/typora-issues/issues/6551)、IME [#6554](https://github.com/typora/typora-issues/issues/6554)
- Obsidian:[Decorations API](https://docs.obsidian.md/Plugins/Editor/Decorations)、[Views and editing mode](https://help.obsidian.md/Editing+and+formatting/Views+and+editing+mode)
- CodeMirror 6:[Decoration example](https://codemirror.net/examples/decoration/)、[Reference](https://codemirror.net/docs/ref/)、相关 issue:#1654 / #1650 / #1684 / #1688 / #1658 / #1406 / #979 / #575 / #625
- 对照编辑器:[Vditor](https://github.com/Vanessa219/vditor)、[HyperMD](https://github.com/laobubu/HyperMD)、[Milkdown](https://milkdown.dev)、[Tiptap Markdown](https://tiptap.dev/docs/editor/markdown)、[Toast UI Editor](https://github.com/nhn/tui.editor)、[ByteMD](https://github.com/bytedance/byteMD)
- 项目内:`docs/design.md` §8.2 / §8.1、`docs/decisions.md` D5 / D26、`docs/review-round1.md` 第 18 项