# 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 ? :
``` ## 块级编辑流程 ### 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"` → `段落内容
` - `` "```js\ncode\n```" `` → `code\n`
模板中按 `blocks[]` 顺序渲染,每个块是一个 `` 节点,因此不会放宽 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 │
│ ├─ 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` 插件根据扩展名自动转为 `