前端图形学实战: 100行代码实现几何画板的撤销重做等功能(vue3 + vite版)

简介: 前端图形学实战: 100行代码实现几何画板的撤销重做等功能(vue3 + vite版)

前言

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究

hello, 大家好, 我是徐小夕, 今天又到了我们的博学时间。

本文是 100+前端几何学应用案例 专栏的第三篇文章, 在第一篇文章 几何学在前端边界计算中的应用和原理分析 和第二篇文章 前端图形学实战: 从零开发几何画板(vue3 + vite版) 中我介绍了几何学在前端领域的应用以及如何从零开发一个几何画板:

image.png

如果大家感兴趣可以在 gitee 查看我的具体代码实现: gitee.com/lowcode-chi…

接下来就继续上次的话题, 来从零实现几何画板撤销重做功能。

我们都知道各种设计工具如figma, PhotoShop, 或者最近比较火的可视化低代码平台如H5-Dooring 都有撤销重做功能, 主要是为了降低用户的误操作成本, 带来更好的搭建体验, 并且这两个功能基本成为了可视化领域的标配功能, 接下来我将带大家介绍一下市面上常用的几种撤销重做的实现方案以及撤销重做功能底层的实用价值。

你将收获

  • 撤销重做的实现思路
  • vuereact 框架下的撤销重做库介绍
  • 从零实现几何画板的撤销重做功能
  • 挖掘 撤销重做 的扩展场景

demo演示

image.png

技术实现

在实现撤销重做功能之前, 我们需要先理清设计思路, 这样才能让自己的代码更健壮。

实现思路

分析了几种撤销重做的场景后我总结出如下几个要点:

  • 支持基础的撤销重做能力(取消和恢复用户操作的能力)
  • 需要限制最大可操作记录数(防止历史记录数过大导致前端性能问题)
  • 操作记录的当前索引(方便做更可控的撤销重做控制)
  • 在撤销的过程中发生的任何改动, 都会清空当前步骤之后的所有记录
  • 操作历史持久化(可选, 即是否需要在用户刷新页面之后仍然保留操作记录历史)

为了让大家更好的理解这些要点,我画了一个 撤销重做 过程的流程图:

image.png

还有一点需要注意的是: 在撤销的过程中发生的任何改动, 都会清空当前步骤之后的所有记录,最终产生一个新的状态分支: image.png

好了, 有了以上的思路, 我们就开始来一步步实现撤销重做功能。

创建记录管理器(recordManager)

为了保证专栏文章的连贯性, 我还是以上一篇文章前端图形学实战: 从零开发几何画板(vue3 + vite版) 中实现的几何画板为例, 采用 vue3 来实现, 其他常用框架如React, Angular 或者 Svelte 用类似的方式也能实现, 如果大家感兴趣也可以尝试用不同框架来实现一套自己的撤销重做插件。

image.png

之前我们的画板数据是存放在 canvasBox 对象中的, 如下:

type shapeType = "rect" | "circle" | "line";
interface IBaseShapeProp {
  type: shapeType;
  key: string;
  style: any;
}
const canvasBox = ref<{ [key in shapeType]: IBaseShapeProp[] }>({
  rect: [],
  circle: [],
  line: [],
});

每次更改画布状态我们都会更新 canvasBox, 为了实现撤销重做功能, 我们需要将每一次操作更改的内容保存起来, 这里我采用目前比较流行的数据快照技术来实现, 接下来我会和大家详细介绍, 这里我们先构建我们的记录管理器(recordManager):

const recordManager = ref({
  snapshots: [
    {
      rect: [],
      circle: [],
      line: [],
    },
  ],
  curIndex: 0,
  maxLimit: 50,
});

其中 snapshots 就是存放我们操作记录的集合, curIndex 是当前操作索引的下标, maxLimit 是最大保存的历史记录数。

往记录管理器中添加操作快照

那么构造好了记录管理器, 我们如何往记录管理器中添加操作快照呢?

最直接的方法就是在每个操作入口(如移动, 创建元素, 删除元素等)都手动往记录管理器(recordManager)中添加变更快照, 但是我们的操作类型很多, 比如:

  • 移动元素
  • 新建元素
  • 删除元素
  • 缩放元素
  • 编辑元素属性

等等, 如果按照上述方法维护成本会很大, 而且可能会出现遗漏操作的风险, 所以不建议采用。

同时在设计可视化编辑器的时候, 我们对页面描述协议(page schema)或组件描述协议(component schema)最好进行统一性设计, 即可以通过一套统一的Schema 来描述整个页面, 对于开放协议可以通过属性引用(ref)来协调。

具体可以参考我之前的分享(点击下图可查看):

在我们的画板应用中, canvasBox 即使这样的一个统一的协议约定, 在canvasBox 对象中我们可以描述整个画布元素, 所以我们可以很轻松的用响应式设计来对 canvasBox 进行监听, 每次canvasBox 的更新即代表一个新的快照, 好在 vue3 提供了非常方便的响应式hooks, 我们可以使用组合式函数的 watch 钩子来实现监听:

