|
| 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,`` 以源码文本呈现 | |
| 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` | 纯函数:扫描 `` → 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 })` 将 `` 替换为真实图片。 |
| 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