基于Web的svg编辑器(1)——撤销重做功能
前面的话
随着近几年前端技术的快速发展,人们更倾向于将应用开发放到网页浏览器上,即 B/S 架构 。相比与传统的 C/S 模式,它的兼容性更好,开发成本更低,且不需要安装,只要打开浏览器的一个页面即可。
Web 的图形编辑器主要使用到了 HTML5 的 Canvas 技术和 SVG 技术。Canvas 是使用 JavaScript 程序绘图,SVG是使用XML文档描述来绘图。SVG 是基于矢量的,放大缩小不失真。而 Canvas 是基于位图的,适合做像素处理,也很适合做 HTML5 小游戏。它们各有优劣,开发时具体使用哪种方案,需要根据自己的需求进行选择。
而我要做的是一个 SVG 编辑器,所以毫无疑问选择了 SVG 技术方案。此外,为更方便的操作 SVG,且使代码有更好的的可读性,而使用了 svg.js 库。svg.js 提供了可读性很好的链式写法,另外这个对学习 svg 也有很大帮助(通过简单的代码就可以生成一个svg )。我会在代码中和 svg.js 相关的代码旁边写上注释,所以你不会 svg.js 也能看懂我的代码。
功能描述
撤销(undo):返回到最后一个操作前的状态。
重做(redo):如果撤销过程中,发现过度撤销,可以通过 “重做”,进入某一个操作后的状态。
一般来说,稍微复杂点的编辑器都是有 撤销/重做 功能的。撤销重做 是一款编辑器的基础功能,它让用户在进行错误操作后,可以让编辑器回滚到错误操作前的状态。
选择实现方案
基于对象序列化的Undo/Redo
实现undo/redo 功能,其中一个方法是 基于 对象序列化 的Undo/Redo 。
每进行一个操作,就 将之前的所有对象序列化(即存储当前视图状态到一个变量中) ,将其推入到名为 undoStack 的栈中。当需要撤销时,undoStack 出栈,将出栈的数据进行解析,还原到 UI 层,此时还要将出栈的序列化数据推入到 redoStack 栈内。
这种模式,优点是代码容易实现,复杂度较低,缺点是当对象数量越多,每次保存状态都要使用的内存也就越大,所以并不是编辑器的首选解决方案。
基于命令模式的 Undo/Redo
命令模式则是 给每一个操作创建一个 command 对象,该对象记录了具体的执行方法(execute)和一个逆执行方法(undo) 。编辑器每进行一次操作,对应的 command 对象会被创建,并执行该命令对象的 execute 方法,然后将这个对象 推入到 undo 栈中。
当用户撤销(undo)时,如果 undo 栈中不为空,弹出 undo 栈顶的 command 对象,执行它的 execute 方法,然后将这个对象推入到 redo 栈中。
重做(redo)的操作和上面类似。如果 redo 栈不为空,弹出栈顶对象,执行 execute 方法,并把这个对象推入到 undo 栈中。
每次进行一个操作时,而创建一个新的 command 时,如果 redo 栈 不为空,将其清空。
有些操作可能是多个操作的组合,这时候需要用到设计模式中的 “组合模式”,将多个操作包装成一个组合操作。每次 execute 和 redo 都遍历组合操作下的子操作。
这种模式因为记录的只是 正向操作 和 逆向操作,自然占用的内存和对象的多少无关。但因为需要推导出每个操作的逆向操作,代码实现比前一种模式复杂,且不能复用。
示例编辑器的撤销重做功能使用了这种模式。
实现
教程示例源代码地址:https://github.com/F-star/web...
演示地址:https://f-star.github.io/web-...
代码部分参考了 svg-edit (一款开源基于web的,Javascript驱动的 svg 绘制编辑器) 的实现。
准备工作
首先我们创建一个 index.html 文件,里面用一个 div#drawing 元素来放 我们的 svg 元素。
为了让代码可读性更好,我使用了 ES6 的模块化,写好后用 babel 编译下就好。
如果要开发比较复杂的编辑器,模块化还是必要的,模块化可以降低代码的耦合度,也更方便进行单元测试。此外还可以考虑引入 typescript 来提供静态类型化,因为开发一个编辑器,无疑要使用到非常多的方法,传入的参数如果不能保证类型的正确,可能会导致意想不到的错误。
下面正式开始编写代码。
首先我们引入 svg.js 库,接着引入我们的入口文件 index.js,并给这个 script 的 type 设置为 module,以获得原生的 ES6 模块化支持。所以你要保证运行下面 html 的浏览器可以支持 ES6 模块化。
<body> <div id="drawing"></div> <script src="https://cdnjs.cloudflare.com/ajax/libs/svg.js/2.6.6/svg.js"></script> <script src="./index.js" type="module"></script> </body>
然后我们开始编写 history.js 文件的相关代码。这里我使用了 ES6 的 class 语法,因为这种写法相比 “原型继承” 的写法,明显可读性更好。当然你也可以用 “原型继承” 的写法,class 只是它的语法糖。
命令类
首先我们创建一个命令基类。
// history.js // 命令基类 class Command { constructor() {} execute() { throw new Error('未重写execute方法!'); // 继承时如果没有覆盖此方法,会报错。通过这种方式,保证继承的子命令类重写此方法。 } undo() { console.error('未重写undo方法!'); // 同上 } }
然后我们就可以根据业务逻辑,包装成一个个子命令类,在需要的时候实例化。下面的 InsertElementCommand 类的作用是创建新元素。
// history.js // 创建不同元素的方法集合 const InsertElement = { // 在 svg 元素下,创建了一个宽高为 size,位于 [x, y],内容为 content 的 text 元素, // 并返回了这个节点对象的引用(svgjs包装后的对象)。 text(x, y, size, content='') { return draw.text(content).move(x, y).size(size); } // 这里还可以写 rect, circle 等方法。 } // 插入元素命令类 export class InsertElementCommand extends Command { // 指定 元素类型 和 需要保存的状态。 constructor(type, ...args) { super(); this.el = null; this.type = type; this.args = args; } execute() { // 这里写创建的方法 console.log('exec') this.el = InsertElement[this.type](...this.args); } undo() { console.log('undo') // 移除元素 this.el.remove(); } }
这里为了更好的通用性,我们创建了一个 InsertElement 对象,里面保存了创建不同类型的各种方法。这个对象其实就是设计模式中 “策略模式” 中 的策略对象。这里,我们对 text 类型的创建代码写在了 InsertElement 对象的 text 方法中了。
CommandManager 对象
这样,我们就写好一个具体的命令类了。接下来,我们需要写一个命令管理对象(CommandManager)来管理我们的创建的所有命令。
// history.js // 命令管理对象 export const cmdManager = (() => { let redoStack = []; // 重做栈 let undoStack = []; // 撤销栈 return { execute(cmd) { cmd.execute(); // 执行execute undoStack.push(cmd); // 入栈 redoStack = []; // 清空 redoStack }, undo() { if (undoStack.length == 0) { alert('can not undo more') return; } const cmd = undoStack.pop(); cmd.undo(); redoStack.push(cmd); }, redo() { if (redoStack.length == 0) { alert('can not redo more') return; } const cmd = redoStack.pop(); cmd.execute(); undoStack.push(cmd); }, } })();
每当我们创建一个 Command 对象后,就要调用 cmdManager.execute(cmd) 方法后,它会执行 Command 对象的 execute 方法,并将这个 Command 对象推入 undoStack 中。
redo/undo 栈的实现方式有很多种,这里为了让代码更直观简单,直接用两个数组来保存两个栈。
而在 svg-edit 中,则使用了双向链表
的方式:使用了一个数组,并给了一个指针,指向一个 Command 对象。指针左边是 undoStack,右边为 redoStack。这样每次撤销重做时,只要修改指针位置,而不需要修改对数组进行操作,时间复杂度更低。
进一步包装
通过下面这样的代码,我们就可以执行并保存每一步操作了。
let cmd = new InsertElementCommand('text', x, y, 20, '好'); cmdManager.execute(cmd);
但如果每个操作都要写下面这样的代码,无疑有些累赘。于是我从 js 原生的方法 [document.execCommand
](https://developer.mozilla.org... 获得了灵感,在全局添加了一个 executeCommand 方法。
// commondAction.js import { InsertElementCommand, cmdManager, } from './history.js' const commondAction = { drawText(...args) { let cmd = new InsertElementCommand('text', ...args); cmdManager.execute(cmd); }, undo() { cmdManager.undo(); }, redo() { cmdManager.redo(); } } // executeCommond 设置为全局方法 window.executeCommond = (cmdName, ...args) => { commondAction[cmdName](...args); }
然后我们通过下面这种方式,就能在任何位置创建 command 对象,并执行它的 execute 命令。
executeCommond('drawText', x, y, 20, '好'); executeCommond('undo'); executeCommond('redo');
随着命令的扩展,我们可以在对第一参数 cmdName 进行解析,判断是创建一个元素,还是修改一个元素的一些参数等(如'create rect', 'update text'),然后调用对应的各种方法。
最后我们在入口 index.js 文件内,将这些命令绑定到事件响应事件上就完事了。
课后练习
你可以下载我在 github 上提供的源码,试着添加 “创建 rect 的功能。
如果你想挑战一下的话,还可以写一个移动元素的功能。如果还要考虑交互的话,会涉及到 mousedown, mousemove, mouseup 三个事件,会有点复杂,可以先不考虑考虑交互,通过传入元素id和坐标的方式来移动元素。