基于tinymce实现多人在线实时协同文本编辑
前言
这可能是最后一次写tinymce相关的文章了,一方面tinymce的底层设计限制了很多功能的实现,另一方面tinymce本身越来越商业化,最新的7版本已经必须配置key,否则面临无法使用的问题。
实时协同
多人在线实时协同文本编辑一直是当前富文本编辑器开发的热点,近十年来几乎所有线上办公的文档编辑软件都实现了这一功能,例如腾讯文档和360云文档等,但是作为开发者,如何在自己开发的软件上实现实时协同编辑,相信这是很多正在使用富文本编辑器开发文档功能的开发者面临的问题。
先上效果
实时协同使用的底层算法有QT和CRDT,这里不展开讲解底层原理,有兴趣的小伙伴可以去自行搜索,我们可以在自己的系统中快速实现协同功能都得益于现成的库——yjs。
yjs的使用
Yjs本身是一个解决冲突的库,对于富文本编辑而言,不同用户在多个客户端编辑的内容一定会产生冲突,例如在同一行同一位置多人一起编辑,内容是否会错乱。Yjs库内部提供了解决冲突的机制,我们调用时只需要将每个用户编辑后的内容传给yjs库,然后拿到yjs库解决冲突后的内容,重新赋值回编辑器内容即可,是不是很简单。
建立中间站-websocket
做到这里的开发者应该很熟悉websocket技术了,这是一种服务端可以主动推送消息给前端页面的一种技术,用来替代古老的轮询机制实现的消息推送。我们需要使用nodejs搭建一个非常简易的websocket服务(当然如果你会java或者有后端可以让后端帮你用其他框架语言搭建)。代码如下,几乎不用解释:
import { WebSocketServer } from "ws";
// 创建 yjs ws 服务
const yjsws = new WebSocketServer({ port: 1234 });
yjsws.on("connection", (conn, req) => {
console.log(req.url); // 标识每一个连接用户,用于广播不同的文件协同
conn.onmessage = (event) => {
yjsws.clients.forEach((conn) => {
conn.send(event.data);
});
};
conn.on("close", (conn) => {
console.log("yjs 用户关闭连接");
});
});
直接复制就行,缺ws依赖就npm install ws来安装,我安装的ws版本是8.17.1,文件名可以命名为server.js,用node server.js命令启动就可以了。
在编辑器中接入websocket服务
这里还要用到一个库y-websocket,这是一个yjs适配的库。同样使用npm安装就可以。
引入yjs和y-websocket
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
连接websocket服务
const ydoc = new Y.Doc();
const timeId = new Date().getTime();
// 使用 y-websocket 连接到 WebSocket 服务器
const provider = new WebsocketProvider('ws://localhost:1234', `user${timeId}`, ydoc);
其中ws://localhost:1234就是websocket的地址,端口由上面的server.js里指定。
将复文本内容传到服务端
获取yjs文档
const yText = ydoc.getText('tinymce');
这一步其实是将当前富文本内容,由yjs格式话为yjs需要的数据结构
接下来在tinymce的init中实现其与服务端通信
const init = ref(
{
placeholder: '请输入内容',
language_url: '/tinymce/langs/zh_Hans.js', // 汉化路径
language: 'zh_Hans', // 语言
license_key: 'gpl',
//...省略其他配置
setup: function (editor) {
editor.on('init', () => {
// 当编辑器初始化完成后,设置内容
editor.setContent('');
// 监听编辑器内容变化,并更新 Yjs 文档
editor.on('input', () => {
ydoc.transact(() => {
yText.delete(0, yText.length);
yText.insert(0, editor.getContent({ format: 'html' }));
});
});
// 监听 Yjs 文档变化,并更新编辑器内容
yText.observe(() => {
const currentContent = editor.getContent({ format: 'html' });
const newContent = yText.toString();
if (currentContent !== newContent) {
editor.setContent(newContent);
}
});
});
},
}
)
注释写的很清晰了,就不解释了。
完整代码
<template>
<div class="container">
<div class="editor-container">
<div class="editor-title">
<Title></Title>
<Info></Info>
</div>
<div v-loading="loading" class="editor-box">
<div class="demo-dfree">
<Editor v-model="myValue" :init="init" :disabled="false" @onEditorChange="handleEditorChange" />
<button @click="test">测试</button>
</div>
</div>
</div>
<div v-if="sidebarShow" class="sidebar">
<Sidebar @close="changeSidebarShow" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, toRefs, watch } from 'vue'
import tinymce from 'tinymce/tinymce'
import Editor from '@tinymce/tinymce-vue'
import Sidebar from "./sidebar.vue";
import Title from "./EditableTitle.vue";
import Info from "./EditableInfo.vue";
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
tinymce.baseURL = 'tinymce'
const ydoc = new Y.Doc();
const timeId = new Date().getTime();
// 使用 y-websocket 连接到 WebSocket 服务器
const provider = new WebsocketProvider('ws://localhost:1234', `user${timeId}`, ydoc);
// 获取 XML 类型的 Yjs 文档
// const type = ydoc.getXmlFragment('tinymce');
const yText = ydoc.getText('tinymce');
const init = ref(
{
placeholder: '请输入内容',
language_url: '/tinymce/langs/zh_Hans.js', // 汉化路径
language: 'zh_Hans', // 语言
license_key: 'gpl',
// content_style: '.mce-content-body{ height: 100%; }',
menubar: false,
inline: true,
toolbar: false,
plugins: 'accordion anchor autolink charmap code codesample directionality emoticons fullscreen image importcss insertdatetime link lists media nonbreaking pagebreak preview quickbars save searchreplace table visualblocks visualchars wordcount kityformula-editor formatpainter comment toc idea',
// quickbars_insert_toolbar: 'undo redo styles',
quickbars_selection_toolbar: 'blocks formatpainter italic underline bold geshi fontfamily fontsize removeformat workItem bullist numlist alignment table insertImg idea test',
skin_url: '/tinymce/skins/ui/oxide',
height: window.innerHeight - 50, // 编辑器高度,可以考虑获取窗口高度,以适配不同设备屏幕
promotion: false, //去除右上角的“Upgrade”的促销按钮
// menubar: false, //菜单栏,true为显示,false可隐藏
// statusbar: false, //控制元素路径显示
quickbars_insert_toolbar: false, //禁用每行开头处自动弹出的工具栏
// quickbars_selection_toolbar: false, // 禁用选中文本时的工具栏
// license_key: 'gpl',
// plugins: 'accordion anchor autolink autosave charmap code codesample directionality emoticons fullscreen image importcss insertdatetime link lists media nonbreaking pagebreak preview quickbars save searchreplace table visualblocks visualchars wordcount kityformula-editor formatpainter comment toc',
// toolbar: 'styles save action undo redo formatpainter blocks bold italic geshi fontfamily fontsize removeformat workItem bullist numlist alignment comment table insertImg searchreplace secondarySidebar version help'
setup: function (editor) {
editor.changeSidebarShow = changeSidebarShow;
editor.on('init', () => {
// 当编辑器初始化完成后,设置内容
editor.setContent('');
// 监听编辑器内容变化,并更新 Yjs 文档
editor.on('input', () => {
ydoc.transact(() => {
yText.delete(0, yText.length);
yText.insert(0, editor.getContent({ format: 'html' }));
});
});
// 监听 Yjs 文档变化,并更新编辑器内容
yText.observe(() => {
const currentContent = editor.getContent({ format: 'html' });
const newContent = yText.toString();
if (currentContent !== newContent) {
editor.setContent(newContent);
}
});
});
},
},
)
provider.on('status', event => {
console.log(event.status); // logs "connected" or "disconnected"
});
const saveDoc = () => { }
const loading = ref(false)
const myValue = ref('')
const handleEditorChange = (newContent) => {
myValue.value = newContent;
};
onMounted(() => {
// initDoc()
})
const sidebarShow = ref(false)
const changeSidebarShow = () => {
sidebarShow.value = !sidebarShow.value
}
const test = () => {
console.log('test', yText)
}
</script>
<style scoped lang="scss">
.container {
display: flex;
.editor-container {
flex: auto;
// padding: 20px 80px;
padding-left: 10rem;
padding-top: 2rem;
overflow: auto;
box-sizing: border-box;
.editor-title {
padding-left: 20px;
}
}
.editor-container::-webkit-scrollBar {
width: 3px;
/*垂直方向的宽*/
height: 7px;
/*水平方向的宽*/
}
.editor-container::-webkit-scrollBar-thumb {
border-radius: 5px;
background-color: #ccc;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
-moz-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
-webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
}
/* 滑块样式定义 */
.editor-container::-webkit-scrollBar-track {
border-radius: 5px;
background: #EDEDED;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
-moz-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
-webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
}
.sidebar {
flex: 0 0 300px;
}
}
.editor-box {
display: flex;
// height: calc(100% - 200px);
overflow: hidden;
.tinymce-editor {
flex: 1;
// height: 100%;
}
}
.demo-dfree {
position: relative;
width: 100%;
box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.2);
text-align: left;
color: #626262;
font-size: 14px;
padding: 20px;
overflow: auto;
}
.demo-dfree *[contentEditable="true"]:hover {
outline: 1px solid #fff;
}
.demo-dfree *[contentEditable="true"]:focus {
outline: #fff solid 1px;
}
.y-cursor {
border-left: 2px solid;
margin-left: -1px;
box-sizing: border-box;
pointer-events: none;
position: absolute;
}
.y-cursor>div {
position: absolute;
top: -1.05em;
font-size: 13px;
background-color: white;
font-family: sans-serif;
padding-left: 2px;
padding-right: 2px;
white-space: nowrap;
}
.draggable {
cursor: move;
}
</style>
后续功能拓展
在启动server.js的控制台,我们能看到用户连接
在代码中我们是以user+时间戳的方式代表用户id,后续可以自己用雪花算法生成用户唯一id,用这些id即可以实现在页面显示当前协同用户有哪些。
实时展示其他人光标这个功能我也有考虑过,所有信息其实都可以拿得到,其他人的光标位置,其他人的id,如何在tinymce中展示稍微有些复杂,可以自行探索。