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

295 lines
12 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.
# 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在可接受范围内
- 后续可优化为增量更新(只重新渲染变化的块)
- **textarea auto-resize** 通过 `scrollHeight` 动态调整高度,无额外 DOM 操作
- **双栏滚动同步** 使用比例计算,避免频繁的绝对位置计算
## 样式体系
| 层级 | 设计 |
|---|---|
| 容器 | `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"` — 操作 `props.modelValue` + `editorTextareaRef`
- **mixed 模式**`@paste="onBlockPaste"` — 操作 `editingSource` + `blockTextareaRef`(包含空文档和 block 编辑两种场景)
粘贴后的 markdown 语法统一为:
```markdown
![原文件名](.doc/20260710_143025_a1b2.png)
```
多张图片粘贴时用换行分隔。视频文件使用相同的 `![alt](path)` 语法,由 `remarkLocalMedia` 插件根据扩展名自动转为 `<video>` 标签。