import { ref, watch } from "vue";
const canvasBox = ref<{ [key in shapeType]: IBaseShapeProp[] }>({
  rect: [],
  circle: [],
  line: [],
});
watch(
 canvasBox, 
 (state, prevState) => {
     // 我们的业务逻辑...
 }, 
 { deep: true }
);

通过上面的方法我们就能实现对 canvasBox 进行监听, 并在其变化时添加我们的操作快照。 (因为我们监听的数据是对象, 所以这里我们设置了 { deep: true })

1. 快照的生成方式

首先我们先来聊聊为什么要创建快照。

这里有一个场景, 假如我们在画布上操作了50次, 如下:

image.png

如果我们单纯的用数组 push 的方式记录每一次操作:

snapshots.push(canvasBox.value);

这种操作会存在很大的风险,因为 JavaScript 的对象是引用类型,变量名只是保存了它们的引用,真正的数据存放在堆内存中,所以 snapshotscanvasBox 会共享一份最新的数据, 导致无法存储之前的历史状态, 所以我们需要使用 深拷贝 或者 创建不可变数据 的方式来生成快照。

对于 创建不可变数据 的方式, 大家如果熟悉 redux 的话不会很陌生, 为了更简单易懂, 我这里采用深拷贝的方式, 首先需要安装一下 xijs :

yarn add xijs

image.png

它是一款面向复杂场景的 javascript 工具库, 内置了很多开箱即用的工具函数, 感兴趣的可以自行体验, 这里就不详细介绍了。

接下来看看我们初级版本的数据快照方法:

import { debounce, cloneDeep } from "xijs";
import { ref, watch } from "vue";
const recordManager = ref({
  snapshots: [
    {
      rect: [],
      circle: [],
      line: [],
    },
  ],
  curIndex: 0,
  maxLimit: 50,
});
const pushRecordFn = (
  state: { [key in shapeType]: IBaseShapeProp[] },
  prevState: { [key in shapeType]: IBaseShapeProp[] }
) => {
  // 记录快照
  recordManager.value.snapshots.push(cloneDeep(state));
  // 更新索引指针
  recordManager.value.curIndex = recordManager.value.snapshots.length - 1;
};
watch(canvasBox, pushRecordFn, { deep: true });

这样我们就能将每次的才操作状态保存下来了。

2. 如果两个状态相同, 则不推入历史记录

我们此时还会发现一种情况, 即canvasBox 更新了, 但是更新的内容没有变, 比如元素从默认状态变成可编辑状态:

image.png

此时是不需要记录到快照里的, 所以针对类似的情况我们需要进行过滤:

const pushRecordFn = (
  state: { [key in shapeType]: IBaseShapeProp[] },
  prevState: { [key in shapeType]: IBaseShapeProp[] }
) => {
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  
  // 如果两个状态相同, 则不推入历史记录
  if (!diff(state, snapshots[curIndex])) {
    return;
  }
  recordManager.value.snapshots.push(cloneDeep(state));
  recordManager.value.curIndex = recordManager.value.snapshots.length - 1;
};

上面代码的 diff 方法可以比较两个对象是否相同, 我们可以在 xijs 中找到对应的方法, 或者自己实现一个比较对象的方法也可。

3. 如果在撤销的过程中重新执行了新的操作, 则创建新的操作分支

这个操作逻辑也就是我们开头介绍的, 这里回顾一下:

image.png

具体实现如下:

const pushRecordFn = (
  state: { [key in shapeType]: IBaseShapeProp[] },
  prevState: { [key in shapeType]: IBaseShapeProp[] }
) => {
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  
  // 如果两个状态相同, 则不推入历史记录
  if (!diff(state, snapshots[curIndex])) {
    return;
  }
  
  // 如果在撤销的过程中重新执行了新的操作, 则覆盖上一个状态
  if (snapshots.length - 1 !== curIndex) {
    snapshots.splice(curIndex + 1, snapshots.length);
  }
  recordManager.value.snapshots.push(cloneDeep(state));
  recordManager.value.curIndex = recordManager.value.snapshots.length - 1;
};

4. 超过了最大限制记录, 删除头部第一项

因为我们限制了最大的历史记录数, 所以当超过了记录上限时, 我们需要删除最前面的一项:

image.png

所以我们完整的快照管理方法如下:

const pushRecordFn = (
  state: { [key in shapeType]: IBaseShapeProp[] },
  prevState: { [key in shapeType]: IBaseShapeProp[] }
) => {
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  
  // 如果两个状态相同, 则不推入历史记录
  if (!diff(state, snapshots[curIndex])) {
    return;
  }
  
  // 如果在撤销的过程中重新执行了新的操作, 则覆盖上一个状态
  if (snapshots.length - 1 !== curIndex) {
    snapshots.splice(curIndex + 1, snapshots.length);
  }
  
  // 超过了最大限制记录
  if (snapshots.length >= maxLimit) {
    snapshots.shift();
  }
  recordManager.value.snapshots.push(cloneDeep(state));
  recordManager.value.curIndex = recordManager.value.snapshots.length - 1;
};

同时为了更好的性能, 我们还可以对监听进行防抖优化( 毕竟移动元素等时候高频操作, 对性能开销相对较大), 具体代码如下:

