Canvas图形编辑器-数据结构与History(undo/redo)

Canvas图形编辑器-数据结构与History(undo/redo)

这是作为 社区老给我推Canvas,于是我也学习Canvas做了个简历编辑器 的后续内容,主要是介绍了对数据结构的设计以及History能力的实现。

关于Canvas简历编辑器项目的相关文章:

描述

对于编辑器而言,History也就是undoredo是必不可少的能力,实现历史记录的方法通常有两种:

  1. 存储全量快照,也就是说我我们每进行一个操作,都需要将全量的数据通常也就是JSON格式的数据存到一个数组里,如果用户此时触发了redo就将全量的数据取出应用到Editor对象当中。这种实现方式的优点是简单,不需要过多的设计,缺点就是一旦操作的多了就容易炸内存。

  2. 基于Op的实现,Op就是对于一个操作的原子化记录,举个例子如果将图形A向右移动3px,那么这个Op就可以是type: "MOVE", offset: [3, 0],那么如果想要做回退操作依然很简单,只需要将其反向操作即type: "MOVE", offset: [-3, 0]就可以了,这种方式的优点是粒度更细,存储压力小,缺点是需要复杂的设计以及计算。

既然我们是从零开始设计一个编辑器,那么大概率是不会采用方案1的,我们更希望能够设计原子化的Op来实现History,所以从这个方向开始我们就需要先设计数据结构。

数据结构

我特别推荐大家去看一下 quill-delta 的数据结构设计,这个数据结构的设计非常棒,其可以用来描述一篇富文本,同时也可以用来构建change对富文本做完整的增删改操作,对于数据的composeinvertdiff等操作也一应俱全,而且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}]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值