字玩FontPlayer开发笔记12 Vue3撤销重做功能

字玩FontPlayer开发笔记12 Vue3撤销重做功能

字玩FontPlayer是笔者开源的一款字体设计工具,使用Vue3 + ElementUI开发,源代码:github | gitee

笔记

撤销重做功能是设计工具必不可少的模块,以前尝试使用成熟的库实现撤销重做功能,但是细节问题有很多,就一直搁置了。这几天着手自己实现撤销重做模块,目前基本成形,虽然还有很多地方待完善,但还是先记录一下成果。

Vue3实现撤销重做的基本原理是记录状态(store)改变,每次状态改变的时候记录一下状态,撤销时恢复上一次记录的状态。虽然原理并不复杂,但在实际项目中遇到的细节问题很多,具体问题如下:

  1. 在组件创建过程中,会用到一些局部变量如mousedown、mousemove,在撤销过程中,store内的变量被更新了,但是局部变量没有随之更新,导致一些情况下逻辑失常。解决办法是把这些局部变量统一放到store里,这样将局部变量变成全局变量,这样在撤销重做时可以调用读取这些变量,然后在保存更新状态时记录这些变量。

  2. 在撤销重做过程中,有时候尽管变量随之更新,但是监听器事件并没有更新,这样就导致了在撤销创建钢笔组件之后,钢笔组件被清空了,但是监听事件并没有被移除,再次创建时,又会重复创建监听器。解决办法是使用一个map记录监听器,添加监听器事件的时候,记录map中对应事件key值为true,每次添加时只有map中没有记录该事件时才会添加,这样保证不会重复添加事件监听器。

  3. 对于一些复杂的模块比如脚本编写,为优化体验在关闭脚本编写窗口以后,执行撤销操作时会将上次编辑的脚本全部撤销,这时直接撤销可能导致用户误操作,所以需要给出提示。实现办法是在undoStack中存放的记录增加undoTip和redoTip字段,如果保存状态时填写了这两项,执行撤销重做操作的时候会给出提示。

  4. 在一些使用滑动条的操作中,连续滑动应该属与一次操作,直至滑动停止后,再次滑动才算下一次操作,对于这些情况需要使用防抖节流保证在一次中只压栈一次。

  5. 在一些情况下,可能会使用watch监听状态变化,以便在状态变换时保存状态,但是这样使用有些危险,比如有时候状态在改变其他状态时被连带改变,在watch中记录数据可能导致一个操作需要执行两次或多次撤销才能完成撤销操作。这些情况需要再具体设计中避免。

具体实现
保存状态

在每次执行用户操作时,记录一下操作前的状态,保存在undoStack中,以便在undo操作中进行恢复原先状态。这个保存操作使用saveState方法完成。

const saveState = (opName: String, opStores: StoreType[], opType: OpType, options: OpOption = {
  newRecord: true,
  undoTip: '',
  redoTip: '',
}) => {
  let stack = []
  if (opType === OpType.Redo) {
    stack = redoStack
  } else if (opType === OpType.Undo) {
    redoStack.length = 0
    stack = undoStack
  }
  let states: any = {}
  for (let i = 0; i < opStores.length; i++) {
    const opStore = opStores[i]
    switch(opStore) {
      case StoreType.EditCharacter: {
        states.editCharacterFile = R.clone(options.editCharacterFile || editCharacterFile.value)
        break
      }
      case StoreType.GlyphCompnent: {
        states.draggable = options.draggable || draggable.value
        states.dragOption = R.clone(options.dragOption || dragOption.value)
        states.checkRefLines = options.checkRefLines || checkRefLines.value
        states.checkJoints = options.checkJoints || checkJoints.value
        //states.jointsCheckedMap = R.clone(options.jointsCheckedMap || jointsCheckedMap.value)
        break
      }
      case StoreType.EditGlyph: {
        states.editGlyph = R.clone(options.editGlyph || editGlyph.value)
        break
      }
      case StoreType.Status: {
        states.editStatus = options.editStatus || editStatus.value
        break
      }
      case StoreType.Tools: {
        states.tool = R.clone(options.tool || tool.value)
        break
      }
      case StoreType.Pen: {
        states.editingPen = R.clone(editingPen.value)
        states.pointsPen = R.clone(pointsPen.value)
        states.selectAnchor = R.clone(selectAnchor.value)
        states.selectPenPoint = R.clone(selectPenPoint.value)
        states.mousedownPen = mousedownPen.value
        states.mousemovePen = mousemovePen.value
        break
      }
      case StoreType.Polygon: {
        states.editingPolygon = R.clone(editingPolygon.value)
        states.pointsPolygon = R.clone(pointsPolygon.value)
        states.mousedownPolygon = mousedownPolygon.value
        states.mousemovePolygon = mousemovePolygon.value
        break
      }
      case StoreType.Rectangle: {
        states.editingRectangle = R.clone(editingRectangle.value)
        states.rectX = R.clone(rectX.value)
        states.rectY = R.clone(rectY.value)
        states.rectWidth = R.clone(rectWidth.value)
        states.rectHeight = R.clone(rectHeight.value)
        break
      }
      case StoreType.Ellipse: {
        states.editingEllipse = R.clone(editingEllipse.value)
        states.ellipseX = R.clone(ellipseX.value)
        states.ellipseY = R.clone(ellipseY.value)
        states.radiusX = R.clone(radiusX.value)
        states.radiusY = R.clone(radiusY.value)
        break
      }
    }
  }
  if (options.newRecord) {
    stack.push({
      opName,
      opStores,
      states,
      options
    })
  } else {
    const record = stack.pop()
    record.opName = opName
    record.opStores = opStores
    record.states = states
    record.options = options
    stack.push(record)
  }
}

