Skip to main content

模板编辑器:历史记录

About 7 minVueVue编辑器设计模式

每一段历史记录,都是回忆的窗口,连接着过去与未来...

前言

撤销、重做、历史记录,听着是有点高大上的样子,单其实本质是是对栈的操作,pop push

需求分析

撤销、重做,就是在操作历史记录,所以重点是历史记录如何实现?

那么很容易

首先我们需要监听编辑器的所有操作,所以,编辑器需要把这些操作全部都要用事件的形式传递出去消息。注册历史记录监听这些消息从而达到一个记录。撤销重做就是对栈的pop push

类似 PhotoShop 的历史记录那样

设计

历史记录

我们需要设计一套事件,为了更好的维护事件,事件名需要用变量来存储管理。且编辑器的操作事件要跟历史记录事件要区分,也就是说,要做命名空间,这样省事儿简单区分。

撤销&重做

我们需要设计一个根据事件类型,来分别实现这个事件的撤销和重做动作

// 数据结构
type RecordActionHandle = Record<
  "componentAdd"|"componentDel",
  Record<"undo"|"restore", (data: Template.BuiltinComponentRecord) => void}>
>

事件

import { readonly } from 'vue'

/**
 * @param {string} namespace
 * @param {string} event
 */
function nameSpace(namespace, event) {
  return namespace + ':' + event
}

export const storeSymbol = Symbol('store')

export const templateChannel = readonly({
  editorToHtml: 'template:to:html',
  /** 画布 缩放变化 */
  stageScaleChange: 'template:scale:change',
  /** 画布 大小变化 */
  stageSizeChange: 'template:size:change',
  /** 画布 边距变化 */
  stagePaddingChange: 'template:padding:change',
  /** 画布 清除 */
  stageClear: 'template:clear',
  /** 画布 撤销 */
  stageUndo: 'template:undo',
  /** 画布 重做 */
  stageRestore: 'template:restore',
  /** 画布 移动 */
  stageMove: 'template:move',
  /** 组 组合 */
  groupPack: 'group:pack',
  /** 组 解散 */
  groupUn: 'group:un',
  /** 组件 添加 */
  componentAdd: 'component:add',
  /** 组件 删除 */
  componentDel: 'component:del',
  /** 组件 选择 */
  componentSelect: 'component:select',
  /** 组件 取消选择 */
  componentSelectUn: 'component:select:un',
  /** 组件 定位变化开始 */
  componentMoveStart: 'component:move:start',
  /** 组件 定位变化 */
  componentMove: 'component:move',
  /** 组件 定位变化结束 */
  componentMoveEnd: 'component:move:end',
  /** 组件 字体变化 */
  componentFontChange: 'component:font:change',
  /** 组件 字体大小变化 */
  componentFontSizeChange: 'component:font:size:change',
  /** 组件 大小变化开始 */
  componentResizeStart: 'component:resize:start',
  /** 组件 大小变化 */
  componentResize: 'component:resize',
  /** 组件 大小变化结束 */
  componentResizeEnd: 'component:resize:end'
})

