系列:一張投影片背後的工程:從零打造網頁簡報編輯器
對應程式版本:Git tag
day-03版本說明:本文記錄當日實作;後續版本增加的物件與欄位不改變這裡的資料分層原則。
畫面上的一張白色投影片,看起來只需要幾個文字框和圖片。但只要加入切換、排序、保存與列印,資料結構就必須回答更多問題:哪一張排在前面?物件屬於哪一頁?圖片內容存在哪裡?縮放比例要不要跟著保存?
上一篇把第一版拆成可以驗收的里程碑。這次先處理所有功能都會依賴的文件模型,並把「需要重新開啟的內容」與「只在當下操作有用的狀態」分開。
判斷方式很直接:關閉瀏覽器再開啟後,這項資料是否仍然有意義?投影片順序、文字內容、圖片尺寸與位置需要保留;目前選取框、文字焦點和畫布縮放則不需要。
| 文件資料 | 介面狀態 |
|---|---|
| 簡報名稱與格式版本 | 目前選取的物件 |
| 畫布邏輯尺寸 | 正在編輯的文字框 |
| 投影片順序與背景 | 拖曳中的暫時位置 |
| 圖文內容、位置及尺寸 | 符合視窗或百分比縮放 |
| 圖片資產 ID | 儲存中與錯誤提示 |
文件資料必須可序列化,並能交給編輯器、縮圖、列印與 IndexedDB 共用。介面狀態只由 React 管理,避免無意義的操作資訊進入儲存格式。
我先把資料分成簡報文件、投影片和投影片物件三層。簡化後的 TypeScript 型別如下:
interface PresentationDocument {
schemaVersion: 1
name: string
canvas: { width: number; height: number }
slideOrder: string[]
slides: Record<string, Slide>
}
interface Slide {
id: string
background: string
elements: SlideElement[]
}
schemaVersion 讓程式讀取資料時能辨識格式;目前只有版本 1,尚未實作格式升級。畫布固定使用 1600 × 900 邏輯座標,所以物件的 x、y、width 和 height 不會因為使用者把畫面縮放成 50% 而改變。顯示與指標位移才套用縮放比例。
slideOrder 只保存投影片 ID,slides 則用 ID 找到完整內容。例如順序從 ['slide-a', 'slide-b'] 改成 ['slide-b', 'slide-a'],兩張投影片的內容都不需要搬動。
排序時只移動陣列中的 ID;刪除時再同步移除順序與對應紀錄。固定 ID 讓目前投影片、列印頁面與之後的操作歷史可以指向同一份資料,不受頁碼改變影響。這也比把投影片深層巢狀在多個介面狀態中更容易做不可變更新。
這一輪新增的 slide/add、slide/move 和 slide/delete 都是純 reducer action。加入投影片可以指定插入位置,移動只更新 slideOrder,刪除會拒絕移除文件中的最後一張投影片。介面上的目前頁面仍由 React state 保存,切頁後不會污染可序列化文件。
文字和圖片都有 id、x、y、width、height,但各自保留需要的欄位:
// 以下省略兩者共用的 id、x、y、width、height
interface TextElement {
type: 'text'
text: string
style: { fontSize: number; color: string; textAlign: TextAlign }
}
interface ImageElement {
type: 'image'
assetId: string
alt: string
}
type SlideElement = TextElement | ImageElement
type 是可辨識聯集的判別欄位。程式確認 element.type === 'text' 後,TypeScript 就知道這個物件一定有 text 和 style;圖片分支則能使用 assetId。後續加入圖形物件時,也沿用 type 分支擴充渲染與更新邏輯。
圖片 Blob 沒有直接塞入簡報文件。它獨立存進 IndexedDB,文件只保留資產 ID。複製投影片時,每張投影片和每個物件都取得新 ID,圖片物件仍引用同一份資產。這避免複製大圖,也為後續操作歷史控制記憶體用量。

畫面取自後續版本:左側列出 presentations 與 assets 兩個儲存區,右側顯示簡報文件紀錄;圖片 Blob 沒有在這張截圖中展開。這項資料分層與 day-03 相同。
這項選擇也有代價:匯入圖片時,先寫入 Blob,成功後才把 assetId 加進文件;當時兩次寫入並非同一筆交易。刪除圖片物件時,也不能直接刪除 Blob,因為其他投影片可能還在引用。後續的專案備份只收集文件目前引用的圖片;自動清理未引用資產仍未實作。
TypeScript 能檢查專案裡的程式碼,卻不能保證 IndexedDB 或未來匯入的 JSON 一定正確。因此讀取資料時仍要從 unknown 開始,由 isPresentationDocument 檢查格式版本、畫布、投影片順序及各種物件欄位,通過後才能替換目前文件。
day-03 的驗證器先處理第一版所需的結構與型別,當時還沒有檢查重複 ID、圖片引用是否存在、有限數字和尺寸範圍。後續版本在文件與備份匯入時,補上投影片及單頁物件的重複 ID、備份圖片引用、有限數字與尺寸邊界檢查。把各階段的限制寫清楚,可以避免把編譯期型別誤當成完整的資料安全保證。
在 day-03 標籤中共有 6 個測試檔、24 個案例通過。其中 reducer 測試會驗證投影片插入、移動、刪除對應紀錄及禁止刪除最後一頁;介面測試確認複製後仍保留內容,IndexedDB 測試則確認文件與圖片 Blob 可以完整往返。
下一篇會使用這份資料建立最小投影片畫面,說明一筆文字物件如何變成可見的 DOM 元素。