这里opStores指当前操作具体要更改哪些状态,在保存状态时除非特殊情况,一般不需要传入状态变量具体的值,只需要传入opStores标识即可,saveState函数会根据标识自动记录状态副本。笔者项目中opStores枚举设置如下:

enum StoreType {
  EditCharacter,
  EditGlyph,
  Tools,
  Pen,
  Polygon,
  Rectangle,
  Ellipse,
  GlyphCompnent,
  Status,
}

除了撤销功能,在记录重做状态的时候,也使用saveState保存状态记录,opType参数就是标明是当前状态记录时压入撤销操作栈还是重做操作栈的。

另外,对于一些特殊情况,比如需要提示或需要手动传入状态时,需要设置options参数,进行标明。

interface OpOption {
  newRecord?: boolean;
  undoTip?: string;
  redoTip?: string;
  [key: string]: any;
}

newRecord作用为是否需要新建记录还是在上一条记录中更新。这对于一些需要把连续操作合并在一个操作记录中的情况非常有用。比如在创建矩形或椭圆形状时,用户需要连续拖动光标以拖拽出矩形或椭圆形状,这其中的连续数据变换不能全部记录成单独状态压栈,但又需要记录跟踪每一步变化,所以这里就可以标注newRecord为false,在上一条记录中更新数据,这样执行撤销操作时,也只用一步就可以撤销整个操作。

// 保存状态
saveState('创建长方形组件',
  [
    StoreType.Rectangle,
    glyph ? StoreType.EditGlyph : StoreType.EditCharacter
  ],
  OpType.Undo,
  {
    newRecord: false,
  }
)

undoTip和redoTip是为了先给用户提示,用户同意后在执行撤销或重做操作。这对于一些重要操作很有必要。

// 保存状态
saveState('编辑脚本与变量', [
  editStatus.value === Status.Glyph ? StoreType.EditGlyph : StoreType.EditCharacter
],
  OpType.Undo,
  {
    undoTip: '撤销编辑脚本与变量操作会将您上次在脚本编辑窗口的全部操作撤销,确认撤销?',
    redoTip: '重做编辑脚本与变量操作会将您上次在脚本编辑窗口的全部操作重做,确认重做?',
    newRecord: true,
  }
)

另外,有时候在记录状态的时候,状态变量实际上已经更改,这时候需要手动在options中传入原先的状态。
对于使用滑动条的操作,需要使用防抖节流保证一次滑动中只保存一次状态:

const saveGlyphEditState = (options) => {
  // 保存状态
  saveState('编辑字形参数', [
    editStatus.value === Status.Glyph ? StoreType.EditGlyph : StoreType.EditCharacter
  ],
    OpType.Undo,
    options,
  )
}

