画板探秘系列:画板中的时光倒流术

前言

经过一个多月的重构,我成功打造了一款功能强大的多端趣味画板。这个画板集成了多种创意画笔,可以让用户体验到全新的绘画效果。无论是在移动端还是PC端,都能享受到较好的交互体验和效果展示。并且此项目拥有许多网上流行的画板功能,包括但不限于前进后退、复制删除、上传下载、多画板和多图层等等。详细功能我就不一一罗列了,期待你的探索。

Link: https://songlh.top/paint-board/

Github: https://github.com/LHRUN/paint-board

在完成重构后,我计划撰写一系列文章,一方面是为了记录技术细节,这是我一直以来的习惯。另一方面则是为了推广一下,期望得到你的使用和反馈,当然如果能点个 Star 就是对我最大的支持。

此篇文章是画板探秘系列的第一篇,我将会详细介绍关于撤销与反撤销的方案,示例皆以 Fabric.js 语法做示例,但思路是想通的。

方案一:画板级缓存

第一个方案是画板级缓存,这个是最简单的。在这种方案下,无需关心具体修改了哪些元素,也无需对整个画布数据进行差异化处理。简单来说,每当需要改变效果时(比如新增、删除、修改元素),只需将当前的画板数据 push 到历史操作栈上,然后撤销与反撤销重新加载相应的数据即可。

这种方案的优势在于简单直接,无需考虑细节,历史操作栈记录了每一步的变更。然而,需要注意的是,由于是整个画布数据的无差别缓存,内存占用会比较大。

一般维护这种历史栈有两种比较流行的方案

  1. 单一操作栈:
  • 使用一个操作栈来记录每一步的操作
  • 通过下标指定当前的状态,实现撤销与反撤销操作
  • 当进行新的操作时,将该操作推入栈中,并更新当前状态的下标
  1. 双栈维护:
  • 维护两个栈,一个是撤销栈,另一个是重做栈
  • 当用户执行新的操作时,将该操作推入撤销栈,并清空重做栈
  • 撤销操作时,从撤销栈中弹出最新的状态,并将其保存到重做栈
  • 重做操作时,从重做栈中弹出状态,并推入撤销栈

以下是一个单一操作栈的简单示例:

class History {
  constructor() {
    this.stack = []; // 用来保存历史状态的数组
    this.currentIndex = -1; // 指向当前状态的下标
  }
  
  // 添加当前状态到历史记录中
  saveState(state) {
    if (this.currentIndex < this.stack.length - 1) {
      this.stack = this.stack.slice(0, this.currentIndex + 1);
    }
    this.stack.push(state);
    this.currentIndex++;
  }
  
  // 撤销,返回上一个状态
  undo() {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      return this.stack[this.currentIndex];
    }
    return null;
  }
  
  // 重做,返回下一个状态
  redo() {
    if (this.currentIndex < this.stack.length - 1) {
      this.currentIndex++;
      return this.stack[this.currentIndex];
    }
    return null;
  }

  // 检查是否可以撤销
  canUndo() {
    return this.currentIndex > 0;
  }

  // 检查是否可以重做
  canRedo() {
    return this.currentIndex < this.stack.length - 1;
  }
  
  // 重置历史记录
  clear() {
    this.stack = [];
    this.currentIndex = -1;
  }
}

方案二:缓存当前操作命令

当采用画板级缓存时,由于占用较大的内存且存在大量重复数据,就引出了第二个方案,即只缓存当前操作命令。这种策略更为灵活和节省内存,通过记录每一步具体的操作,避免了存储整个画布状态的冗余数据。在复杂项目中,合理使用这个方案可以带来很棒的性能提升。

比如

  • 新增元素时,存储一个 “add” 命令以及当前绘制的对象。撤销时,只需对当前元素进行删除。
  • 删除元素时,存储一个 “delete” 命令和被删除元素的标识。撤销时,恢复该被删除元素即可。
  • 修改元素时,存储一个 “modify” 命令和当前变化的元素数据。对于位置移动等简单操作,只需存储位置坐标的变化。对于缩放等复杂操作,存储当前的缩放比例等变化数据。撤销时,根据命令类型进行相应的操作恢复

