富文本编辑器知识体系(一)

22 阅读18分钟

前端富文本编辑器技术选型完全指南

一、富文本编辑器的三大技术流派

在深入选型之前,先理解富文本编辑器的底层实现原理差异:

┌─────────────────────────────────────────────────────────────────┐
                    富文本编辑器技术流派                            
├──────────────────┬──────────────────┬───────────────────────────┤
   L0: 基于            L1: 基于             L2: 完全自绘制            
   contentEditable     contentEditable      不依赖浏览器              
   + execCommand       + 自定义 Model       contentEditable          
├──────────────────┼──────────────────┼───────────────────────────┤
 浏览器原生能力        接管数据模型          自己实现光标、选区、       
 简单但不可控         可控性大幅提升        排版、渲染                 
├──────────────────┼──────────────────┼───────────────────────────┤
 代表:              代表:               代表:                      
 - UEditor          - Slate.js          - Google Docs              
 - wangEditor(v4)  - ProseMirror       - 腾讯文档                  
 - Quill(部分)     - Tiptap            - Canvas/自定义渲染         
                    - Draft.js                                     
                    - Lexical                                      
└──────────────────┴──────────────────┴───────────────────────────┘

二、主流编辑器横向对比

2.1 一览表

维度wangEditorQuillTinyMCESlate.jsProseMirrorTiptapLexical
技术架构L0→L1L0/L1混合L0L1L1L1(基于ProseMirror)L1
框架依赖无(v5)ReactVue/ReactReact
学习曲线⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
可扩展性中高极高极高极高
开箱即用✅ 极好✅ 好✅ 极好❌ 需大量开发❌ 需大量开发✅ 好⚠️ 一般
协同编辑付费插件需自行实现Yjs集成Yjs集成需自行实现
中文支持⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
包体积~200KB~40KB~400KB+~100KB~100KB~150KB~60KB
维护状态活跃较慢活跃(商业)活跃活跃活跃活跃(Meta)
适用场景CMS/后台轻量评论企业级CMS定制编辑器定制编辑器中高定制高性能场景
协议MITBSDMIT/商业MITMITMITMIT

2.2 选型决策树

你的需求是什么?
│
├── 快速上线,功能标准,后台管理系统
│   ├── Vue 项目 → wangEditor v5Tiptap
│   ├── React 项目 → Tiptap(@tiptap/react) 或 Lexical
│   └── 不限框架 → TinyMCE(功能最全)或 wangEditor
│
├── 需要高度定制(自定义块、嵌套结构、特殊交互)
│   ├── React 项目 → Slate.js(最灵活)或 Lexical
│   ├── Vue 项目 → Tiptap(基于ProseMirror,生态好)
│   └── 不限框架 → ProseMirror(底层能力最强)
│
├── 需要协同编辑
│   ├── 预算充足 → TinyMCE 商业版
│   └── 开源方案 → Tiptap + Yjs / ProseMirror + Yjs
│
├── 轻量级(评论框、简单富文本)
│   └── QuillwangEditor(配置精简模式)
│
└── 超大文档、极致性能
    └── LexicalMeta出品,虚拟化渲染)

三、各方案详细代码实战

3.1 wangEditor v5 —— 开箱即用之王

适用场景: 后台管理系统、CMS、博客编辑、中文场景

安装
npm install @wangeditor/editor @wangeditor/editor-for-vue
# Vue3:
# npm install @wangeditor/editor @wangeditor/editor-for-vue@next
Vue 2 完整示例
<template>
  <div class="editor-wrapper">
    <!-- 工具栏 -->
    <Toolbar
      :editor="editor"
      :defaultConfig="toolbarConfig"
      :mode="mode"
      class="toolbar"
    />
    <!-- 编辑区 -->
    <Editor
      :defaultConfig="editorConfig"
      :mode="mode"
      v-model="html"
      class="editor"
      @onCreated="handleCreated"
      @onChange="handleChange"
    />

    <!-- 预览 -->
    <div class="preview">
      <h3>输出 HTML:</h3>
      <div v-html="html" class="preview-content"></div>
    </div>
  </div>
</template>

<script>
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import '@wangeditor/editor/dist/css/style.css';

export default {
  name: 'WangEditorDemo',
  components: { Editor, Toolbar },

  data() {
    return {
      editor: null,
      html: '<p>Hello <strong>wangEditor</strong>!</p>',
      mode: 'default', // 或 'simple' 精简模式

      // 工具栏配置
      toolbarConfig: {
        // 排除不需要的功能
        excludeKeys: [
          'fullScreen', // 排除全屏
          'group-video', // 排除视频
        ],
        // 或者用 toolbarKeys 自定义工具栏顺序
        // toolbarKeys: [ 'bold', 'italic', 'underline', '|', ... ]
      },

      // 编辑器配置
      editorConfig: {
        placeholder: '请输入内容...',
        // 所有粘贴配置
        MENU_CONF: {
          // 上传图片配置
          uploadImage: {
            server: '/api/upload/image',
            fieldName: 'file',
            maxFileSize: 5 * 1024 * 1024, // 5MB
            maxNumberOfFiles: 10,
            allowedFileTypes: ['image/*'],
            // 自定义请求头
            headers: {
              Authorization: 'Bearer xxx',
            },
            // 自定义插入图片(服务端返回格式不统一时)
            customInsert(res, insertFn) {
              const { url, alt, href } = res.data;
              insertFn(url, alt, href);
            },
            // 上传进度回调
            onProgress(progress) {
              console.log('上传进度:', progress);
            },
            onSuccess(file, res) {
              console.log('上传成功:', file.name);
            },
            onFailed(file, res) {
              console.error('上传失败:', file.name);
            },
            onError(file, err, res) {
              console.error('上传错误:', err);
            },
          },

          // 上传视频配置
          uploadVideo: {
            server: '/api/upload/video',
            fieldName: 'file',
            maxFileSize: 100 * 1024 * 1024, // 100MB
          },

          // 代码高亮语言配置
          codeSelectLang: {
            codeLangs: [
              { text: 'JavaScript', value: 'javascript' },
              { text: 'TypeScript', value: 'typescript' },
              { text: 'HTML', value: 'html' },
              { text: 'CSS', value: 'css' },
              { text: 'Python', value: 'python' },
              { text: 'Java', value: 'java' },
            ],
          },
        },
      },
    };
  },

  methods: {
    handleCreated(editor) {
      this.editor = Object.seal(editor); // 用 Object.seal 冻结 editor
      console.log('编辑器创建完成', editor);
    },

    handleChange(editor) {
      // 获取纯文本
      const text = editor.getText();
      // 获取 HTML
      const html = editor.getHtml();
      // 获取 JSON(结构化数据)
      const json = editor.children;

      console.log('内容变化:', { textLength: text.length });

      // 你可以在这里做自动保存、字数统计等
      this.$emit('change', { html, text, json });
    },

    // 外部调用:获取内容
    getContent() {
      return {
        html: this.editor.getHtml(),
        text: this.editor.getText(),
        json: this.editor.children,
      };
    },

    // 外部调用:设置内容
    setContent(html) {
      this.editor.setHtml(html);
    },

    // 外部调用:清空内容
    clear() {
      this.editor.clear();
    },

    // 外部调用:禁用/启用编辑
    toggleDisable(disabled) {
      if (disabled) {
        this.editor.disable();
      } else {
        this.editor.enable();
      }
    },
  },

  // 组件销毁时,销毁编辑器
  beforeDestroy() {
    if (this.editor) {
      this.editor.destroy();
    }
  },
};
</script>

<style scoped>
.editor-wrapper {
  border: 1px solid #e8e8e8;
  border-radius: 4px;
  overflow: hidden;
}
.toolbar {
  border-bottom: 1px solid #e8e8e8;
}
.editor {
  height: 400px;
  overflow-y: auto;
}
.preview {
  padding: 16px;
  border-top: 1px dashed #e8e8e8;
  background: #fafafa;
}
.preview-content {
  padding: 12px;
  background: white;
  border: 1px solid #eee;
  border-radius: 4px;
  min-height: 100px;
}
</style>
自定义扩展:@提及功能
// mention-plugin.js
import { Boot } from '@wangeditor/editor';

// 定义 mention 元素节点
const mentionModule = {
  // 注册新元素
  editorPlugin(editor) {
    const { isInline, isVoid } = editor;

    // mention 是行内元素
    editor.isInline = (elem) => {
      if (elem.type === 'mention') return true;
      return isInline(elem);
    };

    // mention 是 void 元素(不可编辑内部)
    editor.isVoid = (elem) => {
      if (elem.type === 'mention') return true;
      return isVoid(elem);
    };

    return editor;
  },

  // 渲染为 HTML
  elemsToHtml: [
    {
      type: 'mention',
      elemToHtml: (elem) => {
        const { value, info } = elem;
        return `<span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="${value}" data-info='${JSON.stringify(info)}'>@${info.name}</span>`;
      },
    },
  ],

  // 从 HTML 解析
  parseElemsHtml: [
    {
      selector: 'span[data-w-e-type="mention"]',
      parseElemHtml: (domElem) => {
        const value = domElem.getAttribute('data-value') || '';
        const info = JSON.parse(domElem.getAttribute('data-info') || '{}');
        return { type: 'mention', value, info, children: [{ text: '' }] };
      },
    },
  ],

  // 渲染到编辑器
  renderElems: [
    {
      type: 'mention',
      renderElem: (elem) => {
        const span = document.createElement('span');
        span.style.cssText = 'color: #1890ff; background: #e6f7ff; padding: 0 4px; border-radius: 2px; cursor: pointer;';
        span.textContent = `@${elem.info?.name || ''}`;
        return span;
      },
    },
  ],
};

// 注册模块
Boot.registerModule(mentionModule);

3.2 Tiptap —— 现代化最佳实践

适用场景: 中高度定制需求、Notion-like 编辑器、需要协同编辑

安装
# 核心
npm install @tiptap/vue-2 @tiptap/starter-kit

