Luckysheet 实现 excel 多人在线协同编辑(全功能实现增强版)

前言

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

Multi person online edit 多人协同编辑器项目icon-default.png?t=O83Ahttps://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 协同增强版(全功能实现)icon-default.png?t=O83Ahttps://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 支持呀~这样才有动力更新哦!

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

workflow笔记

workflow 介绍 搜狗公司C服务器引擎&#xff0c;编程范式。支撑搜狗几乎所有后端C在线服务&#xff0c;包括所有搜索服务&#xff0c;云输入法&#xff0c;在线广告等&#xff0c;每 日处理数百亿请求。这是一个设计轻盈优雅的企业级程序引擎&#xff0c;可以满足大多数后端与…

【Vulkan入门】09-CreateFrameBuffer

目录 先叨叨git信息关键代码VulkanEnv::FindHostVisitbaleMemoryTypeIndex()TestPipeLine::CreateFramebuffers() 与网上大多数文章不同&#xff0c;其他文章基本上都使用窗口框架&#xff08;X11、GLFW、WSL等&#xff09;提供的surface来显示Vulkan渲染出的图像。我认为那样会…

【人工智能】5G-A技术及应用

文章目录 前言一、5G-A基本概念及产业进展1、5G-A概述2、移动通信发展历史&#xff1a;不断扩大联结规模&#xff0c;扩展业务边界的过程3、标准Ready:首版本R18将于2024年H1冻结4、标准Ready:IMT2020完成5G-A技术测试5、频谱Ready:超大带宽是实现万兆体验的基础6、5G-A全球商用…

与 Cursor AI 对话编程:2小时开发报修维修微信小程序

本文记录了如何通过与 Cursor AI 对话&#xff0c;全程不写一行代码的情况下&#xff0c;完成一个完整的报修小程序。整个过程展示了 AI 如何帮助我们&#xff1a; 生成代码 、解决问题、优化实现、完善细节。 先看一下效果图&#xff1a; 一、项目配置 首先我是这样和 AI 对…

多模态大语言模型 MLLM 部署微调实践

1 MLLM 1.1 什么是 MLLM 多模态大语言模型&#xff08;MultimodalLargeLanguageModel&#xff09;是指能够处理和融合多种不同类型数据&#xff08;如文本、图像、音频、视频等&#xff09;的大型人工智能模型。这些模型通常基于深度学习技术&#xff0c;能够理解和生成多种模…

机器学习:全面学习路径指南

摘要&#xff1a; 本文精心规划了一条从入门到精通机器学习的学习路线&#xff0c;详细涵盖了基础理论构建、核心技术栈掌握、主流算法学习、实践项目锻炼以及前沿领域探索等多个关键阶段。通过逐步深入各个层面&#xff0c;介绍必备的数学知识、编程工具、经典与现代机器学习算…

Kingbase V8R6 数据库自动(逻辑)备份、删除脚本-Linux

脚本说明 1.该脚本为Linux环境下自动备份、删除Kingbase数据库备份脚本&#xff08;逻辑备份&#xff09;&#xff1b; 2.执行脚本前&#xff0c;请先对脚本进行修改后&#xff0c;再使用。脚本效果 1.执行脚本时&#xff0c;若备份目录不存在&#xff0c;则自动创建备份目录…

网络应用技术 实验六:通过 DHCP 管理园区网 IP 地址(华为ensp)

一、实验简介 构建园区网&#xff0c;通过 DHCP 服务器为全网的用户主机提供 IP 地址。 二、实验目的 1 、理解 DHCP 的工作原理&#xff1b; 2 、掌握 DHCP 服务器的创建和配置方法&#xff1b; 3 、掌握将 VirtualBox 虚拟机引入 eNSP 的方法&#xff1b; …

Elasticsearch使用(2):docker安装es、基础操作、mapping映射

1 安装es 1.1 拉取镜像 docker pull swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/elasticsearch:7.17.3 1.2 运行容器 运行elasticsearch容器&#xff0c;挂载的目录给更高的权限&#xff0c;否则可能会因为目录权限问题导致启动失败&#xff1a; docker r…

Flink 核心知识总结:窗口操作、TopN 案例及架构体系详解

目录 一、FlinkSQL 的窗口操作 &#xff08;一&#xff09;窗口类型概述 &#xff08;二&#xff09;不同时间语义下窗口实践 EventTime&#xff08;事件时间&#xff09; ProcessTime&#xff08;处理时间&#xff09; 二、窗口 TopN 案例解析 三、Flink架构体系 &…