此方案在复杂项目中不仅减少了内存占用,还提高了灵活性,合理控制每一步操作。不过就是实现过程比较复杂,需要费较大的精力在 diff 算法上来判断状态的变化,确保数据的准确记录。

以下只是一个简单的示例,与具体项目中的绘制逻辑关联性比较强。

class Command {
  constructor(execute, undo, value) {
    this.execute = execute; // 执行命令
    this.undo = undo;       // 撤销命令
    this.value = value;     // 命令的参数,可以是当前绘制对象
  }

  // 执行
  exec() {
    this.execute(this.value);
  }

  // 撤销
  unexec() {
    this.undo(this.value);
  }
}

class History {
  constructor() {
    this.commands = []; // 保存命令的堆栈
    this.index = -1;    // 操作命令在堆栈中的位置指针
  }
  
  // 执行新命令并入栈
  execute(command) {
    this.commands.slice(0, this.index)
    this.commands.push(command);
    command.exec();
    this.index++;
  }
  
  // 撤销
  undo() {
    if (this.index < 0) return;
    const command = this.commands[this.index];
    if (command) {
      command.unexec();
      this.index--;
    }
  }
  
  // 重做
  redo() {
    const command = this.commands[this.index + 1];
    if (command) {
      command.exec();
      this.index++;
    }
  }
}

画板级缓存优化

但是在处理复杂的绘制效果时,命令级缓存的计算就会变得很复杂,很难准确计算多个元素之间的差异。在这种情况下,我建议采用一个优化方案,对方案一的画板级缓存进行改进。画板级缓存的主要弊端在于其占用的内存较大,但通过对每次操作的画板缓存状态进行 diff 操作,只存储当前差异数据,这样内存占用会大大减少。至于差异比较操作,我推荐使用 jsondiffpatch,它能较的好处理两个对象的差异。

jsondiffpatch 有几个常用的 API

  • jsondiffpatch.diff(left, right[, delta])
    • 比较两个对象 leftright 的差异。可选参数 delta 是一个已知的差异,用于提高性能
  • jsondiffpatch.patch(obj, delta)
    • 将给定的差异 delta 应用到对象 obj 上,返回更新后的对象
  • jsondiffpatch.unpatch(obj, delta)
    • 从对象 obj 上移除给定的差异 delta,返回还原前的对象

以下是将 jsondiffpatch 应用到 History 中,我目前画板是采用的这个方案。

import { diff, unpatch, patch, Delta } from 'jsondiffpatch'

export class History {
  diffs: Array<Delta> = []
  canvasData: Partial<IBoardData> = {}
  index = 0

  constructor() {
    const canvasJson = canvas.toDatalessJSON()
    this.canvasData = canvasJson
  }

  saveState() {
    this.diffs = this.diffs.slice(0, this.index)
    const canvasJson = canvas.toDatalessJSON()
    const delta = diff(canvasJson, this.canvasData)
    this.diffs.push(delta)
    this.index++
    this.canvasData = canvasJson
  }

  undo() {
    if (canvas && this.index > 0) {
      const delta = this.diffs[this.index - 1]
      this.index--
      const canvasJson = patch(this.canvasData, delta)
      canvas.loadFromJSON(canvasJson, () => {
        canvas.requestRenderAll()
        this.canvasData = canvasJson
      })
    }
  }

  redo() {
    if (this.index < this.diffs.length && canvas) {
      const delta = this.diffs[this.index]
      this.index++
      const canvasJson = unpatch(this.canvasData, delta)
      canvas.loadFromJSON(canvasJson, () => {
        canvas.requestRenderAll()
        this.canvasData = canvasJson
      })
    }
  }

  clean() {
    canvas.clear()
    this.index = 0
    this.diffs = []
    this.canvasData = {}
  }
}

总结

感谢你的阅读。以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏。如果有任何问题,欢迎在评论区进行讨论