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