Files
yurou/doc/markdown-editor-design.md
2026-07-06 18:08:35 +08:00

192 lines
7.1 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+滚轮 控制 |