基于Web的svg编辑器(1)——撤销重作功能

最近在作一个网页版的 svg 编辑器,为此学习了编辑器相关方面的知识。本文是个人一些粗浅学习总结,但愿能够给初学者一些思路。

前面的话

随着近几年前端技术的快速发展,人们更倾向于将应用开发放到网页浏览器上,即 B/S 架构 。相比与传统的 C/S 模式,它的兼容性更好,开发成本更低,且不须要安装,只要打开浏览器的一个页面便可。html

Web 的图形编辑器主要使用到了 HTML5 的 Canvas 技术和 SVG 技术。Canvas 是使用 JavaScript 程序绘图,SVG是使用XML文档描述来绘图。SVG 是基于矢量的,放大缩小不失真。而 Canvas 是基于位图的,适合作像素处理,也很适合作 HTML5 小游戏。它们各有优劣,开发时具体使用哪一种方案,须要根据本身的需求进行选择。前端

而我要作的是一个 SVG 编辑器,因此毫无疑问选择了 SVG 技术方案。此外,为更方便的操做 SVG,且使代码有更好的的可读性,而使用了 svg.js 库。svg.js 提供了可读性很好的链式写法,另外这个对学习 svg 也有很大帮助(经过简单的代码就能够生成一个svg )。我会在代码中和 svg.js 相关的代码旁边写上注释,因此你不会 svg.js 也能看懂个人代码。git

功能描述

撤销(undo):返回到最后一个操做前的状态。github

重作(redo):若是撤销过程当中,发现过分撤销,能够经过 “重作”,进入某一个操做后的状态。web

通常来讲,稍微复杂点的编辑器都是有 撤销/重作 功能的。撤销重作 是一款编辑器的基础功能,它让用户在进行错误操做后,可让编辑器回滚到错误操做前的状态。ajax

选择实现方案

基于对象序列化的Undo/Redo

实现undo/redo 功能,其中一个方法是 基于 对象序列化 的Undo/Redo 。typescript

每进行一个操做,就 将以前的全部对象序列化(即存储当前视图状态到一个变量中) ,将其推入到名为 undoStack 的栈中。当须要撤销时,undoStack 出栈,将出栈的数据进行解析,还原到 UI 层,此时还要将出栈的序列化数据推入到 redoStack 栈内。canvas

这种模式,优势是代码容易实现,复杂度较低,缺点是当对象数量越多,每次保存状态都要使用的内存也就越大,因此并非编辑器的首选解决方案。设计模式

基于命令模式的 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和坐标的方式来移动元素。

参考文献

相关文章
相关标签/搜索