基于tinymce实现多人在线实时协同文本编辑

news2024/12/26 0:32:32

基于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中展示稍微有些复杂,可以自行探索。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2059072.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

PPT如何添加水印?推荐两种方法!

在PPT演示文稿中添加水印&#xff0c;可以有效地保护版权或在背景上增加品牌标识。本文将介绍两种在PPT中添加水印的方法&#xff0c;帮助你轻松实现这一功能&#xff0c;一起来看看吧&#xff01; 方法一&#xff1a;在单张幻灯片上添加水印 1、选择目标幻灯片 打开PPT文件&…

防近视台灯有效果吗?学生家长们应该了解护眼台灯怎么选

在当前社会&#xff0c;近视的影响不容小视&#xff0c;除了对视觉健康的影响外&#xff0c;近视还可能对个人的心理健康产生负面影响。视力不佳可能导致自卑感和社会交往障碍&#xff0c;尤其是在儿童和青少年时期。保护视力健康要从小做起&#xff0c;家长们可以关注孩子的用…

【原创教程】电气电工07:网线的制作方法

电气电工经常会遇到做网线,我们做网线需要网线钳与测试仪。需要了解网线的两种接线标准。 我们来看一下网线钳的操作步骤: 这种压线钳也同时具有剥线、剪线功能。 用这种网线钳能制作RJ45网络线接头。RJ11电话线接头、4P电话线接头。适用于RJ45,RJ11型网线 做网线的时候我…

npm安装时一直在idealTree:npm: sill idealTree buildDeps卡住不动解决方法

npm安装xmysql时一直idealTree:npm: sill idealTree buildDeps卡住不动 问题解决&#xff0c;如下图所示 解决方法&#xff1a; 1、查看.npmrc位置&#xff0c;并去目录中删掉.npmrc文件 --在cmd&#xff08;DOS页面&#xff09;界面执行下述指令&#xff0c;可查看 .npmrc 文…

需要频繁查询的文档+索引命名规则

1.规则及样例 规则_S_00_R_240821.1_文档编号推荐命名规则V1.0.txt 模板_T_HV_C_248021.1_传感器及采集器IP分配表V1.0.xlsx 重要的需要频繁参考的文档纳入4段式文档编号体系&#xff1a;文档编号由四段组成&#xff1a; X_XX_X_XXXXXX.X T.........模板Template【其他还有&am…

自抗扰控制ADRC原理解析及案例应用

1. ADRC基本原理 1.1 ADRC的基本概念 自抗扰控制&#xff08;Active Disturbance Rejection Control&#xff0c;ADRC&#xff09;是一种先进的控制策略&#xff0c;由韩京清研究员于1998年提出。ADRC的核心思想是将系统内部和外部的不确定性因素视为总扰动&#xff0c;并通过…

AtCoder Beginner Contest 367 A~F

A.Shout Everyday&#xff08;枚举&#xff09; 题意&#xff1a; 在 A t C o d e r AtCoder AtCoder 王国&#xff0c;居民们每天都要在 A A A 点大声喊出他们对章鱼烧的热爱。 住在 A t C o d e r AtCoder AtCoder 王国的高桥每天 B B B 点睡觉&#xff0c; C C C 点…

day17:一文弄懂“无参装饰器”、“有参装饰器”和“叠加装饰器”

目录 一、无参装饰器1. 什么是装饰器2. 为何要用装饰器3. 如何用解决方案一&#xff1a;失败&#xff0c;优化见↓↓↓解决方案二&#xff1a;失败&#xff0c;优化见↓↓↓解决方案三&#xff1a;失败&#xff0c;优化见↓↓↓方案三的优化一&#xff1a;将index的参数写活了方…

重磅发布!天途多自由度无人机调试台

无人机调试、测试和试飞很容易受空域、场地、环境、失控炸机和操作失误等限制。天途TE55多自由度无人机整机调试台应运而生&#xff01; 突破空域限制 天途TE55多自由度无人机整机调试台&#xff0c;突破场地空域限制&#xff0c;不到0.7平米的空间&#xff0c;即可完成小型无人…

[数据集][目标检测]建筑工地楼层空洞检测数据集VOC+YOLO格式2588张1类别

