最近在作一個網頁版的 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 。typescript
每進行一個操做,就 將以前的全部對象序列化(即存儲當前視圖狀態到一個變量中) ,將其推入到名爲 undoStack 的棧中。當須要撤銷時,undoStack 出棧,將出棧的數據進行解析,還原到 UI 層,此時還要將出棧的序列化數據推入到 redoStack 棧內。canvas
這種模式,優勢是代碼容易實現,複雜度較低,缺點是當對象數量越多,每次保存狀態都要使用的內存也就越大,因此並非編輯器的首選解決方案。設計模式
命令模式則是 給每個操做建立一個 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)來管理咱們的建立的全部命令。
// 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和座標的方式來移動元素。