Bootstrap

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

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;