# 常用扩展
npm install @tiptap/extension-image @tiptap/extension-link \
  @tiptap/extension-placeholder @tiptap/extension-code-block-lowlight \
  @tiptap/extension-color @tiptap/extension-text-style \
  @tiptap/extension-task-list @tiptap/extension-task-item \
  @tiptap/extension-table @tiptap/extension-table-row \
  @tiptap/extension-table-cell @tiptap/extension-table-header \
  @tiptap/extension-character-count

# 代码高亮
npm install lowlight
Vue 2 完整示例
<template>
  <div class="tiptap-editor" :class="{ focused: isFocused }">
    <!-- 工具栏 -->
    <div v-if="editor" class="toolbar">
      <!-- 标题 -->
      <div class="toolbar-group">
        <select
          :value="currentHeading"
          @change="setHeading($event.target.value)"
          class="toolbar-select"
        >
          <option value="paragraph">正文</option>
          <option value="1">标题 1</option>
          <option value="2">标题 2</option>
          <option value="3">标题 3</option>
          <option value="4">标题 4</option>
        </select>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 基础格式 -->
      <div class="toolbar-group">
        <button
          @click="editor.chain().focus().toggleBold().run()"
          :class="{ active: editor.isActive('bold') }"
          title="加粗 (Ctrl+B)"
        >
          <strong>B</strong>
        </button>
        <button
          @click="editor.chain().focus().toggleItalic().run()"
          :class="{ active: editor.isActive('italic') }"
          title="斜体 (Ctrl+I)"
        >
          <em>I</em>
        </button>
        <button
          @click="editor.chain().focus().toggleStrike().run()"
          :class="{ active: editor.isActive('strike') }"
          title="删除线"
        >
          <s>S</s>
        </button>
        <button
          @click="editor.chain().focus().toggleCode().run()"
          :class="{ active: editor.isActive('code') }"
          title="行内代码"
        >
          &lt;/&gt;
        </button>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 文字颜色 -->
      <div class="toolbar-group">
        <input
          type="color"
          :value="editor.getAttributes('textStyle').color || '#000000'"
          @input="editor.chain().focus().setColor($event.target.value).run()"
          title="文字颜色"
          class="color-picker"
        />
      </div>

      <div class="toolbar-divider"></div>

      <!-- 列表 -->
      <div class="toolbar-group">
        <button
          @click="editor.chain().focus().toggleBulletList().run()"
          :class="{ active: editor.isActive('bulletList') }"
          title="无序列表"
        >
          • 列表
        </button>
        <button
          @click="editor.chain().focus().toggleOrderedList().run()"
          :class="{ active: editor.isActive('orderedList') }"
          title="有序列表"
        >
          1. 列表
        </button>
        <button
          @click="editor.chain().focus().toggleTaskList().run()"
          :class="{ active: editor.isActive('taskList') }"
          title="任务列表"
        >
          ☑ 任务
        </button>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 引用 & 代码块 -->
      <div class="toolbar-group">
        <button
          @click="editor.chain().focus().toggleBlockquote().run()"
          :class="{ active: editor.isActive('blockquote') }"
          title="引用"
        >
          引用
        </button>
        <button
          @click="editor.chain().focus().toggleCodeBlock().run()"
          :class="{ active: editor.isActive('codeBlock') }"
          title="代码块"
        >
          代码块
        </button>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 插入 -->
      <div class="toolbar-group">
        <button @click="addImage" title="插入图片">
          🖼 图片
        </button>
        <button @click="setLink" title="插入链接">
          🔗 链接
        </button>
        <button
          @click="editor.chain().focus().setHorizontalRule().run()"
          title="分割线"
        >
          ── 分割线
        </button>
        <button @click="insertTable" title="插入表格">
          📊 表格
        </button>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 撤销/重做 -->
      <div class="toolbar-group">
        <button
          @click="editor.chain().focus().undo().run()"
          :disabled="!editor.can().undo()"
          title="撤销 (Ctrl+Z)"
        >
          ↩ 撤销
        </button>
        <button
          @click="editor.chain().focus().redo().run()"
          :disabled="!editor.can().redo()"
          title="重做 (Ctrl+Shift+Z)"
        >
          ↪ 重做
        </button>
      </div>
    </div>

    <!-- 编辑区域 -->
    <editor-content :editor="editor" class="editor-content" />

    <!-- 底部状态栏 -->
    <div v-if="editor" class="status-bar">
      <span>{{ characterCount }} 字符</span>
      <span>{{ wordCount }} 词</span>
    </div>
  </div>
</template>

<script>
import { Editor, EditorContent } from '@tiptap/vue-2';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import TextStyle from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import CharacterCount from '@tiptap/extension-character-count';
import { lowlight } from 'lowlight';

export default {
  name: 'TiptapEditor',
  components: { EditorContent },

  props: {
    value: { type: String, default: '' },
    editable: { type: Boolean, default: true },
    maxLength: { type: Number, default: null },
  },

  data() {
    return {
      editor: null,
      isFocused: false,
    };
  },

  computed: {
    characterCount() {
      return this.editor?.storage.characterCount.characters() || 0;
    },
    wordCount() {
      return this.editor?.storage.characterCount.words() || 0;
    },
    currentHeading() {
      if (!this.editor) return 'paragraph';
      for (let i = 1; i <= 4; i++) {
        if (this.editor.isActive('heading', { level: i })) return String(i);
      }
      return 'paragraph';
    },
  },

  mounted() {
    this.editor = new Editor({
      // 内容
      content: this.value,

      // 是否可编辑
      editable: this.editable,

      // 扩展列表——Tiptap 的核心设计:一切皆扩展
      extensions: [
        // StarterKit 包含了基础扩展(段落、标题、加粗、斜体等)
        // 但我们要用 CodeBlockLowlight 替换默认的 codeBlock
        StarterKit.configure({
          codeBlock: false, // 禁用默认代码块,用高亮版替换
        }),

        // 图片
        Image.configure({
          inline: true,
          allowBase64: true,
          HTMLAttributes: {
            class: 'editor-image',
          },
        }),

        // 链接
        Link.configure({
          openOnClick: false, // 编辑模式下点击不跳转
          autolink: true,     // 自动识别URL
          linkOnPaste: true,  // 粘贴时自动转链接
          HTMLAttributes: {
            target: '_blank',
            rel: 'noopener noreferrer',
          },
        }),

        // 占位符
        Placeholder.configure({
          placeholder: '开始写作...',
        }),

        // 代码块 + 语法高亮
        CodeBlockLowlight.configure({
          lowlight,
          defaultLanguage: 'javascript',
        }),

        // 文字颜色
        TextStyle,
        Color,

        // 任务列表
        TaskList,
        TaskItem.configure({
          nested: true, // 支持嵌套
        }),

        // 表格
        Table.configure({
          resizable: true,
        }),
        TableRow,
        TableCell,
        TableHeader,

        // 字数统计
        CharacterCount.configure({
          limit: this.maxLength,
        }),
      ],

      // 事件回调
      onUpdate: ({ editor }) => {
        const html = editor.getHTML();
        this.$emit('input', html); // v-model 支持
        this.$emit('change', {
          html,
          json: editor.getJSON(),
          text: editor.getText(),
        });
      },

      onFocus: () => {
        this.isFocused = true;
        this.$emit('focus');
      },

      onBlur: () => {
        this.isFocused = false;
        this.$emit('blur');
      },

      onSelectionUpdate: ({ editor }) => {
        this.$emit('selection-change', editor);
      },
    });
  },

  methods: {
    // 设置标题级别
    setHeading(level) {
      if (level === 'paragraph') {
        this.editor.chain().focus().setParagraph().run();
      } else {
        this.editor
          .chain()
          .focus()
          .toggleHeading({ level: parseInt(level) })
          .run();
      }
    },

    // 插入图片
    addImage() {
      const url = window.prompt('请输入图片 URL:');
      if (url) {
        this.editor.chain().focus().setImage({ src: url }).run();
      }
    },

    // 文件上传方式插入图片
    async uploadImage(file) {
      const formData = new FormData();
      formData.append('file', file);
      try {
        const res = await fetch('/api/upload', {
          method: 'POST',
          body: formData,
        });
        const { url } = await res.json();
        this.editor.chain().focus().setImage({ src: url }).run();
      } catch (err) {
        console.error('图片上传失败:', err);
      }
    },

    // 设置链接
    setLink() {
      const previousUrl = this.editor.getAttributes('link').href;
      const url = window.prompt('请输入链接 URL:', previousUrl);

      if (url === null) return; // 取消
      if (url === '') {
        // 移除链接
        this.editor.chain().focus().extendMarkRange('link').unsetLink().run();
        return;
      }

      this.editor
        .chain()
        .focus()
        .extendMarkRange('link')
        .setLink({ href: url })
        .run();
    },

    // 插入表格
    insertTable() {
      this.editor
        .chain()
        .focus()
        .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
        .run();
    },

    // 外部API:获取内容
    getContent() {
      return {
        html: this.editor.getHTML(),
        json: this.editor.getJSON(),
        text: this.editor.getText(),
      };
    },

    // 外部API:设置内容
    setContent(content) {
      this.editor.commands.setContent(content);
    },
  },

  watch: {
    value(newVal) {
      const currentHtml = this.editor.getHTML();
      if (newVal !== currentHtml) {
        this.editor.commands.setContent(newVal, false);
      }
    },
    editable(newVal) {
      this.editor.setEditable(newVal);
    },
  },

  beforeDestroy() {
    this.editor?.destroy();
  },
};
</script>

<style>
/* 编辑器外框 */
.tiptap-editor {
  border: 1px solid #d9d9d9;
  border-radius: 8px;
  overflow: hidden;
  transition: border-color 0.2s;
}
.tiptap-editor.focused {
  border-color: #1890ff;
  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}

/* 工具栏 */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 2px;
  padding: 8px;
  border-bottom: 1px solid #e8e8e8;
  background: #fafafa;
}
.toolbar-group {
  display: flex;
  gap: 2px;
}
.toolbar-divider {
  width: 1px;
  height: 24px;
  background: #d9d9d9;
  margin: 0 6px;
}
.toolbar button {
  padding: 4px 8px;
  border: 1px solid transparent;
  border-radius: 4px;
  background: transparent;
  cursor: pointer;
  font-size: 13px;
  color: #333;
  transition: all 0.15s;
  white-space: nowrap;
}
.toolbar button:hover {
  background: #e6f7ff;
  border-color: #91d5ff;
}
.toolbar button.active {
  background: #1890ff;
  color: white;
  border-color: #1890ff;
}
.toolbar button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
.toolbar-select {
  padding: 4px 8px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  font-size: 13px;
  background: white;
  cursor: pointer;
}

.color-picker {
  width: 32px;
  height: 28px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  padding: 2px;
  cursor: pointer;
  background: white;
}

/* 编辑区域 */
.editor-content {
  padding: 16px 20px;
  min-height: 300px;
  max-height: 600px;
  overflow-y: auto;
}

/* ============ Tiptap 内部内容样式(ProseMirror)============ */
/* 注意:这些样式不能加 scoped,因为是渲染在 .ProseMirror 内部的 */
.ProseMirror {
  outline: none;
  font-size: 15px;
  line-height: 1.75;
  color: #333;
}

.ProseMirror > * + * {
  margin-top: 0.75em;
}

/* 占位符 */
.ProseMirror p.is-editor-empty:first-child::before {
  color: #adb5bd;
  content: attr(data-placeholder);
  float: left;
  height: 0;
  pointer-events: none;
}

/* 标题 */
.ProseMirror h1 { font-size: 2em; font-weight: 700; margin-top: 1em; }
.ProseMirror h2 { font-size: 1.5em; font-weight: 700; margin-top: 0.8em; }
.ProseMirror h3 { font-size: 1.25em; font-weight: 600; margin-top: 0.6em; }
.ProseMirror h4 { font-size: 1.1em; font-weight: 600; margin-top: 0.5em; }

/* 引用 */
.ProseMirror blockquote {
  border-left: 4px solid #1890ff;
  padding-left: 16px;
  margin-left: 0;
  color: #666;
  background: #f9f9f9;
  padding: 12px 16px;
  border-radius: 0 4px 4px 0;
}

/* 代码块 */
.ProseMirror pre {
  background: #282c34;
  color: #abb2bf;
  border-radius: 8px;
  padding: 16px;
  overflow-x: auto;
  font-family: 'Fira Code', 'Consolas', monospace;
  font-size: 14px;
  line-height: 1.5;
}
.ProseMirror pre code {
  background: none;
  color: inherit;
  padding: 0;
}

/* 行内代码 */
.ProseMirror code {
  background: #f0f0f0;
  color: #d63384;
  padding: 2px 6px;
  border-radius: 3px;
  font-size: 0.9em;
}

/* 链接 */
.ProseMirror a {
  color: #1890ff;
  text-decoration: underline;
  cursor: pointer;
}

/* 图片 */
.ProseMirror img.editor-image {
  max-width: 100%;
  height: auto;
  border-radius: 4px;
  margin: 8px 0;
}

/* 任务列表 */
.ProseMirror ul[data-type="taskList"] {
  list-style: none;
  padding-left: 0;
}
.ProseMirror ul[data-type="taskList"] li {
  display: flex;
  align-items: flex-start;
  gap: 8px;
}
.ProseMirror ul[data-type="taskList"] li label {
  margin-top: 3px;
}
.ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p {
  text-decoration: line-through;
  color: #999;
}

/* 表格 */
.ProseMirror table {
  border-collapse: collapse;
  width: 100%;
  margin: 12px 0;
}
.ProseMirror th,
.ProseMirror td {
  border: 1px solid #d9d9d9;
  padding: 8px 12px;
  text-align: left;
  min-width: 80px;
}
.ProseMirror th {
  background: #fafafa;
  font-weight: 600;
}
.ProseMirror .selectedCell {
  background: #e6f7ff;
}

/* 分割线 */
.ProseMirror hr {
  border: none;
  border-top: 2px solid #e8e8e8;
  margin: 20px 0;
}

/* 状态栏 */
.status-bar {
  display: flex;
  gap: 16px;
  padding: 6px 16px;
  border-top: 1px solid #e8e8e8;
  background: #fafafa;
  font-size: 12px;
  color: #999;
}
</style>

3.3 Tiptap 自定义扩展:@提及(Mention)

这是 Tiptap 最强大的能力——自定义 Node/Mark 扩展:

src/extensions/MentionExtension.js

import { Node, mergeAttributes } from '@tiptap/core';
import { VueRenderer } from '@tiptap/vue-2';
import tippy from 'tippy.js';
import MentionList from './MentionList.vue';

/**
 * 自定义 Mention 扩展
 * 输入 @ 后弹出用户列表,选择后插入 @用户名 标签
 */
export default Node.create({
  name: 'mention',

  // 定义为行内元素、void 元素(不可编辑内部内容)
  group: 'inline',
  inline: true,
  selectable: false,
  atom: true, // 作为一个原子节点(整体选中/删除)

  // 定义该节点的属性
  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: (element) => element.getAttribute('data-mention-id'),
        renderHTML: (attributes) => ({
          'data-mention-id': attributes.id,
        }),
      },
      label: {
        default: null,
        parseHTML: (element) => element.getAttribute('data-mention-label'),
        renderHTML: (attributes) => ({
          'data-mention-label': attributes.label,
        }),
      },
    };
  },

  // 从 HTML 解析
  parseHTML() {
    return [
      {
        tag: 'span[data-type="mention"]',
      },
    ];
  },

  // 渲染为 HTML
  renderHTML({ node, HTMLAttributes }) {
    return [
      'span',
      mergeAttributes(
        {
          'data-type': 'mention',
          class: 'mention-tag',
        },
        HTMLAttributes
      ),
      `@${node.attrs.label}`,
    ];
  },

  // 渲染为文本(用于 getText())
  renderText({ node }) {
    return `@${node.attrs.label}`;
  },

  // 添加输入建议(核心:@ 触发)
  addProseMirrorPlugins() {
    const editor = this.editor;

    return [
      // 使用 ProseMirror 插件监听输入
      createMentionPlugin({
        editor,
        char: '@', // 触发字符
        // 查询用户列表的函数
        items: async (query) => {
          // 这里可以调用 API 搜索用户
          const allUsers = [
            { id: 1, name: '张三', avatar: '👤' },
            { id: 2, name: '李四', avatar: '👤' },
            { id: 3, name: '王五', avatar: '👤' },
            { id: 4, name: '赵六', avatar: '👤' },
            { id: 5, name: 'Admin', avatar: '👑' },
          ];

          return allUsers
            .filter((user) =>
              user.name.toLowerCase().includes(query.toLowerCase())
            )
            .slice(0, 5);
        },
        // 渲染下拉列表
        render: () => {
          let component;
          let popup;

          return {
            onStart: (props) => {
              component = new VueRenderer(MentionList, {
                parent: this,
                propsData: props,
              });

              popup = tippy('body', {
                getReferenceClientRect: props.clientRect,
                appendTo: () => document.body,
                content: component.element,
                showOnCreate: true,
                interactive: true,
                trigger: 'manual',
                placement: 'bottom-start',
              });
            },

            onUpdate: (props) => {
              component.updateProps(props);
              popup[0].setProps({
                getReferenceClientRect: props.clientRect,
              });
            },

            onKeyDown: (props) => {
              if (props.event.key === 'Escape') {
                popup[0].hide();
                return true;
              }
              return component.ref?.onKeyDown(props.event);
            },

            onExit: () => {
              popup[0].destroy();
              component.destroy();
            },
          };
        },
      }),
    ];
  },
});

/**
 * 创建 Mention 的 ProseMirror 插件(简化版)
 */
function createMentionPlugin({ editor, char, items, render }) {
  const { Plugin, PluginKey } = require('prosemirror-state');

  return new Plugin({
    key: new PluginKey('mention'),

    state: {
      init() {
        return { active: false, query: '', range: null };
      },
      apply(tr, prev) {
        const meta = tr.getMeta('mention');
        if (meta) return meta;
        if (tr.docChanged) return { active: false, query: '', range: null };
        return prev;
      },
    },

    view() {
      let rendererInstance = null;

      return {
        update: async (view, prevState) => {
          const { state } = view;
          const { selection } = state;
          const { $from } = selection;

          // 检测光标前是否有 @ 字符
          const textBefore = $from.parent.textContent.slice(0, $from.parentOffset);
          const match = textBefore.match(new RegExp(`\\${char}([\\w\\u4e00-\\u9fa5]*)$`));

          if (!match) {
            if (rendererInstance) {
              rendererInstance.onExit();
              rendererInstance = null;
            }
            return;
          }

          const query = match[1];
          const from = $from.pos - query.length - 1;
          const to = $from.pos;

          const matchedItems = await items(query);

          const props = {
            editor,
            query,
            items: matchedItems,
            clientRect: () => {
              const coords = view.coordsAtPos(from);
              return new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
            },
            command: (item) => {
              editor
                .chain()
                .focus()
                .deleteRange({ from, to })
                .insertContent({
                  type: 'mention',
                  attrs: { id: item.id, label: item.name },
                })
                .insertContent(' ')
                .run();
            },
          };

          if (!rendererInstance) {
            rendererInstance = render();
            rendererInstance.onStart(props);
          } else {
            rendererInstance.onUpdate(props);
          }
        },

        destroy() {
          if (rendererInstance) {
            rendererInstance.onExit();
          }
        },
      };
    },
  });
}

src/extensions/MentionList.vue

