Canvas图形编辑器-数据结构与History(undo/redo)
Canvas图形编辑器-数据结构与History(undo/redo)
这是作为 社区老给我推Canvas,于是我也学习Canvas做了个简历编辑器 的后续内容,主要是介绍了对数据结构的设计以及History能力的实现。
- 在线编辑: https://windrunnermax.github.io/CanvasEditor
- 开源地址: https://github.com/WindrunnerMax/CanvasEditor
关于Canvas简历编辑器项目的相关文章:
- 社区老给我推Canvas,我也学习Canvas做了个简历编辑器
- Canvas图形编辑器-数据结构与History(undo/redo)
- Canvas图形编辑器-我的剪贴板里究竟有什么数据
- Canvas简历编辑器-图形绘制与状态管理(轻量级DOM)
- Canvas简历编辑器-Monorepo+Rspack工程实践
描述
对于编辑器而言,History也就是undo和redo是必不可少的能力,实现历史记录的方法通常有两种:
存储全量快照,也就是说我我们每进行一个操作,都需要将全量的数据通常也就是
JSON格式的数据存到一个数组里,如果用户此时触发了redo就将全量的数据取出应用到Editor对象当中。这种实现方式的优点是简单,不需要过多的设计,缺点就是一旦操作的多了就容易炸内存。基于
Op的实现,Op就是对于一个操作的原子化记录,举个例子如果将图形A向右移动3px,那么这个Op就可以是type: "MOVE", offset: [3, 0],那么如果想要做回退操作依然很简单,只需要将其反向操作即type: "MOVE", offset: [-3, 0]就可以了,这种方式的优点是粒度更细,存储压力小,缺点是需要复杂的设计以及计算。
既然我们是从零开始设计一个编辑器,那么大概率是不会采用方案1的,我们更希望能够设计原子化的Op来实现History,所以从这个方向开始我们就需要先设计数据结构。
数据结构
我特别推荐大家去看一下 quill-delta 的数据结构设计,这个数据结构的设计非常棒,其可以用来描述一篇富文本,同时也可以用来构建change对富文本做完整的增删改操作,对于数据的compose、invert、diff等操作也一应俱全,而且quill-delta也可以是富文本OT协同算法的实现,这其中的设计还是非常牛逼的。
其实我之前也没有设计过数据结构,更不用谈设计Op去实现历史记录功能了,所以我在设计数据结构的时候是抓耳挠腮、寝食难安,想设计出 quill-delta 这种级别的数据描述几乎是不可能了,所以只能依照我的想法来简单地设计,这其中有很多不完善的地方后边可能还会有所改动。
因为之前也没有接触过Canvas,所以我的主要目标是学习,所以我希望任何的实现都以尽可能简单的方向走。那么在这里我认为任何元素都是矩形,因为绘制矩阵是比较简单的,所以图形元素基类的x, y, width, height属性是确定的,再加上还有层级结构,那么就再加一个z,此外由于需要标识图形,所以还需要给其设置一个id。
class Delta {
public readonly id: string;
protected x: number;
protected y: number;
protected z: number;
protected width: number;
protected height: number;
}
因为我想做一个插件化的实现,也就是说所有的图形都应该继承这个类,那么这个自定义的函数体肯定是需要存储自己的数据,所以在这里加一个attrs属性,又因为想简单实现整个功能,所以这个数据类型就被定义为Record<string, string>。因为是插件化的,每个图形的绘制应该由子类来实现,所以需要定义绘制函数的抽象方法,于是一个数据结构就这么设计好了,关于插件化的设计我们后续可以再继续聊。
abstract class Delta {
public readonly id: string;
protected x: number;
protected y: number;
protected z: number;
protected width: number;
protected height: number;
public attrs: DeltaAttributes;
public abstract drawing: (ctx: CanvasRenderingContext2D) => void;
}
那么现在已经有了基本的数据结构,我们可以设想一下究竟应该有哪几种操作,经过考虑大概无非是 插入INSERT、删除DELETE、移动MOVE、调整大小RESIZE、修改属性REVISE,这五个Op就可以覆盖我们对于当前编辑器图形的所有操作了,所以我们后续的设计都要围绕着这五个操作来进行。
看起来其实并不难,但实际上想要将其设计好并不容易,因为我们目标是History所以我们不光要顾及正向的操作,还需要设计好invert也就是反向操作,依旧以之前的MOVE操作举例,我们移动一个元素可以使用MOVE(3, 0),反向操作就可以直接生成也就是MOVE(3, 0).invert = MOVE(-3, 0),那么RESIZE操作呢,尤其是在多选操作时的RESIZE,我们需要想办法让其能够实现invert操作,一种方法是记录每个点的移动距离,但是这样对于每个Op存储的信息有点过多,我们在构造一个正向的Op时也需要将相关的数据拉到Op中,同样对于REVISE而言我们需要将属性的前值和后值都放在Op中才可以继续执行。
那么如何比较好的解决这个问题呢,很明显如果我们想用轻量的数据来承载内容,那么先前的数据在不一定会使用的情况下我们是没必要存储的,那是不是可以自动提取相关的内容作为invert-op呢,当然是可以的,我们可以在进行invert的时候,将未操作前的Delta一并作为参数传入就好了,我们可以来验证一下,我们的函数签名将会是Op.invert(Delta) = Op'。
// Prev DeltaSet
[{id: "xxx", x: x1, y: y1, width: w1, height: h1}]
// ResizeOp
RESIZE({id: "xxx", x: x2, y: y2})
// Next DeltaSet
[{id: "xxx", x: x1 + x2, y: y1 + y2, width: w1, height: w1}]
// Invert InsertOp
RESIZE({id: "xxx", x: -x2, y: -y2})
// Prev DeltaSet
[{id: "xxx", x: x1, y: y1, width: w1, height: h1}]
// ResizeOp
RESIZE({id: "xxx", x: x2, y: y2, width: w2, height: h2})
// Next DeltaSet
[{id: "xxx", x: x2, y: y2, width: w2, height: h2}]
// Invert InsertOp
RESIZE({id: "xxx", x: x1, y: y1, width: w1, height: h1})
看起来是没有问题的,所以我们现在可以设计全量的Op和Invert方法了,在这里因为我最开始是预计要设计组合也就是将几个图形组合在一起操作的能力,所以还预留了一个parentId作为后期开发拓展用,但是暂时是用不上的所以这个字段暂时可以忽略。下面的Invert实际上就是case by case地进行转换,INSERT -> DELETE、DELETE -> INSERT、MOVE -> MOVE、RESIZE -> RESIZE、REVISE -> REVISE。这其中的DeltaSet可以理解为当前的所有Delta数据,类型签名类似于Record<string, Delta>,是扁平的结构,便于数据查找。
export type OpPayload = {
[OP_TYPE.INSERT]: { delta: Delta; parentId: string };
[OP_TYPE.DELETE]: { id: string; parentId: string };
[OP_TYPE.MOVE]: { ids: string[]; x: number; y: number };
[OP_TYPE.RESIZE]: { id: string; x: number; y: number; width: number; height: number };
[OP_TYPE.REVISE]: { id: string; attrs: DeltaAttributes };
};
export class Op<T extends OpType> {
public readonly type: T;
public readonly payload: OpPayload[T];
constructor(type: T, payload: OpPayload[T]) {
this.type = type;
this.payload = payload;
}
public invert(prev: DeltaSet) {
switch (this.type) {
case OP_TYPE.INSERT: {
const payload = this.payload as OpPayload[typeof OP_TYPE.INSERT];
const { delta, parentId } = payload;
return new Op(OP_TYPE.DELETE, { id: delta.id, parentId });
}
case OP_TYPE.DELETE: {
const payload = this.payload as OpPayload[typeof OP_TYPE.DELETE];
const { id, parentId } = payload;
const delta = prev.get(id);
if (!delta) return null;
return new Op(OP_TYPE.INSERT, { delta, parentId });
}
case OP_TYPE.MOVE: {
const payload = this.payload as OpPayload[typeof OP_TYPE.MOVE];
const { x, y, ids } = payload;
return new Op(OP_TYPE.MOVE, { ids, x: -x, y: -y });
}
case OP_TYPE.RESIZE: {
const payload = this.payload as OpPayload[typeof OP_TYPE.RESIZE];
const { id } = payload;
const delta = prev.get(id);
if (!delta) return null;
const { x, y, width, height } = delta.getRect();
return new Op(OP_TYPE.RESIZE, { id, x, y, width, height });
}
case OP_TYPE.REVISE: {
const payload = this.payload as OpPayload[typeof OP_TYPE.REVISE];
const { id, attrs } = payload;
const delta = prev.get(id);
if (!delta) return null;
const prevAttrs: DeltaAttributes = {};
for (const key of Object.keys(attrs)) {
prevAttrs[key] = delta.getAttr(key);
}
return new Op(OP_TYPE.REVISE, { id, attrs: prevAttrs });
}
default:
break;
}
return null;
}
}
History
既然我们已经设计好了基于Op的原子化操作以及数据结构,那么紧接着我们就可以开始做History能力了,在这里首先需要注意我们先前对于Invert的思想是让其根据DeltaSet自动先生成InvertOp,在这里我们可以有两种方案来实现。
第一种方式是在应用
Op之前我们先根据当前的DeltaSet自动生成一个InvertOp,然后将这个Op交给History模块存储起来作为Undo的组操作即可。第二种方式是我们在应用
Op之前首先生成一遍新的Previous DeltaSet,是一个immer的副本,然后将Prev DeltaSet以及Next DeltaSet一并作为OnChangeEvent交给History模块进行后续的操作。
最终我是选择了方案二作为整体实现,倒是没有什么具体依据,只是觉得这个immer的副本可能不仅会在这里使用,作为事件的一部分分发先前的数据值我认为是合理的,所以在应用Op的时候大致实现如下。
public apply(op: OpSetType, applyOptions?: ApplyOptions) {
const options = applyOptions || { source: "user", undoable: true };
const previous = new DeltaSet(this.editor.deltaSet.getDeltas());
switch (op.type) {
// 根据不同的`Op`执行不同的操作
}
this.editor.event.trigger(EDITOR_EVENT.CONTENT_CHANGE, {
previous,
current: this.editor.deltaSet,
changes: op,
options,
});
}
其实我们也可以看到,整个编辑器内部的通信是依赖于event这个模块的,也就是说这个apply函数不会直接调用History的相关内容,我们的History模块是独立挂载CONTENT_CHANGE事件的。那么紧接着,我们需要设计History模块的数据存储,我们先来明确一下想要实现的内容,现在原子化的Op已经设计好了,所以在设计History模块时就不需要全量保存快照了,但是如果每个操作都需要并入History Stack的话可能并不是很好,通常都是有N个Op的一并Undo/Redo,所以这个模块应该有一个定时器与缓存数组还有最大时间,如果在N毫秒秒内没有新的Op加入的话就将Op并入History Stack,还有就是常规的undo stack以及redo stack,栈存储的内容也不应该很大,所以还需要设置最大存储量。
export class History {
private readonly DELAY = 800;
private readonly STACK_SIZE = 100;
private temp: OpSetType[];
private undoStack: OpSetType[][];
private redoStack: OpSetType[][];
private timer: ReturnType<typeof setTimeout> | null;
}
前边也提到过我们都是通过事件来进行通信的,所以这里需要先挂载事件,并且在这里将Invert的Op构建好,将其置入批量操作的缓存中。
constructor(private editor: Editor) {
this.editor.event.on(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange, 10);
}
destroy() {
this.editor.event.off(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange);
}
private onContentChange = (e: ContentChangeEvent) => {
if (!e.options.undoable) return void 0;
this.redoStack = [];
const { previous, changes } = e;
const invert = changes.invert(previous);
if (invert) {
this.temp.push(invert);
if(!this.timer) {
this.timer = setTimeout(this.collectImmediately, this.DELAY);
}
}
};
后来我在思考一个问题,如果这N毫秒内用户进行了Undo操作应该怎么办,后来想想实际上很简单,此时只需要清除定时器,将暂存的Op[]立即放置于Redo Stack即可。
private collectImmediately = () => {
if (!this.temp.length) return void 0;
this.undoStack.push(this.temp);
this.temp = [];
this.redoStack = [];
this.timer && clearTimeout(this.timer);
this.timer = null;
if (this.undoStack.length > this.STACK_SIZE) this.undoStack.shift();
};
后边就是实际进行redo和undo的操作了,只不过在这里批量操作是使用循环每个Op都需要单独Apply的,这样感觉并不是很好,毕竟需要修改多次,虽然后边的渲染我只会进行一次批量渲染,但是这里事件触发的次数有点多,另外这里有个点还需要注意,我们在History模块里进行的操作,本身不应该再记入History中,所以这里还有一个ApplyOptions的设置需要注意。此外,在undo之后需要将这部分内容再次invert之后入redo stack,反过来也是一样的,此时我们直接取当前编辑器的DeltaSet即可。
public undo() {
this.collectImmediately();
if (!this.undoStack.length) return void 0;
const ops = this.undoStack.pop();
if (!ops) return void 0;
this.editor.canvas.mask.clearWithOp();
this.redoStack.push(
ops.map(op => op.invert(this.editor.deltaSet)).filter(Boolean) as OpSetType[]
);
this.editor.logger.debug("UNDO", ops);
ops.forEach(op => this.editor.state.apply(op, { source: "undo", undoable: false }));
}
public redo() {
if (!this.redoStack.length) return void 0;
const ops = this.redoStack.pop();
if (!ops) return void 0;
this.editor.canvas.mask.clearWithOp();
this.undoStack.push(
ops.map(op => op.invert(this.editor.deltaSet)).filter(Boolean) as OpSetType[]
);
this.editor.logger.debug("REDO", ops);
ops.forEach(op => this.editor.state.apply(op, { source: "redo", undoable: false }));
}
最后
本文我们介绍总结了我们的图形编辑器中数据结构的设计以及History模块的实现,虽然暂时不涉及到Canvas本身,但是这都是作为编辑器本身的基础能力,也是通用的能力可以学习。后边我们可以介绍的能力还有很多,例如复制粘贴模块、画布分层、事件管理、无限画布、按需绘制、性能优化、焦点控制、参考线、富文本、快捷键、层级控制、渲染顺序、事件模拟、PDF排版等等,整体来说还是比较有意思的,欢迎关注我并留意后续的文章。
Canvas图形编辑器-数据结构与History(undo/redo)的更多相关文章
- canvas图形编辑器
原文地址:http://jeffzhong.space/2017/11/02/drawboard/ 使用canvas进行开发项目,我们离不开各种线段,曲线,图形,但每次都必须用代码一步一步的实现.有没 ...
- 从Undo,Redo谈命令模式
一般的应用软件中,通常会提供Redo和Undo的操作,比如Paint.NET中的动作面板,Word中的撤销重做,一般我们按Ctrl-Z即可回退到上次操作. 要实现上面的这一功能,最直观的想法就是,我们 ...
- MFC图形编辑器
前言 vs2015竟然可以完美打开工程,哈哈可以直接生成类图了.由于内容较多,所以根据内容的重要性会安排详略. https://github.com/bajdcc/GraphEditor/releas ...
- MySQL,MariaDB:Undo | Redo [转]
本文是介绍MySQL数据库InnoDB存储引擎重做日志漫游 00 – Undo LogUndo Log 是为了实现事务的原子性,在MySQL数据库InnoDB存储引擎中,还用Undo Log来实现多版 ...
- iOS: 为画板App增加 Undo/Redo(撤销/重做)操作
这个随笔的内容以上一个随笔为基础,(在iOS中实现一个简单的画板),上一个随笔实现了一个简单的画板: 今天我们要为这个画板增加Undo/Redo操作,当画错了一笔,可以撤销它,或者撤销之后后悔了, ...
- [转]MySQL日志——Undo | Redo
本文是介绍MySQL数据库InnoDB存储引擎重做日志漫游 00 – Undo LogUndo Log 是为了实现事务的原子性,在MySQL数据库InnoDB存储引擎中,还用Undo Log来实现多版 ...
- Undo/Redo for Qt Tree Model
Undo/Redo for Qt Tree Model eryar@163.com Abstract. Qt contains a set of item view classes that use ...
- 【转载】MySQL 日志 undo | redo
本文是介绍MySQL数据库InnoDB存储引擎重做日志漫游 00 – Undo LogUndo Log 是为了实现事务的原子性,在MySQL数据库InnoDB存储引擎中,还用Undo Log来实现多版 ...
- MySQL InnoDB存储引擎undo redo解析
本文介绍MySQL数据库InnoDB存储引擎重做日志漫游 00 – Undo Log Undo Log 为了实现事务原子,在MySQL数据库InnoDB存储引擎,还使用Undo Log(简称:MVCC ...
- MySQL日志Undo&Redo
00 – Undo LogUndo Log 是为了实现事务的原子性,在MySQL数据库InnoDB存储引擎中,还用Undo Log来实现多版本并发控制(简称:MVCC). - 事务的原子性(Atomi ...
随机推荐
- WireShark学习笔记(一)
1.从WireShark分析网络层协议的传输 下面是网络接口层协议,从图中可以看到两个相邻设备的MAC地址,因此该网络包才能以接力的方式传送到目的地址. 下面是网络层,在这个包中,主要的任务是把TCP ...
- GaussDB(DWS)运维利刃:TopSQL工具解析
本文分享自华为云社区<GaussDB(DWS)运维利刃:TopSQL工具解析>,作者:胡辣汤. 在生产环境中,难免会面临查询语句出现异常中断.阻塞时间长等突发问题,如果没能及时记录信息,事 ...
- 摆脱鼠标系列 - 浏览器操作 - Vimium C 插件 f 显示链接字母 jk上下移动
为什么 摆脱鼠标系列 - 浏览器操作 - Vimium C 插件 f 显示链接字母 jk上下移动 百度搜索资料的时候,争取少用鼠标 关闭当前页签 x 左边页签 J 右边页签 K 搜索 /关键字回车 n
- 解决linux平台无法使用getch()的问题
参考https://www.cnblogs.com/jiangxinnju/p/5516906.html#:~:text=%E5%8F%A6%E5%A4%96%E5%A4%A7%E5%AE%B6%E5 ...
- Sub-process /usr/bin/dpkg returned an error code (1)问题
在用apt-get安装软件包的时候遇到E: Sub-process /usr/bin/dpkg returned an error code (1)问题,解决方法如下: cd /var/lib/dpk ...
- Educational Codeforces Round 141:C. Yet Another Tournament
一.来源:Problem - C - Codeforces 二.题面 三.思路 读题: 其他人的胜场由位次决定,对于第i位,其胜场为i-1 人数为\(5·10^5\),不是5(看错了) 每个人和自己比 ...
- Android 设置任意View的背景为圆形或圆角
原文地址: Android 设置任意View的背景为圆形或圆角 - Stars-One的杂货小窝 我的需求主要还是在SurfaceView需要设置为圆形背景,在网上找了下,发现一个可行方法,做个记录 ...
- 逆向通达信Level-2 续十一 (无帐号登陆itrend研究版)
<续九>无帐号打开了itrend研究版但是用不了.今次无帐号登陆itrend研究版可以使用行情. 演示三图 1. 首先成功在金融终端无帐号登陆成功. 2. 同理应用在itrend研究版,却 ...
- Cloud XR面临的问题以及Cloud XR主要应用场景
cloud xr面临的问题 带宽要求高:cloud xr需要实时把一个高码率的视频流,从云端传输到终端,这需要一个非常大的带宽. 延迟要求低:在传输的过程中,它需要一个非常低的时延,XR每进行一个新动 ...
- django(cookie与session、中间件、auth模块)
一 cookie与session 1 发展史及简介 """ 发展史 1.网站都没有保存用户功能的需求,所有用户访问返回的结果都是一样的 eg:新闻.博客.文章 2.出现了 ...