let opTimer = null
let opstatus = false
watch(editGlyph, (newValue, oldValue) => {
  if (opTimer) {
    clearTimeout(opTimer)
  }
  opTimer = setTimeout(() => {
    opstatus = false
    clearTimeout(opTimer)
  }, 500)
  if (!opstatus) {
    saveGlyphEditState({
      editGlyph: oldValue,
    })
    opstatus = true
  }
}, {
  deep: true,
})
更新状态

在执行撤销或重做操作时,需要更新状态,统一封装成函数:

const updateState = (record) => {
  for (let i = 0; i < record.opStores.length; i++) {
    const opStore = record.opStores[i]
    switch(opStore) {
      case StoreType.EditCharacter: {
        for (let i = 0; i < selectedFile.value.characterList.length; i++) {
          if (editCharacterFileUUID.value === selectedFile.value.characterList[i].uuid) {
            selectedFile.value.characterList[i] = record.states.editCharacterFile
          }
        }
        break
      }
      case StoreType.EditGlyph: {
        for (let i = 0; i < glyphs.value.length; i++) {
          if (glyphs.value[i].uuid === editGlyphUUID.value) {
            glyphs.value[i] = record.states.editGlyph
          }
        }
        for (let i = 0; i < radical_glyphs.value.length; i++) {
          if (radical_glyphs.value[i].uuid === editGlyphUUID.value) {
            radical_glyphs.value[i] = record.states.editGlyph
          }
        }
        for (let i = 0; i < stroke_glyphs.value.length; i++) {
          if (stroke_glyphs.value[i].uuid === editGlyphUUID.value) {
            stroke_glyphs.value[i] = record.states.editGlyph
          }
        }
        for (let i = 0; i < comp_glyphs.value.length; i++) {
          if (comp_glyphs.value[i].uuid === editGlyphUUID.value) {
            comp_glyphs.value[i] = record.states.editGlyph
          }
        }
        break
      }
      case StoreType.Tools: {
        tool.value = record.states.tool
        break
      }
      case StoreType.Status: {
        editStatus.value = record.states.editStatus
        break
      }
      case StoreType.Pen: {
        pointsPen.value = record.states.pointsPen
        editingPen.value = record.states.editingPen
        selectAnchor.value = record.states.selectAnchor
        selectPenPoint.value = record.states.selectPenPoint
        mousedownPen.value = record.states.mousedownPen
        mousemovePen.value = record.states.mousemovePen
        break
      }
      case StoreType.Polygon: {
        pointsPolygon.value = record.states.pointsPolygon
        editingPolygon.value = record.states.editingPolygon
        mousedownPolygon.value = record.states.mousedownPolygon
        mousemovePolygon.value = record.states.mousemovePolygon
        break
      }
      case StoreType.Rectangle: {
        editingRectangle.value = record.states.editingRectangle
        rectX.value = record.states.rectX
        rectY.value = record.states.rectY
        rectWidth.value = record.states.rectWidth
        rectHeight.value = record.states.rectHeight
        break
      }
      case StoreType.Ellipse: {
        editingEllipse.value = record.states.editingEllipse
        ellipseX.value = record.states.ellipseY
        ellipseY.value = record.states.ellipseX
        radiusX.value = record.states.radiusX
        radiusY.value = record.states.radiusY
        break
      }
    }
  }
}
撤销操作

每次执行撤销操作时,需要将记录保存至重做操作列表(redoStack),然后更新状态。

const undo = () => {
  if (!undoStack.length) return
  const record = undoStack[undoStack.length - 1]
  if (record.options.undoTip) {
    ElMessageBox.confirm(
      record.options.undoTip,
      `撤销${record.opName}`,
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
      }
    )
    .then(() => {
      undoStack.pop()
      saveState(record.opName, record.opStores, OpType.Redo, record.options)
      updateState(record)
      ElMessage({
        type: 'success',
        message: `撤销${record.opName}`,
      })
    })
    .catch(() => {
    })
  } else {
    undoStack.pop()
    saveState(record.opName, record.opStores, OpType.Redo, record.options)
    updateState(record)
  }
}
重做操作

每次执行重做操作时,需要先更新状态,然后将记录保存至撤销操作列表(undoStack)。

