Luckysheet 实现 excel 多人在线协同编辑(全功能实现增强版)
前言
感谢大家对 Multi person online edit(多人在线编辑器) 项目的支持,mpoe 项目使用 quill、luckysheet、canvas-editor 实现的 md、excel、word 在线协同编辑,欢迎大家Fork 代码,多多 Start哦~
Multi person online edit 多人协同编辑器项目https://gitee.com/wfeng0/mpoe 经过大家反馈和咨询,还是对 luckysheet 的协同更加感兴趣,但是原项目有些乱,有些功能也没有完善,因此,单独将Luckysheet 抽离成新项目,争取实现完整的协同功能。
本项目仅实现luckysheet协同哈,使用 sequelize 作为ORM数据库连接,方便大家迁移,同时,也做了兼容,没有数据库的用户,只是不能持久化数据,协同功能不受任何影响。为了规范代码,使用 typescript 构建,没有使用任何前端框架,实现最简单的luckysheet协同增强版。
创建两个实例对象
由于luckysheet是挂载在window上,因此,同一个页面不能直接创建两个实例对象,但是可以通过 iframe 实现:
实现效果如下:
初始化协同
配置Lucky sheet的协同非常简单:
const options = {allowUpdate: true, // 配置协同功能loadUrl: "/api/loadLuckysheet", // 初始化 celldata 数据updateUrl: WS_SERVER_URL, // 协同服务转发服务// ...other option};
配置 allowUpdate
是否允许操作表格后的后台更新,与updateUrl
配合使用。如果要开启共享编辑,此参数必须设置为true.
配置 loadUrl
loadUrl是初始化 celldata 数据的一个http接口请求,底层实现是通过post发送请求,初始化sheet 数据:
$.post(loadurl, {"gridKey" : server.gridKey}, function (d) {})
因此,需要在服务端创建一个 post 请求的接口,处理并返回数据:
配置 updateUrl
操作表格后,实时保存数据的websocket地址,此接口也是共享编辑的接口地址,过共享编辑功能,可以实现Luckysheet实时保存数据和多人同步数据,每一次操作都会发送不同的参数到后台,具体的操作类型和参数参见表格操作。
/*** 创建 Web Socket 服务*/
export function createWebSocketServer(port: number) {const wsServer = new WebSocketServer({ port });logger.info(`ws server is running at: ws://localhost:${port}`);wsServer.on("connection", (client) => {console.log("==> user connected");client.on("error", console.error);client.on("close", () => {});client.on("message", (data) => {console.log("received: %s", data);});});
}
进行数据解析:根据官网的描述,发送给后端的数据默认是经过pako压缩过后的,需要进行解析,转换为可识别对象操作:
/*** Pako 数据解析*/
export function unzip(str: string) {const chartData = str.toString().split("").map((i) => i.charCodeAt(0));const binData = new Uint8Array(chartData);const data = pako.inflate(binData);return decodeURIComponent(String.fromCharCode(...Array.from(new Uint16Array(data))));
}
解析数据如下:
配置协同数据结构
上面的讲述的都是 前台向后台发送数据,那么,协同服务应该返回什么数据结构给 luckysheet呢? 根据 luckysheet/src/controller/server.js 中的返回参数分析,协同服务需要按照下列数据返回:
/*** 处理广播给其他客户端事件,客户端接收服务端要求数据结构:* * data: 修改的命令* id: "7a" websocket的id* username: 用户名(用于显示 xxx 正在编辑)* type: * # message === '用户退出' 用户退出时,关闭协同编辑时其提示框* # type == 1 send 成功或失败* # type == 2 更新数据* # type == 3 多人操作不同选区("t": "mv")(用不同颜色显示其他人所操作的选区)* # type == 4 批量指令更新* # type == 5 showloading* # type == 6 hideloading*/if (data === "exit") return JSON.stringify({ message: "用户退出", id: userid });// 这里仅做 2 3 类型处理,其他类型自行拓展哈
const info = { data, id: userid, username, type: data.t === "mv" ? 3 : 2 }
return JSON.stringify(info);
配置上诉后,即可实现初步协同,如下:
Sequelize
Sequelize 是一个基于 promise 的 Node.js ORM, 目前支持 Postgres, MySQL, MariaDB, SQLite 以及 Microsoft SQL Server. 它具有强大的事务支持, 关联关系, 预读和延迟加载,读取复制等功能。本项目使用其构建,意在只需要书写表模型,即可完成复杂的 luckysheet数据结构存储。同时,还能检测连接状态,使得没有数据库的用户,也可以体验协同。
class DataBase {private _connected: boolean = false; // 连接状态private _sequelize: Sequelize | null = null; // 连接对象/*** 初始化数据库*/public init() {// 创建连接const URL = `mysql://${user}:${password}@${host}:${port}/${database}`;this._sequelize = new Sequelize(URL, { logging });// 测试连接this._sequelize.authenticate().then(...).catch(...)}
}
初始化模型
Sequelize 是通过模型进行数据操作的,因此,我们需要提供对应的模型结构:
/*** Worker Books 工作簿模型表*/import { Model, Sequelize } from "sequelize";export class WorkerBookModel extends Model {// 通过 declare 定义模型类型declare gridKey: string;declare title: string;declare lang?: string;// 需要向外提供 注册模型的静态方法static registerModule(sequelize: Sequelize) {WorkerBookModel.init(....)}
}
同步模型
Model.sync()
- 如果表不存在,则创建该表(如果已经存在,则不执行任何操作)Model.sync({ force: true })
- 将创建表,如果表已经存在,则将其首先删除Model.sync({ alter: true })
- 这将检查数据库中表的当前状态(它具有哪些列,它们的数据类型等),然后在表中进行必要的更改以使其与模型匹配.
force: true 会导致表数据丢失,请谨慎使用!!!
在这里就不过多介绍 sequelize 相关知识了,大家自行查阅文档哈。
协同存储实现
Luckysheet 每一次操作都会保存历史记录,用于撤销和重做,如果在表格初始化的时候开启了共享编辑功能,则会通过websocket将操作实时更新到后台。因此,我们根据传递到后台的操作类型,更新数据库状态,不就实现了协同存储了嘛。
单个单元格刷新
async function v(data: string) {// 1. 解析 rc 单元格const { t, r, c, v, i } = <OperateData>JSON.parse(data);logger.info("[CRDT DATA]:", data);// 纠错判断if (t !== "v") return logger.error("t is not v.");if (isEmpty(i)) return logger.error("i is undefined.");if (isEmpty(r) || isEmpty(c)) return logger.error("r or c is undefined.");// 场景一:单个单元格插入值if (v && v.v && v.m) {// 判断表内是否存在当前记录const exist = await CellDataService.hasCellData(i, r, c);if(exist) CellDataService.updateCellData() else CellDataService.createCellData()}// 场景二:剪切/粘贴到某个单元格 - 会触发两次广播if (v === null) {// 删除该记录await CellDataService.deleteCellData(i, r, c);}// 场景三: 删除单元格内容if (v && !v.v && !v.m){// 删除记录await CellDataService.deleteCellData(i, r, c);}
}
范围单元格刷新
上诉是一个标准的范围单元格协同消息,我们需要根据 range row column 和 v 的数组,循环处理每一条数据项:
// 循环列,取 v 的内容,然后创建记录for (let index = 0; index < v.length; index++) {// 这里面的每一项,都是一条记录for (let j = 0; j < v[index].length; j++) {// 解析内部的 r c 值const item = v[index][j];const r = range.row[0] + index;const c = range.column[0] + j;// 根据 r c 存储数据}}
隐藏行/列 行高/列宽处理
行高列宽及隐藏行列,均触发在 t="cg" 中:
边框及合并单元格处理
// k borderInfo 边框处理// {"t":"cg","i":"e73f971d606...","v":[{"rangeType":"range","borderType":"border-all","color":"#000","style":"1","range":[{"row":[0,0],"column":[0,0],"row_focus":0,"column_focus":0,"left":0,"width":73,"top":0,"height":19,"left_move":0,"width_move":73,"top_move":0,"height_move":19}]}],"k":"borderInfo"}// {"t":"cg","i":"e73f971d......","v":[{"rangeType":"range","borderType":"border-all","color":"#000","style":"1","range":[{"row":[2,7],"column":[1,2],"row_focus":2,"column_focus":1,"left":74,"width":73,"top":40,"height":19,"left_move":74,"width_move":147,"top_move":40,"height_move":119,}]}],"k":"borderInfo"}// {"t":"cg","i":"e73f971d......","v":[{"rangeType":"range","borderType":"border-bottom","color":"#000","style":"1","range":[{"left":148,"width":73,"top":260,"height":19,"left_move":148,"width_move":73,"top_move":260,"height_move":19,"row":[13,13],"column":[2,2],"row_focus":13,"column_focus":2}]}],"k":"borderInfo"}if (k === "borderInfo") {// 处理 rangeTypefor (let idx = 0; idx < borderInfo.length; idx++) {const border = borderInfo[idx];const { rangeType, borderType, color, style, range } = border;// 这里能拿到 i range 判断是否存在// declare row_start?: number;// declare row_end?: number;// declare col_start?: number;// declare col_end?: number;const info: ConfigBorderModelType = {worker_sheet_id: i,rangeType,borderType,row_start: range[0].row[0],row_end: range[0].row[1],col_start: range[0].column[0],col_end: range[0].column[1],};const exist = await ConfigBorderService.hasConfigBorder(info);if (exist) {// 更新await ConfigBorderService.updateConfigBorder({config_border_id: exist.config_border_id,...info,color,style: Number(style),});} else {// 创建新的边框记录await ConfigBorderService.createConfigBorder({...info,style: Number(style),color,});}}}
合并单元格的处理可能麻烦些:
// 合并单元格 - 又是一个先删除后新增的操作,由luckysheet 前台设计决定的// {"t":"all","i":"e73f971....","v":{"merge":{"1_0":{"r":1,"c":0,"rs":3,"cs":3}},},"k":"config"}// {"t":"all","i":"e73f971....","v":{"merge":{"1_0":{"r":1,"c":0,"rs":3,"cs":3},"9_1":{"r":9,"c":1,"rs":5,"cs":3}},},"k":"config"}// {"t":"all","i":"e73f971....","v":{"merge":{"9_1":{"r":9,"c":1,"rs":5,"cs":3}},},"k":"config"}// 先删除await ConfigMergeService.deleteMerge(i);// 再新增for (const key in v.merge) {if (Object.prototype.hasOwnProperty.call(v.merge, key)) {const { r, c, rs, cs } = v.merge[key];await ConfigMergeService.createMerge({worker_sheet_id: i,r,c,rs,cs,});}}
获取数据的时候,需要处理两个地方: config 及 celldata
/* eslint-disable */// 4. 查询 merge 数据 - 这里不仅要体现在 config 中,还要体现在 celldata.mc 中const merges = await ConfigMergeService.findAll(worker_sheet_id);merges?.forEach((merge) => {// 拼接 r_c 格式const { r, c } = merge.dataValues;// @ts-ignoretemp.config.merge[`${r}_${c}`] = merge.dataValues;// 配置 celldata mc 属性const currentMergeCell = temp.celldata.find(// @ts-ignore(i) => i.r == r && i.c == c);// @ts-ignoreif (currentMergeCell) currentMergeCell.v.mc = merge.dataValues;});
图片及统计图处理
这块内容还有些前台的东西需要二开,后面会同步更新 git ,大家关注下仓库,start 下。
luckysheet-crdt: Luckysheet 协同增强版(全功能实现)https://gitee.com/wfeng0/luckysheet-crdt
图片上传,需要使用到两个新的 API:uploadImage、imageUrlHandle,默认情况下,插入的图片是以base64的形式放入sheet数据中,但是图片放入 sheet 中,进行协同传输,会导致node 解析数据堆栈溢出,因此,需要自定义图片上传方法:
// 处理协同图片上传uploadImage: async (file: File) => {// 此处拿到的是上传的 file 对象,进行文件上传 ,配合 node 接口实现const formData = new FormData();formData.append("image", file);const { data } = await fetch({url: "/api/uploadImage",method: "POST",data: formData,});// *** 关键步骤:需要返回一个地址给 luckysheet ,用于显示图片if (data.code === 200) return Promise.resolve(data.url);else return Promise.resolve("image upload error");},
看大家的接口设计哈,如果直接返回能访问的服务器路径,其实不用第二个接口也能实现,这里就都简单介绍一下:
// 处理上传图片的地址imageUrlHandle: (url: string) => {// 已经是 // http data 开头则不处理if (/^(?:\/\/|(?:http|https|data):)/i.test(url)) {return url;}// 不然拼接服务器路径return SERVER_URL + url;},
在协同存储上处理如下:
查询数据库,并处理为 luckysheet 初始化数据类型:
即可实现图片协同存储:
统计图的后面再更新哈,还在研究中~
总结
1. luckysheet 的协同并不难,很多东西源码底层已经封装好了,我们只需要按照官网说明,处理响应的操作即可;
2. 当然,库还有些没有完善的功能,需要大家自行拓展;
3. 后续会持续更新,关注大家的需求,也会考虑封装一个 npm 包,提供给大家,下载即用;
4. 大家多多start 支持呀~这样才有动力更新哦!
相关文章:

Luckysheet 实现 excel 多人在线协同编辑(全功能实现增强版)
前言 感谢大家对 Multi person online edit(多人在线编辑器) 项目的支持,mpoe 项目使用 quill、luckysheet、canvas-editor 实现的 md、excel、word 在线协同编辑,欢迎大家Fork 代码,多多 Start哦~ Multi person online edit 多人协同编辑器…...

vue 给div增加title属性
省略号 移入显示文字 在很多时候,我们页面上其实有时候展示不出来很多很多文字的,这个时候我们就不得不对这个文字进行处理,但是我们鼠标放到文字上时,还想展示所有的文字,这种方式其实有2种 一Tooltip 文字提示 第一…...

设计模式之工厂模式:从汽车工厂到代码工厂
~犬📰余~ “我欲贱而贵,愚而智,贫而富,可乎? 曰:其唯学乎” 工厂模式概述 想象一下你走进一家4S店准备买车。作为顾客,你不需要知道汽车是如何被制造出来的,你只需要告诉销售顾问&a…...

人脸识别Adaface之libpytorch部署
目录 1. libpytorch下载2. Adaface模型下载3. 模型转换4. c推理4.1 前处理4.2 推理4.3 编译运行4.3.1 写CMakeLists.txt4.3.2 编译4.3.3 运行 1. libpytorch下载 参考: https://blog.csdn.net/liang_baikai/article/details/127849577 下载完成后,将其解…...
vue3+echarts+websocket分时图与K线图实时推送
一、父组件代码: <template> <div class"chart-box" v-loading"loading"> <!-- tab导航栏 --> <div class"tab-box"> <div class"tab-list"> <div v-for"(item, index) in tabList…...
小程序开发实战项目:构建简易待办事项列表
随着移动互联网的飞速发展,小程序以其便捷性、即用即走的特点,成为了连接用户与服务的重要桥梁。无论是电商平台的购物助手,还是餐饮行业的点餐系统,小程序都在各个领域发挥着巨大的作用。 小程序开发基础 1. 小程序简介 小程序是…...

SD Express 卡漏洞导致笔记本电脑和游戏机遭受内存攻击
Positive Technologies 最近发布的一份报告揭示了一个名为 DaMAgeCard 的新漏洞,攻击者可以利用该漏洞利用 SD Express 内存卡直接访问系统内存。 该漏洞利用了 SD Express 中引入的直接内存访问 (DMA) 功能来加速数据传输速度,但也为对支持该标准的设备…...

前端node环境安装:nvm安装详细教程(安装nvm、node、npm、cnpm、yarn及环境变量配置)
需求:在做前端开发的时候,有的时候 这个项目需要 node 14 那个项目需要 node 16,我们也不能卸载 安装 。这岂不是很麻烦。这个时候 就需要 一个工具 来管理我们的 node 版本和 npm 版本。 下面就分享一个 nvm 工具 用来管理 node 版本。 这个…...

java之集合(详细-Map,Set,List)
1集合体系概述 1.1集合的概念 集合是一种容器,用来装数据的,类似于数组,但集合的大小可变,开发中也非常常用。 1.2集合分类 集合分为单列集合和多列集合 Collection代表单列集合,每个元素(数据ÿ…...
常见LeetCode-Saw200
用来记录需要知道见过的题型: LeetCode2-两数相加 说明:以链表的形势给了你每个位的数字,而且是逆序,直接从开头(个位)遍历相加。带上进位即可。有一个为空就直接计算另一个和进位。 LeetCode-3.无重复字符…...

Unity 制作一个视频播放器(打包后,可在外部编辑并放置新的视频)
效果展示: 在这里,我把视频名称(Json)和对应的视频资源都放在了StreamingAssets文件夹下,以便于打包后,客户还可以自己在外部增加、删除、修改对应的视频资料。 如有需要,请联细抠抠。...

MySQL-SQL语句
文章目录 一. SQL语句介绍二. SQL语句分类1. 数据定义语言:简称DDL(Data Definition Language)2. 数据操作语言:简称DML(Data Manipulation Language)3. 数据查询语言:简称DQL(Data Query Language)4. 数据控制语言:简称DCL(Data …...
腾讯微信大数据面试题及参考答案
DNS 协议是否使用 UDP? DNS(Domain Name System)协议主要使用 UDP(User Datagram Protocol),但也会使用 TCP(Transmission Control Protocol)。 UDP 是一种无连接的传输协议,它的特点是简单、高效。DNS 在进行域名解析时,大部分情况下使用 UDP。因为 UDP 的开销小,对…...

Python跳动的爱心
系列文章 序号直达链接表白系列1Python制作一个无法拒绝的表白界面2Python满屏飘字表白代码3Python无限弹窗满屏表白代码4Python李峋同款可写字版跳动的爱心5Python流星雨代码6Python漂浮爱心代码7Python爱心光波代码8Python普通的玫瑰花代码9Python炫酷的玫瑰花代码10Python多…...

计算机启动过程 | Linux 启动流程
注:本文为“计算机启动、 Linux 启动”相关文章合辑。 替换引文部分不清晰的图。 探索计算机的启动过程 Aleksandr Goncharov 2023/04/21 很多人对计算机的启动方式很感兴趣。只要设备开启,这就是魔法开始和持续的地方。在本文中,我们将概…...

反射简单介绍
反射就是从类里拿东西 有的人可能会想为什么不能用io流,从上往下一行一行的读也能获取类中的信息,为什么要用反射呢? 假如我们io流,从左到右一行一行的读取数据,如果碰到局部变量和成员变量同名,怎么区分&a…...

工具篇--GitHub Desktop 使用
文章目录 前言一、GitHub Desktop 的使用:1.1 通过官网下载GitHub Desktop和安装:1.2 安装和使用:1.2.1 填充自己的标识:1.2.3 克隆项目:1.2.4 git 常用忽略项配置: 二、代码的更新和提交:2.1 代…...

单臂路由配置
知识点 单臂路由指在路由器上的一个接口配置子接口(逻辑接口)来实现不同vlan间通信 路由器上的每个物理接口都可以配置多个子接口(逻辑接口) 公司的财务部、技术部和业务部有多台计算机,它们使用一台二层交换机进行互…...

河工oj第七周补题题解2024
A.GO LecturesⅠ—— Victory GO LecturesⅠ—— Victory - 问题 - 软件学院OJ 代码 统计 #include<bits/stdc.h> using namespace std;double b, w;int main() {for(int i 1; i < 19; i ) {for(int j 1; j < 19; j ) {char ch; cin >> ch;if(ch B) b …...
卷积的数学原理与作用
一、一维卷积 (一)定义 数学定义 给定一个输入序列 x [ x 1 , x 2 , ⋯ , x n ] x [x_1,x_2,\cdots,x_n] x[x1,x2,⋯,xn] 和一个卷积核(滤波器) k [ k 1 , k 2 , ⋯ , k m ] k [k_1,k_2,\cdots,k_m] k[k1,k2,⋯,…...
React hook之useRef
React useRef 详解 useRef 是 React 提供的一个 Hook,用于在函数组件中创建可变的引用对象。它在 React 开发中有多种重要用途,下面我将全面详细地介绍它的特性和用法。 基本概念 1. 创建 ref const refContainer useRef(initialValue);initialValu…...
Neo4j 集群管理:原理、技术与最佳实践深度解析
Neo4j 的集群技术是其企业级高可用性、可扩展性和容错能力的核心。通过深入分析官方文档,本文将系统阐述其集群管理的核心原理、关键技术、实用技巧和行业最佳实践。 Neo4j 的 Causal Clustering 架构提供了一个强大而灵活的基石,用于构建高可用、可扩展且一致的图数据库服务…...

RNN避坑指南:从数学推导到LSTM/GRU工业级部署实战流程
本文较长,建议点赞收藏,以免遗失。更多AI大模型应用开发学习视频及资料,尽在聚客AI学院。 本文全面剖析RNN核心原理,深入讲解梯度消失/爆炸问题,并通过LSTM/GRU结构实现解决方案,提供时间序列预测和文本生成…...

云原生玩法三问:构建自定义开发环境
云原生玩法三问:构建自定义开发环境 引言 临时运维一个古董项目,无文档,无环境,无交接人,俗称三无。 运行设备的环境老,本地环境版本高,ssh不过去。正好最近对 腾讯出品的云原生 cnb 感兴趣&…...

基于IDIG-GAN的小样本电机轴承故障诊断
目录 🔍 核心问题 一、IDIG-GAN模型原理 1. 整体架构 2. 核心创新点 (1) 梯度归一化(Gradient Normalization) (2) 判别器梯度间隙正则化(Discriminator Gradient Gap Regularization) (3) 自注意力机制(Self-Attention) 3. 完整损失函数 二…...
【SpringBoot自动化部署】
SpringBoot自动化部署方法 使用Jenkins进行持续集成与部署 Jenkins是最常用的自动化部署工具之一,能够实现代码拉取、构建、测试和部署的全流程自动化。 配置Jenkins任务时,需要添加Git仓库地址和凭证,设置构建触发器(如GitHub…...
Spring Security 认证流程——补充
一、认证流程概述 Spring Security 的认证流程基于 过滤器链(Filter Chain),核心组件包括 UsernamePasswordAuthenticationFilter、AuthenticationManager、UserDetailsService 等。整个流程可分为以下步骤: 用户提交登录请求拦…...
微服务通信安全:深入解析mTLS的原理与实践
🔥「炎码工坊」技术弹药已装填! 点击关注 → 解锁工业级干货【工具实测|项目避坑|源码燃烧指南】 一、引言:微服务时代的通信安全挑战 随着云原生和微服务架构的普及,服务间的通信安全成为系统设计的核心议题。传统的单体架构中&…...

【Linux】Linux安装并配置RabbitMQ
目录 1. 安装 Erlang 2. 安装 RabbitMQ 2.1.添加 RabbitMQ 仓库 2.2.安装 RabbitMQ 3.配置 3.1.启动和管理服务 4. 访问管理界面 5.安装问题 6.修改密码 7.修改端口 7.1.找到文件 7.2.修改文件 1. 安装 Erlang 由于 RabbitMQ 是用 Erlang 编写的,需要先安…...
Java 与 MySQL 性能优化:MySQL 慢 SQL 诊断与分析方法详解
文章目录 一、开启慢查询日志,定位耗时SQL1.1 查看慢查询日志是否开启1.2 临时开启慢查询日志1.3 永久开启慢查询日志1.4 分析慢查询日志 二、使用EXPLAIN分析SQL执行计划2.1 EXPLAIN的基本使用2.2 EXPLAIN分析案例2.3 根据EXPLAIN结果优化SQL 三、使用SHOW PROFILE…...