export const recordChannel = readonly({
  /** 画布 缩放变化 */
  stageScaleChange: nameSpace('record', templateChannel.stageScaleChange),
  /** 画布 大小变化 */
  stageSizeChange: nameSpace('record', templateChannel.stageSizeChange),
  /** 画布 边距变化 */
  stagePaddingChange: nameSpace('record', templateChannel.stagePaddingChange),
  /** 画布 边距变化 */
  stageClear: nameSpace('record', templateChannel.stageClear),
  /** 画布 撤销 */
  stageUndo: nameSpace('record', templateChannel.stageUndo),
  /** 画布 重做 */
  stageRestore: nameSpace('record', templateChannel.stageRestore),
  /** 画布 移动 */
  stageMove: nameSpace('record', templateChannel.stageMove),
  /** 组 组合 */
  groupPack: nameSpace('record', templateChannel.groupPack),
  /** 组 解散 */
  groupUn: nameSpace('record', templateChannel.groupUn),
  /** 组件 添加 */
  componentAdd: nameSpace('record', templateChannel.componentAdd),
  /** 组件 删除 */
  componentDel: nameSpace('record', templateChannel.componentDel),
  /** 组件 选择 */
  componentSelect: nameSpace('record', templateChannel.componentSelect),
  /** 组件 取消选择 */
  componentSelectUn: nameSpace('record', templateChannel.componentSelectUn),
  /** 组件 定位变化开始 */
  componentMoveStart: nameSpace('record', templateChannel.componentMoveStart),
  /** 组件 定位变化 */
  componentMove: nameSpace('record', templateChannel.componentMove),
  /** 组件 定位变化结束 */
  componentMoveEnd: nameSpace('record', templateChannel.componentMoveEnd),
  /** 组件 字体变化 */
  componentFontChange: nameSpace('record', templateChannel.componentFontChange),
  /** 组件 字体变化 */
  componentFontSizeChange: nameSpace('record', templateChannel.componentFontSizeChange),
  /** 组件 大小变化开始 */
  componentResizeStart: nameSpace('record', templateChannel.componentResizeStart),
  /** 组件 大小变化 */
  componentResize: nameSpace('record', templateChannel.componentResize),
  /** 组件 大小变化结束 */
  componentResizeEnd: nameSpace('record', templateChannel.componentResizeEnd)
})

/**
 * @type {Readonly<Record<Template.BuiltinComponentType, string>>}
 */
export const recordTypeMap = readonly({
  [templateChannel.stageScaleChange]: '画布 缩放变化',
  [templateChannel.stageSizeChange]: '画布 大小变化',
  [templateChannel.stagePaddingChange]: '画布 边距变化',
  [templateChannel.stageMove]: '画布 移动',
  [templateChannel.stageClear]: '画布 清空',
  [templateChannel.groupPack]: '组 组合',
  [templateChannel.groupUn]: '组 解散',
  [templateChannel.componentAdd]: '组件 添加',
  [templateChannel.componentDel]: '组件 删除',
  [templateChannel.componentSelect]: '组件 选择',
  [templateChannel.componentSelectUn]: '组件 取消选择',
  [templateChannel.componentMoveStart]: '组件 定位变化开始',
  [templateChannel.componentMove]: '组件 定位变化',
  [templateChannel.componentMoveEnd]: '组件 定位变化结束',
  [templateChannel.componentFontChange]: '组件 字体变化',
  [templateChannel.componentFontSizeChange]: '组件 字体大小变化',
  [templateChannel.componentResizeStart]: '组件 大小变化开始',
  [templateChannel.componentResize]: '组件 大小变化',
  [templateChannel.componentResizeEnd]: '组件 大小变化结束'
})

NameSpace 很简单,加个前缀不就好了

实现

import moment from 'moment'
import { onBeforeUnmount, onMounted } from 'vue'
import { recordChannel, recordTypeMap } from '@/views/template/constant'
import { createRecord } from '@/views/template/utils/record'
import eventBus from '@/utils/eventBus'

/**
 * @this {Template.Store}
 * @param {Template.BuiltinComponentRecordType} type
 * @param {Template.BuiltinComponent} component
 * @param {boolean} [record] 是否记录
 */
function recordHandle(type, component = null, record = true) {
  if (component) {
    const recordData = createRecord(type, component)

    console.group('record')
    console.info('事件', recordTypeMap[type])
    console.info('组件id', component.id)
    console.info('组件uid', component.uid)
    console.info('组件props', component.props)
    console.info('时间戳', moment().format('yyyy-MM-DD HH:mm:ss'))
    console.groupEnd()

    if (record && this.record.length <= 20) {
      this.record.push(recordData)
    }

    return
  }

  console.group('record')
  console.info('事件', recordTypeMap[type])
  console.info('时间戳', moment().format('yyyy-MM-DD HH:mm:ss'))
  console.groupEnd()
}