Vscode配置自动切换node版本

Vscode配置自动切换node版本 问题描述 开发环境安装了很多Node JS版本&#xff0c;项目经常切换也常常忘记了使用了什么版本&#xff0c;所以最好在打开项目terminal&#xff0c;安装依赖&#xff0c;启动项目前自动设置好版本 具体配置 .vscode/settings.json中,添加如下代…

【Linux 篇】Docker 的容器之海与镜像之岛:于 Linux 系统内探索容器化的奇妙航行

文章目录&#xff1a; 【Linux 篇】Docker 的容器之海与镜像之岛&#xff1a;于 Linux 系统内探索容器化的奇妙航行前言安装docker-centos7 【Linux 篇】Docker 的容器之海与镜像之岛&#xff1a;于 Linux 系统内探索容器化的奇妙航行 &#x1f4ac;欢迎交流&#xff1a;在学习…

leetcode108.将有序数组转换为二叉搜索树

标签&#xff1a;二叉搜索树 给你一个整数数组 nums &#xff0c;其中元素已经按 升序 排列&#xff0c;请你将其转换为一棵平衡二叉搜索树。 示例 1&#xff1a; 输入&#xff1a;nums [-10,-3,0,5,9] 输出&#xff1a;[0,-3,9,-10,null,5] 解释&#xff1a;[0,-10,5,null,…

C# 探险之旅:第二节 - 定义变量与变量赋值

欢迎再次踏上我们的C#学习之旅。今天&#xff0c;我们要聊一个超级重要又好玩的话题——定义变量与变量赋值。想象一下&#xff0c;你正站在一个魔法森林里&#xff0c;手里拿着一本空白的魔法书&#xff08;其实就是你的代码编辑器&#xff09;&#xff0c;准备记录下各种神奇…

基于事件驱动的websocket简单实现

websocket的实现 什么是websocket&#xff1f; WebSocket 是一种网络通信协议&#xff0c;旨在为客户端和服务器之间提供全双工、实时的通信通道。它是在 HTML5 规范中引入的&#xff0c;可以让浏览器与服务器进行持久化连接&#xff0c;以便实现低延迟的数据交换。 WebSock…

libaom 源码分析:av1_rd_use_partition 函数

libaom libaom 是 AOMedia Video 1 (AV1) 视频编码格式的参考实现库,由 Alliance for Open Media (AOMedia) 开发和维护。AV1 是一个高效、开放、免专利授权的下一代视频编解码标准,设计目标是提供较高的视频压缩效率,同时适配各种分辨率、码率和平台。下载:git clone http…

如何恢复使用 Cursor 免费试用

当用户尝试创建过多免费试用账户时&#xff0c;会收到提示&#xff1a;“Too many free trial accounts used on this machine. Please upgrade to pro.” 这限制了用户的试用次数。AI大眼萌帮助大家绕过 Cursor 的设备指纹验证&#xff0c;以继续享受免费试用。 &#x1f6a8;…

【Excel学习记录】01-认识Excel

1.之前的优秀软件Lotus-1-2-3 默认公式以等号开头 兼容Lotus-1-2-3的公式写法&#xff0c;不用写等号 &#xff1a; 文件→选项→高级→勾选&#xff1a;“转换Lotus-1-2-3公式(U)” 备注&#xff1a;对于大范围手动输入公式可以使用该选项&#xff0c;否则请不要勾选&#x…

网络安全——防火墙

基本概念 防火墙是一个系统&#xff0c;通过过滤传输数据达到防止未经授权的网络传输侵入私有网络&#xff0c;阻止不必要流量的同时允许必要流量进入。防火墙旨在私有和共有网络间建立一道安全屏障&#xff0c;因为网上总有黑客和恶意攻击入侵私有网络来破坏&#xff0c;防火…

kafka进阶_4.kafka扩展

文章目录 一、Controller选举二、Kafka集成2.1、大数据应用场景2.1.1、Flume集成2.1.2、Spark集成2.1.3、Flink集成 2.2、Java应用场景(SpringBoot集成) 三、Kafka常见问题3.1、Kafka都有哪些组件&#xff1f;3.2、分区副本AR, ISR, OSR的含义&#xff1f;3.3、Producer 消息重…