首页 > 其他分享 >两天实现思维导图的协同编辑?用Yjs真的可以

两天实现思维导图的协同编辑?用Yjs真的可以

时间:2023-11-04 12:34:54浏览次数:41  
标签:协同 const uid 导图 children Yjs data 节点

最近使用 Yjs 给自己开源的一个思维导图加上了协同编辑的功能,得益于该框架的强大,一直觉得很复杂的协同编辑能力没想到实现起来异常的简单,所以通过本文来安利给各位。

要实现协同编辑,目前主要有两种算法,一是 OT(Operational Transformation) ,二是 CRDT(Conflict-free Replicated Data Type) ,目前用的更多的是 OT ,它需要通过服务端来处理冲突,并将处理后的数据发送到各个端进行同步,CRDT 也支持这种模式,另外还支持直接在客户端处理冲突,然后通过点对点通信同步到其他客户端。

OT 是对编辑的数据操作进行转换,所以 OT 算法的实现依赖于编辑器数据模型的设计,不同的数据模型需要实现不同的操作转换算法。而 CRDT 本质是数据结构,通过数据结构的设计保证并发操作数据的最终一致性。所以只要将你的数据结构转换成它的数据结构即可帮你处理冲突和同步,在收到同步后的数据再转换回你的数据结构最后更新你的编辑器即可。相对而言,使用 CRDT 实现会更简单一点。

关于 OTCRDT 更详细的原理我也不会,各位可以搜索一下相关的文章,接下来看一下我是如何通过 Yjs 实现协同编辑的,先来看一下最终效果:

3.gif

安装

首先安装Yjs

npm i yjs

另外Yjs提供了一些网络同步的库,比如通过websocketwebrtc等等,详细介绍可以查看这个文档Connection Provider。每个库除了提供客户端的js npm包外,还提供了对应的服务端Nodejs的实现代码供你参考和测试,可以说是非常贴心了。我使用的是webrtc方式:

npm i y-webrtc

依赖就是这两个,接下来进行实例化:

import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'

class Cooperate {
  constructor(opt) {
      // 思维导图应用实例
      this.mindMap = opt.mindMap
      // Yjs文档实例
      this.ydoc = new Y.Doc()
      // 网络连接实例
      this.provider = new WebrtcProvider('房间名称', this.ydoc, {
          signaling: ['http:ip:port']// webrtc的信令服务器
      })
  }
}

Yjs暴露给我们使用的主要是一些共享类型的数据Shared Types,比如Y.mapY.arrayY.text,使用起来就和jsmaparray对象基本是一样的,非常简单,具体使用哪种需要根据你的数据结构来决定。

Doc实例就是用来承载这些共享数据的容器。

只要实例化网络同步库时传入Doc实例,就能实现不同客户端的数据同步了,webrtc是需要通过服务端来传递信令数据的,所以需要传入信令服务器的地址。

编辑数据

我的思维导图数据结构本质就是一棵树:

{
    data: {
        text: 'xxx',
        uid: 'xxx',
        other: 'xxx'
    },
    children: [
        {
            data: {
                text: 'xxx',
                uid: 'xxx',
                other: 'xxx'
            },
            children: []
        }
    ]
}

但是Yjs并没有提供树结构的共享类型,那么怎么办呢,很简单,转换一下就好了,我们可以将树结构转换成如下结构的map类型:

{
    uid: {
        data: {
            text: 'xxx',
            uid: 'xxx',
            other: 'xxx'
        },
        children: ['uid1', 'uid2'],
    },
    uid2: {
        data: {
            text: 'xxx',
            uid: 'xxx',
            other: 'xxx'
        },
        children: [],
    }
}

通过uid来关联节点数据,children中只保存子节点的uid

转换也不难,相信对于算法都很强的各位来说是分分钟的事情,而我算法很拉,只能写出以下方法:

class Cooperate {
    // 树结构转平级对象
    transformTreeDataToObject(data) {
        const res = {}
        const walk = (root, parent) => {
            const uid = root.data.uid
            // 将自己的id添加到父节点的children属性中
            if (parent) {
                parent.children.push(uid)
            }
            // 以uid为key添加到对象上
            res[uid] = {
                isRoot: !parent,
                data: {
                    ...root.data
                },
                children: []
            }
            // 遍历子节点,同时把自己传进去
            if (root.children && root.children.length > 0) {
                root.children.forEach(item => {
                    walk(item, res[uid])
                })
            }
        }
        walk(data, null)
        return res
    }
}

