系列:一張投影片背後的工程:從零打造網頁簡報編輯器
對應程式版本:Git tag
day-07版本說明:本文記錄當日的文字與圖片操作;後續圖形與群組功能仍共用相同的物件框與提交方式。
有了多張投影片後,下一個問題是讓內容不只是畫在畫布上。文字要能直接編輯,圖片要能從本機匯入,兩者都要能被選取、移動和刪除,操作結果還必須回到同一份文件資料。
文字與圖片都需要 id、位置與尺寸,因此兩種元件能使用同一套物件框和拖曳流程。各自特有的資料仍由可辨識聯集分開:文字保存內容和樣式,圖片保存資產 ID 與替代文字。
type SlideElement = TextElement | ImageElement
interface TextElement {
type: 'text'
text: string
style: { fontSize: number; color: string; textAlign: TextAlign }
}
interface ImageElement {
type: 'image'
assetId: string
alt: string
}
渲染時只要檢查 element.type,TypeScript 就能縮小型別,再交給 TextElementView 或 ImageElementView。兩個元件收到相同的畫布尺寸、縮放比例、選取回呼與位置提交回呼,差異只留在真正不同的內容處理。
按下「新增文字」會建立一個帶預設尺寸與樣式的物件,加入目前投影片,接著把它設為已選取和正在編輯。連續新增時稍微錯開位置,避免物件完全疊在一起。
文字內容使用受控的原生 textarea。平常設為唯讀,雙擊或按 Enter 才開始編輯;onChange 送出 element/set-text action。這樣瀏覽器原生的游標、選字和中文輸入法仍能工作,不必自行實作文字編輯器。
選取、編輯與文件內容分開保存。selectedElementId 和 editingElementId 只屬於當下介面;真正需要重新開啟的文字則由 reducer 寫回文件。中文組字的按鍵保護會留到 Day 09 詳細處理。
檔案選擇器用 accept="image/png,image/jpeg" 提示可用格式,但仍由 validateImageFile 實際檢查 MIME type 與 20 MB 上限。accept 不能取代驗證,因為外部檔案仍是不可信輸入。
通過後,程式使用瀏覽器的 createImageBitmap(file) 讀取圖片寬高,立即關閉 bitmap,再計算初始矩形。大圖會等比例縮進 800 × 500 範圍,小圖維持原尺寸,最後置中於 1600 × 900 畫布。
const fitScale = Math.min(
1,
800 / intrinsicSize.width,
500 / intrinsicSize.height,
)
圖片 Blob 先寫入 IndexedDB,文件物件只保存 assetId。同一張圖片出現在編輯區、縮圖和列印頁面時,都由 AssetImage 讀取同一份資產;複製投影片也不會複製 Blob。若讀取或保存失敗,文件不會加入那個圖片物件,工具列會顯示錯誤。
useElementDrag 同時服務兩種物件。按下主要指標時先選取物件並呼叫 setPointerCapture,後續移出物件邊界仍能收到事件。移動期間的位置只放在元件的暫時 state,畫面可以立即跟著游標更新;pointerup 才提交一次 element/move。
const { position, pointerHandlers } = useElementDrag({
position: { x: element.x, y: element.y },
size: element,
canvasSize,
scale,
onMoveCommit,
})
pointercancel 會丟棄暫時位置,不修改文件。文字正在編輯時則停用拖曳,避免使用者選字時移動整個文字框。縮放後的位移換算由共用座標函式處理,下一篇會專門驗證這條路徑。
純函式測試確認 PNG/JPEG 與 20 MB 邊界,也驗證 2400 × 1200 圖片會縮成 800 × 400 並保持比例。Reducer 測試涵蓋文字新增、修改、移動和刪除,且原文件不被突變。
介面測試分別確認中文輸入,以及匯入一張模擬 PNG 後,編輯區、縮圖與列印區都取得圖片;接著讀回 IndexedDB,檢查文件中的圖片尺寸,並用資產 ID 取回對應的資產紀錄。day-07 當時的 6 個測試檔、24 個案例全部通過,Edge 也完成中文輸入、圖片保存和拖曳驗收。
下一篇會把顯示縮放、螢幕位移與邏輯座標串起來,確認同一次拖曳在不同比例下得到正確結果。