const redo = () => {
  if (!redoStack.length) return
  const record = redoStack[redoStack.length - 1]
  if (record.options.redoTip) {
    ElMessageBox.confirm(
      record.options.redoTip,
      `重做${record.opName}`,
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
      }
    )
    .then(() => {
      redoStack.pop()
      updateState(record)
      saveState(record.opName, record.opStores, OpType.Undo, record.options)
      ElMessage({
        type: 'success',
        message: `重做${record.opName}`,
      })
    })
    .catch(() => {
    })
  } else {
    redoStack.pop()
    updateState(record)
    saveState(record.opName, record.opStores, OpType.Undo, record.options)
  }
}
清空操作栈
const clearState = () => {
  redoStack.length = 0
  undoStack.length = 0
}

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

2024年博客之星年度评选|第一步——创作影响力评审入围Top300名单 | 博客之星陪跑指南

2024年博客之星年度评选&#xff5c;第一步——创作影响力评审入围Top300名单 | 博客之星陪跑指南 2024年博客之星年度评选正在如火如荼地进行中&#xff01;作为博客圈最具影响力的评选活动之一&#xff0c;今年的评选吸引了众多优秀博主的参与。现在&#xff0c;距离Top300入…

全面评测 DOCA 开发环境下的 DPU:性能表现、机器学习与金融高频交易下的计算能力分析

本文介绍了我在 DOCA 开发环境下对 DPU 进行测评和计算能力测试的一些真实体验和记录。在测评过程中&#xff0c;我主要关注了 DPU 在高并发数据传输和深度学习场景下的表现&#xff0c;以及基本的系统性能指标&#xff0c;包括 CPU 计算、内存带宽、多线程/多进程能力和 I/O 性…

CSRF漏洞学习总结

一、什么是CSRF漏洞&#xff1f; CSRF&#xff08;Cross-Site Request Forgery&#xff0c;跨站请求伪造&#xff09;是一种网络攻击&#xff0c;它利用受害者在受信任网站上的已认证会话&#xff0c;来执行非预期的行动。这种攻击的核心在于&#xff0c;攻击者能够诱使受害者…

模型剪枝及yolov5剪枝实践

文章目录 1、模型剪枝1、 稀疏化训练2、模型剪枝2.1 非结构化剪枝2.2 结构化剪枝2.3 一些疑惑&#xff1a;2.3.1 剪枝后参数量不变&#xff1f; 3、微调 【结构化剪枝掉点太多&#xff0c;不如一开始就选个小模型训练。非结构化剪枝只是checkpoint文件变小了&#xff0c;推理速…

黑马程序员C++ P1-P40

一.注释和常量 1.多行注释&#xff1a;/*...............*/ ; 单行注释&#xff1a;//.............. 2.常量&#xff1a;用于记录程序中不可修改的量 。定义方式&#xff1a;宏常量#define定义在文件上方 ;const修饰变量 3.标识符命名规则&#xff1a;标识符不能是关键字&a…

Airflow:BranchOperator实现动态分支控制流程

Airflow是用于编排复杂工作流的开源平台&#xff0c;支持在有向无环图&#xff08;dag&#xff09;中定义、调度和监控任务。其中一个关键特性是能够使用BranchOperator创建动态的、有条件的工作流。在这篇博文中&#xff0c;我们将探索BranchOperator&#xff0c;讨论它是如何…

怎么使用CRM软件?操作方法和技巧有哪些?

什么是CRM&#xff1f; 嘿&#xff0c;大家好&#xff01;你知道吗&#xff0c;在当今这个数字化时代里&#xff0c;我们每天都在与各种各样的客户打交道。无论是大公司还是小型企业&#xff0c;都希望能够更好地管理这些关系并提高业务效率。这时候就轮到我们的“老朋友”——…

java开发,IDEA转战VSCODE配置(mac)

一、基本java开发环境配置 前提&#xff1a;已经安装了jdk、maven、vscode&#xff0c;且配置了环境变量 1、安装java相关的插件 2、安装spring相关的插件 3、vscode配置maven环境 打开 VsCode -> 首选项 -> 设置&#xff0c;也可以在setting.json文件中直接编辑&…

AI模型提示词(prompt)优化-实战(一)

一、prompt作用 用户与AI模型沟通的核心工具&#xff0c;用于引导模型生成特定内容、控制输出质量、调整行为模式&#xff0c;并优化任务执行效果&#xff0c;从而提升用户体验和应用效果 二、prompt结构 基本结构 角色&#xff1a;设定一个角色&#xff0c;给AI模型确定一个基…

