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}]