324 lines
14 KiB
Markdown
324 lines
14 KiB
Markdown
# 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:`.` 开头自动隐藏;Windows:Rust 端调用 `attrib +h` 设置隐藏属性 |
|
||
| 生命周期 | 随 md 文件创建/删除/重命名自动联动 |
|
||
| 引用方式 | markdown 中使用相对路径 `` |
|
||
|
||
### 整体流程
|
||
|
||
```
|
||
用户 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" │
|
||
│ │ }) │
|
||
│ └─ 光标处插入  │
|
||
└──────────────────┬──────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────┐
|
||
│ 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
|
||

|
||
```
|
||
多张图片粘贴时用换行分隔。视频文件使用相同的 `` 语法,由 `remarkLocalMedia` 插件根据扩展名自动转为 `<video>` 标签。
|