这样我们就可以使用Y.map类型的数据了,创建一下实例:

class Cooperate {
    constructor(opt) {
        this.mindMap = opt.mindMap
        this.ydoc = new Y.Doc()
        // 共享数据
        this.ymap = this.ydoc.getMap()
        // 监听共享数据改变
        this.ymap.observe(this.onObserve)
    }

    onObserve() {
        // todo
    }
}

可以通过observe方法监听共享数据的修改,这样当我们调用ymapymap.setymap.delete等方法修改数据后就可以监听到改变了,因为我们实例化了WebrtcProvider的网络同步实例,所以其他客户端也能监听到你所做的修改,就是这么简单。

首先需要将初始思维导图数据同步到ymap中:

class Cooperate {
    constructor(opt) {
        // ...
        // 思维导图树结构转平级对象结构
        this.currentData = this.transformTreeDataToObject(data)
        // 将思维导图数据添加到共享数据中
        Object.keys(this.currentData).forEach(uid => {
          this.ymap.set(uid, this.currentData[uid])
        })
    }
}

遍历转换后的对象调用ymap.set方法添加到ymap数据中即可。

然后思维导图数据有变动后会发送事件,所以可以在这个事件回调里找出更新点更新ymap数据:

class Cooperate {
    constructor(opt) {
        // ...
        this.mindMap.on('data_change', (data) => {
            // 更新后的思维导图数据同样转换对象结构
            const newData = this.transformTreeDataToObject(data)
            // 上一次的思维导图数据
            const oldData = this.currentData
            this.currentData = newData
            // 在transact方法中多次修改ymap只会触发一次事件
            this.ydoc.transact(() => {
                // 找出新增的或修改的思维导图节点
                Object.keys(newData).forEach(uid => {
                    // 新增的或已经存在的,如果数据发生了改变
                    if (!oldData[uid] || !isSameObject(oldData[uid], newData[uid])) {
                        this.ymap.set(uid, newData[uid])
                    }
                })
                // 找出删除的思维导图节点
                Object.keys(oldData).forEach(uid => {
                    if (!newData[uid]) {
                        this.ymap.delete(uid)
                    }
                })
            })
        })
    }
}

逻辑很简单,就是比对当前和上一次的数据,找出更新的思维导图节点,然后同步到ymap数据中即可,这样就会触发自己和其他客户端的observe事件,在该事件的回调中能拿到Yjs帮我们处理完冲突后的数据,我们再更新思维导图即可:

class Cooperate {
    onObserve(event) {
        // 获取到当前同步后的数据
        const data = event.target.toJSON()
        // 如果数据没有改变直接返回
        if (isSameObject(data, this.currentData)) return
        this.currentData = data
        // 平级对象转树结构
        const res = this.transformObjectToTreeData(data)
        if (!res) return
        // 更新思维导图画布
        this.mindMap.renderer.setData(res)
        this.mindMap.render()
    }
}

获取到同步后的最新数据,先和当前的数据对比一下,因为前面说了也会触发自己客户端的observe事件,防止没有必要的更新。

然后将对象结构再转换回思维导图需要的树结构,最后调用相关方法更新思维导图画布即可实现同步更新。

同样贴一下对象转树结构的方法:

class Cooperate {
    // 将平级对象转树结构
    transformObjectToTreeData(data) {
        const uids = Object.keys(data)
        if (uids.length <= 0) return null
        // 找出根节点的uid
        const rootKey = uids.find(uid => {
            return data[uid].isRoot
        })
        // 根节点不存在直接返回
        if (!rootKey || !data[rootKey]) return null
        // 根节点
        const res = {
            data: data[rootKey].data,
            children: []
        }
        const map = {}
        map[rootKey] = res
        // 遍历所有uid
        uids.forEach(uid => {
            // 找出父节点的uid
            const parentUid = this.findParentUid(data, uid)
            // 当前节点的数据
            const cur = data[uid]
            // 如果已经添加到了缓存对象上,那么直接使用缓存的数据即可
            // 否则需要进行缓存
            const node = map[uid] || {
                data: cur.data,
                children: []
            }
            if (!map[uid]) {
                map[uid] = node
            }
            // 如果存在父节点
            if (parentUid) {
                // 找出当前节点在兄弟节点中的索引
                const index = data[parentUid].children.findIndex(item => {
                    return item === uid
                })
                // 如果还没遍历到父节点,也就是父节点还没添加到缓存对象上,那么直接帮父节点进行缓存
                if (!map[parentUid]) {
                    map[parentUid] = {
                        data: data[parentUid].data,
                        children: []
                    }
                }
                // 将自己添加到父节点的子节点的指定位置
                map[parentUid].children[index] = node
            }
        })
        return res
    }