<template>
  <div class="mention-list">
    <div
      v-for="(item, index) in items"
      :key="item.id"
      class="mention-item"
      :class="{ selected: index === selectedIndex }"
      @click="selectItem(index)"
      @mouseenter="selectedIndex = index"
    >
      <span class="avatar">{{ item.avatar }}</span>
      <span class="name">{{ item.name }}</span>
    </div>
    <div v-if="!items.length" class="mention-empty">
      未找到匹配用户
    </div>
  </div>
</template>

<script>
export default {
  name: 'MentionList',
  props: {
    items: { type: Array, required: true },
    command: { type: Function, required: true },
  },

  data() {
    return { selectedIndex: 0 };
  },

  watch: {
    items() {
      this.selectedIndex = 0;
    },
  },

  methods: {
    onKeyDown(event) {
      if (event.key === 'ArrowUp') {
        this.selectedIndex =
          (this.selectedIndex - 1 + this.items.length) % this.items.length;
        return true;
      }
      if (event.key === 'ArrowDown') {
        this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
        return true;
      }
      if (event.key === 'Enter') {
        this.selectItem(this.selectedIndex);
        return true;
      }
      return false;
    },

    selectItem(index) {
      const item = this.items[index];
      if (item) {
        this.command(item);
      }
    },
  },
};
</script>

<style scoped>
.mention-list {
  background: white;
  border: 1px solid #e8e8e8;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  padding: 4px;
  min-width: 180px;
}
.mention-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.1s;
}
.mention-item.selected,
.mention-item:hover {
  background: #e6f7ff;
}
.avatar {
  font-size: 18px;
}
.name {
  font-size: 14px;
  color: #333;
}
.mention-empty {
  padding: 12px;
  text-align: center;
  color: #999;
  font-size: 13px;
}
</style>

3.4 Slate.js (React) —— 极致灵活的底层框架

适用场景: 高度自定义编辑器(类 Notion、飞书文档)

安装
npm install slate slate-react slate-history
完整示例
import React, { useState, useCallback, useMemo } from 'react';
import { createEditor, Editor, Transforms, Text, Element as SlateElement } from 'slate';
import { Slate, Editable, withReact, useSlate, useSelected, useFocused } from 'slate-react';
import { withHistory } from 'slate-history';

// ============ 1. 自定义元素渲染 ============

/**
 * Slate 的核心理念:你完全控制每个节点如何渲染
 * 通过 renderElement 和 renderLeaf 两个函数
 */
const RenderElement = ({ attributes, children, element }) => {
  // attributes 必须展开到最外层 DOM
  // children 必须作为子节点渲染
  switch (element.type) {
    case 'heading-one':
      return <h1 {...attributes} style={{ fontSize: '2em', fontWeight: 700, marginTop: '0.5em' }}>{children}</h1>;

    case 'heading-two':
      return <h2 {...attributes} style={{ fontSize: '1.5em', fontWeight: 700, marginTop: '0.4em' }}>{children}</h2>;

    case 'heading-three':
      return <h3 {...attributes} style={{ fontSize: '1.25em', fontWeight: 600 }}>{children}</h3>;

    case 'blockquote':
      return (
        <blockquote
          {...attributes}
          style={{
            borderLeft: '4px solid #1890ff',
            paddingLeft: 16,
            color: '#666',
            background: '#f9f9f9',
            padding: '12px 16px',
            borderRadius: '0 4px 4px 0',
            margin: '8px 0',
          }}
        >
          {children}
        </blockquote>
      );

    case 'code-block':
      return (
        <pre
          {...attributes}
          style={{
            background: '#282c34',
            color: '#abb2bf',
            padding: 16,
            borderRadius: 8,
            fontFamily: "'Fira Code', monospace",
            fontSize: 14,
            overflow: 'auto',
          }}
        >
          <code>{children}</code>
        </pre>
      );

    case 'bulleted-list':
      return <ul {...attributes} style={{ paddingLeft: 24 }}>{children}</ul>;

    case 'numbered-list':
      return <ol {...attributes} style={{ paddingLeft: 24 }}>{children}</ol>;

    case 'list-item':
      return <li {...attributes}>{children}</li>;

    case 'image':
      return <ImageElement attributes={attributes} element={element}>{children}</ImageElement>;

    case 'divider':
      return (
        <div {...attributes} contentEditable={false} style={{ margin: '20px 0' }}>
          <hr style={{ border: 'none', borderTop: '2px solid #e8e8e8' }} />
          {children}
        </div>
      );

    default:
      return <p {...attributes} style={{ marginBottom: '0.5em', lineHeight: 1.75 }}>{children}</p>;
  }
};

/**
 * 叶子节点渲染(处理文本级别的格式:加粗、斜体、颜色等)
 */
const RenderLeaf = ({ attributes, children, leaf }) => {
  let el = children;

  if (leaf.bold) {
    el = <strong>{el}</strong>;
  }
  if (leaf.italic) {
    el = <em>{el}</em>;
  }
  if (leaf.underline) {
    el = <u>{el}</u>;
  }
  if (leaf.strikethrough) {
    el = <s>{el}</s>;
  }
  if (leaf.code) {
    el = (
      <code
        style={{
          background: '#f0f0f0',
          color: '#d63384',
          padding: '2px 6px',
          borderRadius: 3,
          fontSize: '0.9em',
        }}
      >
        {el}
      </code>
    );
  }
  if (leaf.color) {
    el = <span style={{ color: leaf.color }}>{el}</span>;
  }

  return <span {...attributes}>{el}</span>;
};

// ============ 2. 图片元素组件(Void 元素) ============

const ImageElement = ({ attributes, children, element }) => {
  const selected = useSelected();
  const focused = useFocused();

  return (
    <div {...attributes} contentEditable={false}>
      <img
        src={element.url}
        alt={element.alt || ''}
        style={{
          display: 'block',
          maxWidth: '100%',
          borderRadius: 4,
          boxShadow: selected && focused ? '0 0 0 3px #1890ff' : 'none',
          margin: '8px 0',
        }}
      />
      {children}
    </div>
  );
};

// ============ 3. 工具栏组件 ============

const Toolbar = () => {
  const editor = useSlate(); // 获取编辑器实例

  return (
    <div style={{
      display: 'flex',
      flexWrap: 'wrap',
      gap: 2,
      padding: 8,
      borderBottom: '1px solid #e8e8e8',
      background: '#fafafa',
    }}>
      {/* 文本格式 */}
      <MarkButton format="bold" label="B" style={{ fontWeight: 700 }} />
      <MarkButton format="italic" label="I" style={{ fontStyle: 'italic' }} />
      <MarkButton format="underline" label="U" style={{ textDecoration: 'underline' }} />
      <MarkButton format="strikethrough" label="S" style={{ textDecoration: 'line-through' }} />
      <MarkButton format="code" label="</>" />

      <Divider />

      {/* 块级格式 */}
      <BlockButton format="heading-one" label="H1" />
      <BlockButton format="heading-two" label="H2" />
      <BlockButton format="heading-three" label="H3" />
      <BlockButton format="blockquote" label="引用" />
      <BlockButton format="code-block" label="代码块" />

      <Divider />

      {/* 列表 */}
      <BlockButton format="bulleted-list" label="• 列表" />
      <BlockButton format="numbered-list" label="1. 列表" />

      <Divider />

      {/* 插入 */}
      <InsertImageButton />
      <InsertDividerButton />
    </div>
  );
};

const Divider = () => (
  <span style={{
    width: 1, height: 24, background: '#d9d9d9', margin: '0 6px', alignSelf: 'center',
  }} />
);

// ============ 4. 工具栏按钮组件 ============

/**
 * Mark按钮(文本级格式:加粗、斜体等)
 */
const MarkButton = ({ format, label, style = {} }) => {
  const editor = useSlate();
  const isActive = isMarkActive(editor, format);

  return (
    <button
      style={{
        padding: '4px 8px',
        border: '1px solid transparent',
        borderRadius: 4,
        background: isActive ? '#1890ff' : 'transparent',
        color: isActive ? 'white' : '#333',
        cursor: 'pointer',
        fontSize: 13,
        ...style,
      }}
      onMouseDown={(e) => {
        e.preventDefault(); // 防止失去焦点
        toggleMark(editor, format);
      }}
    >
      {label}
    </button>
  );
};

/**
 * Block按钮(块级格式:标题、引用等)
 */
const BlockButton = ({ format, label }) => {
  const editor = useSlate();
  const isActive = isBlockActive(editor, format);

  return (
    <button
      style={{
        padding: '4px 8px',
        border: '1px solid transparent',
        borderRadius: 4,
        background: isActive ? '#1890ff' : 'transparent',
        color: isActive ? 'white' : '#333',
        cursor: 'pointer',
        fontSize: 13,
      }}
      onMouseDown={(e) => {
        e.preventDefault();
        toggleBlock(editor, format);
      }}
    >
      {label}
    </button>
  );
};

/**
 * 插入图片按钮
 */
const InsertImageButton = () => {
  const editor = useSlate();

  return (
    <button
      style={{
        padding: '4px 8px', border: '1px solid transparent',
        borderRadius: 4, cursor: 'pointer', fontSize: 13,
      }}
      onMouseDown={(e) => {
        e.preventDefault();
        const url = window.prompt('请输入图片URL:');
        if (url) {
          insertImage(editor, url);
        }
      }}
    >
      🖼 图片
    </button>
  );
};

/**
 * 插入分割线按钮
 */
const InsertDividerButton = () => {
  const editor = useSlate();

  return (
    <button
      style={{
        padding: '4px 8px', border: '1px solid transparent',
        borderRadius: 4, cursor: 'pointer', fontSize: 13,
      }}
      onMouseDown={(e) => {
        e.preventDefault();
        Transforms.insertNodes(editor, {
          type: 'divider',
          children: [{ text: '' }],
        });
        // 在分割线后插入空段落
        Transforms.insertNodes(editor, {
          type: 'paragraph',
          children: [{ text: '' }],
        });
      }}
    >
      ── 分割线
    </button>
  );
};

// ============ 5. 编辑器操作工具函数 ============

const LIST_TYPES = ['bulleted-list', 'numbered-list'];

/**
 * 检查 Mark 是否激活
 */
function isMarkActive(editor, format) {
  const marks = Editor.marks(editor);
  return marks ? marks[format] === true : false;
}

/**
 * 切换 Mark
 */
function toggleMark(editor, format) {
  const isActive = isMarkActive(editor, format);
  if (isActive) {
    Editor.removeMark(editor, format);
  } else {
    Editor.addMark(editor, format, true);
  }
}

/**
 * 检查 Block 是否激活
 */
function isBlockActive(editor, format) {
  const [match] = Editor.nodes(editor, {
    match: (n) =>
      !Editor.isEditor(n) &&
      SlateElement.isElement(n) &&
      n.type === format,
  });
  return !!match;
}

/**
 * 切换 Block 类型
 */
function toggleBlock(editor, format) {
  const isActive = isBlockActive(editor, format);
  const isList = LIST_TYPES.includes(format);

  // 先解除所有列表包裹
  Transforms.unwrapNodes(editor, {
    match: (n) =>
      !Editor.isEditor(n) &&
      SlateElement.isElement(n) &&
      LIST_TYPES.includes(n.type),
    split: true,
  });

  // 设置节点类型
  Transforms.setNodes(editor, {
    type: isActive ? 'paragraph' : isList ? 'list-item' : format,
  });

  // 如果是列表,需要包裹
  if (!isActive && isList) {
    Transforms.wrapNodes(editor, {
      type: format,
      children: [],
    });
  }
}

/**
 * 插入图片
 */
function insertImage(editor, url) {
  const image = { type: 'image', url, children: [{ text: '' }] };
  Transforms.insertNodes(editor, image);
  // 在图片后插入空段落
  Transforms.insertNodes(editor, {
    type: 'paragraph',
    children: [{ text: '' }],
  });
}

// ============ 6. 自定义 withInlines 插件 ============

/**
 * 告诉编辑器哪些是 Void 元素(不可编辑内部内容的)
 */
function withCustomElements(editor) {
  const { isVoid, isInline } = editor;

  editor.isVoid = (element) => {
    return ['image', 'divider'].includes(element.type)
      ? true
      : isVoid(element);
  };

  return editor;
}

// ============ 7. 主组件 ============

const initialValue = [
  {
    type: 'heading-one',
    children: [{ text: 'Slate.js 富文本编辑器' }],
  },
  {
    type: 'paragraph',
    children: [
      { text: '这是一个' },
      { text: '完全自定义', bold: true },
      { text: '的富文本编辑器。Slate 让你控制' },
      { text: '每一个细节', italic: true, color: '#1890ff' },
      { text: '。' },
    ],
  },
  {
    type: 'blockquote',
    children: [{ text: 'Slate 的理念:提供构建编辑器的积木,而不是一个完整的编辑器。' }],
  },
  {
    type: 'code-block',
    children: [{ text: 'const editor = useMemo(\n  () => withCustomElements(withHistory(withReact(createEditor()))),\n  []\n);' }],
  },
  {
    type: 'paragraph',
    children: [{ text: '' }],
  },
];

export default function SlateEditor() {
  // 创建编辑器实例(useMemo 确保只创建一次)
  const editor = useMemo(
    () => withCustomElements(withHistory(withReact(createEditor()))),
    []
  );

  const [value, setValue] = useState(initialValue);

  const renderElement = useCallback((props) => <RenderElement {...props} />, []);
  const renderLeaf = useCallback((props) => <RenderLeaf {...props} />, []);

  // 快捷键处理
  const handleKeyDown = useCallback(
    (event) => {
      // Ctrl/Cmd + B = 加粗
      if (event.ctrlKey || event.metaKey) {
        switch (event.key) {
          case 'b':
            event.preventDefault();
            toggleMark(editor, 'bold');
            break;
          case 'i':
            event.preventDefault();
            toggleMark(editor, 'italic');
            break;
          case 'u':
            event.preventDefault();
            toggleMark(editor, 'underline');
            break;
          case '`':
            event.preventDefault();
            toggleMark(editor, 'code');
            break;
          default:
            break;
        }
      }

      // Markdown 快捷输入(在行首输入特定字符后按空格触发)
      if (event.key === ' ') {
        const { selection } = editor;
        if (selection && selection.anchor.offset > 0) {
          const [node] = Editor.node(editor, selection);
          if (Text.isText(node)) {
            const textBeforeCursor = node.text.slice(0, selection.anchor.offset);

            // # + 空格 = H1
            if (textBeforeCursor === '#') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'heading-one');
              return;
            }
            // ## + 空格 = H2
            if (textBeforeCursor === '##') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'heading-two');
              return;
            }
            // ### + 空格 = H3
            if (textBeforeCursor === '###') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'heading-three');
              return;
            }
            // > + 空格 = 引用
            if (textBeforeCursor === '>') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'blockquote');
              return;
            }
            // - 或 * + 空格 = 无序列表
            if (textBeforeCursor === '-' || textBeforeCursor === '*') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'bulleted-list');
              return;
            }
            // 1. + 空格 = 有序列表
            if (/^\d+\.$/.test(textBeforeCursor)) {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'numbered-list');
              return;
            }
            // ``` + 空格 = 代码块
            if (textBeforeCursor === '```') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'code-block');
              return;
            }
          }
        }
      }

      // Enter 在代码块中:插入换行而不是新段落
      if (event.key === 'Enter' && !event.shiftKey) {
        const [codeBlock] = Editor.nodes(editor, {
          match: (n) => SlateElement.isElement(n) && n.type === 'code-block',
        });
        if (codeBlock) {
          event.preventDefault();
          editor.insertText('\n');
          return;
        }
      }
    },
    [editor]
  );

  return (
    <div style={{
      border: '1px solid #d9d9d9',
      borderRadius: 8,
      overflow: 'hidden',
      maxWidth: 800,
      margin: '40px auto',
    }}>
      <Slate
        editor={editor}
        value={value}
        onChange={(newValue) => {
          setValue(newValue);

          // 检查内容是否真的变了(排除纯选区变化)
          const isContentChange = editor.operations.some(
            (op) => op.type !== 'set_selection'
          );
          if (isContentChange) {
            // 自动保存、同步等
            console.log('内容变化:', JSON.stringify(newValue));
          }
        }}
      >
        <Toolbar />
        <div style={{ padding: '16px 20px', minHeight: 300 }}>
          <Editable
            renderElement={renderElement}
            renderLeaf={renderLeaf}
            onKeyDown={handleKeyDown}
            placeholder="开始写作...(支持 Markdown 快捷输入)"
            spellCheck
            autoFocus
            style={{
              outline: 'none',
              fontSize: 15,
              lineHeight: 1.75,
            }}
          />
        </div>

        {/* 底部状态栏 */}
        <StatusBar />
      </Slate>
    </div>
  );
}

/**
 * 状态栏组件
 */
const StatusBar = () => {
  const editor = useSlate();

  const getStats = () => {
    const text = Editor.string(editor, []);
    return {
      chars: text.length,
      words: text.trim() ? text.trim().split(/\s+/).length : 0,
      blocks: editor.children.length,
    };
  };

  const stats = getStats();

  return (
    <div style={{
      display: 'flex',
      gap: 16,
      padding: '6px 16px',
      borderTop: '1px solid #e8e8e8',
      background: '#fafafa',
      fontSize: 12,
      color: '#999',
    }}>
      <span>{stats.chars} 字符</span>
      <span>{stats.words} 词</span>
      <span>{stats.blocks} 块</span>
    </div>
  );
};
Slate.js 序列化:JSON ↔ HTML 互转
// serializer.js

import { Text } from 'slate';
import escapeHtml from 'escape-html';

/**
 * Slate JSON → HTML
 */
export function slateToHtml(nodes) {
  return nodes.map((node) => serializeNode(node)).join('');
}

function serializeNode(node) {
  // 文本节点
  if (Text.isText(node)) {
    let text = escapeHtml(node.text);
    if (node.bold) text = `<strong>${text}</strong>`;
    if (node.italic) text = `<em>${text}</em>`;
    if (node.underline) text = `<u>${text}</u>`;
    if (node.strikethrough) text = `<s>${text}</s>`;
    if (node.code) text = `<code>${text}</code>`;
    if (node.color) text = `<span style="color:${node.color}">${text}</span>`;
    return text;
  }

  // 元素节点
  const children = node.children.map((n) => serializeNode(n)).join('');

  switch (node.type) {
    case 'heading-one':
      return `<h1>${children}</h1>`;
    case 'heading-two':
      return `<h2>${children}</h2>`;
    case 'heading-three':
      return `<h3>${children}</h3>`;
    case 'blockquote':
      return `<blockquote>${children}</blockquote>`;
    case 'code-block':
      return `<pre><code>${children}</code></pre>`;
    case 'bulleted-list':
      return `<ul>${children}</ul>`;
    case 'numbered-list':
      return `<ol>${children}</ol>`;
    case 'list-item':
      return `<li>${children}</li>`;
    case 'image':
      return `<img src="${escapeHtml(node.url)}" alt="${escapeHtml(node.alt || '')}" />`;
    case 'divider':
      return '<hr />';
    case 'paragraph':
    default:
      return `<p>${children}</p>`;
  }
}

/**
 * HTML → Slate JSON(简化版,生产环境建议用 slate-html-serializer)
 */
export function htmlToSlate(html) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  return deserializeElement(doc.body);
}

