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

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