首页 > 其他分享 >字玩FontPlayer开发笔记12 Vue3撤销重做功能

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

时间:2025-01-21 16:59:24浏览次数:3  
标签:12 clone value states record FontPlayer Vue3 StoreType options

字玩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
}

标签:12,clone,value,states,record,FontPlayer,Vue3,StoreType,options
From: https://blog.csdn.net/toysmaker/article/details/145286667

相关文章

  • 7-4 BCD解密分数 10全屏浏览切换布局作者 翁恺单位 浙江大学BCD数是用一个字节来表达
    mmp,这个题目说了一大堆东西,讲的乱七八糟的看都看不懂,但是注意到最后就是相当于是18%16=2//个位,18/12=1//高位,这个讲的什么乱七八糟的我们先不管,其实就是一个10进制到16进制的转化。所以处理如下:#include<stdio.h>intmain(){   intnum=0;//最好给一个初始值,以后c+......
  • MIPI RAW10 RAW12 RAW14格式解析
    看了一些关于mipi格式的文章,不是图示错误,就是废话太多,不然就是代码辣眼睛。raw14用7*8个byte表示4*14格式的像素raw12用3*8个byte表示2*12格式的像素raw10用5*8个byte表示4*10格式的像素解析代码如下://raw14b1,b2,b3,b4,b5,b6,b7分别为连续读出的七个uint8类型的数据,p1,p......
  • Vue3 在defineProps中某个属性的默认值使用多语言i18n 异常defineProps()` in <script
    原代码<scriptsetuplang="ts">constprops=defineProps({modelValue:{type:Array,default:[]},typeName:{type:String,default:t('TypeName')},disabled:{type:Boolean,default:false......
  • vue3新增API
    Vue3引入了许多新的API和特性,以下是一些主要的新增API列表:组合式APIsetup:组合式API的入口函数。ref:创建一个响应式的引用对象。reactive:将一个普通对象转换为响应式对象。computed:用于定义计算属性。watch和watchEffect:用于侦听响应式数据的变化。toRefs和toR......
  • 通过sqlserver添加修改系统管理员账号.120510
    一,思路:在其他服务器B,通过sa账号远程登录需增加/修改系统管理员账号的服务器A,用sqlserver的xp_cmdshell功能,模拟cmd通过netuser命令进行添加或者修改系统管理员账号。特别提示:此解决方案只适用于服务器没有加域且忘记管理员密码的用户使用,请不要作为非法用途!二,解决方法:0,在服务......
  • SQL Server 2005部署备份任务.120308
    环境:SQLServer2005任务:1,每日凌晨1点给本地sqlserver做本地完整备份,且只保留7天的本地备份;2,每日凌晨5点将本地的备份打包上传到存储服务器。思路:1,sqlserver2005不支持SQLServer2000的sqlmaint命令,所以,需要通过数据库维护计划进行备份,而不能一步成型的写bat脚本;2,备份地址......
  • vue3 如何结构props还能响应式
    在Vue3.5及其更新版本中,确实引入了一些改进,使得解构props并保持响应性变得更加简单。具体来说,Vue3.5引入了对setup函数中的props解构的原生支持,通过编译时优化确保解构后的变量仍然是响应式的。Vue3.5中直接解构props在Vue3.5及以上版本中,你可以直接在setup......
  • vue3 tsx ref获取dom写法
    在Vue3中使用TSX并结合ref来获取DOM元素或组件实例,可以通过Vue提供的组合式API(CompositionAPI)来实现。Vue3支持JSX/TSX语法,允许你在函数式组件中编写类似JSX的代码。下面是一个具体的示例,展示了如何在TSX中使用ref来获取DOM元素。步骤安装必要的依赖......
  • vue3 setup函数 有哪些参数,props、{attrs,slots,emit}等
    在Vue3中,setup函数是CompositionAPI的入口点,用于替代传统的data、methods、computed等选项。setup函数可以接收两个参数:props和context。下面详细解释这两个参数及其用途。setup函数签名import{SetupContext}from'vue';exportdefault{props:{//......
  • 20250120 T2 simu题解
    简单模拟(simu)【题目描述】给定$a_1,a_2,\ldots,a_n$和$b_1,b_2,\ldots,b_n$。对于所有整数$x\in[l,r]$,模拟以下过程并求出变量$res$的最终的值。res=0fori=1ton:ifx>=a[i]:x=x-a[i]res=res+b[i]【输入格式】......