惠州网站建设是什么渠道查官网
前言
历史记录管理器是系统中的重要功能,而实现历史记录,有多种方案,例如: 存储数据快照、使用命令模式等。
在协同场景中,分布式撤销是非常重要的功能,如果历史记录通过快照存储,是很难实现的;命令式需要调整添加协同用户标识,在执行命令时,进行业务判断。
今天,我们使用 Yjs 的一个工具函数 UndoManager 功能,实现一个分布式撤销功能,支持协同场景下,独立撤销操作(自己只能撤销自己的操作)。
撤销实现案例
例如基于 Konva 图层快照:
public patchHistory() {// 当前图层的JSON串 - 不直接使用 toJSON(),避免影响原图层const layerClone = mainLayer.clone();// 当前图层的 MD5const layerJson = JSON.stringify(layerClone.toObject());const layerMD5 = MD5(layerJson);// 被添加图层与最后缓存的记录是否一致const lastLayer = this.undoStack[this.undoStack.length - 1];const lastLayerJson = JSON.stringify(lastLayer?.toObject());const lastLayerMD5 = MD5(lastLayerJson);// 如果最后一个记录与当前记录一致,则不添加记录if (layerMD5 === lastLayerMD5) return console.log("历史记录一致");this.undoStack.push(layerClone);// 如果记录数大于 HISTORY_MAX_RECORED,则删除最前的记录while (this.undoStack.length > HistoryManager.MaxHistoryCount) this.undoStack.shift();}
例如基于命令模式:
// 执行命令并添加到历史记录
executeCommand(command) {// 如果当前不是最新状态,移除后面的历史记录if (this.currentIndex < this.history.length - 1) {this.history = this.history.slice(0, this.currentIndex + 1);}// 执行命令command.execute();// 添加到历史记录this.history.push(command);this.currentIndex++;// 如果历史记录超过最大数量,移除最旧的记录if (this.history.length > this.maxSize) {this.history.shift();this.currentIndex--;}this.updateUI();
}
上诉仅是实现历史记录的一种方法,具体的还得根据自己的项目实际,选择合适的方案。今天,为大家介绍下 Yjs 协同中的历史记录管理器使用案例,支持分布式撤销(自己只能撤销自己的操作)。
Y.UndoManager
官网链接:https://docs.yjs.dev/api/undo-manager,其用法也非常简单,如下:
import * as Y from 'yjs'// 可以是任何的 Yjs AbstractType
const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext)ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => ''
undoManager.redo()
ytext.toString() // => 'abc'
请注意,Y.UndoManager 是自带两个操作栈(undoStack|redoStack),堆栈中存放的是由 Yjs 维护的堆栈对象 StackItem :
构造函数
new Y.UndoManager(scope: Y.AbstractType | Array<Y.AbstractType> [{captureTimeout: number, trackedOrigins: Set<any>, deleteFilter: function(item):boolean}])
构造函数中,必须传递一个 Yjs 的共享数据类型;可选参数中,`captureTimeout` 表示时间间隔(默认为 500 毫秒)内创建的编辑,会合并成一个记录,将其设置为 0 以单独捕获每个更改;`trackedOrigins
` 则表示跟踪不同源的修改;
而 `deleteFilter` 则是删除过滤函数,源码中,当记录被弹出时,会判断当前记录项是否需要删除,文档中没有指明其作用,不过判断应该是为了弹出时,可以保留其记录,某些特殊场景下做拓展使用。
API 详解
undoManager.undo()
撤消 UndoManager 堆栈上的最后一个作,反向作将放在重做堆栈上。
undoManager.redo()
重做重做堆栈上的最后一个操作,即之前的撤消被撤消。
undoManager.stopCapturing()
调用 stopCapturing()
以确保放在 UndoManager 上的下一个作不会与上一个作合并 。前面说了,`captureTimeout` 是一定时间内的操作,都会被合并为一个记录存储,如果在下一个操作之前,手动调用 `stopCapturing
`,则下一个操作会被记录为一个独立的操作。
// without stopCapturing
ytext.insert(0, 'a')
ytext.insert(1, 'b')
undoManager.undo()
ytext.toString() // => '' (note that 'ab' was removed)// with stopCapturing
ytext.insert(0, 'a')
// 手动调用 stopCapturing 那么下一个操作会被单独记录
undoManager.stopCapturing()
ytext.insert(0, 'b')
undoManager.undo()
ytext.toString() // => 'a' (note that only 'b' was removed)
undoManager.on('stack-item-added' ... )
undoManager.on('stack-item-popped' ...)
undoManager.on('stack-item-updated', ...)
事件都比较简单,这里不展开说了哈
指定跟踪的来源
共享文档上的每个更改都有一个来源,也就是每一个共享数据类型的变更,都会有一个 origin 对象,标记着更改来源,默认是 null:
官网推荐我们,将每一个修改,都封装到事务中,以便减少事件调用及指定事件源,因此,我们通过事务来调整共享数据类型。
这个没这么难理解的,之前我们都是直接调用的数据方法,下面的两次 insert 会引起两次数据更新:
ytext.insert(0, 'a')
ytext.insert(1, 'b')
// ymap.set(key,value)
// yarray.push(...)
官网推荐我们这样,事务中的执行的更改,只会引起一次数据更新:
doc.transact(()=>{ytext.insert(0, 'a')ytext.insert(1, 'b')// ymap.set(key,value)// yarray.push(...)
},origin)
而 UndoManager 中的 `trackedOrigins`,就是跟踪指定源的修改(这个源的修改需要记录,其他源的修改不需要记录)。
画布案例
有了以上的基础知识后,我们来实现基础的分布式撤销就简单多了。
但是请注意!StackItem 存储的是共享数据类型哈!并不是快照、命令,我们需要结合实际的项目,去做适配。下面简单实现下多人协同场景下的撤销,实现一个基础的画布应用:
Konva
Konva 的知识我就不多介绍了,它不是本章的重点,仅是作为绘制工具类使用。
constructor(container: HTMLDivElement, width: number, height: number) {this.stage = new Konva.Stage({ container, width, height });this.layer = new Konva.Layer();this.stage.add(this.layer);}
这是最简单的 Konva 代码了。
Yjs
yjs 主要处理的事,是创建 Doc,维护共享数据类型,维护历史记录管理器:
export class Collaborate {private doc: Y.Doc;private yArray: Y.Array<unknown>;private undoManager: Y.UndoManager;private localOrigin: { userid: string; clientID: number };constructor(room: string, url?: string) {this.doc = new Y.Doc();// 初始化 originthis.localOrigin = { userid: "local", clientID: this.doc.clientID };// provider 不一定需要创建,如果非协同场景,则不需要,但是一定需要 Yjs 的数据结构,使用 Array 实现数据存储this.yArray = this.doc.getArray();// 如果用户传递了 url 则尝试连接该地址if (url) {// this.provider = new WebsocketProvider(url, room, this.doc);}this.undoManager = new Y.UndoManager(this.yArray, {trackedOrigins: new Set([this.localOrigin]),captureTimeout: 400,});// 监听变化 - 数据驱动更新,因此此处应该直接调用 draw.render 方法this.yArray.observeDeep(() => draw.render());}
}
当然!别忘了,所有的共享数据类型,需要使用 doc.transact 事务封装!!!不然,UndoManager无法捕获!
HistoryManager
这里的历史记录管理器仅作为外部调用使用,内部不创建堆栈,依赖 Y.UndoManager 实现:
export class HistoryManager {private collaborate: Collaborate;constructor(collaborate: Collaborate) {this.collaborate = collaborate;}undo() {this.collaborate.getUndoManager().undo();}redo() {this.collaborate.getUndoManager().redo();}
}
其原理图大致如下:用户A执行操作,使用事务跟踪并记录当前的操作,此时,会引起共享数据类型的更新,(如果协同场景下,那么会广播此更新,使得所有客户端的共享数据类型同步),远端监听到更新后,进行视图更新。
关键步骤
协同中心 yjs 提供事务执行
// 提供事务执行transact(fn: () => void) {this.doc.transact(fn, this.localOrigin);}
draw中提供视图更新方法(本例执行数据驱动,大家可以根据自己项目实际调整)
/*** @description render 渲染函数 - konva 图形由 Yjs Doc 数据驱动*/render() {// 先清空当前图层,再根据 YArray 重建视图this.layer.destroyChildren();// 从 YArray 解析数据const yArray = this.collaborate.getYArray();for (const yShape of yArray.toArray() as Array<Konva.ShapeConfig>) {const { type } = yShape as { type?: string };if (type === "rect") {const rect = new Konva.Rect(yShape);this.layer.add(rect);}if (type === "circle") {const circle = new Konva.Circle(yShape);this.layer.add(circle);}}this.layer.batchDraw();}
用户操作封装到事务中,目前是将所有的图形属性添加到 shape 中
/*** @description 添加图形 - 添加的图形是需要同步到 Yjs Doc*/addShape(shape: Konva.ShapeConfig) {// 同步到 Yjs Doc(放入带有本地来源的事务中,便于撤销/重做)const yArray = this.collaborate.getYArray();this.collaborate.transact(() => {yArray.push([shape]);});}
实现效果:
拓展:
目前我们Array 中直接存储的是 shape 的配置项,但是我们想更新一个数据(移动元素|更改颜色)等场景时,该如何操作?
draw.getStage().on("dragend", (e) => {const node = e.target;const id = node.getAttr("id") as string | undefined;if (!id) return;const collaborate = draw.getCollaborate();const yarray = collaborate.getYArray();const arrayMap = yarray.toArray();const idx = arrayMap.findIndex((m) => m.id === id);if (idx === -1) return;const newShapeAttrs = Object.assign({}, yarray.get(idx), node.attrs);console.log("==> ", newShapeAttrs);// 重新更新数据collaborate.transact(() => {// 删除原数据yarray.delete(idx, 1);// 在同一位置新增yarray.insert(idx, [newShapeAttrs]);});
});
这样设计,每次更新位置,都会引起 Array 的先删除后增加,但是实际的元素又没有变化!但是,YArray 不支持修改单个属性,array[idx] = [newShapeAttrs] 类似这样修改是不支持的
可以单个修改属性的是 YMap ,因此,可以设计为 YArray<YMap> 的数据结构,拿到单个数据项,做属性修改,这部分大家可以自行优化下。
富文本案例
上述画布案例中,画布数据就是一个个对象属性 (konva.ShapeConfig),因此使用数组存储,如何是富文本实现呢? Yjs 为我们提供了 Y.Text 类型,做富文本最合适。
定义基础页面结构
监听输入:
/*** @description 监听输入事件*/
editor.addEventListener("compositionend", (e) => {const text = e.data;handleInput(text);
});editor.addEventListener("input", (e: Event) => {// 需要兼容换行 空格等其他特殊的字符const event = e as InputEvent;if (event.isComposing) return;const text = (e as InputEvent).data ?? "";handleInput(text);
});
封装协同控制中心
还是上一个案例类似,协同控制中心仅负责创建共享数据类型,维护 UndoManager,
const ydoc = new Y.Doc();
const ytext = ydoc.getText("text");// 创建本地 origin
const localOrigin = { userId, clientID: ydoc.clientID };// 创建 y-websocket provider
const provider = new WebsocketProvider("ws://localhost:9999", "easy-painting", ydoc);// 创建 Y.UndoManager
const undoManager = new Y.UndoManager([ytext], { trackedOrigins: new Set([localOrigin]) });// 监听更新
ytext.observeDeep((_e, transaction) => {// 本地更新不执行if (transaction.origin === localOrigin) return;updateView();
});// 封装 doc.transact
function transact(fn: () => void) {ydoc.transact(() => {fn();undoManager.stopCapturing();}, localOrigin);
}
创建历史记录管理
核心思想还是调用 undomanager 哈
// 创建 history
const history = {undo: () => {undoManager.undo();},redo: () => {undoManager.redo();},
};
数据处理
本例采用 YText.Delta 作为共享数据类型,插入数据时使用 insert ,执行格式化时使用 format,删除时用 delete,更多的操作大家自行拓展哈。
// 输入框输入事件执行函数
function handleInput(text: string) {if (!text) return;console.log("input text: ", text);// 取当前光标位置 || ytext.length 作为插入位置const index = getTextIndex().start || ytext.length;// 执行 ytext.insert 命令transact(() => {ytext.insert(index, text);});
}
/*** @description 执行命令*/
function executeCommand(command: string, value?: string) {// 调用命令document.execCommand(command, false, value);// 获取当前的 索引const { start, end } = getTextIndex();transact(() => {// 对选择区域进行 formatif (start !== end) {ytext.format(start, end - start, { [command]: value ?? true });} else {// 否则对当前光标位置进行 formatytext.format(start, 0, { [command]: value ?? true });}});
}
// 监听 delete 实现
editor.addEventListener("keydown", (e) => {if (e.key === "Backspace") {// 获取当前光标位置const { start, end } = getTextIndex();if (start === end) {// 如果当前光标位置为0,则不执行删除if (start === 0) return;// 否则删除当前光标位置前一个字符transact(() => {ytext.delete(start, end - start);});}}
});
视图更新
这里采用的简单的实现方式哈,执行 `document.execCommand(command, false, value);`,实现简单的样式,因此视图更新,就是识别 Delta 数据,转换为 HTML,赋给编辑器:
/*** @description 视图更新*/
function updateView() {console.log("==> 视图更新", ytext.toDelta());editor.innerHTML = ytext.toDelta().map((item: DeltaItem) => {// 如果没有属性,则直接返回文本if (!item.attributes) {// 处理换行符return item.insert.replace(/\n/g, "<br>");}// 需要根据属性类型,返回对应的 html 标签let content = item.insert;// 处理换行符content = content.replace(/\n/g, "<br>");// 按照优先级和兼容性顺序应用样式// 多个属性需要嵌套应用,例如: {"strikeThrough": true, "bold": true} => <s><strong>text</strong></s>if (item.attributes.bold) {content = `<strong>${content}</strong>`;}if (item.attributes.italic) {content = `<em>${content}</em>`;}if (item.attributes.strikeThrough) {content = `<s>${content}</s>`;}if (item.attributes.underline) {content = `<u>${content}</u>`;}if (item.attributes.foreColor) {content = `<span style="color: ${item.attributes.foreColor}">${content}</span>`;}return content;}).join("");
}
实现效果如下:
这就是协同场景下的分布式撤销,这仅是一个简单的示例哈,还有很多细节需要完善,例如,直接执行 editor.innerHTML 会导致用户光标丢失,协同用户光标优化、用户光标存储等等,大家可以自行处理。
总结
Y.UndoManager 不仅仅可以用于协同场景下的分布式撤销,当然,也可以用于本地实现历史记录管理,只是一般情况下,都是自己适配项目实现。
上诉两个案例,从协同控制、历史记录,以及底层数据操作,原理都是类似的,都需要初始化一个 UndoManager ,并且指定追踪的源,通过保持一致的共享数据类型,实现页面展示的一致性。
大家可以针对Yjs 官网的各个案例进行阅读,相信大家对协同的理解会更加深刻。