function deserializeElement(el) {
  if (el.nodeType === Node.TEXT_NODE) {
    return [{ text: el.textContent }];
  }

  const children = Array.from(el.childNodes)
    .flatMap((child) => deserializeElement(child))
    .filter(Boolean);

  if (children.length === 0) {
    children.push({ text: '' });
  }

  switch (el.nodeName) {
    case 'BODY':
      return children;
    case 'H1':
      return [{ type: 'heading-one', children }];
    case 'H2':
      return [{ type: 'heading-two', children }];
    case 'H3':
      return [{ type: 'heading-three', children }];
    case 'BLOCKQUOTE':
      return [{ type: 'blockquote', children }];
    case 'PRE':
      return [{ type: 'code-block', children: [{ text: el.textContent }] }];
    case 'UL':
      return [{ type: 'bulleted-list', children }];
    case 'OL':
      return [{ type: 'numbered-list', children }];
    case 'LI':
      return [{ type: 'list-item', children }];
    case 'P':
      return [{ type: 'paragraph', children }];
    case 'IMG':
      return [{ type: 'image', url: el.src, children: [{ text: '' }] }];
    case 'HR':
      return [{ type: 'divider', children: [{ text: '' }] }];
    case 'STRONG':
    case 'B':
      return children.map((child) => ({ ...child, bold: true }));
    case 'EM':
    case 'I':
      return children.map((child) => ({ ...child, italic: true }));
    case 'U':
      return children.map((child) => ({ ...child, underline: true }));
    case 'S':
    case 'DEL':
      return children.map((child) => ({ ...child, strikethrough: true }));
    case 'CODE':
      return children.map((child) => ({ ...child, code: true }));
    default:
      return children;
  }
}

3.5 Lexical (React) —— Meta 出品的新一代编辑器

适用场景: 高性能、大文档、Meta 技术栈

安装
npm install lexical @lexical/react @lexical/rich-text @lexical/list \
  @lexical/link @lexical/code @lexical/table @lexical/utils \
  @lexical/selection @lexical/html
完整示例
import React, { useCallback } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';

// 节点类型
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { CodeNode, CodeHighlightNode } from '@lexical/code';
import { TableNode, TableCellNode, TableRowNode } from '@lexical/table';

// 格式化命令
import {
  FORMAT_TEXT_COMMAND,
  FORMAT_ELEMENT_COMMAND,
  UNDO_COMMAND,
  REDO_COMMAND,
  $getSelection,
  $isRangeSelection,
  $createParagraphNode,
} from 'lexical';
import { $createHeadingNode, $isHeadingNode } from '@lexical/rich-text';
import { $createQuoteNode } from '@lexical/rich-text';
import {
  INSERT_ORDERED_LIST_COMMAND,
  INSERT_UNORDERED_LIST_COMMAND,
  REMOVE_LIST_COMMAND,
} from '@lexical/list';
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
import { TRANSFORMERS } from '@lexical/markdown';

// ============ 编辑器配置 ============

const editorConfig = {
  namespace: 'MyLexicalEditor',

  // 主题样式映射
  theme: {
    paragraph: 'editor-paragraph',
    heading: {
      h1: 'editor-h1',
      h2: 'editor-h2',
      h3: 'editor-h3',
    },
    text: {
      bold: 'editor-bold',
      italic: 'editor-italic',
      underline: 'editor-underline',
      strikethrough: 'editor-strikethrough',
      code: 'editor-code-inline',
    },
    quote: 'editor-quote',
    code: 'editor-code-block',
    list: {
      ul: 'editor-ul',
      ol: 'editor-ol',
      listitem: 'editor-li',
    },
    link: 'editor-link',
  },

  // 注册所有用到的节点类型
  nodes: [
    HeadingNode,
    QuoteNode,
    ListNode,
    ListItemNode,
    LinkNode,
    AutoLinkNode,
    CodeNode,
    CodeHighlightNode,
    TableNode,
    TableCellNode,
    TableRowNode,
  ],

  onError(error) {
    console.error('Lexical Error:', error);
  },
};

// ============ 工具栏插件 ============

function ToolbarPlugin() {
  const [editor] = useLexicalComposerContext();
  const [activeFormats, setActiveFormats] = React.useState({
    bold: false,
    italic: false,
    underline: false,
    strikethrough: false,
    code: false,
  });
  const [blockType, setBlockType] = React.useState('paragraph');

  // 监听选区变化,更新工具栏状态
  React.useEffect(() => {
    return editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          setActiveFormats({
            bold: selection.hasFormat('bold'),
            italic: selection.hasFormat('italic'),
            underline: selection.hasFormat('underline'),
            strikethrough: selection.hasFormat('strikethrough'),
            code: selection.hasFormat('code'),
          });

          // 检查块级类型
          const anchorNode = selection.anchor.getNode();
          const parent = anchorNode.getParent();
          if ($isHeadingNode(parent)) {
            setBlockType(parent.getTag()); // 'h1', 'h2', 'h3'
          } else {
            setBlockType(parent?.getType?.() || 'paragraph');
          }
        }
      });
    });
  }, [editor]);

  // 格式化文本
  const formatText = (format) => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
  };

  // 设置块类型
  const formatBlock = (type) => {
    editor.update(() => {
      const selection = $getSelection();
      if ($isRangeSelection(selection)) {
        switch (type) {
          case 'h1':
            // 如果当前已经是 h1,切回段落
            if (blockType === 'h1') {
              selection.getNodes().forEach((node) => {
                if ($isHeadingNode(node)) {
                  node.replace($createParagraphNode());
                }
              });
            } else {
              const heading = $createHeadingNode('h1');
              selection.insertNodes([heading]);
            }
            break;
          case 'h2': {
            const heading = $createHeadingNode('h2');
            selection.insertNodes([heading]);
            break;
          }
          case 'h3': {
            const heading = $createHeadingNode('h3');
            selection.insertNodes([heading]);
            break;
          }
          case 'quote': {
            const quote = $createQuoteNode();
            selection.insertNodes([quote]);
            break;
          }
          default:
            break;
        }
      }
    });
  };

  const btnStyle = (active) => ({
    padding: '4px 8px',
    border: '1px solid transparent',
    borderRadius: 4,
    background: active ? '#1890ff' : 'transparent',
    color: active ? 'white' : '#333',
    cursor: 'pointer',
    fontSize: 13,
  });

  return (
    <div style={{
      display: 'flex',
      flexWrap: 'wrap',
      gap: 2,
      padding: 8,
      borderBottom: '1px solid #e8e8e8',
      background: '#fafafa',
    }}>
      {/* 文本格式 */}
      <button style={btnStyle(activeFormats.bold)}
        onClick={() => formatText('bold')}>
        <strong>B</strong>
      </button>
      <button style={btnStyle(activeFormats.italic)}
        onClick={() => formatText('italic')}>
        <em>I</em>
      </button>
      <button style={btnStyle(activeFormats.underline)}
        onClick={() => formatText('underline')}>
        <u>U</u>
      </button>
      <button style={btnStyle(activeFormats.strikethrough)}
        onClick={() => formatText('strikethrough')}>
        <s>S</s>
      </button>
      <button style={btnStyle(activeFormats.code)}
        onClick={() => formatText('code')}>
        {'</>'}
      </button>

      <span style={{ width: 1, height: 24, background: '#d9d9d9', margin: '0 6px', alignSelf: 'center' }} />

      {/* 块级格式 */}
      <button style={btnStyle(blockType === 'h1')}
        onClick={() => formatBlock('h1')}>H1</button>
      <button style={btnStyle(blockType === 'h2')}
        onClick={() => formatBlock('h2')}>H2</button>
      <button style={btnStyle(blockType === 'h3')}
        onClick={() => formatBlock('h3')}>H3</button>
      <button style={btnStyle(blockType === 'quote')}
        onClick={() => formatBlock('quote')}>引用</button>

      <span style={{ width: 1, height: 24, background: '#d9d9d9', margin: '0 6px', alignSelf: 'center' }} />

      {/* 列表 */}
      <button style={btnStyle(false)}
        onClick={() => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND)}>
        • 列表
      </button>
      <button style={btnStyle(false)}
        onClick={() => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND)}>
        1. 列表
      </button>

      <span style={{ width: 1, height: 24, background: '#d9d9d9', margin: '0 6px', alignSelf: 'center' }} />

      {/* 撤销/重做 */}
      <button style={btnStyle(false)}
        onClick={() => editor.dispatchCommand(UNDO_COMMAND)}>
        ↩ 撤销
      </button>
      <button style={btnStyle(false)}
        onClick={() => editor.dispatchCommand(REDO_COMMAND)}>
        ↪ 重做
      </button>
    </div>
  );
}

// ============ HTML导出插件 ============

function HtmlExportPlugin({ onHtmlChange }) {
  const [editor] = useLexicalComposerContext();

  React.useEffect(() => {
    return editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        const html = $generateHtmlFromNodes(editor);
        onHtmlChange?.(html);
      });
    });
  }, [editor, onHtmlChange]);

  return null;
}

// ============ 自动保存插件 ============

function AutoSavePlugin({ interval = 5000 }) {
  const [editor] = useLexicalComposerContext();

  React.useEffect(() => {
    let hasChanges = false;

    const unregister = editor.registerUpdateListener(() => {
      hasChanges = true;
    });

    const timer = setInterval(() => {
      if (hasChanges) {
        const editorState = editor.getEditorState();
        const json = JSON.stringify(editorState.toJSON());
        localStorage.setItem('lexical-draft', json);
        console.log('自动保存成功');
        hasChanges = false;
      }
    }, interval);

    return () => {
      unregister();
      clearInterval(timer);
    };
  }, [editor, interval]);

  return null;
}

// ============ 恢复草稿插件 ============

function RestoreDraftPlugin() {
  const [editor] = useLexicalComposerContext();

  React.useEffect(() => {
    const draft = localStorage.getItem('lexical-draft');
    if (draft) {
      try {
        const state = editor.parseEditorState(draft);
        editor.setEditorState(state);
        console.log('草稿已恢复');
      } catch (e) {
        console.warn('草稿恢复失败:', e);
      }
    }
  }, [editor]);

  return null;
}

// ============ 主组件 ============