/**
 * @param {Template.Store} store
 */
export function useRecord(store) {
  let move = false
  let resize = false

  /**
   * @param {Template.Event<Template.BuiltinComponent | null>} e
   */
  const componentChange = function (e) {
    switch (e.type) {
      case 'component:add':
      case 'component:del':
      case 'component:font:change':
        recordHandle.apply(store, [e.type, e.detail])
        break
      case 'component:resize:start':
        resize = true
        break
      case 'component:resize':
        recordHandle.apply(store, [e.type, e.detail, false])
        break
      case 'component:resize:end':
        if (e.target === 'property') {
          recordHandle.apply(store, [e.type, e.detail])
        } else if (resize) {
          resize = false
          recordHandle.apply(store, [e.type, e.detail])
        }
        break
      case 'component:move:start':
        recordHandle.apply(store, [e.type, e.detail, false])
        break
      case 'component:move':
        move = true
        break
      case 'component:move:end':
        if (e.target === 'property') {
          recordHandle.apply(store, [e.type, e.detail])
        } else if (move) {
          move = false
          recordHandle.apply(store, [e.type, e.detail])
        }
    }
  }

  onMounted(function () {
    eventBus.$on(recordChannel.componentAdd, componentChange)
    eventBus.$on(recordChannel.componentDel, componentChange)
    eventBus.$on(recordChannel.componentMoveStart, componentChange)
    eventBus.$on(recordChannel.componentMove, componentChange)
    eventBus.$on(recordChannel.componentMoveEnd, componentChange)
    eventBus.$on(recordChannel.componentFontChange, componentChange)
    eventBus.$on(recordChannel.componentResizeStart, componentChange)
    eventBus.$on(recordChannel.componentResize, componentChange)
    eventBus.$on(recordChannel.componentResizeEnd, componentChange)
  })

  onBeforeUnmount(function () {
    eventBus.$off(recordChannel.componentAdd, componentChange)
    eventBus.$off(recordChannel.componentDel, componentChange)
    eventBus.$off(recordChannel.componentMoveStart, componentChange)
    eventBus.$off(recordChannel.componentMove, componentChange)
    eventBus.$off(recordChannel.componentMoveEnd, componentChange)
    eventBus.$off(recordChannel.componentFontChange, componentChange)
    eventBus.$off(recordChannel.componentSizeChange, componentChange)
    eventBus.$off(recordChannel.componentResizeStart, componentChange)
    eventBus.$off(recordChannel.componentResize, componentChange)
    eventBus.$off(recordChannel.componentResizeEnd, componentChange)
  })
}

因为历史记录不涉及到视图层的东西,所以写个hooks即可,首先注册这些历史记录事件,编辑器操作后触发该事件。

这里要说明一下 componentChange 的参数是 TemplateEvent 这是自定义的事件对象,为什么要这样做

因为需要规范化事件对象,去传递我们需要的参数

为什么不采用 Event 呢?

因为Event有很多属性是用不上的,编辑器触发的也不是传统dom事件

函数 componentChange

主要是过滤和处理一些事件压入历史记录栈,但是,有些事件是不必要全部记录。例如移动事件,这个当拖动控件时会一直触发,我们只需要记录 component:move:end 事件即可,得到最终移动的位置。

函数 recordHandle

在这个函数里,才真正的开始往历史记录栈里压入数据,当然可以做最大历史记录限制。

撤销&重做

