前端富文本编辑器技术选型完全指南
一、富文本编辑器的三大技术流派
在深入选型之前,先理解富文本编辑器的底层实现原理差异:
┌─────────────────────────────────────────────────────────────────┐
│ 富文本编辑器技术流派 │
├──────────────────┬──────────────────┬───────────────────────────┤
│ L0: 基于 │ L1: 基于 │ L2: 完全自绘制 │
│ contentEditable │ contentEditable │ 不依赖浏览器 │
│ + execCommand │ + 自定义 Model │ contentEditable │
├──────────────────┼──────────────────┼───────────────────────────┤
│ 浏览器原生能力 │ 接管数据模型 │ 自己实现光标、选区、 │
│ 简单但不可控 │ 可控性大幅提升 │ 排版、渲染 │
├──────────────────┼──────────────────┼───────────────────────────┤
│ 代表: │ 代表: │ 代表: │
│ - UEditor │ - Slate.js │ - Google Docs │
│ - wangEditor(v4) │ - ProseMirror │ - 腾讯文档 │
│ - Quill(部分) │ - Tiptap │ - Canvas/自定义渲染 │
│ │ - Draft.js │ │
│ │ - Lexical │ │
└──────────────────┴──────────────────┴───────────────────────────┘
二、主流编辑器横向对比
2.1 一览表
| 维度 |
wangEditor |
Quill |
TinyMCE |
Slate.js |
ProseMirror |
Tiptap |
Lexical |
| 技术架构 |
L0→L1 |
L0/L1混合 |
L0 |
L1 |
L1 |
L1(基于ProseMirror) |
L1 |
| 框架依赖 |
无(v5) |
无 |
无 |
React |
无 |
Vue/React |
React |
| 学习曲线 |
⭐⭐ |
⭐⭐⭐ |
⭐⭐ |
⭐⭐⭐⭐⭐ |
⭐⭐⭐⭐⭐ |
⭐⭐⭐ |
⭐⭐⭐⭐ |
| 可扩展性 |
中 |
中 |
中高 |
极高 |
极高 |
高 |
极高 |
| 开箱即用 |
✅ 极好 |
✅ 好 |
✅ 极好 |
❌ 需大量开发 |
❌ 需大量开发 |
✅ 好 |
⚠️ 一般 |
| 协同编辑 |
❌ |
❌ |
付费插件 |
需自行实现 |
Yjs集成 |
Yjs集成 |
需自行实现 |
| 中文支持 |
⭐⭐⭐⭐⭐ |
⭐⭐⭐ |
⭐⭐⭐⭐ |
⭐⭐⭐ |
⭐⭐⭐ |
⭐⭐⭐ |
⭐⭐⭐ |
| 包体积 |
~200KB |
~40KB |
~400KB+ |
~100KB |
~100KB |
~150KB |
~60KB |
| 维护状态 |
活跃 |
较慢 |
活跃(商业) |
活跃 |
活跃 |
活跃 |
活跃(Meta) |
| 适用场景 |
CMS/后台 |
轻量评论 |
企业级CMS |
定制编辑器 |
定制编辑器 |
中高定制 |
高性能场景 |
| 协议 |
MIT |
BSD |
MIT/商业 |
MIT |
MIT |
MIT |
MIT |
2.2 选型决策树
你的需求是什么?
│
├── 快速上线,功能标准,后台管理系统
│ ├── Vue 项目 → wangEditor v5 或 Tiptap
│ ├── React 项目 → Tiptap(@tiptap/react) 或 Lexical
│ └── 不限框架 → TinyMCE(功能最全)或 wangEditor
│
├── 需要高度定制(自定义块、嵌套结构、特殊交互)
│ ├── React 项目 → Slate.js(最灵活)或 Lexical
│ ├── Vue 项目 → Tiptap(基于ProseMirror,生态好)
│ └── 不限框架 → ProseMirror(底层能力最强)
│
├── 需要协同编辑
│ ├── 预算充足 → TinyMCE 商业版
│ └── 开源方案 → Tiptap + Yjs / ProseMirror + Yjs
│
├── 轻量级(评论框、简单富文本)
│ └── Quill 或 wangEditor(配置精简模式)
│
└── 超大文档、极致性能
└── Lexical(Meta出品,虚拟化渲染)
三、各方案详细代码实战
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="行内代码"
>
</>
</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>