export default function LexicalEditor() {
  const [htmlOutput, setHtmlOutput] = React.useState('');

  const onChange = useCallback((editorState) => {
    // editorState 是不可变的,可以安全序列化
    const json = editorState.toJSON();
    console.log('Editor state:', json);
  }, []);

  return (
    <div style={{ maxWidth: 800, margin: '40px auto' }}>
      <LexicalComposer initialConfig={editorConfig}>
        <div style={{
          border: '1px solid #d9d9d9',
          borderRadius: 8,
          overflow: 'hidden',
        }}>
          {/* 工具栏 */}
          <ToolbarPlugin />

          {/* 编辑区 */}
          <div style={{ padding: '16px 20px', minHeight: 300, position: 'relative' }}>
            <RichTextPlugin
              contentEditable={
                <ContentEditable
                  style={{
                    outline: 'none',
                    fontSize: 15,
                    lineHeight: 1.75,
                    minHeight: 250,
                  }}
                />
              }
              placeholder={
                <div style={{
                  position: 'absolute',
                  top: 16,
                  left: 20,
                  color: '#adb5bd',
                  pointerEvents: 'none',
                  fontSize: 15,
                }}>
                  开始写作...(支持 Markdown 快捷输入)
                </div>
              }
              ErrorBoundary={LexicalErrorBoundary}
            />
          </div>

          {/* 功能插件(不渲染UI,纯逻辑) */}
          <HistoryPlugin />
          <ListPlugin />
          <LinkPlugin />
          <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
          <OnChangePlugin onChange={onChange} />
          <HtmlExportPlugin onHtmlChange={setHtmlOutput} />
          <AutoSavePlugin interval={5000} />
          <RestoreDraftPlugin />
        </div>
      </LexicalComposer>

      {/* HTML 输出预览 */}
      <div style={{
        marginTop: 24,
        padding: 16,
        border: '1px solid #e8e8e8',
        borderRadius: 8,
        background: '#fafafa',
      }}>
        <h3>HTML 输出:</h3>
        <pre style={{
          background: '#282c34',
          color: '#abb2bf',
          padding: 12,
          borderRadius: 4,
          overflow: 'auto',
          maxHeight: 200,
          fontSize: 13,
        }}>
          {htmlOutput}
        </pre>
      </div>
    </div>
  );
}

Lexical 对应的 CSS:

/* lexical-theme.css */
.editor-paragraph { margin-bottom: 8px; line-height: 1.75; }
.editor-h1 { font-size: 2em; font-weight: 700; margin: 16px 0 8px; }
.editor-h2 { font-size: 1.5em; font-weight: 700; margin: 12px 0 6px; }
.editor-h3 { font-size: 1.25em; font-weight: 600; margin: 10px 0 4px; }
.editor-bold { font-weight: 700; }
.editor-italic { font-style: italic; }
.editor-underline { text-decoration: underline; }
.editor-strikethrough { text-decoration: line-through; }
.editor-code-inline {
  background: #f0f0f0;
  color: #d63384;
  padding: 2px 6px;
  border-radius: 3px;
  font-family: monospace;
  font-size: 0.9em;
}
.editor-quote {
  border-left: 4px solid #1890ff;
  padding: 12px 16px;
  margin: 8px 0;
  color: #666;
  background: #f9f9f9;
}
.editor-code-block {
  background: #282c34;
  color: #abb2bf;
  padding: 16px;
  border-radius: 8px;
  font-family: 'Fira Code', monospace;
  font-size: 14px;
  overflow: auto;
}
.editor-ul { padding-left: 24px; list-style-type: disc; }
.editor-ol { padding-left: 24px; list-style-type: decimal; }
.editor-li { margin: 4px 0; }
.editor-link { color: #1890ff; text-decoration: underline; }

3.6 Quill —— 轻量级快速方案

适用场景: 评论框、简单编辑、快速集成

npm install quill@1.3.7
# Vue 封装
npm install vue-quill-editor
<template>
  <div class="quill-wrapper">
    <quill-editor
      ref="editor"
      v-model="content"
      :options="editorOptions"
      @change="onEditorChange"
      @focus="onEditorFocus"
      @blur="onEditorBlur"
    />
    <div class="char-count">{{ charCount }} / {{ maxLength }} 字</div>
  </div>
</template>

<script>
import 'quill/dist/quill.snow.css';
import { quillEditor } from 'vue-quill-editor';

// 自定义图片上传handler
function imageHandler() {
  const input = document.createElement('input');
  input.setAttribute('type', 'file');
  input.setAttribute('accept', 'image/*');
  input.click();

  input.onchange = async () => {
    const file = input.files[0];
    if (!file) return;

    // 文件大小校验
    if (file.size > 5 * 1024 * 1024) {
      alert('图片不能超过5MB');
      return;
    }

    const formData = new FormData();
    formData.append('file', file);

    try {
      const res = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });
      const { url } = await res.json();

      // 获取光标位置,插入图片
      const quill = this.quill;
      const range = quill.getSelection(true);
      quill.insertEmbed(range.index, 'image', url);
      quill.setSelection(range.index + 1);
    } catch (err) {
      console.error('上传失败:', err);
    }
  };
}

export default {
  name: 'QuillEditorDemo',
  components: { quillEditor },

  props: {
    value: { type: String, default: '' },
    maxLength: { type: Number, default: 10000 },
  },

  data() {
    return {
        content: this.value,
        editorOptions: {
        theme: 'snow',
        placeholder: '请输入内容...',
        modules: {
          toolbar: {
            container: [
              [{ header: [1, 2, 3, 4, false] }],
              ['bold', 'italic', 'underline', 'strike'],
              [{ color: [] }, { background: [] }],
              [{ align: [] }],
              [{ list: 'ordered' }, { list: 'bullet' }],
              [{ indent: '-1' }, { indent: '+1' }],
              ['blockquote', 'code-block'],
              ['link', 'image', 'video'],
              ['clean'], // 清除格式
            ],
            // 自定义处理函数
            handlers: {
              image: imageHandler,
            },
          },

          // 剪贴板配置:控制粘贴行为
          clipboard: {
            matchVisual: false, // 不匹配视觉样式(减少脏HTML)
          },

          // 语法高亮(需要额外安装 highlight.js)
          // syntax: {
          //   highlight: (text) => hljs.highlightAuto(text).value,
          // },

          // 历史记录
          history: {
            delay: 1000,
            maxStack: 100,
            userOnly: true,
          },
        },

        // 支持的格式白名单(安全考虑)
        formats: [
          'header',
          'bold', 'italic', 'underline', 'strike',
          'color', 'background',
          'align',
          'list', 'indent',
          'blockquote', 'code-block',
          'link', 'image', 'video',
        ],
      },
    };
  },

  computed: {
    charCount() {
      // 获取纯文本长度
      if (!this.$refs.editor) return 0;
      const quill = this.$refs.editor.quill;
      if (!quill) return 0;
      return quill.getText().trim().length;
    },
  },

  watch: {
    value(newVal) {
      if (newVal !== this.content) {
        this.content = newVal;
      }
    },
    content(newVal) {
      this.$emit('input', newVal);
    },
  },

  methods: {
    onEditorChange({ quill, html, text }) {
      // 字数限制
      if (text.trim().length > this.maxLength) {
        quill.deleteText(this.maxLength, quill.getLength());
        return;
      }
      this.$emit('change', { html, text, delta: quill.getContents() });
    },

    onEditorFocus(quill) {
      this.$emit('focus', quill);
    },

    onEditorBlur(quill) {
      this.$emit('blur', quill);
    },

    // ====== 外部 API ======
    getQuill() {
      return this.$refs.editor?.quill;
    },
    getHTML() {
      return this.content;
    },
    getText() {
      return this.getQuill()?.getText()?.trim() || '';
    },
    getDelta() {
      return this.getQuill()?.getContents();
    },
    setHTML(html) {
      this.content = html;
    },
    clear() {
      this.content = '';
    },
    focus() {
      this.getQuill()?.focus();
    },
    disable() {
      this.getQuill()?.enable(false);
    },
    enable() {
      this.getQuill()?.enable(true);
    },
    // 插入文本到光标位置
    insertText(text) {
      const quill = this.getQuill();
      const range = quill.getSelection(true);
      quill.insertText(range.index, text);
    },
    // 插入嵌入内容
    insertEmbed(type, value) {
      const quill = this.getQuill();
      const range = quill.getSelection(true);
      quill.insertEmbed(range.index, type, value);
      quill.setSelection(range.index + 1);
    },
  },

  mounted() {
    // 可在此注册自定义 Blot(Quill 的扩展机制)
    this.registerCustomBlots();
  },

  methods: {
    // ...上面的方法

    registerCustomBlots() {
      const Quill = require('quill');
      const Inline = Quill.import('blots/inline');

      // 自定义 @提及 Blot
      class MentionBlot extends Inline {
        static create(data) {
          const node = super.create();
          node.setAttribute('data-mention-id', data.id);
          node.setAttribute('data-mention-name', data.name);
          node.textContent = `@${data.name}`;
          node.style.cssText = 'color:#1890ff;background:#e6f7ff;padding:0 4px;border-radius:2px;';
          return node;
        }
        static value(node) {
          return {
            id: node.getAttribute('data-mention-id'),
            name: node.getAttribute('data-mention-name'),
          };
        }
        static formats(node) {
          return {
            id: node.getAttribute('data-mention-id'),
            name: node.getAttribute('data-mention-name'),
          };
        }
      }
      MentionBlot.blotName = 'mention';
      MentionBlot.tagName = 'span';
      MentionBlot.className = 'mention-tag';

      Quill.register(MentionBlot);
    },
  },
};
</script>

<style scoped>
.quill-wrapper {
  position: relative;
}
.quill-wrapper >>> .ql-container {
  min-height: 300px;
  font-size: 15px;
  line-height: 1.75;
}
.quill-wrapper >>> .ql-editor {
  min-height: 300px;
  padding: 16px 20px;
}
.quill-wrapper >>> .ql-toolbar {
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}
.quill-wrapper >>> .ql-container {
  border-bottom-left-radius: 8px;
  border-bottom-right-radius: 8px;
}
.char-count {
  position: absolute;
  bottom: 8px;
  right: 12px;
  font-size: 12px;
  color: #999;
}
</style>

