基於Web的svg編輯器(1)——撤銷重做功能

Fstar_發表於2019-04-20

最近在做一個網頁版的 svg 編輯器,為此學習了編輯器相關方面的知識。本文是我的一些粗淺學習總結,希望可以給初學者一些思路。

前面的話

隨著近幾年前端技術的快速發展,人們更傾向於將應用開發放到網頁瀏覽器上,即 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 都遍歷組合操作下的子操作。

這種模式因為記錄的只是 正向操作 和 逆向操作,自然佔用的記憶體和物件的多少無關。但因為需要推匯出每個操作的逆向操作,程式碼實現比前一種模式複雜,且不能複用。

示例編輯器的撤銷重做功能使用了這種模式。

實現

教程示例原始碼地址:github.com/F-star/web-…

演示地址:f-star.github.io/web-editor-…

程式碼部分參考了 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和座標的方式來移動元素。

參考文獻

相關文章