import { debounce } from "xijs";
watch(canvasBox, debounce(pushRecordFn, 300), { deep: true });

实现撤销重做功能

在实现上面的快照管理之后, 实现撤销重做就非常简单了, 我们只需要做好阈值管理即可:

import { cloneDeep } from "xijs";
const undo = () => {
  // 撤销
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  // 如果到下限了, 直接返回
  if (curIndex === 0) return;
  recordManager.value.curIndex--;
  canvasBox.value = cloneDeep(
    recordManager.value.snapshots[recordManager.value.curIndex]
  );
};
const redo = () => {
  // 重做
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  // 超过最大记录长度, 直接返回
  if (curIndex >= snapshots.length - 1) {
    return;
  }
  recordManager.value.curIndex++;
  canvasBox.value = recordManager.value.snapshots[recordManager.value.curIndex];
};

接下来看看我们实现的效果:

image.png

扩展

对于撤销重做, 我们可以进一步扩展, 比如可以利用快照里记录, 实现快照动画(可以参考鼻笔者之前开发的 Gif生成器), 实现批量重复操作, 类似ps里的功能, 我们还可以对快照进行埋点, 实现页面级别的监控等。

后期规划

后面会继续完善画板, 实现图层管理, 导入导出, 下载等可视化编辑器常用的功能, 如果大家感兴趣, 可以参考我的github: gitee.com/lowcode-chi…

如果文章对你有帮助, 欢迎点赞评论, 让我们一起探索真正的前端技术。


目录
相关文章
|
5天前
|
JSON 前端开发 搜索推荐
惊!这些前端技术竟然能让你的网站实现个性化推荐功能!
【10月更文挑战第30天】随着互联网技术的发展,个性化推荐已成为提升用户体验的重要手段。前端技术如JavaScript通过捕获用户行为数据、实时更新推荐结果等方式,在实现个性化推荐中扮演关键角色。本文将深入解析这些技术,并通过示例代码展示其实际应用。
18 4
|
8天前
|
前端开发 数据管理 测试技术
前端自动化测试:Jest与Cypress的实战应用与最佳实践
【10月更文挑战第27天】本文介绍了前端自动化测试中Jest和Cypress的实战应用与最佳实践。Jest适合React应用的单元测试和快照测试,Cypress则擅长端到端测试,模拟用户交互。通过结合使用这两种工具,可以有效提升代码质量和开发效率。最佳实践包括单元测试与集成测试结合、快照测试、并行执行、代码覆盖率分析、测试环境管理和测试数据管理。
20 2
|
8天前
|
前端开发 JavaScript 数据可视化
前端自动化测试:Jest与Cypress的实战应用与最佳实践
【10月更文挑战第26天】前端自动化测试在现代软件开发中至关重要,Jest和Cypress分别是单元测试和端到端测试的流行工具。本文通过解答一系列问题,介绍Jest与Cypress的实战应用与最佳实践,帮助开发者提高测试效率和代码质量。
22 2
|
12天前
|
前端开发 JavaScript 开发工具
Vite 4.0 发布,下一代的前端工具链
【10月更文挑战第21天】Vite 4.0 的发布标志着前端开发领域的又一次重要进步。它为开发者带来了更高效、更智能、更具创新性的开发体验,正逐渐成为下一代前端工具链的引领者。
|
14天前
|
存储 缓存 算法
前端算法:优化与实战技巧的深度探索
【10月更文挑战第21天】前端算法:优化与实战技巧的深度探索
13 1
|
15天前
|
人工智能 资源调度 数据可视化
【AI应用落地实战】智能文档处理本地部署——可视化文档解析前端TextIn ParseX实践
2024长沙·中国1024程序员节以“智能应用新生态”为主题,吸引了众多技术大咖。合合信息展示了“智能文档处理百宝箱”的三大工具:可视化文档解析前端TextIn ParseX、向量化acge-embedding模型和文档解析测评工具markdown_tester,助力智能文档处理与知识管理。
|
15天前
|
监控 JavaScript 前端开发
前端的混合之路Meteor篇(六):发布订阅示例代码及如何将Meteor的响应数据映射到vue3的reactive系统
本文介绍了 Meteor 3.0 中的发布-订阅模型,详细讲解了如何在服务器端通过 `Meteor.publish` 发布数据,包括简单发布和自定义发布。客户端则通过 `Meteor.subscribe` 订阅数据,并使用 MiniMongo 实现实时数据同步。此外,还展示了如何在 Vue 3 中将 MiniMongo 的 `cursor` 转化为响应式数组,实现数据的自动更新。
|
22天前
|
资源调度 前端开发 JavaScript
Vite:新一代前端构建工具的革命性体验
【10月更文挑战第13天】Vite:新一代前端构建工具的革命性体验
|
22天前
|
前端开发 JavaScript 中间件
Vite:下一代前端构建工具的崛起
【10月更文挑战第13天】Vite:下一代前端构建工具的崛起
|
22天前
|
JSON 前端开发 JavaScript
Vite:新一代前端构建工具的崛起
【10月更文挑战第13天】Vite:新一代前端构建工具的崛起