四、协同编辑实现(Tiptap + Yjs)

这是最常被问到的高级功能,以下是完整的实现方案:

4.1 架构图

┌──────────────────────────────────────────────────────────┐
│                   协同编辑架构                             │
│                                                          │
│  ┌─────────┐    WebSocket    ┌──────────────┐            │
│  │ 客户端A  │ ◄────────────► │              │            │
│  │ Tiptap   │                │   Yjs Server │            │
│  │ + Y.js   │                │  (y-websocket│            │
│  └─────────┘                │   provider)  │            │
│                              │              │            │
│  ┌─────────┐    WebSocket    │              │            │
│  │ 客户端B  │ ◄────────────► │              │            │
│  │ Tiptap   │                └──────┬───────┘            │
│  │ + Y.js   │                       │                    │
│  └─────────┘                   持久化存储                 │
│                              ┌──────┴───────┐            │
│  ┌─────────┐                 │   LevelDB /  │            │
│  │ 客户端C  │ ...            │   PostgreSQL │            │
│  └─────────┘                 └──────────────┘            │
│                                                          │
│  Yjs 使用 CRDT 算法,无需中心化冲突解决                     │
│  每个客户端维护本地文档副本,增量同步                        │
└──────────────────────────────────────────────────────────┘

4.2 服务端

npm install y-websocket y-leveldb
// server/collab-server.js

const http = require('http');
const WebSocket = require('ws');
const { setupWSConnection } = require('y-websocket/bin/utils');
const { LeveldbPersistence } = require('y-leveldb');

// 持久化存储
const persistence = new LeveldbPersistence('./yjs-docs');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Yjs WebSocket Server Running');
});

const wss = new WebSocket.Server({ server });

wss.on('connection', (ws, req) => {
  // 从 URL 中获取文档 ID
  const docName = req.url.slice(1).split('?')[0] || 'default';

  console.log(`[Collab] Client connected to doc: ${docName}`);
  console.log(`[Collab] Active connections: ${wss.clients.size}`);

  // 建立 Yjs WebSocket 连接
  setupWSConnection(ws, req, {
    docName,
    persistence,
    // gc: true, // 垃圾回收
  });

  ws.on('close', () => {
    console.log(`[Collab] Client disconnected from doc: ${docName}`);
  });
});

const PORT = 1234;
server.listen(PORT, () => {
  console.log(`[Collab] WebSocket server running on ws://localhost:${PORT}`);
});

4.3 客户端(Tiptap + Yjs)

# 安装协同依赖
npm install yjs y-websocket @tiptap/extension-collaboration \
  @tiptap/extension-collaboration-cursor
<template>
  <div class="collab-editor">
    <!-- 在线用户列表 -->
    <div class="online-users">
      <span class="label">在线:</span>
      <span
        v-for="user in onlineUsers"
        :key="user.clientId"
        class="user-badge"
        :style="{ background: user.color }"
      >
        {{ user.name }}
      </span>
      <span v-if="!connected" class="status-disconnected">⚠ 连接断开,尝试重连中...</span>
    </div>

    <!-- 工具栏(复用上面Tiptap的工具栏,此处省略) -->
    <div v-if="editor" class="toolbar">
      <button
        @click="editor.chain().focus().toggleBold().run()"
        :class="{ active: editor.isActive('bold') }"
      >B</button>
      <button
        @click="editor.chain().focus().toggleItalic().run()"
        :class="{ active: editor.isActive('italic') }"
      >I</button>
      <!-- ...其他按钮 -->
    </div>

    <!-- 编辑区 -->
    <editor-content :editor="editor" class="editor-content" />

    <!-- 连接状态 -->
    <div class="status-bar">
      <span :class="connected ? 'status-online' : 'status-offline'">
        {{ connected ? '● 已连接' : '○ 离线' }}
      </span>
      <span>{{ onlineUsers.length }} 人在线</span>
    </div>
  </div>
</template>

<script>
import { Editor, EditorContent } from '@tiptap/vue-2';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// 随机颜色
const COLORS = [
  '#f44336', '#e91e63', '#9c27b0', '#673ab7',
  '#3f51b5', '#2196f3', '#03a9f4', '#00bcd4',
  '#009688', '#4caf50', '#8bc34a', '#ff9800',
];
function getRandomColor() {
  return COLORS[Math.floor(Math.random() * COLORS.length)];
}

// 获取当前用户信息(实际从登录状态获取)
function getCurrentUser() {
  const stored = localStorage.getItem('collab-user');
  if (stored) return JSON.parse(stored);

  const user = {
    name: '用户' + Math.floor(Math.random() * 1000),
    color: getRandomColor(),
  };
  localStorage.setItem('collab-user', JSON.stringify(user));
  return user;
}

export default {
  name: 'CollabEditor',
  components: { EditorContent },

  props: {
    // 文档ID,不同ID对应不同文档
    docId: {
      type: String,
      required: true,
    },
    wsUrl: {
      type: String,
      default: 'ws://localhost:1234',
    },
  },

  data() {
    return {
      editor: null,
      provider: null,
      ydoc: null,
      connected: false,
      onlineUsers: [],
      currentUser: getCurrentUser(),
    };
  },

  mounted() {
    this.initCollabEditor();
  },

  methods: {
    initCollabEditor() {
      // 1. 创建 Yjs 文档
      this.ydoc = new Y.Doc();

      // 2. 创建 WebSocket Provider(连接服务端)
      this.provider = new WebsocketProvider(
        this.wsUrl,
        this.docId, // 文档标识符
        this.ydoc,
        {
          connect: true,
          // 自动重连配置
          resyncInterval: 3000,
          maxBackoffTime: 10000,
          // WebSocket 参数
          params: {
            // token: 'xxx', // 可传认证token
          },
        }
      );

      // 3. 监听连接状态
      this.provider.on('status', ({ status }) => {
        this.connected = status === 'connected';
        console.log(`[Collab] 连接状态: ${status}`);
      });

      // 4. 设置当前用户的 awareness 信息(光标、用户名等)
      this.provider.awareness.setLocalStateField('user', {
        name: this.currentUser.name,
        color: this.currentUser.color,
      });

      // 5. 监听在线用户变化
      this.provider.awareness.on('change', () => {
        const states = this.provider.awareness.getStates();
        this.onlineUsers = [];
        states.forEach((state, clientId) => {
          if (state.user) {
            this.onlineUsers.push({
              clientId,
              ...state.user,
            });
          }
        });
      });

      // 6. 创建编辑器
      this.editor = new Editor({
        extensions: [
          StarterKit.configure({
            // 使用 Collaboration 的历史记录,禁用默认的
            history: false,
          }),

          // 协同编辑核心扩展
          Collaboration.configure({
            document: this.ydoc,
            // 指定 Yjs 中的 XML Fragment 字段名
            field: 'content',
          }),

          // 协同光标
          CollaborationCursor.configure({
            provider: this.provider,
            user: this.currentUser,
            // 自定义光标渲染
            render: (user) => {
              const cursor = document.createElement('span');
              cursor.classList.add('collab-cursor');
              cursor.style.borderColor = user.color;

              const label = document.createElement('span');
              label.classList.add('collab-cursor-label');
              label.style.background = user.color;
              label.textContent = user.name;
              cursor.appendChild(label);

              return cursor;
            },
          }),
        ],

        // 不需要设置初始 content,Yjs 会从服务端同步
      });
    },

    // 断开连接
    disconnect() {
      this.provider?.disconnect();
    },

    // 重新连接
    reconnect() {
      this.provider?.connect();
    },

    // 获取文档快照(用于导出)
    getSnapshot() {
      return {
        html: this.editor.getHTML(),
        json: this.editor.getJSON(),
        // Yjs 二进制快照(可用于恢复)
        yjsState: Y.encodeStateAsUpdate(this.ydoc),
      };
    },

    // 从快照恢复
    restoreFromSnapshot(yjsState) {
      Y.applyUpdate(this.ydoc, new Uint8Array(yjsState));
    },
  },

  watch: {
    docId(newId, oldId) {
      if (newId !== oldId) {
        // 文档切换时,销毁旧连接,建立新连接
        this.destroy();
        this.initCollabEditor();
      }
    },
  },

  beforeDestroy() {
    this.destroy();
  },

  methods: {
    // ... 上面的methods

    destroy() {
      this.editor?.destroy();
      this.provider?.disconnect();
      this.provider?.destroy();
      this.ydoc?.destroy();
    },
  },
};
</script>

<style>
.collab-editor {
  border: 1px solid #d9d9d9;
  border-radius: 8px;
  overflow: hidden;
}

/* 在线用户列表 */
.online-users {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  background: #f0f0f0;
  border-bottom: 1px solid #e8e8e8;
  font-size: 13px;
}
.online-users .label {
  color: #666;
}
.user-badge {
  padding: 2px 8px;
  border-radius: 12px;
  color: white;
  font-size: 12px;
}
.status-disconnected {
  color: #ff4d4f;
  margin-left: auto;
}

/* 协同光标样式 */
.collab-cursor {
  position: relative;
  border-left: 2px solid;
  margin-left: -1px;
  margin-right: -1px;
  pointer-events: none;
  word-break: normal;
}
.collab-cursor-label {
  position: absolute;
  top: -1.4em;
  left: -1px;
  padding: 1px 6px;
  border-radius: 4px 4px 4px 0;
  color: white;
  font-size: 11px;
  font-weight: 500;
  white-space: nowrap;
  user-select: none;
  pointer-events: none;
  line-height: 1.4;
}

/* 状态栏 */
.status-bar {
  display: flex;
  gap: 16px;
  padding: 6px 16px;
  border-top: 1px solid #e8e8e8;
  background: #fafafa;
  font-size: 12px;
}
.status-online { color: #52c41a; }
.status-offline { color: #ff4d4f; }
</style>