Skip to content

Commit 2b868f0

Browse files
committed
docs(v0.14): 编辑器基础体验设计规格(CodeMirror 6)
1 parent 9ee1e6e commit 2b868f0

1 file changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# v0.14 子项目 A:编辑器基础体验改造设计规格(CodeMirror 6)
2+
3+
> 状态:✅ 已批准(2026-08-27,7 节逐节确认)
4+
> 所属版本:v0.14
5+
> 范围:子项目 A(编辑器基础体验)——图片可视 / H2-H3 / 撤销;B/C/D 另设 spec
6+
7+
## 1. 背景与目标
8+
9+
### 1.1 痛点(用户反馈,2026-08-27)
10+
11+
| # | 痛点 | 现状根因 |
12+
|---|------|----------|
13+
| A1 | 编辑状态下图片位置不可视 | `NoteEditView` 用纯 textarea,`![alt](path)` 以源码文本呈现 |
14+
| A2 | H2/H3 添加有问题 | textarea 失焦/滚动后光标重定位靠 `scrollTop` 比例估算(v0.13.9 hack),选区不权威;且无即时视觉反馈 |
15+
| A3 | 没有撤销能力 | 无 Ctrl+Z;版本链只在显式保存建快照,粒度不解决编辑中误操作 |
16+
17+
### 1.2 目标
18+
19+
1. 编辑状态图片**内联可视**(可点击放大,复用阅读视图 `NoteImage`)
20+
2. H2/H3 **输入即生效**(`## ` 自然输入 + 语法高亮 + 折叠 + 快捷键 + 按钮态同步)
21+
3. 编辑会话内 **Ctrl+Z / Ctrl+Y 全量撤销**(含工具栏操作)
22+
4. **崩溃不丢字**:自动保存 + localStorage 草稿恢复层
23+
5. **存储格式零变更**:`notes.content` 保持 Markdown 字符串——版本链/diff/FTS5/导出全不动
24+
25+
## 2. 方案选型记录
26+
27+
### 2.1 编辑器引擎:CodeMirror 6 取代 TipTap(已裁决)
28+
29+
| 维度 | TipTap(WYSIWYG) | **CodeMirror 6(增强 Markdown 源码编辑)** |
30+
|---|---|---|
31+
| 编辑对象 | ProseMirror 结构化文档 | Markdown 源码 |
32+
| 存储格式 | 需 Markdown ↔ PM JSON 桥接 | **Markdown 不变** |
33+
| 版本系统影响 | 桥接风险 | **零影响** |
34+
| 依赖重量 | ProseMirror 全家桶 | ~200KB gzipped |
35+
| 图片可视 | WYSIWYG | decoration widget 内联渲染 |
36+
| 撤销 | 内建 | 内建 |
37+
38+
裁决理由:版本链稳定性优先,无转换风险,依赖更轻。WYSIWYG 非刚需——语法高亮 + 图片内联已覆盖核心诉求。
39+
40+
### 2.2 图片渲染变体:widget + React 挂载(已裁决)
41+
42+
| 变体 | 结论 |
43+
|---|---|
44+
| A. widget + React createRoot 挂载 NoteImage | ✅ 采纳——保留点击放大,`destroy()` 统一清理 root 防泄漏 |
45+
| B. widget + 原生 `<img>` | 备选——无放大,实现更简单 |
46+
| C. 语法高亮 + hover 预览 | 否决——编辑区不可见本体 |
47+
48+
### 2.3 标题方案:输入驱动 + 工具栏辅助(已裁决)
49+
50+
输入 `# `/`## `/`### ` 即生效(语法高亮 + 折叠箭头即时反馈),工具栏按钮降级为补充通道,快捷键 Ctrl+1/2/3 对齐 Obsidian 习惯。
51+
52+
### 2.4 保存方案:双计时器 + 草稿恢复层(已裁决)
53+
54+
自动保存(idle 2s / maxWait 30s,与现有一致)之上增加 localStorage 草稿层:崩溃/强杀后重开可恢复零丢失。
55+
56+
## 3. 架构设计
57+
58+
### 3.1 组件层次
59+
60+
```
61+
NotesPage(不变)
62+
├── NoteReadingView(不变——阅读仍走 react-markdown + NoteMarkdown)
63+
├── RichEditorView(新容器——替代 NoteEditView 的编辑角色)
64+
│ ├── CodeMirror EditorView(核心编辑区,非受控)
65+
│ ├── ImageDecorationPlugin(新——图片内联渲染)
66+
│ ├── NoteImageWidget(新——decoration widget,React 挂载 NoteImage)
67+
│ ├── 工具栏(保留按钮,适配 CM Command)
68+
│ └── NoteEditView(保留——降级回退路径,textarea 全功能)
69+
├── VersionPanel(不变)
70+
└── ...
71+
```
72+
73+
### 3.2 新增文件清单
74+
75+
| 文件 | 职责 | 行数预算 |
76+
|---|---|---|
77+
| `components/RichEditorView.tsx` | 编辑容器:CM 挂载 + 工具栏 + 保存接线 + 降级护栏 | ≤300 |
78+
| `hooks/useCodeMirror.ts` | CM 生命周期封装(挂载/卸载/doc 更新/updateListener) | ≤150 |
79+
| `components/imageDecoration.ts` | 纯函数:扫描 `![alt](src)` → ranges(可单测) | ≤100 |
80+
| `components/imageDecorationPlugin.ts` | ViewPlugin:decoration 计算与更新 | ≤120 |
81+
| `components/NoteImageWidget.tsx` | WidgetType:toDOM + React 挂载 + 失败占位 + destroy 清理 | ≤120 |
82+
| `utils/resolveNoteImageSrc.ts` | 纯函数:图片 src 解析(从 NoteImage 提取,编辑/阅读共用) | ≤60 |
83+
| `utils/draftStore.ts` | 纯函数:草稿读写/清除/损坏降级 | ≤80 |
84+
| `commands/headingCommand.ts` | CM Command:标题转换 + Ctrl+1/2/3 绑定 | ≤100 |
85+
86+
### 3.3 依赖变更
87+
88+
```
89+
+ codemirror(聚合包)
90+
+ @codemirror/lang-markdown(语法高亮 + 标题折叠)
91+
- 无 TipTap / ProseMirror / @uiw/react-codemirror(手写 hook 封装,符合项目无 UI 包装库风格)
92+
```
93+
94+
## 4. 详细设计
95+
96+
### 4.1 图片内联渲染(A1)
97+
98+
**机制**:`ViewPlugin.fromClass` 扫描 doc,用 `Decoration.replace(from, to, { widget })` 将 `![alt](src)` 替换为真实图片。
99+
100+
- 支持行内与独立行两种形态;嵌套括号 URL 正确处理;非图片链接不误匹配
101+
- `NoteImageWidget.toDOM()` 返回容器 → `createRoot(container).render(<NoteImage …>)`
102+
- **destroy() 必须 `root.unmount()`**——防 React root 泄漏(CM 滚动/重绘重建 widget 时反复挂载)
103+
- 图片加载失败 / 路径解析失败 → 灰底占位 + 原语法文本(不吞语法,可点击定位编辑)
104+
- `ignoreEvent` 返回 true(点击不进入编辑);删除 = 选中 widget 按 Delete(CM 原生支持)
105+
- `loading="lazy"` 惰性加载;仅 `docChanged` 时重算 decorations
106+
- `resolveNoteImageSrc` 从 `NoteImage` 提取为共享纯函数——编辑/阅读同一解析逻辑
107+
108+
### 4.2 撤销/重做(A3)
109+
110+
- CM 内建 history(`@codemirror/commands`):Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z
111+
- 撤销栈为**编辑会话内内存态**:切换笔记/退出编辑即清空
112+
- 自动保存只读 `doc.toString()` 不产生 transaction——与 history 无干扰
113+
- 工具栏操作(标题/图片等)经 `view.dispatch` 产生 transaction → **天然进撤销栈**(textarea 版做不到)
114+
115+
### 4.3 H2/H3 标题(A2)
116+
117+
- 输入驱动:行首 `# `、`## `、`### ` 即时高亮 + 折叠箭头(lang-markdown 自带)
118+
- `headingCommand(level): Command`——工具栏/快捷键共用;单行/多行选区逐行转换,已是标题的行跳过,光标落末行标记后
119+
- **不复用 `markdownEdit.ts` 的 `headingLines`**(字符串+offset 模型)——改用 CM 原生 `Text.lines` + `lineAt(pos)`,原生支持 CRLF 边界;`markdownEdit.ts` 保留(textarea 降级路径仍消费)
120+
- 快捷键:Ctrl+1/2/3 设置级别;Ctrl+Shift+↑↓ 层级升降(CM 选区语义,天然精确)
121+
- 折叠:`foldGutter` + 标题折叠,长图文笔记可折叠到只看标题
122+
- 工具栏按钮显示当前行标题级别高亮态(UI 反馈)
123+
124+
### 4.4 保存与草稿恢复(A5)
125+
126+
**自动保存**(保留现有机制,改驱动源):
127+
128+
```
129+
updateListener(每次 transaction)
130+
↓ doc.toString() 与快照比较
131+
↓ 有变化 → dirty=true → 重启双计时器(idle 2s debounce / maxWait 30s)
132+
↓ 到点 → invoke("update_note", { id, title, content, createVersion: false })
133+
```
134+
135+
**草稿恢复层**(新增):
136+
137+
```
138+
每次 doc 变化(节流 1s)
139+
↓ localStorage["note-draft:{noteId}"] = { title, content, updatedAt }
140+
141+
保存成功 / 正常退出编辑 → 清除草稿
142+
143+
下次打开同一笔记(编辑模式挂载时)
144+
↓ 存在草稿 && 草稿.updatedAt > DB.updatedAt
145+
↓ 提示"检测到未保存的编辑草稿,是否恢复?" → [恢复] [丢弃]
146+
```
147+
148+
- title 与 content 统一为一个 dirty 标记(任一变化触发),保存时合并调用
149+
- 草稿损坏(JSON parse 失败)→ 丢弃 + status 提示,不阻塞编辑
150+
- 草稿与版本链正交——恢复后走正常保存,不污染版本历史
151+
152+
### 4.5 降级护栏
153+
154+
```
155+
RichEditorView
156+
├── try 挂载 CodeMirror → 成功 → 富编辑(图片/高亮/撤销/折叠)
157+
└── catch → setFallback(true) → 渲染原 NoteEditView(textarea 全功能保留)
158+
```
159+
160+
编辑器是核心资产,绝不因渲染层失败而不可用(与"AI 能力本地兜底"同构)。
161+
162+
### 4.6 退出编辑竞态防护(延续 v0.13.6 教训)
163+
164+
ESC/完成/Ctrl+E 三出口:先 `await` 读取 doc + 落库,再刷新列表——避免"读旧值"竞态重演。
165+
166+
## 5. 错误处理与降级
167+
168+
| 失败场景 | 处理策略 | 出口 |
169+
|---|---|---|
170+
| 图片路径解析失败 | 灰底占位 + 原语法文本(不吞语法) | 编辑区可见、可修复 |
171+
| 图片加载失败 | 同左,"图片加载失败"占位 | 编辑区可见 |
172+
| `update_note` 保存失败 | status 展示;草稿层已兜底;不卡编辑态 | 状态栏 + 下次打开可恢复 |
173+
| 草稿 localStorage 损坏 | 丢弃 + status 提示,不阻塞 | 状态栏 |
174+
| CM 初始化失败(极端) | 降级 textarea(NoteEditView 保底) | 编辑可用性不丢 |
175+
| `import_note_image` 失败 | 沿用现有:status 提示,不落脏数据 | 状态栏 |
176+
177+
## 6. 测试计划
178+
179+
### 6.1 纯函数层
180+
181+
| 测试目标 | 覆盖点 |
182+
|---|---|
183+
| `imageDecoration.ts` | 行内/独立行/无 alt/嵌套括号 URL/多图同行/非图片链接不误匹配 |
184+
| `resolveNoteImageSrc` | 相对路径/绝对路径/外链 URL/空 src |
185+
| `headingCommand` 纯逻辑 | 单行/多行选区/已是标题跳过/H6 边界/光标落位 |
186+
| `draftStore.ts` | 写入/读取/清除/损坏 JSON 降级/时间戳比较 |
187+
188+
### 6.2 组件层(jsdom + @testing-library)
189+
190+
| 测试目标 | 覆盖点 |
191+
|---|---|
192+
| `useCodeMirror` | 挂载建 EditorView/doc 初始化/卸载销毁/doc 更新同步 |
193+
| `RichEditorView` | 渲染 CM/工具栏 H2 点击 → doc 出现 `## `/Ctrl+Z 撤销恢复 |
194+
| 草稿恢复 UI | 有草稿且更新 → 提示弹窗;恢复/丢弃两分支 |
195+
| 降级护栏 | 强制 CM 初始化失败 → 回退 textarea |
196+
197+
### 6.3 保存集成(mock invoke + fake timers)
198+
199+
- 输入 → 双计时器触发 `update_note`
200+
- 退出编辑 → flush 后 invoke 顺序正确(防 v0.13.6 竞态回归)
201+
- 保存失败 → status 展示 + 草稿仍在
202+
203+
### 6.4 手动验收
204+
205+
- 编辑长图文笔记:图片可见/可放大/可删除,折叠 H1 定位章节
206+
- 强杀进程后重开 → 草稿恢复提示
207+
208+
## 7. 范围外(后续子项目)
209+
210+
- 子项目 B:笔记颜色能力 / Word 对齐(另设 spec)
211+
- 子项目 C:分组分类修复 / Obsidian 对齐 / 项目深度联动(另设 spec)
212+
- 子项目 D:采集转化质量攻坚(另设 spec)
213+
214+
## 8. 决策记录
215+
216+
| 决策 | 结论 | 理由 |
217+
|---|---|---|
218+
| 编辑器引擎 | CodeMirror 6 | 存储零变更、版本链零风险、依赖轻 |
219+
| 图片渲染 | widget + React 挂载 | 保留点击放大;destroy 清理防泄漏 |
220+
| 标题交互 | 输入驱动 + 辅助 | 符合 Markdown 心智,折叠获导航质变 |
221+
| 保存 | 双计时器 + 草稿层 | 崩溃零丢失,与本地优先一致 |
222+
| 降级 | CM 失败回退 textarea | 编辑永不失效 |

0 commit comments

Comments
 (0)