    // 找到父节点的uid
    findParentUid(data, targetUid) {
        const uids = Object.keys(data)
        let res = ''
        uids.forEach(uid => {
            const children = data[uid].children
            const isParent =
                  children.findIndex(childUid => {
                      return childUid === targetUid
                  }) !== -1
            if (isParent) {
                res = uid
            }
        })
        return res
    }
}

到这里,编辑数据的协同处理就已经结束了,是不是so easy。

感知数据

所谓感知数据就是用来显示其他协作人员的信息,一般就是其他人员当前的光标位置及对应的名字或头像,主要是用来提示当前这里谁在编辑,你就不要过来了,虽说冲突可以被处理掉,但是实际上大多数时候的协同编辑都是大家一起编辑一个文档不同的部分,而不是一起互相制造冲突,那样可能会打起来,效率反而低了。

感知数据完全可以你自己来传输,但是Yjs也提供了这个能力,每个Connection Provider都支持传输感知数据,使用起来同样非常简单。

对于思维导图场景,显示其他协作者的实时鼠标位置其实没有必要,因为大多数操作都是要在选中节点的情况下进行的,所以只要在激活的节点上显示激活该节点的协作人员信息即可,同样有相关的事件可以监听:

class Cooperate {
    constructor(opt) {
        // ...
        // provider提供的感知数据处理对象
        this.awareness = this.provider.awareness
        // 监听思维导图的节点激活事件
        this.mindMap.on('node_active', (node, nodeList) => {
            // 调用setLocalStateField方法设置或更新感知状态数据
            this.awareness.setLocalStateField(this.userInfo.name, {
                // 用户信息
                userInfo: {
                    ...this.userInfo
                },
                // 当前激活的节点uid列表
                nodeIdList: nodeList.map(item => {
                    return item.uid
                })
            })
        })
    }
}

可以通过awareness属性获取Connection Provider提供的感知数据处理对象,然后在节点的激活事件回调函数中设置或更新协作人员激活的节点列表,同样,awareness也提供了监听其他协作者感知数据改变的方法:

class Cooperate {
    constructor(opt) {
        // ...
        this.awareness.on('change', () => {
            const walk = (list, callback) => {
                list.forEach(value => {
                    const userName = Object.keys(value)[0]
                    if (!userName) return
                    const data = value[userName]
                    const userInfo = data.userInfo
                    const nodeIdList = data.nodeIdList
                    // 遍历协作人员激活的节点uid列表
                    nodeIdList.forEach(uid => {
                        // 通过uid找到节点实例
                        const node = this.mindMap.renderer.findNodeByUid(uid)
                        if (node) {
                            callback(node, userInfo)
                        }
                    })
                })
            }
            // 清除之前的数据
            walk(this.currentAwarenessData, (node, userInfo) => {
                node.removeUser(userInfo)
            })
            // 设置当前数据
            const data = Array.from(this.awareness.getStates().values())
            this.currentAwarenessData = data
            walk(data, (node, userInfo) => {
                // 不显示自己
                if (userInfo.id === this.userInfo.id) return
                // 在节点上方显示当前操作的人员的头像
                node.addUser(userInfo)
            })
        })
    }
}

逻辑同样很简单清晰,在感知数据改变后先清除画布上当前的信息,然后再根据新信息进行渲染。

到这里,给一个思维导图添加基本的协同编辑能力就完成了。

总结

本文详细介绍了我是如何使用Yjs给一个思维导图加上协同编辑的能力,可以看到使用Yjs实现协同编辑整体逻辑是非常简单清晰的,对于原有代码逻辑的入侵也非常小,只要做一下数据结构的转换工作和感知数据的渲染即可,所以Yjs非常适合个人开发者或小团队。

