切换markdown渲染为unified
This commit is contained in:
191
doc/markdown-editor-design.md
Normal file
191
doc/markdown-editor-design.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 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+滚轮 控制 |
|
||||
Reference in New Issue
Block a user