Unreal Engine 5 C++ Advanced Action RPG 十章笔记

第十章 Survival Game Mode 2-Game Mode Test Map 设置游戏规则进行游戏玩法 生成敌人玩家是否死亡敌人死亡是否需要刷出更多 肯定:难度增加否定:玩家胜利 流程 新的游戏模式类游戏状态新的数据表来指定总共有多少波敌人生成逻辑UI告诉当前玩家的敌人波数 3-Survival Game M…

设计模式的艺术-代理模式

结构性模式的名称、定义、学习难度和使用频率如下表所示&#xff1a; 1.如何理解代理模式 代理模式&#xff08;Proxy Pattern&#xff09;&#xff1a;给某一个对象提供一个代理&#xff0c;并由代理对象控制对原对象的引用。代理模式是一种对象结构型模式。 代理模式类型较多…

每日一题洛谷P1423 小玉在游泳c++

#include<iostream> using namespace std; int main() {double s;cin >> s;int n 0;double sum 0;double k 2;while (sum < s) {sum k;n;k * 0.98;}cout << n << endl;return 0; }

Python3 OS模块中的文件/目录方法六

一. 简介 前面文章简单学习了Python3中 OS模块中的文件/目录的部分函数。 本文继续来学习 OS模块中文件、目录的操作方法。 二. Python3 OS模块中的文件/目录方法 1. os.lseek() 方法、os.lstat() 方法 os.lseek() 方法用于在打开的文件中移动文件指针的位置。在Unix&#…

HTB:Heist[WriteUP]

目录 连接至HTB服务器并启动靶机 信息收集 使用rustscan对靶机TCP端口进行开放扫描 将靶机TCP开放端口号提取并保存 使用nmap对靶机TCP开放端口进行脚本、服务扫描 使用nmap对靶机TCP开放端口进行漏洞、系统扫描 使用nmap对靶机常用UDP端口进行开放扫描 使用smbclient匿…

【HarmonyOS NEXT】华为分享-碰一碰开发分享

关键词&#xff1a;鸿蒙、碰一碰、systemShare、harmonyShare、Share Kit 华为分享新推出碰一碰分享&#xff0c;支持用户通过手机碰一碰发起跨端分享&#xff0c;可实现传输图片、共享wifi等。我们只需调用系统 api 传入所需参数拉起对应分享卡片模板即可&#xff0c;无需对 U…

使用Inno Setup软件制作.exe安装包

1.下一步&#xff1a; 2. 填写 程序名字 和 版本号&#xff1a; 3.设置安装路径信息 4.添加要打包的exe和依赖文件 5.为应用程序创建关联的文件 如果不需要就直接取消勾选 6.创建快捷方式 &#xff08;1&#xff09;第一种&#xff1a;常用 &#xff08;1&#xff09;第二种&am…

CPU 缓存基础知识

并发编程首先需要简单了解下现代CPU相关知识。通过一些简单的图&#xff0c;简单的代码&#xff0c;来认识CPU以及一些常见的问题。 目录 CPU存储与缓存的引入常见的三级缓存结构缓存一致性协议MESI协议缓存行 cache line 通过代码实例认识缓存行的重要性 CPU指令的乱序执行通过…

初步搭建并使用Scrapy框架

目录 目标 版本 实战 搭建框架 获取图片链接、书名、价格 通过管道下载数据 通过多条管道下载数据 下载多页数据 目标 掌握Scrapy框架的搭建及使用&#xff0c;本文以爬取当当网魔幻小说为案例做演示。 版本 Scrapy 2.12.0 实战 搭建框架 第一步&#xff1a;在D:\pyt…

Python - itertools- pairwise函数的详解

前言&#xff1a; 最近在leetcode刷题时用到了重叠对pairwise,这里就讲解一下迭代工具函数pairwise,既介绍给大家&#xff0c;同时也提醒一下自己&#xff0c;这个pairwise其实在刷题中十分有用&#xff0c;相信能帮助到你。 参考官方讲解&#xff1a;itertools --- 为高效循…

YOLO-cls训练及踩坑记录

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 前言 一、模型训练 二、测试 三、踩坑记录 1、推理时设置的imgsz不生效 方法一&#xff1a; 方法二&#xff1a; 2、Windows下torchvision版本问题导致报错 总结 前…