前言
感谢大家对 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") {
// 处理 rangeType
for (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-ignore
temp.config.merge[`${r}_${c}`] = merge.dataValues;
// 配置 celldata mc 属性
const currentMergeCell = temp.celldata.find(
// @ts-ignore
(i) => i.r == r && i.c == c
);
// @ts-ignore
if (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 支持呀~这样才有动力更新哦!