const recordActionHandle = {
  componentAdd: {
    /**
     * @this {Template.Store}
     * @param {Template.BuiltinComponentRecord} record
     */
    undo(record) {
      this.restore.push(record)

      eventBus.$emit(
        templateChannel.componentDel,
        new TemplateEvent(templateChannel.componentDel, {
          detail: record.component,
          target: 'stage'
        }),
        false
      )
    },
    /**
     * @this {Template.Store}
     * @param {Template.BuiltinComponentRecord} record
     */
    restore(record) {
      eventBus.$emit(
        templateChannel.componentAdd,
        new TemplateEvent(templateChannel.componentAdd, {
          detail: record.component,
          target: 'stage'
        }),
        false
      )
    }
  },
  componentDel: {
    /**
     * @this {Template.Store}
     * @param {Template.BuiltinComponentRecord} record
     */
    undo(record) {
      this.restore.push(record)

      eventBus.$emit(
        templateChannel.componentAdd,
        new TemplateEvent(templateChannel.componentAdd, {
          detail: record.component,
          target: 'stage'
        }),
        false
      )
    },
    /**
     * @this {Template.Store}
     * @param {Template.BuiltinComponentRecord} record
     */
    restore(record) {
      eventBus.$emit(
        templateChannel.componentDel,
        new TemplateEvent(templateChannel.componentDel, {
          detail: record.component,
          target: 'stage'
        }),
        false
      )
    }
  },
  componentGeneral: {
    /**
     * @this {Template.Store}
     * @param {Template.BuiltinComponentRecord} record
     */
    undo(record) {
      undoGeneral.apply(this, [record])
    },
    /**
     * @this {Template.Store}
     * @param {Template.BuiltinComponentRecord} record
     */
    restore(record) {
      restoreGeneral.apply(this, [record])
    }
  }
}

撤销和重做分为2种类型

  1. 常规类型

  2. 非常规类型

为什么这样讲?

控件的添加和删除是一个比较特殊的操作,会涉及到控件的初始化销毁,而普通的,像位置调整、各种属性的调整、这些是不影响控件的声明周期,所以称之为普通操作。

上述代码可以看到,具体实现也不是说直接在这里写具体的撤销和重写,因为完全没必要,因为本身这些创建和删除等等操作都有一个事件来通知完成。所以,只需要反着触发事件即可(默认所有编辑器的事件都会被log日志打印出来,但是操作历史记录不需要log,所以emit的第二个参数是false

/**
 * @this {Template.Store}
 * @param {'undo'|'restore'} action
 * @param {Template.BuiltinComponentRecord} record
 */
export function recordHandle(action, record) {
  switch (record.type) {
    case 'component:add':
      recordActionHandle.componentAdd[action].apply(this, [record])
      break
    case 'component:del':
      recordActionHandle.componentDel[action].apply(this, [record])
      break
    case 'component:move:end':
    case 'component:resize:end':
    case 'component:font:change':
      recordActionHandle.componentGeneral[action].apply(this, [record])
      break
  }
}

编辑器监听undo 和 restore 事件,调用此方法

// /template/index.vue

/**
 * 撤销
 */
const stageUndo = function () {
  const popRecord = store.record.pop()

  if (popRecord) {
    recordHandle.apply(store, ['undo', popRecord])
  }
}

/**
 * 重做
 */
const stageRestore = function () {
  const popRecord = store.restore.pop()

  if (popRecord) {
    recordHandle.apply(store, ['restore', popRecord])
  }
}

onMounted(function () {
  eventBus.$on(templateChannel.stageUndo, stageUndo)
  eventBus.$on(templateChannel.stageRestore, stageRestore)
})

onUnmounted(function () {
  eventBus.$off(templateChannel.stageUndo, stageUndo)
  eventBus.$off(templateChannel.stageRestore, stageRestore)
})

编辑器撤销时需要判断一下是否还有历史记录存在

在这里我并没有使用一个队列来存放历史记录,可能我当时想错了,正常来讲是维护一个队列即可。

感慨一句

Vue3把事件移除还有不在支持监听组件的原生事件(必须手动声明emit),不再支持过滤器,真不适应,当然我也相信这些改动也是经过深思熟虑的。

最后

希望我的设计能给你带来一点点的启发,如果有,请不要忘了点赞转发留言哦

请问小姐你的心怎么走
请问小姐你的心怎么走
Last update:
Contributors: 机车靓仔