前端图形学实战: 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…

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


目录
相关文章
|
3天前
|
前端开发 API
(WEB前端编辑DWG)在线CAD如何实现图形识别功能
mxcad 提供的图形识别功能可帮助用户快速识别和提取 CAD 图纸中的各种图形,如直线、多段线、弧线、圆及图块,显著提升设计效率。此功能不仅适用于图形分类,还能进行数量统计和快速定位,减少手动操作。用户可通过 API 进行二次开发,自定义识别逻辑。具体步骤包括打开在线示例、选择识别功能、设置识别参数并开始识别。更多开发文档请关注公众号:梦想云图网页 CAD。
|
8天前
|
存储 JSON 前端开发
node使用token来实现前端验证码和登录功能详细流程[供参考]=‘很值得‘
本文介绍了在Node.js中使用token实现前端验证码和登录功能的详细流程,包括生成验证码、账号密码验证以及token验证和过期处理。
17 0
node使用token来实现前端验证码和登录功能详细流程[供参考]=‘很值得‘
|
1月前
|
前端开发 开发者
在前端开发中,webpack 作为一个强大的模块打包工具,为我们提供了丰富的功能和扩展性
【9月更文挑战第1天】在前端开发中,Webpack 作为强大的模块打包工具,提供了丰富的功能和扩展性。本文重点介绍 DefinePlugin 插件,详细探讨其原理、功能及实际应用。DefinePlugin 可在编译过程中动态定义全局变量,适用于环境变量配置、动态加载资源、接口地址配置等场景,有助于提升代码质量和开发效率。通过具体配置示例和注意事项,帮助开发者更好地利用此插件优化项目。
64 13
|
29天前
|
缓存 监控 前端开发
前端性能优化实战:让你的网站快如闪电的十大秘籍
【9月更文挑战第3天】通过以上十大秘籍的实践,您可以显著提升网站的前端性能,让您的网站在竞争激烈的互联网环境中脱颖而出,为用户带来更加流畅和愉悦的体验。记住,前端性能优化是一个永无止境的过程,只有不断迭代和优化,才能保持网站的竞争力。
|
2月前
|
C# 前端开发 UED
WPF数据验证实战:内置控件与自定义规则,带你玩转前端数据验证,让你的应用程序更上一层楼!
【8月更文挑战第31天】在WPF应用开发中,数据验证是确保输入正确性的关键环节。前端验证能及时发现错误,提升用户体验和程序可靠性。本文对比了几种常用的WPF数据验证方法,并通过示例展示了如何使用内置验证控件(如`TextBox`)及自定义验证规则实现有效验证。内置控件结合`Validation`类可快速实现简单验证;自定义规则则提供了更灵活的复杂逻辑支持。希望本文能帮助开发者更好地进行WPF数据验证。
49 0
|
2月前
|
开发者 Android开发 iOS开发
Xamarin开发者的神器!揭秘你绝不能错过的插件和工具,让你的开发效率飞跃式提升
【8月更文挑战第31天】Xamarin.Forms 是一个强大的框架,让开发者通过单一共享代码库构建跨平台移动应用,支持 iOS、Android 和 Windows。使用 C# 和 XAML,它简化了多平台开发流程,保持一致的用户体验。本指南通过创建一个简单的 “HelloXamarin” 应用介绍 Xamarin.Forms 的基本功能和工作原理。首先配置 Visual Studio 开发环境,然后创建并运行一个包含标题、按钮和消息标签的示例应用,展示如何定义界面布局及处理按钮点击事件。这帮助开发者快速入门 Xamarin.Forms,提高跨平台应用开发效率。
32 0
|
2月前
|
前端开发 Java UED
JSF 面向组件开发究竟藏着何种奥秘?带你探寻可复用 UI 组件设计的神秘之路
【8月更文挑战第31天】在现代软件开发中,高效与可维护性至关重要。JavaServer Faces(JSF)框架通过其面向组件的开发模式,提供了构建复杂用户界面的强大工具,特别适用于设计可复用的 UI 组件。通过合理设计组件的功能与外观,可以显著提高开发效率并降低维护成本。本文以一个具体的 `MessageComponent` 示例展示了如何创建可复用的 JSF 组件,并介绍了如何在 JSF 页面中使用这些组件。结合其他技术如 PrimeFaces 和 Bootstrap,可以进一步丰富组件库,提升用户体验。
44 0
|
2月前
|
API UED 开发者
如何在Uno Platform中轻松实现流畅动画效果——从基础到优化,全方位打造用户友好的动态交互体验!
【8月更文挑战第31天】在开发跨平台应用时,确保用户界面流畅且具吸引力至关重要。Uno Platform 作为多端统一的开发框架,不仅支持跨系统应用开发,还能通过优化实现流畅动画,增强用户体验。本文探讨了Uno Platform中实现流畅动画的多个方面,包括动画基础、性能优化、实践技巧及问题排查,帮助开发者掌握具体优化策略,提升应用质量与用户满意度。通过合理利用故事板、减少布局复杂性、使用硬件加速等技术,结合异步方法与预设缓存技巧,开发者能够创建美观且流畅的动画效果。
57 0
|
2月前
|
前端开发 开发者 Apache
揭秘Apache Wicket项目结构:如何打造Web应用的钢铁长城,告别混乱代码!
【8月更文挑战第31天】Apache Wicket凭借其组件化设计深受Java Web开发者青睐。本文详细解析了Wicket项目结构,帮助你构建可维护的大型Web应用。通过示例展示了如何使用Maven管理依赖,并组织页面、组件及业务逻辑,确保代码清晰易懂。Wicket提供的页面继承、组件重用等功能进一步增强了项目的可维护性和扩展性。掌握这些技巧,能够显著提升开发效率,构建更稳定的Web应用。
76 0
|
2月前
|
前端开发 JavaScript 开发者
【前端革新力】React与CSS-in-JS完美邂逅:从styled-components到emotion,全面解析样式管理新趋势的实战应用与优势剖析!
【8月更文挑战第31天】CSS-in-JS 作为一种新兴的样式管理方式,近年来在前端社区受到广泛关注。它将样式嵌入 JavaScript,实现了样式与逻辑的高度耦合,提升了开发效率并解决了全局样式污染等问题。本文通过具体代码示例,探讨 CSS-in-JS 在 React 开发中的应用,并分享实践心得。首先介绍了 CSS-in-JS 的基本概念,然后详细展示了如何使用 styled-components 和 emotion 这两个流行库创建样式化组件。
38 0