? ? ? ? 对于word的协同编辑,已经构思很久了,但是没有找到合适的插件。今天推荐基于canvas/svg 的富文本编辑器 ?canvas-editor,能实现类似word的基础功能,如果后续有更好的,也会及时更新。
canvas-editorhttps://hufe.club/canvas-editor/
canvas-editor: 同步自https://github.com/Hufe921/canvas-editorhttps://gitee.com/mr-jinhui/canvas-editor
? ? ? ? 虽然canvas-editor做的还不错,API都比较完善,但是对协同部分还是空缺,因此我们此次的重点是实现协同部分的代码,难免会修改源码部分。因此,我们需要阅读源码,实现 ts 代码的编写,修改其源码,实现协同。
? ? ? ? 大家可以直接从 github下载 ,也可以从刚才给的 gitee 下。
npm i? // 下载相关依赖
npm run dev // 启动服务
npm run build // 打包项目
? ? ? ? 启动后,能出来与demo一致的页面,即完成了这一步。
? ? ? ? 用户闪烁的光标目前还没有思路实现,后面会攻克技术难点,但是用户选取可以通过API实现:
? ? ? ? ?但是这个API会导致我的选取也会发生改变,因此,不能直接使用,需要添加新的API
? ? ? ? 简单解释一下文件,command文件向外暴露了API, command 指向 commandAdapt 文件,Adapt 文件中,有需要的全部对象,包括 画布、选取对象等,可以直接进行底层绘制。
public setUserRange(startIndex: number, endIndex: number, payload?: string) {
if (startIndex < 0 || endIndex < 0 || endIndex < startIndex) return
const isReadonly = this.draw.isReadonly()
if (isReadonly) return
// 根据 index 获取 domList 设置颜色
const elementList = this.draw.getElementList()
for (let i = startIndex; i <= endIndex; i++) {
elementList[i].highlight = payload||'#F5EEA0'
}
this.draw.render({
isSetCursor: false,
isCompute: false
})
}
???????? 这样用户选取,才不会影响我的选取,而取消选取就是设置透明色即可。
// 用户取消选取
public setUserUnRange(startIndex: number, endIndex: number) {
if (startIndex < 0 || endIndex < 0 || endIndex < startIndex) return
const isReadonly = this.draw.isReadonly()
if (isReadonly) return
// 根据 index 获取 domList 设置颜色
const elementList = this.draw.getElementList()
for (let i = startIndex; i <= endIndex; i++) {
elementList[i].highlight = 'transparent'
}
this.draw.render({
isSetCursor: false,
isCompute: false
})
}
? ? ? ? ?用户的光标是无状态的,因此需要记录光标信息,不然我重新设置了选取,上次的选取是需要取消哦,这个后面再说。
? ? ? ? 协同的核心就是数据一致性,因此,我们需要根据现有的数据结构实现CRDT。
// editor/core/websocket
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IWebsocketProviderStatus } from '../../interface/Websocket'
export class Ydoc {
private ydoc: Y.Doc
private ymap: Y.Map<unknown>
private ytext: Y.Text
private provider: any | undefined
private connect: boolean | undefined
private url: string
private roomname: string
constructor(url: string, roomname: string) {
console.log('new Ydoc')
this.url = url
this.roomname = roomname
this.connect = false
// 创建 YDoc 文档
this.ydoc = new Y.Doc()
this.ymap = this.ydoc.getMap('map')
this.ytext = this.ydoc.getText('text')
this.ymap.observe(() => {})
this.ytext.observe(() => {})
// 【方案二】 websocket 方式实现协同(已自己搭建 websocket 服务)
this.provider = new WebsocketProvider(this.url, this.roomname, this.ydoc)
// 监听链接状态F·
this.provider.on('status', (event: IWebsocketProviderStatus) => {
let { status } = event
if (status === 'connected') this.connect = true
else this.connect = false
})
}
public disConnection() {
if (!this.connect) return
this.provider.disconnect()
}
}
????????入口文件 index.ts 实现创建并传参
// 创建 websocket
if (ydocInfo) {
let { url, roomname, userid, username, color } = ydocInfo
if (!url || !roomname || !userid || !username)
throw Error('参数错误,url、roomname、userid、username必传!')
// 1. 如果存在,则创建协同
ydoc = new Ydoc(url, roomname, userid, this.command, color)
Reflect.set(window, 'ydoc', ydoc)
console.log(`用户${username}初始化`)
ydoc.userInitEditor(`用户${username}`)
}
? ? ? ? ?这样,整个编辑器需要实现协同的地方,都能调用 ydoc 实现。
? ? ? ? Yjs 的基本使用中,通过Map设置数据,observe观察器实现数据获取,协同部分不懂得可以看上一篇文章:
? ? ? ? 这样,用户每次初始化 Editor的时候,都会广播其他用户:
? ? ? ? 用户每次操作鼠标抬起,都会触发setRangeStyle事件:
? ? ? ? ?因此,在这个事件中捕获用户的选区操作;
?????????yjs中则是正常转发,然后调用上面实现的选区API:
public userRange({ data }: IYMapObserve) {
let { startIndex, endIndex, userid, color } = data
this.command.setUserRange(startIndex, endIndex, userid, color)
}
? ? ? ? 效果如下:
? ? ? ? 现在的选区还是有bug的,用户退出后,无法识别,还有就是单击时,无法优化选区。
? ? ? ? 如上图,我点击时,理论上只占用一个格子,不应该有选区【用户光标目前还没能实现】? if (startIndex === endIndex) return 如果点击的开始与结束相同,则不进行渲染。还有用户退出时,清空用户选区:
? ? ? ? ?实现删除历史选区,并删除lastRange 记录即可。
? ? ? ?CanvasEvent监听了input 事件,实现监听用户的输入,修改参数实现在draw 中获取用户数据,文档变化时,会调用 draw 中的方法:
????????因此,在这里通过yjs广播事件,修改参数后,就能拿到用户新增的数据了:
// 内容区变化
public contentChangeHandle(payload: IEditorData) {
/**
* 因此在这里需要重新解析用户的选区设置,不然会导致选区异常 BUG
*/
// 这里要解析 userRange
let { header, footer, main } = payload
main.forEach(item => {
if (item.userRange) {
delete item.highlight
delete item.userRange
}
})
this.setValue({ header, footer, main })
}
? ? ? ? 实现效果:
? ? ? ? 删除实现:
? ? ? ? keydown.ts 中对每个事件做了监听,在该文件实现广播,还是拿到本地的数据,进行数据解析,重新渲染。
?
? ? ? ? 效果如下:
?
? ? ? ? 样式的协同,就是基于API实现的,因为在main.ts中,所有的菜单栏操作,都是基于API实现,因此,我们需要在API调用处,进行统一处理即可
// 选区样式改变
public rangeStyleChange(payload: IRangeStyle) {
// 样式只能针对 用户的当前选区
// 直接使用 element 的事件机制
let { startIndex = 0, endIndex = 0, attr, value } = payload
const isReadonly = this.draw.isReadonly()
if (isReadonly) return
if (startIndex === endIndex) return
// 根据 index 获取 domList 设置颜色
const elementList = this.draw.getElementList()
for (let i = startIndex; i <= endIndex; i++) {
let el = elementList[i]
if (el) {
switch (attr) {
case 'color':
value ? (el.color = <string | undefined>value) : delete el.color
break
case 'bold':
value ? (el.bold = true) : delete el.bold
break
case 'italic':
value ? (el.italic = true) : delete el.italic
break
case 'fontSize':
break
case 'underline':
value ? (el.underline = true) : delete el.underline
break
case 'highlight':
// 这里还有BUG,因为用户选区结束又被设置透明
value
? (el.highlight = <string | undefined>value)
: delete el.highlight
break
default:
break
}
}
}
this.draw.render({
isSetCursor: false,
isCompute: false
})
}
? ? ? ? 效果如下:
? ? ? ? 用户协同选区与高亮冲突了,这个还得在想办法处理。
? ? ? ? 想要打包,需要注释 main.ts 中的window.onload 事件,将Editor 暴露到window身上
? ? ? ? 打包后,将dist 放置到项目 public/libs.canvas-editor下【如果你打包报错,基本上是TS语法检查的问题 let const 引入没用的模块等】
? ? ? ? 这样已经实现了基本的协同编辑了,至于说 菜单栏、目录,其实也是它自己加上的,然后调用API实现:
? ? ? ? ?剩下的就是自行实现菜单栏,调用API即可。
? ? ? ? 对这个文章简单说一下: