Skip to content

Commit 51896a7

Browse files
committed
docs(v0.15): 笔记体验打磨设计规格定稿(标题切换/自适应/折叠/图片落盘/剪贴板/换行一致)
1 parent bdf13e0 commit 51896a7

1 file changed

Lines changed: 256 additions & 0 deletions

File tree

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# v0.15 笔记体验打磨设计规格(标题切换 / 全站自适应 / 折叠 / 图片落盘 / 剪贴板导入 / 换行一致)
2+
3+
> 状态:✅ 已批准(2026-08-30,6 节逐节确认)
4+
> 所属版本:v0.15
5+
> 范围:v0.14 交付后用户反馈的 6 项笔记相关改进(4 项缺陷 / 2 项体验增强)
6+
7+
## 1. 背景与目标(痛点 → 根因)
8+
9+
v0.14(编辑器 CM6 / 视觉系统 / 知识体系 / 采集质量)交付后,用户实测反馈 6 项:
10+
11+
| # | 反馈 | 现状根因(代码定位) |
12+
|---|------|---------------------|
13+
| 1 | 加 H1 后再点按钮"无取消" | `headingCommand.ts:50` 与 `markdownEdit.ts:82` 对已是标题的行**跳过**(纯 no-op);按钮有光标行级别高亮态(`RichEditorView.tsx:167`)暗示可切换但点了没反应 |
14+
| 2 | 组件大小不能自适应 | 全站固定宽列硬编码:笔记页 240/320/180(`GroupSidebar.tsx:270`、`NoteListView.tsx:100`、`NoteReadingView.tsx:93`)、会话页 320(`SessionListPanel.tsx:299`)、知识页 260/320(`KnowledgePage.tsx:220`、`KnowledgeDetailPanel.tsx:132`)、课堂页 320(`ClassroomPage.tsx:376`);默认窗口仅 960×720(`tauri.conf.json`)——正文区被挤压;`NoteImage.tsx:86` 固定 240×160 CSS 缩略(原 TODO(v0.12.0)) |
15+
| 3 | 笔记组、笔记不可收起 | 组侧栏已按 kind 分区折叠(`GroupSidebar.tsx:375`,记忆 localStorage);但中部列表平铺(v0.12.2 决策 1:组行只过滤不展开),组/列表整列无折叠 |
16+
| 4 | 用户插入的图片需复制一份 | 🖼 文件选择已复制(`import_note_image`,`commands_note_images.rs:79` ✅);但 🌐 链接图只存外链 URL(源站删除即失效)、剪贴板粘贴(待 #5)未落盘、手写/粘贴的绝对路径引用既不显示(`resolve_note_image_path` 只认 4 种前缀)也无副本 |
17+
| 5 | 编辑模式不支持剪贴板导入图片 | 编辑器(CM 与 textarea 降级)**均无 paste 处理**;快速记录已有剪贴板捕获先例(`GroupSidebar.tsx:147` + `capture_fragment` 的 `image_b64`) |
18+
| 6 | 编辑态换行、阅读态连上 | CommonMark 软换行(单个 `\n`)= 空格;编辑态按行展示,阅读态 `NoteMarkdown`(react-markdown + remark)把单 `\n` 渲染为空格 → 语义不一致 |
19+
20+
**目标**:编辑与阅读所见一致;图片资源零丢失;笔记页与全站布局随窗口自适应;笔记组/笔记可收起。
21+
22+
## 2. 方案选型记录(逐条裁决)
23+
24+
### 2.1 H1 切换语义(#1)
25+
26+
| 方案 | 结论 |
27+
|---|---|
28+
| **A. set-or-toggle 切换语义(采纳)** | 逐行:行已精确等于所点级别 → 剥掉 `#×level+空格` 还原普通段;其他情况 → 置为该级别。多行选区逐行独立判定。CM 与 textarea 双路径同步修改,配套单测改造 |
29+
| B. 保留跳过 + 独立"清除格式"按钮 | 按钮更多,发现成本高,否决 |
30+
| C. 仅保留升/降级(Ctrl+Shift+↑↓) | 与用户"点 H1 取消 H1"预期不符,否决 |
31+
32+
边界:纯空白行保持现有行为(加标记——不具有"取消"语义);光标落位逻辑不变(末行标记后)。
33+
34+
### 2.2 全站自适应(#2)——用户裁决:**全部页面**
35+
36+
统一策略:**所有固定宽列改为"可拖拽 + 宽度记忆 + 最小/最大约束 + 窄窗自动折叠"**;组件内部自适应(工具栏换行、表格横向滚动已有;图片改为自适应尺寸)。
37+
38+
### 2.3 收起(#3)——用户裁决:**分组树 + 整列折叠**
39+
40+
中部列表改分组树(组头可收起,记忆);左栏/中栏/右栏大纲全部支持整列折叠(窄条形态);组头"点击名字=过滤"的 v0.12.2 决策 1 **保持不变**(chevron 才有折叠动作)。
41+
42+
### 2.4 图片落盘(#4)——用户裁决:**每个入口保证复制;不做巡检**
43+
44+
- 🖼 本地文件:已有(`import_note_image`),保持
45+
- 剪贴板粘贴(与 #5 同条线):落盘
46+
- 🌐 链接图:**自动下载复制**(推荐,用户确认);失败降级插原 URL + 提示
47+
- 手写/粘贴绝对路径引用:**不做巡检/不改写**——登记为已知边界(用户裁决:入口全落盘后巡检不再必要;将来若观察到高频粘贴本地路径 Markdown,再在 paste 钩子转换)
48+
- 删除笔记:顺带清理 `notes-images/{nid}/` 目录(防孤立残留,用户确认;失败不阻断删除)
49+
50+
### 2.5 剪贴板导入(#5)
51+
52+
CM 侧 `EditorView.domEventHandlers.paste` 读 `event.clipboardData`(同步、无权限弹窗);textarea 降级路径 `onPaste` 同款(共享 util)。后端新增 `import_note_image_b64`(base64 IPC,同 `capture_fragment` 先例)。
53+
54+
### 2.6 换行一致(#6)——用户裁决:**单换行=硬换行渲染 `<br>`**
55+
56+
阅读渲染启用 remark-breaks;零源数据变更(存量内容自动修复);两空格/`\` 强断行语义保留;不加设置项(YAGNI,将来需要再提)。
57+
58+
## 3. 架构设计
59+
60+
### 3.1 组件/模块层次
61+
62+
```
63+
NotesPage(编排层:列状态 useColumnWidth + 折叠态)
64+
├── GroupSidebar(受控 width/folded)
65+
├── NoteListView(分组树版:NoteTreeSection 组头行 + 平铺行)
66+
├── NoteReadingView(大纲列并入统一列折叠体系)
67+
├── RichEditorView(+ paste 处理 + 🌐 链接图下载导入)
68+
│ └── NoteEditView(降级:+ paste 处理)
69+
├── useClipboardImagePaste(共享 util/hook)
70+
├── useColumnWidth + ColumnResizer(全站通用布局装备)
71+
└── NoteMarkdown(+ remark-breaks)
72+
SessionsPage / KnowledgePage / ClassroomPage(列迁移,复用同一装备)
73+
Rust:commands_note_images.rs(+ b64/url 命令 + 删除清理;提取 save_image_bytes)
74+
```
75+
76+
### 3.2 新增/修改文件清单
77+
78+
| 文件 | 变更 | 行数预算 |
79+
|---|---|---|
80+
| `hooks/useColumnWidth.ts` | 新:列宽状态 + localStorage 记忆 + 拖拽计算 | ≤120 |
81+
| `components/ColumnResizer.tsx` | 新:拖拽手柄(方向 L/R) | ≤80 |
82+
| `components/NoteTreeSection.tsx` | 新:分组树组头行(chevron/计数/色条/过滤) | ≤120 |
83+
| `hooks/useClipboardImagePaste.ts` | 新:剪贴板图片提取(ClipboardEvent → {blob,mime} + base64) | ≤100 |
84+
| `components/NoteListView.tsx` | 改:平铺 → 分组树(search/tag/sort 激活时退化平铺) | ≤300(超限拆 NoteTreeSection 后应可控) |
85+
| `components/NoteReadingView.tsx` | 改:大纲列接入统一折叠(移除独立 outlineCollapsed) | ≤280 |
86+
| `components/RichEditorView.tsx` | 改:paste 处理 + 链接图命令切换 + 工具栏文案 | ≤280 |
87+
| `components/NoteEditView.tsx` | 改:onPaste 处理 | ≤300 |
88+
| `components/NoteMarkdown.tsx` | 改:+ remarkBreaks | ≤250 |
89+
| `components/NoteImage.tsx` | 改:固定 240×160 → 自适应(maxWidth 100% + maxHeight 60vh + 原始比例) | ≤100 |
90+
| `pages/NotesPage.tsx` | 改:列状态编排 + 组数据传 NoteListView + 折叠窄条 | ≤300(超限拆 ColumnHost 壳) |
91+
| `pages/SessionsPage.tsx` / `KnowledgePage.tsx` / `ClassroomPage.tsx` | 改:列迁移(原样宽度作默认值) | — |
92+
| `commands/headingCommand.ts` | 改:toggle 语义 | ≤120 |
93+
| `components/markdownEdit.ts` | 改:headingLines toggle 语义 | ≤150 |
94+
| `app/src-tauri/src/commands_note_images.rs` | 改:提取 save_image_bytes + import_note_image_b64/url + 删除清理 | ≤260 |
95+
| `app/src-tauri/src/db_notes.rs`(或命令层) | 改:delete_note 后清理图片目录 | ±15 |
96+
97+
### 3.3 依赖变更
98+
99+
```
100+
前端 + remark-breaks@^4(unified 11 系,与 react-markdown 10 / remark-gfm 4 兼容)
101+
Rust 零新依赖(下载走已有 ureq;校验走已有 image crate)
102+
```
103+
104+
## 4. 详细设计
105+
106+
### 4.1 标题切换(#1)
107+
108+
**纯逻辑**(`computeHeadingChanges` / `headingLines` 同步改,规则一致):
109+
110+
```
111+
对每行:
112+
m = 行首 /^#{1,6}\s/ 匹配
113+
行已等于目标级别(m[0].len === level)→ 变更:删 [行首, 行首+level+1)(剥 `#×level+空格`)
114+
其他(其他级别标题或普通段)→ 变更:行首插 `#×level+空格`
115+
—— 其他级别标题先剥后置(先删原标记再插新标记),净效果=替换
116+
```
117+
118+
- 多行选区逐行独立判定;光标落末行标记后(不变)
119+
- `headingCommand` 的 `changes.length === 0` 卫兵保留(全行均无变化时无空 transaction)
120+
- **测试改造**:`headingCommand.test.ts` 原"已是标题的行跳过不叠加"用例改为"同级别行剥除标记/不同级别行置为该级别";`headingLines` 对应用例同步;补 H1→普通段、H2 行点 H1、普通行点 H1 三类 golden
121+
- 高亮闭环:toggle 后光标行级别变化 → `onHeadingLevelChange` 更新 → 按钮高亮同步消失
122+
123+
### 4.2 全站自适应(#2)
124+
125+
**布局装备**(三件套,全站复用):
126+
127+
1. `useColumnWidth(key, { default, min, max })`:state(像素)+ `useEffect` 持久化 `layout:col-width:{key}`;暴露 `width/setWidth/reset`
128+
2. `ColumnResizer`:4px 宽手柄,`onPointerDown` → `setPointerCapture` + 移动回调(方向感知:左列拖右边界 → width 增;右列拖左边界 → 负向);拖拽中 `user-select: none`;最小/最大夹取;双击 = 恢复默认
129+
3. 窄窗自动折叠:页面根容器 `ResizeObserver` + 阈值阶梯(<1100 大纲折叠;<860 组侧栏折叠;<700 中列折叠);**用户手动态优先**——窗口回宽后恢复用户手动折叠/展开选择;折叠/展开态持久化 `layout:col-fold:{key}`
130+
131+
**列迁移清单**(默认宽度=现状值,逐列登记):
132+
133+
| 列 | 当前 | min | max |
134+
|---|---|---|---|
135+
| GroupSidebar | 240 | 180 | 320 |
136+
| NoteListView / FeedFragmentList | 320 | 240 | 420 |
137+
| 大纲 | 180 | 140 | 260 |
138+
| SessionListPanel | 320 | 240 | 420 |
139+
| Knowledge 左 | 260 | 200 | 360 |
140+
| KnowledgeDetailPanel | 320 | 260 | 420 |
141+
| Classroom 右 | 320 | 240 | 420 |
142+
143+
**折叠形态**:窄条 26px(图标容器,title 提示),点击展开;展开态恢复记忆宽度。折叠/窄条组件 `ColumnBar`(≤80 行,随 ColumnResizer 同文件 or 同包)。
144+
145+
**组件内自适应**:
146+
- `NoteImage`:`width: "auto", maxWidth: "100%", maxHeight: "60vh"`(原始比例,窄窗不溢出;点击放大保持不变)
147+
- 工具栏:现有 `flexWrap` 保留;窗口过窄时提示语("Ctrl+Shift+↑↓层级…")允许省略(`display: none` via 容器查询或保留——低优先)
148+
149+
**编排**:`NotesPage` 持有 3 列状态(侧栏/列表/大纲)+ 折叠态,传给受控子组件;`SessionsPage`/`KnowledgePage`/`ClassroomPage` 各自接入同款(每页 1-2 列,工作量机械)。
150+
151+
### 4.3 分组树 + 整列折叠(#3)
152+
153+
**分组树(NoteListView 版)**:
154+
155+
- props 增 `groups: NoteGroup[]`(NotesPage 已有 `groups` state,直接复用)
156+
- 渲染结构:`[📁 未分组]`(`group_id == null`,可折叠)→ 各组按 `GroupSidebar` 同款 kind 排序?**否**——保持 `list_notes`/`search_notes` 返回顺序,仅分组挂载,组顺序=组 id 升序(稳定);组头渲染:chevron(`▸/▾`)+ 组色条(`resolveNoteColor` 已贯通)+ 组名 + `N` 计数
157+
- **交互契约**(v0.12.2 决策 1 保持):组头**名字区点击 = 过滤切换**(`groupFilter` toggle);chevron `stopPropagation` 仅折叠/展开;折叠态持久化 `notes:group-fold:{gid}`(未分组区 `notes:group-fold:none`)
158+
- **退化规则**:`keyword`/`tagFilter` 激活或 `sortMode != "updated-desc"` 时 → 平铺(树上下文搜索无意义);恢复条件清除后还原树
159+
- **批量勾选**:树态下"全选"作用于当前可见集合(与现有"只删可见子集"安全边界一致,`NoteListView.tsx:85` 逻辑保持)
160+
- 空组:**不渲染组头**(无笔记的组不进树;组信息在 GroupSidebar 管理)——避免空头占位噪音
161+
- 组件拆分:`NoteTreeSection`(组头 + 子树容器),NoteListView 保持 ≤300 行
162+
163+
**整列折叠**:
164+
165+
- GroupSidebar / NoteListView / 大纲:`NotesPage` 列状态统一驱动;折叠 = 窄条 26px + 图标(📁/📝/📑)+ 展开
166+
- 大纲列:**移除** `NoteReadingView` 内的独立 `outlineCollapsed` 悬浮按钮体系(`NoteReadingView.tsx:48,92-147`),并入统一列折叠(行为等价:✕ 收起 → 窄条;窄条点击展开)
167+
- 与 4.2 窄窗自动折叠共用同一套 `layout:col-fold:{key}` 状态与 `ColumnBar` 组件
168+
169+
### 4.4 图片落盘三入口 + 清理(#4)
170+
171+
**4.4.1 后端核心提取**(`commands_note_images.rs`):
172+
173+
```
174+
save_image_bytes(data_dir, note_id, bytes, ext_hint) -> Result<String, String>
175+
① 字节数 ≤ IMAGE_MAX_BYTES(10MB)
176+
② image::guess_format + ImageReader 解码验证(坏图拒绝;不信任 MIME/扩展名)
177+
③ 落盘 notes-images/{nid}/{ts}.{ext}(唯一名冲突 _1/_2 复用现有逻辑)
178+
④ 返回 "notes-images/{nid}/{name}"
179+
import_image_file 改为委托(fs 复制→读字节→save_image_bytes)
180+
```
181+
182+
**4.4.2 新命令 `import_note_image_b64(note_id, image_b64)`**:
183+
- base64 解码 → `save_image_bytes`(ext 以解码格式为准)
184+
- 校验:note 存在、`image_b64` 非空、解码后 ≤10MB
185+
- 传输:base64 字符串(10MB 图 ≈ 13MB JSON——与 `capture_fragment` 同款先例)
186+
187+
**4.4.3 新命令 `import_note_image_url(note_id, url)`**:
188+
- 校验 URL → http/https;`ureq` GET(timeout 10s、跟随重定向 ≤3、限流 10MB)
189+
- Content-Type 白名单(image/*)或按 `image::guess_format` 兜底
190+
- `save_image_bytes` 落盘 → 返回相对引用
191+
- 失败 → `Err`(前端降级:插原 URL + status 提示"外链图下载失败,原链接已插入")
192+
193+
**4.4.4 前端接线**:
194+
- 🌐 链接图:`prompt` 后先 invoke `import_note_image_url`,成功插相对引用;失败降级插原 URL(status 提示)
195+
- 剪贴板(4.4.5/4.5):粘贴 → `import_note_image_b64`
196+
- 工具栏文案:`🖼 图片`(本地)"/ 🌐 链接图(下载为本地)"
197+
198+
**4.4.5 删除清理**:`delete_note` 命令/Rust 层在 DB 删除成功后 `remove_dir_all(data_dir/notes-images/{id}/)`,失败 eprintln 不阻断(残留无害、无用户可见错误)
199+
200+
**已知边界(登记)**:正文中手写/粘贴的绝对路径/`data:` 引用不改写、不吸收(用户裁决:入口全落盘后巡检不需要;观察将来频度再议)。
201+
202+
### 4.5 剪贴板导入图片(#5)
203+
204+
- `useClipboardImagePaste`(纯提取,两个编辑器共用):
205+
```
206+
extractClipboardImage(e: ClipboardEvent | React.ClipboardEvent) -> { blob, mime } | null
207+
规则:items 中首个 image/* → blob;多图取第一张;超 10MB 前置拦截(status 提示)
208+
```
209+
- **CM 侧**:extensions 增 `EditorView.domEventHandlers`:
210+
```
211+
"paste" (e):
212+
图 = extractClipboardImage(e)
213+
无图 → return false(默认文本粘贴)
214+
有图 → e.preventDefault() → blobToBase64(提取自 GroupSidebar,移入 utils)→
215+
invoke import_note_image_b64 → 成功:dispatch insert `![图片](rel)\n`(userEvent input.paste)
216+
失败:status 提示(文本粘贴不补救——下一帧用户可 Ctrl+V;诚实降级)
217+
```
218+
- **textarea 侧**:`onPaste` 同款(复用同一 extract + import 函数;插入用 `applyEdit(insertAtCursor(...))`)
219+
- 落盘后 CM decoration 插件自动重算 → 图片即时内联显示
220+
- 权限:`event.clipboardData` 同步读取,WebView2 paste 手势下无需 `navigator.clipboard` 权限弹窗
221+
222+
### 4.6 换行一致(#6)
223+
224+
- `app/package.json` + `remark-breaks@^4`
225+
- `NoteMarkdown.tsx`:`remarkPlugins` 增 `remarkBreaks`(置于 remarkGfm/remarkMath/remarkMarkHighlight 之后)
226+
- 语义:单 `\n` 段落内 → `<br>`;`\` 断行与两空格断行保持;列表/引用内续行同样断行(符合直觉)
227+
- **存量影响**:既有笔记中的软换行从"空格"变"换行"——正是本次修复目标;**全量 vitest 走查**:`NoteReadingView.test.tsx`/`NotesPage.test.tsx`/`NoteMarkdown` 相关断言若有文本拼接断言需同步
228+
- 不加设置项(用户确认直接生效)
229+
230+
## 5. 验收标准
231+
232+
1. **H1**:光标在 `# 标题` 行点 H1 → 变普通段,按钮高亮消失;H2 行点 H1 → `#` 级;多行选区混合级别逐行正确;textarea 降级路径行为一致
233+
2. **自适应**:各页列可拖拽、记忆重启后保持;窗口缩到阈值链 → 大纲/侧栏/列表自动折叠,回宽恢复;图片窄窗不溢出
234+
3. **收起**:分组树组头收起/展开 + 记忆;组头点击=过滤不变;三列均可靠窄条折叠/展开
235+
4. **图片**:剪贴板粘贴图、链接图、文件选择图全部落盘 `notes-images/{nid}/`;删源文件图不丢;删笔记清空其图片目录;坏图/超限/下载失败有提示不脏内容
236+
5. **换行**:编辑态单换行 → 阅读态 `<br>`;强断行(`\`、两空格)不回归
237+
6. 非功能:`cargo test`、vitest、`tsc --noEmit`、`vite build` 全绿;新增文件带 `@ai-context`;单文件 ≤300 行(超限登记豁免)
238+
239+
## 6. 边界(本版不做)
240+
241+
- 手写/粘贴绝对路径引用改写(登记,观察后再定)
242+
- 图片垃圾回收(孤儿图清理仅限删除笔记时;历史孤儿后续单独任务)
243+
- 设置项:硬换行开关、列宽主题化
244+
- 服务端/移动端;无障碍深度打磨
245+
246+
## 7. 实施顺序(自底向上,AGENTS.md §3.6)
247+
248+
1. **纯逻辑层**(先改先测):
249+
- `headingCommand.ts` / `markdownEdit.ts` toggle 语义 + 单测改造
250+
- `commands_note_images.rs`:`save_image_bytes` 提取 + `import_note_image_b64/url` + 删除清理 + 单测
251+
- `NoteMarkdown.tsx` remark-breaks + 渲染断言测试
252+
- `useClipboardImagePaste`、`useColumnWidth`、blobToBase64 移入 utils
253+
2. **业务层**:`NoteTreeSection` 分组树 + NoteListView 改造;NotesPage 列状态编排;RichEditorView/NoteEditView paste 接线 + 链接图命令切换
254+
3. **系统/UI 层**:IPC 注册(`lib.rs`);Sessions/Knowledge/Classroom 列迁移;NoteImage 自适应
255+
4. **文档**:`docs/versions/v0.15.md` + spec 归档 + CHANGELOG(交付时)
256+
5. **验证**:全量测试 + 真机走查清单

0 commit comments

Comments
 (0)