圖形編輯器:歷史記錄設(shè)計
大家好,我是前端西瓜哥。今天講一下圖形編輯器如何實(shí)現(xiàn)歷史記錄,做到撤銷重做。
其實(shí)就是版本號的更替。每個版本保存一個狀態(tài)。
數(shù)據(jù)結(jié)構(gòu)
要記錄圖形編輯器的歷史記錄,支持撤銷重做功能,需要兩個棧:撤銷(undo)棧和重做(redo)棧。
每當(dāng)用戶進(jìn)行一個操作(比如移動一個圖形),就會產(chǎn)生一個新的版本,將這個操作產(chǎn)生的狀態(tài)保持加入到 undo 棧頂,此外 redo 棧會清空。因?yàn)橛脩艨赡艹蜂N了幾次然后產(chǎn)生了新的操作,無法重做它們了。
當(dāng)用戶撤銷,undo 棧出棧,并放到 redo 棧,然后使用 undo 棧頂?shù)臓顟B(tài)。當(dāng)用戶重做時,redo 棧出棧,再放到 undo 棧上,并應(yīng)用 undo 棧頂?shù)臓顟B(tài)。
原理大概這樣。
瀏覽器的回退前進(jìn)的表現(xiàn)其實(shí)就是一個很常見的例子。
數(shù)據(jù)結(jié)構(gòu)還有另一種方案:雙向鏈表加兩個指針,一個指針指向當(dāng)前版本狀態(tài),另一個指針指向 redo 最后一次可執(zhí)行到達(dá)的狀態(tài)。
然后是如果要支持協(xié)同的場景,你的撤回不會回到之前的版本,而是將之前的版本的狀態(tài)拿出來作為一個新的版本。
協(xié)同中你也不能撤回別人的操作,只能撤回自己的,并且要用協(xié)同算法處理和其他協(xié)同者的沖突邏輯。
要保存哪些狀態(tài)
那么我們的狀態(tài)要保存哪些狀態(tài)呢?
- 圖形樹數(shù)據(jù)
- 圖形樹需要的引用
- 一些設(shè)置
圖形樹是必要的,我們需要用它渲染畫布內(nèi)容。此外還有游離在圖形樹之外的被用到的對象,比如圖層、被多次引用的圖形。你可以也把它們也放到圖形樹里面去。
最后是一些需要共享的設(shè)置,比如表格的行高、篩選條件等。
像是顏色主題、國際化語言設(shè)置則不需要?dú)v史記錄,它是用戶自己選擇的個性化定制。
我們看具體的幾種實(shí)現(xiàn)。
全量快照
每次操作得到的新狀態(tài),完全拷貝一份保存起來。
因?yàn)閷ο笕绻皇菧\拷貝,其中的引用對象可能會被意外地修改,通常我們會選擇 序列化成字符串 保存,即JSON.stringify。撤銷重做的時候再解析出來作為當(dāng)前狀態(tài)。
優(yōu)點(diǎn)是實(shí)現(xiàn)簡單。巨大的優(yōu)點(diǎn)。
缺點(diǎn)是當(dāng)狀態(tài)很大的時候,每次生成快照都會比較耗時,且操作很多產(chǎn)生很多版本時,需要大量的內(nèi)存空間保存這些完整狀態(tài)。
如果畫布上有一萬個獨(dú)立的實(shí)體,就意味著每進(jìn)行一次操作,就要將這個一萬個實(shí)體深拷貝一份。100 次就是 100w,很恐怖。
僅推薦簡單的圖形編輯器使用,或者做 demo 用。
補(bǔ)?。╬atch)
全量快照讓編輯器的上限很低,不是最優(yōu)解。
一種更好的解法,是 打補(bǔ)?。╬atch)。
基于上一個版本 1,打一個補(bǔ)丁,變成下一個版本 2。同時我們記錄一個反向的補(bǔ)丁,撤回的時候能通過它從版本 2 回到版本 1。
這個方案對應(yīng)了設(shè)計模式的 命令模式,我們構(gòu)建 Command 類,這個類有 execute、redo、undo 方法,這些方法會對傳入的舊的狀態(tài)對象打補(bǔ)丁,得到一個新的狀態(tài)。
比如添加矩形命令,execute 和 redo 時我們會往圖形樹的末尾加一個矩形對象,undo 就是將這個矩形從圖形樹中移除。undo 棧和 redo 棧此時記錄的就是一個個 command 對象了。
純純用樸實(shí)無華的命令模式去實(shí)現(xiàn),還是有點(diǎn)坑的。因?yàn)橐獙?shí)現(xiàn)的命令太多了,比如添加圖形、修改圖形屬性、刪除圖形、對幾個圖形做右對齊等,這些都要自己一個個實(shí)現(xiàn) redo 和 undo。復(fù)雜一點(diǎn)就要抓瞎,建議找一些輪子。比如 immer、y.js。
使用補(bǔ)丁方案還有一個好處,就是方便實(shí)現(xiàn) “動作” 功能。(當(dāng)然這不是一個優(yōu)先級很高的功能)
比如我們想要給一個圖形先順時針旋轉(zhuǎn) 45 度,然后向右移動 10 個單位,我們希望記錄這兩個操作,給其他圖形也應(yīng)用這些操作。
快照的方式就不好搞,或許我們可以對比新舊狀態(tài)找不同推斷出行為,但不好搞。因?yàn)閷傩缘淖兓赡軄碜圆煌牟僮?,比如移動,可以通過移動工具相對位移產(chǎn)生,也可能直接屬性面板改 x 值,也可能是通過對齊操作產(chǎn)生的。
patch 就很適合。
什么時候保存狀態(tài)
我們需要確認(rèn)一個操作完成的時刻,將它加入到歷史記錄中。
我們操作圖形,會產(chǎn)生一些 中間狀態(tài)。比如移動一個圖形,拖拽的過程中不生產(chǎn)一個歷史版本,直到拖拽結(jié)束才記錄。
一種方式是:操作圖形的替身,操作結(jié)束后才更新真正的狀態(tài)。
一些編輯器,比如 Adobe Illustrator、AutoCAD,我們在操作圖形的時候,會看到一個臨時的替身,就是將被選中圖形的輪廓線或拷貝做鼠標(biāo)的跟隨,鼠標(biāo)釋放后才真正修改圖形屬性。
還比如顏色的修改,在拾色器中挑選顏色時不會立即修改圖形,在點(diǎn)擊確認(rèn)才真正修改圖形顯示在畫布上。
另一種方式是:直接操作真正的狀態(tài),在操作結(jié)束的時候,記錄這個時刻的狀態(tài)。
第一種方式的好處是,狀態(tài)沒有中間狀態(tài),替身操作完,計算出新狀態(tài)應(yīng)用到真正的狀態(tài)上就好了。
第二種方式就要額外在操作開始時,保存原始狀態(tài)的快照,因?yàn)橹笪覀儠a(chǎn)生中間狀態(tài),然后在操作結(jié)束后計算 patch。
但第二種方式用戶體驗(yàn)會更好些,用戶能實(shí)時看到一個圖形的變化,判斷是不是自己需要的效果,而不是看到一個 “通往未來的幻影”。