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也就是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}]
// 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})

看起来是没有问题的,所以我们现在可以设计全量的OpInvert方法了,在这里因为我最开始是预计要设计组合也就是将几个图形组合在一起操作的能力,所以还预留了一个parentId作为后期开发拓展用,但是暂时是用不上的所以这个字段暂时可以忽略。下面的Invert实际上就是case by case地进行转换,INSERT -> DELETEDELETE -> INSERTMOVE -> MOVERESIZE -> RESIZEREVISE -> 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,在这里我们可以有两种方案来实现。

  1. 第一种方式是在应用Op之前我们先根据当前的DeltaSet自动生成一个InvertOp,然后将这个Op交给History模块存储起来作为Undo的组操作即可。

  2. 第二种方式是我们在应用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的话可能并不是很好,通常都是有NOp的一并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;
}

前边也提到过我们都是通过事件来进行通信的,所以这里需要先挂载事件,并且在这里将InvertOp构建好,将其置入批量操作的缓存中。

  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();};

后边就是实际进行redoundo的操作了,只不过在这里批量操作是使用循环每个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排版等等,整体来说还是比较有意思的,欢迎关注我并留意后续的文章。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/819171.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【个人博客搭建】(3)添加SqlSugar ORM

1、安装sqlsugar。在models下的依赖项那右击选择管理Nuget程序包&#xff0c;输入sqlsugarcore&#xff08;因为我们用的是netcore&#xff0c;而不是net famework所以也对应sqlsugarcore&#xff09;&#xff0c;出来的第一个就是了&#xff0c;然后点击选择版本&#xff0c;一…

esp32联网获取时间和天气(四)

说明 本章节需要先学习之前&#xff08;三&#xff09;中获取当前时间方法&#xff0c;本文基于platformIO&#xff0c;需提前安装timelib库&#xff0c;可以参考之前&#xff08;三&#xff09; 代码 代码如下&#xff0c;需要一点http知识&#xff0c;可以自行百度 #incl…

密码学 | 椭圆曲线 ECC 密码学入门(四)

目录 正文 1 曲线方程 2 点的运算 3 求解过程 4 补充&#xff1a;有限域 ⚠️ 知乎&#xff1a;【密码专栏】动手计算双线性对&#xff08;中&#xff09; - 知乎 ⚠️ 写在前面&#xff1a;本文属搬运博客&#xff0c;自己留着学习。注意&#xff0c;这篇博客与前三…

代码随想录算法训练营Day56|LC583 两个字符串的删除操作LC72 编辑距离

一句话总结&#xff1a;看起来复杂&#xff0c;动规分析以后就比较简单。 原题链接&#xff1a;583 两个字符串的删除操作 本质就是求两个字符串的最短子序列的长度。已经做过&#xff0c;不再详解。 class Solution {public int minDistance(String word1, String word2) {/…

Day13-Java基础之运算符和表达式

运算符和表达式 运算符&#xff1a; 就是对常量或者变量进行操作的符号。 比如&#xff1a; - * / 表达式&#xff1a; 用运算符把常量或者变量连接起来的&#xff0c;符合Java语法的式子就是表达式。 比如&#xff1a;a b 这个整体就是表达式。 而其中是算术运算符的…

Python(11):网络编程

文章目录 一、一些基本概念二、软件的开发架构&#xff08;c/s架构和b/s架构&#xff09;三、OSI模型四、socket套接字编程1.socket编程过程2.python中的socket编程 一、一些基本概念 来了解一些网络的基本概念 名词解释IP&#xff08;互联网协议地址&#xff09;IP用来标识网…

【华为OD机试C++】统计字符

《最新华为OD机试题目带答案解析》:最新华为OD机试题目带答案解析,语言包括C、C++、Python、Java、JavaScript等。订阅专栏,获取专栏内所有文章阅读权限,持续同步更新! 文章目录 描述输入描述输出描述示例代码描述 输入一行字符,分别统计出包含英文字母、空格、数字和其它…

linux设置程序在开机时自启动

​常见的有三种方式&#xff1a;1、/etc/rc.local文件中添加自启动命令 对于某些没有使用systemd的Linux发行版&#xff0c;可以在/etc/rc.local文件中添加自启动命令。请确保该文件具有可执行权限。例如&#xff0c;在/etc/rc.local文件中添加以下内容&#xff1a; /…

PCB基础介绍

