系列:一張投影片背後的工程:從零打造網頁簡報編輯器
對應程式版本:Git tag
v0.4.0版本說明:本文把每一筆會改變文件的 action 當成歷史單位,介面預覽狀態不寫入歷史。
上一篇談圖片替換時,舊資產不能立刻清理,因為復原還可能把 assetId 指回它。
復原功能也不是把每次滑鼠移動都存起來。拖曳過程若產生數十筆歷史,使用者按一次 Ctrl+Z 只會退回一個像素。前面把拖曳預覽留在介面 state,放開滑鼠才提交文件 action,現在正好能把一次完整操作記成一步。
useDocumentHistory 在文件 reducer 外再包一層 reducer:
interface DocumentHistory {
past: PresentationDocument[]
present: PresentationDocument
future: PresentationDocument[]
}
一般 commit 先交給 presentationReducer。若 reducer 回傳原本文件,代表操作沒有造成變更,不新增歷史;有變更時才把目前文件放進 past,以新文件取代 present,並清空 future。
v0.4.0 把 HISTORY_LIMIT 設為 50,提交時的關鍵判斷只有幾行:
const next = presentationReducer(history.present, action.action)
if (next === history.present) return history
return {
past: [...history.past, history.present].slice(-HISTORY_LIMIT),
present: next,
future: [],
}
復原會把 past 最後一筆移到現在,原本的現在則放到 future 前端;重做反向處理。past 最多保留 50 份文件,也就是最多能連續復原 50 步,避免長時間編輯讓記憶體無限制增加。這些快照也不是 50 份完整複本:reducer 只替被修改的路徑建立新物件,沒變動的投影片與物件會在快照之間共用;圖片在文件裡只是 assetId,Blob 留在 IndexedDB,不會跟著複製。
從 IndexedDB 還原或匯入備份時使用 replace,它會同時清空 past 與 future。這避免使用者在剛載入專案後按復原,突然回到啟動時的空白文件。
執行復原或重做時,介面也會清除選取與文字編輯焦點。選取狀態本來就不屬於文件歷史;若保留舊 ID,物件被復原刪除後可能留下不存在的選取目標。
純 reducer 測試先新增文字,再驗證復原、重做與「復原後重新編輯會清空 future」;連續提交 55 次後,past 只保留 50 筆。介面測試則把文字框從 520 × 150 拉成 620 × 200,再輸入會被拒絕的寬度 0,按一次復原便回到原尺寸,證明拖曳只留下單一步驟,被拒絕的輸入也沒有多出一步。
讀者可在 Git tag v0.4.0 執行 npm ci、npm run dev,以 Edge 開啟本機網址:新增文字框後按 Esc 離開文字輸入,確認寬高為 520 × 150;拖曳右下角控制點,讓寬高都改變;在「寬度」欄輸入 0 並移開焦點,欄位會恢復原值。此時按一次「復原」,寬高應一起回到 520 × 150;再按「重做」,兩個值應一起恢復成拖曳後的尺寸。對應的固定測試資料可用 npm test -- src/editor/useDocumentHistory.test.ts src/App.test.tsx 執行。
按鈕與 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y 最後都呼叫同一組歷史操作,因此不需要替不同入口維護兩套行為。
不過在這一版,「一次操作一步」只涵蓋拖曳、縮放,以及失焦才提交的數字欄位。文字框、簡報名稱、講者備註與顏色欄位都是每次 onChange 就提交一次。文字框仍有焦點時,全域 Ctrl+Z 不會接手,而是交由輸入欄位處理;離開編輯後,文件歷史裡卻是每次輸入各一步。
這個限制也能重現:在 v0.4.0 新增文字框,逐字鍵入 60 個英數字後按 Esc,接著連按工具列的「復原」50 次,文字框仍會留下前 10 個字;新增文字框那一步已經被 50 筆上限擠掉。到 v0.16.0 仍是每次文字變更各佔一步。較合理的做法是把同一次文字編輯合併成一筆歷史。
歷史負責同一次編輯中的退回與重做;關閉頁面後還能接著編輯,則要靠下一篇的 IndexedDB 文件與圖片保存流程。