Files
yurou/doc/markdown-editor-design.md
2026-07-10 16:07:53 +08:00

12 KiB
Raw Blame History

MarkdownEditor 块级编辑设计文档

概述

MarkdownEditor 是 yurou语柔的核心编辑组件支持两种编辑模式

模式 说明
双栏split 左侧 textarea 编辑源码,右侧 unified 渲染 HTML 预览,滚动同步
单栏wysiwyg 逐块渲染与编辑:点击任意块弹出其原始 markdown 源码,失焦后自动重新渲染

技术栈

  • 解析remark-parse + remark-gfmGFM 扩展:表格、任务列表、删除线)
  • 转换remark-rehypemdast → hast
  • 安全rehype-sanitize(过滤 XSS保留 data-* 属性)
  • 输出rehype-stringifyhast → 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. 编辑阶段(startEditfinalizeEdit

用户点击某个 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 次 processSyncN = 块数)
    • 典型文档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. 开头自动隐藏WindowsRust 端调用 attrib +h 设置隐藏属性
生命周期 随 md 文件创建/删除/重命名自动联动
引用方式 markdown 中使用相对路径 ![alt](.doc/xxx.png)

整体流程

用户 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"            │
│  │   })                                 │
│  └─ 光标处插入 ![alt](.doc/xxx.png)     │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│  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.mda/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.mdnew.md rename_entry 检查并重命名 .doc/.new/ App.vue 更新 currentFilePath
粘贴图片时目录被删 save_media_fileensure_resources_dir 自动重建 .doc/ 正常写入

编辑模式兼容

paste 事件同时绑定在两种模式的 textarea 上:

  • edit 模式@paste="onEditorPaste" — 操作 props.modelValue + editorTextareaRef
  • mixed 模式@paste="onBlockPaste" — 操作 editingSource + blockTextareaRef(包含空文档和 block 编辑两种场景)

粘贴后的 markdown 语法统一为:

![原文件名](.doc/20260710_143025_a1b2.png)

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