Files
yurou/doc/markdown-editor-design.md
2026-07-16 16:40:00 +08:00

324 lines
14 KiB
Markdown
Raw Permalink 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.
# MarkdownEditor 块级编辑设计文档
## 概述
`MarkdownEditor` 是 yurou语柔的核心编辑组件支持两种编辑模式
| 模式 | 说明 |
|---|---|
| **双栏**split | 左侧 textarea 编辑源码,右侧 unified 渲染 HTML 预览,滚动同步 |
| **单栏**wysiwyg | 逐块渲染与编辑:点击任意块弹出其原始 markdown 源码,失焦后自动重新渲染 |
## 技术栈
- **解析**`remark-parse` + `remark-gfm`GFM 扩展:表格、任务列表、删除线)
- **转换**`remark-rehype`mdast → hast
- **安全**`rehype-sanitize`(过滤 XSS保留 `data-*` 属性)
- **输出**`rehype-stringify`hast → HTML 字符串)
- **框架**Vue 3 Composition API + TypeScript
## 整体架构
```
modelValue (markdown 字符串)
├─── split 模式 ──────────────────────────
│ │
│ ├── textarea (左栏,编辑源码)
│ │ 输入 → emit('update:modelValue')
│ │
│ └── div.markdown-preview (右栏v-html)
│ unified.processSync(md) → HTML
│ 滚动同步textarea.scrollTop ↔ div.scrollTop
└─── wysiwyg 模式 ────────────────────────
├── remark-parse → mdast
│ 提取每个顶层子节点的 position (offset)
├── 每个 block:
│ source = md.slice(start, end) // 原始源码
│ html = unified.processSync(source) // 渲染 HTML
├── blocks[] = [{id, source, html, startOffset, endOffset}, ...]
└── 模板中 v-for:
editingBlockId === block.id
? <textarea v-model="editingSource" @blur="finalize" />
: <div v-html="block.html" @click="startEdit" />
```
## 块级编辑流程
### 1. 解析阶段(`parseBlocks`
```
输入:"### 标题\n\n段落内容\n\n```js\ncode\n```"
remark-parse 构建 mdast:
root
├── heading (depth=3) position: {start: {offset: 0}, end: {offset: 9}}
│ └── text "标题"
├── paragraph position: {start: {offset: 10}, end: {offset: 18}}
│ └── text "段落内容"
└── code (lang="js") position: {start: {offset: 19}, end: {offset: 33}}
└── "code\n"
提取每个顶层节点:
blocks[0] = {id:"b0", source:"### 标题\n\n", startOffset:0, endOffset:9}
blocks[1] = {id:"b1", source:"段落内容\n\n", startOffset:10, endOffset:18}
blocks[2] = {id:"b2", source:"```js\ncode\n```", startOffset:19, endOffset:33}
```
### 2. 渲染阶段
每个 block 独立调用 `unified().processSync(block.source)` 渲染为 HTML
- `"### 标题\n\n"``<h3>标题</h3>`
- `"段落内容\n\n"``<p>段落内容</p>`
- `` "```js\ncode\n```" `` → `<pre><code class="language-js">code\n</code></pre>`
模板中按 `blocks[]` 顺序渲染,每个块是一个 `<div>`hover 时显示暖色背景提示可点击。
### 3. 编辑阶段(`startEdit` → `finalizeEdit`
```
用户点击某个 block
startEdit(block.id)
│ editingBlockId = block.id
│ editingSource = block.source // 显示原始 markdown 源码
│ nextTick → autoResize + focus + 光标移到末尾
<textarea v-model="editingSource" /> 替换该块的 <div>
│ monospace 字体暖色边框auto-resize
│ Ctrl+S → 先 finalize 再触发 save-shortcut
│ Esc → cancelEdit (丢弃修改)
失焦 (blur)
finalizeEdit()
│ 如果 editingSource !== block.source:
│ before = md.slice(0, block.startOffset)
│ after = md.slice(block.endOffset)
│ emit('update:modelValue', before + newSource + after)
父组件 update:modelValue → modelValue 变化
watch(modelValue) → parseBlocks(newMd) → 重新渲染所有 block
```
### 4. 边界情况处理
| 场景 | 处理方式 |
|---|---|
| 空文档 | 显示 placeholder 文本,点击后插入换行符触发解析 |
| 块被删除所有内容 | 替换为空字符串,该块从 mdast 中消失 |
| 块中增加空行 | 该块的 markdown 包含空行 → mdast 解析为多个块 → 下次渲染自动拆分 |
| 代码块内编辑 | 三引号 + 语言标记全部显示在 textarea 中,用户自由编辑 |
| 列表 | 整个列表(含所有 li作为一个块编辑时看到完整列表的 markdown |
| 表格 | 整表作为一个块 |
| 解析失败 | `catch` 后用 `escapeHtml(source)` 显示原文 |
## 双栏模式流程
```
modelValue → unified.processSync(md) → HTML → v-html 渲染
textarea (左) preview (右)
│ │
├─ input → emit │
├─ keydown │
│ ├─ Ctrl+S → save │
│ ├─ Tab → 2 spaces │
│ └─ 括号 → auto-pair │
│ │
└─ scroll → 同步比例 ──────► scrollTop
```
## 数据流
```
App.vue (content ref)
├── :model-value="content"
├── :display-mode="displayMode" ← 设置面板切换
├── @update:model-value="onUpdate" → content = val
└── @save-shortcut="doSave"
DirectorySidebar.vue (设置面板)
├── :display-mode="displayMode"
└── @toggle-display-mode="onToggleDisplayMode"
→ displayMode 切换 + localStorage 持久化
MarkdownEditor.vue
├── split 模式: 与 App.vue 双向绑定,实时渲染预览
└── wysiwyg 模式: 解析 → 块 → 点击编辑 → 局部替换 → emit
```
## 性能考量
- **unified processor** 在组件 setup 时创建一次,全局复用
- **parseBlocks** 每次 modelValue 变化时调用,包含 N 次 `processSync`N = 块数)
- 典型文档50 块)约需 50-100ms在可接受范围内
- 后续可优化为增量更新(只重新渲染变化的块)
- **CodeMirror 实例数** 完整编辑模式保持一个实例;混合模式只为当前活动块创建实例
- **双栏滚动同步** 使用比例计算,避免频繁的绝对位置计算
## 代码编辑体验
Markdown 源码编辑由 CodeMirror 6 提供,完整编辑模式与混合模式的活动块复用
`MarkdownCodeEditor.vue`。组件继续通过 `update:modelValue` 传递纯 Markdown 字符串,
不会改变外层的保存和文件切换数据流。
### 编辑能力
- Markdown 语法着色,以及围栏代码块内部的语言着色
- 括号、引号自动闭合列表续行Tab/Shift+Tab 缩进
- 输入三个反引号或波浪号后自动打开语言提示
- 语言提示支持继续输入筛选、方向键选择、Enter 确认和 Ctrl+Space 手动触发
- 围栏语言确认后按 Enter 自动补齐结束围栏,并将光标放在代码区域
- 最近使用的五种语言通过 localStorage 排在提示列表前面
语言名称、别名和 Shiki 映射集中定义在 `src/lib/markdownLanguages.ts`
无法识别的围栏语言按纯文本处理,不阻断 Markdown 渲染或编辑。
### 预览高亮
预览仍先经过 unified 和 rehype-sanitize。安全过滤完成后可信的 Shiki 高亮器
替换 `<pre><code>` 节点,因此不会放宽 Markdown 原始 HTML 的安全策略。
Shiki 核心和语言语法均按需加载:只有文档包含代码块时才初始化高亮器,只有某种
语言第一次出现在预览中时才加载对应语法。高亮器和已加载语言会在应用生命周期内
复用。Shiki 与 CodeMirror 共用 `--code-*` 语义色变量,每套应用主题同时定义界面色
和代码色CodeMirror 围栏内容还复用 Shiki 的 token 划分,确保混合模式点击前后逐词
颜色一致。切换主题时预览与活动编辑器会即时使用新颜色,无需重新解析文档。
## 样式体系
| 层级 | 设计 |
|---|---|
| 容器 | `border-[#e0dbcf]` 暖米色边框,`shadow-2xl` 阴影,`bg-white` |
| 文字 | `text-[#38342e]` 深棕色,`font-serif` |
| 标题 | `text-[#2d2a26]` 更深,`font-weight: 700` |
| 链接 | `text-[#bf6a3b]` 暖橙色,`underline` |
| 行内代码 | `bg-[#f4f1ea]` 暖灰底,`text-[#bf6a3b]` |
| 引用 | 左边框 `4px solid #bf6a3b`,斜体灰色 |
| 表格 | 斑马条纹 `bg-[#faf9f6]`,表头 `bg-[#f4f1ea]` |
| 块 hover | `bg-[#faf9f6]` 微暖灰,提示可点击 |
| 编辑 textarea | `bg-[#faf9f6]``border-[#bf6a3b]``font-mono` |
| 缩放 | `calc(1rem * var(--editor-zoom, 1))` CSS 变量,由 App.vue 的 Ctrl+滚轮 控制 |
## 粘贴图片/文件功能
### 概述
支持在编辑区域edit 模式和 mixed 模式)中通过 `Ctrl+V` 粘贴剪贴板中的图片或文件。媒体文件自动拷贝到与 markdown 文件同级的隐藏资源文件夹中,编辑区域插入 markdown 图片语法引用。
### 隐藏资源文件夹约定
```
工作目录/
├── doc.md ← markdown 文档
└── .doc/ ← 隐藏资源文件夹(. + 文件名 stem
├── 20260710_140530_a1b2.png
└── 20260710_140531_c3d4.png
```
| 规则 | 说明 |
|---|---|
| 命名 | `{md文件名}``.{stem}/`(如 `test.md``.test/` |
| 可见性 | macOS/Linux`.` 开头自动隐藏WindowsRust 端调用 `attrib +h` 设置隐藏属性 |
| 生命周期 | 随 md 文件创建/删除/重命名自动联动 |
| 引用方式 | markdown 中使用相对路径 `![alt](.doc/xxx.png)` |
### 整体流程
```
用户 Ctrl+V 粘贴图片
┌─────────────────────────────────────────┐
│ textarea @paste 事件 │
│ 检查 clipboardData.items │
│ ├─ image/png, image/jpeg, ... │ ← 截图粘贴
│ └─ kind === 'file' │ ← 文件管理器复制粘贴
│ │
│ 如有媒体项 → e.preventDefault() │
│ 无媒体项 → 走浏览器默认文本粘贴 │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ 前端 processPaste() │
│ ├─ FileReader.readAsDataURL(blob) │
│ ├─ 去掉 "data:xxx;base64," 前缀 │
│ ├─ invoke("save_media_file", { │
│ │ path: ".doc/20260710_xxx.png", │
│ │ data: "base64string" │
│ │ }) │
│ └─ 光标处插入 ![alt](.doc/xxx.png) │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ Rust save_media_file │
│ ├─ base64 decode → Vec<u8> │
│ ├─ ensure_resources_dir() 创建目录 │
│ │ └─ Windows: attrib +h 隐藏 │
│ └─ fs::write() 写入文件 │
└─────────────────────────────────────────┘
```
### 前端关键函数
| 函数 | 位置 | 说明 |
|---|---|---|
| `processPaste()` | `MarkdownEditor.vue` | 核心粘贴处理:检测剪贴板媒体 → 生成文件名 → 调 Rust 保存 → 拼接 markdown 语法 |
| `blobToBase64()` | 同上 | FileReader 读取 blob 为去前缀 base64 字符串 |
| `generateMediaFilename()` | 同上 | `YYYYMMDD_HHmmss_随机4位.ext` |
| `imageExtFromMime()` | 同上 | MIME type → 文件扩展名映射 |
| `onEditorPaste()` | 同上 | edit 模式 textarea 的 paste handler |
| `onBlockPaste()` | 同上 | mixed 模式 block textarea 的 paste handler |
### Rust 端关键函数
| 命令/函数 | 位置 | 说明 |
|---|---|---|
| `save_media_file(path, data)` | `lib.rs` | base64 解码 → 创建目录(+Windows 隐藏) → 写入文件 |
| `resources_dir_path(file_path)` | 同上 | `a/b/doc.md``a/b/.doc`(仅 .md 文件) |
| `ensure_resources_dir(dir_path)` | 同上 | 创建目录 + Windows `attrib +h` 隐藏 |
| `hide_windows_dir(dir_path)` | 同上 | `#[cfg(windows)]` 调用 `attrib +h` |
### 生命周期联动
| 操作 | Rust 行为 | 前端行为 |
|---|---|---|
| 新建 `doc.md` | `create_file` 同时调用 `ensure_resources_dir` 创建 `.doc/` | 无额外操作 |
| 删除 `doc.md` | `delete_entry` 检查并删除 `.doc/`(如存在) | App.vue 关闭文件引用 |
| 重命名 `doc.md``new.md` | `rename_entry` 检查并重命名 `.doc/``.new/` | App.vue 更新 `currentFilePath` |
| 粘贴图片时目录被删 | `save_media_file``ensure_resources_dir` 自动重建 `.doc/` | 正常写入 |
### 编辑模式兼容
paste 事件同时绑定在两种模式的 textarea 上:
- **edit 模式**`@paste="onEditorPaste"` — 按 CodeMirror 当前选区替换 `modelValue`
- **mixed 模式**`@paste="onBlockPaste"` — 按活动块 CodeMirror 选区替换 `editingSource`
粘贴后的 markdown 语法统一为:
```markdown
![原文件名](.doc/20260710_143025_a1b2.png)
```
多张图片粘贴时用换行分隔。视频文件使用相同的 `![alt](path)` 语法,由 `remarkLocalMedia` 插件根据扩展名自动转为 `<video>` 标签。