当然以上只是个人的最简单实践,可能会存在一些问题,日后如果遇到了再来分享。

标签:协同,const,uid,导图,children,Yjs,data,节点
From: https://blog.51cto.com/u_15319948/8180756

相关文章

  • 第一章思维导图
    模块一 资金时间价值地计算及应用 模块二 技术方案经济效果评价模块三 技术方案不确定性分析模块四 技术方案现金流量表的编制模块五 设备更新分析模块六 价值工程在工程建设中的应用模块七 新技术、新工艺和新材料应用方案的技术经济分析 ......
  • 浅述边缘计算场景下的云边端协同融合架构的应用场景示例
    云计算正在向一种更加全局化的分布式节点组合形态进阶,而边缘计算是云计算能力向边缘侧分布式拓展的新触角。随着城市建设进程加快,海量设备产生的数据,若上传到云端进行处理,会对云端造成巨大压力。如果利用边缘计算来让云端的能力下沉,则可以很好地解决海量数据的处理问题,让云端的数据......
  • 函数的概念与性质|思维导图
    前言编辑制作中。。。。。。思维导图[全屏]......
  • 电影推荐与管理系统Python+Django网页界面+协同过滤推荐算法【计算机毕设项目】
    一、介绍电影推荐管理系统。本系统使用Python作为主要开发语言,前端采用HTML、CSS、BootStrap等技术语言框架搭建展示界面,后端采用Django作为功能逻辑处理,并使用Ajax实现前端与和后端的通信。其主要实现功能如下:系统平台分为管理员和用户两个角色用户可以登录、注册、查看电影、发表......
  • 电影推荐与管理系统Python+Django网页界面+协同过滤推荐算法【计算机毕设项目】
    一、介绍电影推荐管理系统。本系统使用Python作为主要开发语言,前端采用HTML、CSS、BootStrap等技术语言框架搭建展示界面,后端采用Django作为功能逻辑处理,并使用Ajax实现前端与和后端的通信。其主要实现功能如下:系统平台分为管理员和用户两个角色用户可以登录、注册、查看电影......
  • 数字孪生协同仿真:复杂电机篇
    01.简介电机仿真是现代机电工程研究领域中的重要环节,始于20世纪后半叶,为工程师提供了一种研究、设计和优化各种电机系统的新方式。时至今日,从传统的电动机到现代的电动汽车动力系统,电机仿真技术在电机设计、性能分析和控制策略开发领域发挥着关键作用。电机仿真广泛应用于各种领......
  • RTL8852BE网卡导致MIUI+的多屏协同卡顿
    米粉,一直用MIUI+实现手机与电脑的多屏协同功能,最近换电脑后发现卡顿严重,还经常无响应,于是下决心研究下多屏协同的本质与外部依赖。不论谁家的多屏协同,都是启用WiFi-Direct实现的手机与电脑互联,卡顿说明两者的连接速率不够。建立连接后,在Win11上任务管理器的性能里,能找到类型为WiF......
  • 图书推荐与管理系统Python+协同过滤推荐算法+Django网页界面
    一、介绍图书管理与推荐系统。使用Python作为主要开发语言。前端采用HTML、CSS、BootStrap等技术搭建界面结构,后端采用Django作为逻辑处理,通过Ajax等技术实现数据交互通信。在图书推荐方面使用经典的协同过滤算法作为推荐算法模块。主要功能有:角色分为普通用户和管理员普通用户可注......
  • 图书推荐管理系统Python+Django网页界面+协同过滤推荐算法
    一、介绍图书管理与推荐系统。使用Python作为主要开发语言。前端采用HTML、CSS、BootStrap等技术搭建界面结构,后端采用Django作为逻辑处理,通过Ajax等技术实现数据交互通信。在图书推荐方面使用经典的协同过滤算法作为推荐算法模块。主要功能有:角色分为普通用户和管理员普通用......
  • 汽车零部件加工刀具,“数控刀具协同设计制造与服务关键技术研究及应用示范”召开工作会
    成都工具研究所有限公司的前身是成都工具研究所,于1956年创建于北京,是原机械工业部的直属研究所,是我国机械工业的综合性工具科研机构。公司官网:http://www.ctri.com.cn/公司主要从事精密切削工具、精密测量仪器以及表面改性处理技术的技术研究、产品开发和应用服务。2021年3月17日......