# 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"` → `标题
`
- `"段落内容\n\n"` → `段落内容
`
- `` "```js\ncode\n```" `` → `code\n
`
模板中按 `blocks[]` 顺序渲染,每个块是一个 ``,hover 时显示暖色背景提示可点击。
### 3. 编辑阶段(`startEdit` → `finalizeEdit`)
```
用户点击某个 block
│
▼
startEdit(block.id)
│ editingBlockId = block.id
│ editingSource = block.source // 显示原始 markdown 源码
│ nextTick → autoResize + focus + 光标移到末尾
│
▼
替换该块的
│
│ 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+滚轮 控制 |