系列:一張投影片背後的工程:從零打造網頁簡報編輯器
對應程式版本:Git tag
day-06版本說明:本文記錄當日實作;後續加入略過投影片等功能時,仍沿用相同的 ID 與順序模型。
一張投影片已經能顯示文字與圖片,接下來要把它變成真正的簡報:使用者必須能新增、切換、複製、排序與刪除頁面。這些操作看似只是改變左側縮圖,實際上必須同時維持顯示順序、投影片內容與目前頁面的關係。

左側縮圖依文件順序顯示,藍色背景標示目前頁面;下方按鈕負責複製、前移、後移與刪除。
文件使用 slideOrder 保存順序,slides 則依固定 ID 保存內容:
interface PresentationDocument {
slideOrder: string[]
slides: Record<string, Slide>
}
例如 ['slide-a', 'slide-b'] 改成 ['slide-b', 'slide-a'],只是兩個 ID 交換位置,投影片內容不用搬動。畫面上的頁碼由陣列索引加一算出;排序後頁碼會改變,ID 不會。
左側清單也用這個 ID 當 React key:
{presentation.slideOrder.map((slideId, index) => (
<button
key={slideId}
aria-label={`投影片 ${index + 1}`}
aria-current={slideId === activeSlideId ? 'page' : undefined}
>
<SlideRenderer slide={presentation.slides[slideId]} />
</button>
))}
React 因此能在插入、移動或刪除後辨識同一張投影片。activeSlideId 則是介面狀態,只表示現在打開哪一頁,不需要寫進簡報文件。
新增投影片時,用 crypto.randomUUID() 產生新 ID,把空白頁加到順序尾端,再切換到新頁。切換縮圖只改變 activeSlideId,文件本身完全不變;同時清除物件選取與文字編輯狀態,避免上一頁的 UI 狀態殘留到下一頁。
複製需要比新增多一步。新投影片與其中每個物件都取得新 ID,之後編輯副本才不會改到原頁。文字樣式也建立新物件;圖片物件則保留原本的 assetId,讓兩頁共用同一份 Blob,不必複製大型圖片。
const copy = {
...activeSlide,
id: makeId('slide'),
elements: activeSlide.elements.map(duplicateElement),
}
排序只更新 slideOrder。Reducer 先複製陣列,再用 splice 移除原位置並插入目標位置;slides 物件維持原參照。目標索引會限制在第一頁到最後一頁之間,邊界上的前移或後移按鈕也直接停用。
刪除目前頁面前,介面先找下一頁;若目前已是最後一頁,就改用上一頁。Reducer 再同步移除 slideOrder 中的 ID 與 slides 裡的內容,避免留下無法到達的紀錄。
最後一張投影片不能刪除。這項規則同時存在於按鈕狀態和 reducer:按鈕提供立即回饋,reducer 則保護所有呼叫路徑。即使未來加入快捷鍵或其他入口,文件仍至少保留一頁。
Reducer 測試確認指定位置插入、順序移動、刪除內容紀錄,以及拒絕刪除最後一頁;介面測試則實際新增並切換頁面,確認兩頁不會共用物件,也驗證複製內容、刪除後的目前頁面及邊界按鈕。
npm run validate:edge 另外在 Edge 執行複製、排序與刪除流程,縮圖內容和自動儲存皆通過。day-06 當時共有 6 個測試檔、24 個案例通過,lint 與 production build 也都成功。
下一篇會回到投影片內部,整理文字與圖片從建立、選取、拖曳到保存時共用哪些操作,又有哪些資料必須分開處理。