数据集格式&#xff1a;Pascal VOC格式YOLO格式(不包含分割路径的txt文件&#xff0c;仅仅包含jpg图片以及对应的VOC格式xml文件和yolo格式txt文件) 图片数量(jpg文件个数)&#xff1a;2588 标注数量(xml文件个数)&#xff1a;2588 标注数量(txt文件个数)&#xff1a;2588 标注…

基于vue框架的办公用品管理系统i52wc(程序+源码+数据库+调试部署+开发环境)系统界面在最后面。

系统程序文件列表 项目功能&#xff1a;部门,员工,办公用品,入库记录,出库记录,申领信息 开题报告内容 基于Vue框架的办公用品管理系统 开题报告 一、引言 随着企业规模的扩大和日常运营的复杂化&#xff0c;办公用品的管理成为了一个不容忽视的重要环节。传统的办公用品管…

Java中接口

接口的定义和使用 练习 public abstract class Animal {private String name;private int age;public Animal() {}public Animal(String name, int age) {this.name name;this.age age;}public String getName() {return name;}public void setName(String name) {this.name…

ChatGPT、Claude 和 Gemini 在数据分析方面的比较(第 2 部分)

欢迎来到雲闪世界。欢迎回到我的系列文章的第二部分&#xff0c;ChatGPT、Claude 和 Gemini 在数据分析方面的比较&#xff01;在本系列中&#xff0c;我旨在比较这些 AI 工具在各种数据科学和分析任务中的表现&#xff0c;以帮助其他数据爱好者和专业人士根据自己的需求选择最…

告别图片堆成山, 图片转pdf工具:你的整理小能手来啦!

嘿&#xff0c;各位小伙伴&#xff01;你们有没有觉得&#xff0c;现在拍照比吃饭还日常&#xff0c;手机、电脑里堆满了照片&#xff0c;找起来简直跟大海捞针似的&#xff1f;别急&#xff0c;我今儿个就来给你们支个招——图片转PDF大法&#xff0c;一键变成整整齐齐的PDF文…

【Java-异常】

异常&#xff1a;程序在运行期间产生的一些错误 Java通过API中Throwable类的众多子类描述各种不同的异常。Java异常都是对象&#xff0c;是Throwable子类的实例。 Throwable可以划分为两个大类&#xff1a; Error错误类&#xff1a;会导致JVM虚拟机宕机 Exception异常类&…

Java---二维数组

一.数组的维数 假象&#xff1a;一维数组 二维数组&#xff1a;数组中的元素是一维数组 二.五子棋游戏 import javax.swing.*;public class Array06 {static String[][] matrix new String[15][15];static String black "⚫";static String white "⚪"…

SQL进阶技巧:多维分析之如何还原任意维度组合下的维度列簇名称?【利用grouping_id逆向分析】

目 录 0 需求描述 1 数据准备 2 问题分析 3 小结 0 需求描述 现有用户访问日志表 visit_log ,每一行数据表示一条用户访问日志。 需求: (1)按照如下维度组合 (province), (province, city), (province, city, device_type) 计算用户访问量,要求一条SQL语句统计所所…

[000-01-018].第3节:Linux环境下ElasticSearch环境搭建

我的后端学习笔记大纲 我的ElasticSearch学习大纲 1.Linux系统搭建ES环境&#xff1a; 1.1.单机版&#xff1a; a.安装ES-7.8版本 1.下载ES: 2.上传与解压&#xff1a;将下载的tar包上传到服务器software目录下&#xff0c;然后解压缩&#xff1a;tar -zxvf elasticsearch-7…

logstash入门学习

1、入门示例 1.1、安装 Redhat 平台 rpm --import http://packages.elasticsearch.org/GPG-KEY-elasticsearch cat > /etc/yum.repos.d/logstash.repo <<EOF [logstash-5.0] namelogstash repository for 5.0.x packages baseurlhttp://packages.elasticsearch.org…

以太坊 Pectra 升级四个月倒计时,哪些更新值得期待?

撰文&#xff1a;Ignas&#xff0c;DeFi Research 编译&#xff1a;J1N&#xff0c;Techub News 现在市场有充分的理由看空以太坊。因为自 2023 年初的市场低点以来&#xff0c; SOL 的涨幅比以太坊高 6.8 倍&#xff0c; 过去两年内 ETH/BTC 交易对的跌幅为 47%。 现在是以太…