一&#xff0c;单层板&#xff1a; 1&#xff0c;铜皮 和导线类似&#xff0c;提供电路板上的电信号传导路径。 因为铜具有良好的导热性能&#xff0c;因此铜皮还可以用于散热。在高功率电子设备中&#xff0c;通过在PCB上增加铜皮面积和散热片&#xff0c;可以提高散热效果…

【洛谷题解】 P6995 [NEERC2014] Knockout Racing

分析 按照题意模拟即可。 我们只需要依次找到第 i i i&#xff08; 1 ≤ i ≤ N 1\le i\le N 1≤i≤N&#xff09;辆车在 t j t_j tj​ 时刻时所在的位置&#xff0c;看这个位置是不是在区间 [ x j , y j ] [x_j,y_j] [xj​,yj​] 之中&#xff0c;如果是&#xff0c;答案…

数字晶体管数字三极管

数字晶体管 指内部集成了电阻的三极管&#xff0c;有PNP和NPN型&#xff0c;也有双管&#xff0c;双管有3种形式&#xff0c;其中一种是PNPNPN。下面以双NPN示例&#xff0c;好处是外面没有电阻&#xff0c;批量应用时&#xff0c;焊点费用就可省下不少。双NPN的用在串口自动下…

Flutter知识点 --- key

Flutter 中的 Key 对象在Widget树的构建、更新和状态管理中扮演着重要角色。它主要用于帮助Flutter框架在Widget树发生变动时正确地识别和保留Widget的对应关系&#xff0c;以及在某些情况下维护状态。下面是Key的作用与原理的详细阐述&#xff1a; Key的作用 标识Widget的唯一…

开源相机管理库Aravis例程学习(二)——连续采集multiple-acquisition-main-thread

开源相机管理库Aravis例程学习&#xff08;二&#xff09;——连续采集multiple-acquisition-main-thread 简介例程代码函数说明arv_camera_set_acquisition_modearv_camera_create_streamarv_camera_get_payloadarv_buffer_newarv_stream_push_bufferarv_camera_start_acquisi…

目标检测YOLO实战应用案例100讲-基于轻量化神经网络的目标检测算法研究与应用(续)

目录 轻量化目标检测模型的参数量化 4.1引言 4.2模型量化的原理及过程 4.3实验结果及分析

如何看待2023年图灵奖

目录 1.概述 2.计算复杂性理论 3.随机性和伪随机性 4.学术生涯和领导力 1.概述 图灵奖&#xff08;Turing Award&#xff09;&#xff0c;全称A.M.图灵奖&#xff08;ACM A.M Turing Award&#xff09;&#xff0c;是由计算机领域的最高学术机构——美国计算机协会&#xf…

ElasticSearch 集群添加用户安全认证功能(设置访问密码)

前言 在6.8之前免费版本并不包含安全认证功能&#xff0c;之后版本有开放一些基础认证功能&#xff1b;为了防止各种事故&#xff0c;一般都会设置es集群的访问密码&#xff1b;但是在我尝试设置访问密码的时候发现&#xff0c;设置访问密码的前提必须要设置集群证书&#xff0…

java:多线程

多线程 在java程序中同时执行多个线程,每个线程独立执行不同的任务. 可以提高程序的性能和资源利用率,增加程序的并发性. 多线程的作用 1,提高程序性能 可以将一个任务分解成多个子任务并行处理,从而提高程序的运行速度 2,提高资源利用率 可以更好地利用CPU资源,提高CPU…

如何实现Windows RDP 远程桌面异地跨网连接

Windows RDP远程桌面的应用非常广泛。远程桌面协议(RDP)是一个多通道(multi-channel)的协议&#xff0c;让使用者(所在计算机称为用户端或本地计算机)连上提供微软终端机服务的计算机(称为服务端或远程计算机)。大部分的Windows版本都有用户端所需软件&#xff0c;有些其他操作…

Python Flask Web 框架-API接口开发_4

一、1、安装 Falsk 当前用户安装 pip3 install --user Flask 确认安装成功&#xff1a; 进入python交互模式看下Flask的介绍和版本&#xff1a; $ python3>>> import flask >>> print(flask.__doc__)flask~~~~~A microframework based on Werkzeug. Its …

病毒繁殖-第12届蓝桥杯选拔赛Python真题精选

[导读]&#xff1a;超平老师的Scratch蓝桥杯真题解读系列在推出之后&#xff0c;受到了广大老师和家长的好评&#xff0c;非常感谢各位的认可和厚爱。作为回馈&#xff0c;超平老师计划推出《Python蓝桥杯真题解析100讲》&#xff0c;这是解读系列的第52讲。 病毒繁殖&#xf…