12 KiB
12 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+滚轮 控制 |
粘贴图片/文件功能
概述
支持在编辑区域(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 中使用相对路径  |
整体流程
用户 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" │
│ │ }) │
│ └─ 光标处插入  │
└──────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 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"— 操作props.modelValue+editorTextareaRef - mixed 模式:
@paste="onBlockPaste"— 操作editingSource+blockTextareaRef(包含空文档和 block 编辑两种场景)
粘贴后的 markdown 语法统一为:

多张图片粘贴时用换行分隔。视频文件使用相同的  语法,由 remarkLocalMedia 插件根据扩展名自动转为 <video> 标签。