# 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 中使用相对路径 `![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>` 标签。
