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

最近在做一个网页版的 svg 编辑器,为此学习了编辑器相关方面的知识。本文是我的一些粗浅学习总结,希望可以给初学者一些思路。(沉迷NS无法自拔的我,终于白玩之中抽出大量时间写下这篇文章)

前面的话

随着近几年前端技术的快速发展,人们更倾向于将应用开发放到网页浏览器上,即 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-editor-tutorial

演示地址:https://f-star.github.io/web-editor-tutorial/undo/

代码部分参考了 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
获得了灵感,在全局添加了一个 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和坐标的方式来移动元素。

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335