系列:一張投影片背後的工程:從零打造網頁簡報編輯器
對應程式版本:Git tag
v0.1.0版本說明:本文整理第一版的文字互動規則;後續多選、群組與文字格式功能仍先判斷是否正在輸入或組字。
物件可以選取後,Delete 和 Backspace 就有兩種完全不同的意思:可能是刪除一個投影片物件,也可能只是刪除文字中的一個字。中文輸入法更容易暴露這個衝突,因為組字期間的按鍵不能被全域快捷鍵搶走。
這一篇先把「選取」、「正在編輯」和「文件內容」分開,再決定每個事件應該由誰處理。
文件只保存投影片與物件內容;選取框和游標焦點屬於目前畫面的暫時狀態:
| 狀態 | 用途 | 是否保存 |
|---|---|---|
selectedElementId |
顯示選取框,決定快捷鍵的操作目標 | 否 |
editingElementId |
讓指定文字框取得焦點並可輸入 | 否 |
element.text |
真正要在下次開啟時還原的文字 | 是 |
因此,一個文字框可以被選取但尚未編輯;只有雙擊、按下 Enter,或新增文字後,才會讓兩個 ID 指向同一個物件。按 Esc 離開文字輸入時保留選取,按畫布空白處或在非輸入狀態按 Esc 才完全取消選取。
刪除物件的事件監聽器掛在視窗上,但它先排除播放模式、中文組字、文字編輯和所有文字輸入目標:
if (
event.isComposing ||
editingElementId ||
isTextEntryTarget(event.target)
) {
return
}
if (
(event.key === 'Delete' || event.key === 'Backspace') &&
selectedElementId
) {
event.preventDefault()
updateDocument({
type: 'element/delete',
slideId: resolvedActiveSlideId,
elementId: selectedElementId,
})
}
isTextEntryTarget 同時辨識 input、textarea 與 contenteditable。所以只要焦點在可輸入的地方,刪除鍵就交還給瀏覽器;只有沒有輸入焦點、且已選取物件時,才會刪除整個物件。
KeyboardEvent.isComposing 表示按鍵正處於 compositionstart 到 compositionend 之間。即使焦點或事件路徑之後增加新元件,這一層檢查仍能避免中文組字被當成編輯器快捷鍵。
文字框使用原生 textarea,平常是唯讀,進入編輯後才取得焦點。輸入元件再做一層事件隔離:
onCompositionStart={() => { compositionRef.current = true }}
onCompositionEnd={() => { compositionRef.current = false }}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === 'Escape' && !compositionRef.current) {
event.currentTarget.blur()
}
}}
這裡不攔截 Delete 或 Backspace 的預設行為,讓瀏覽器正常刪除文字;stopPropagation() 則避免事件再傳到畫布的快捷鍵處理。組字期間按 Esc 也不會強制失焦,等組字結束後才可用 Esc 結束編輯。onBlur 只清除 editingElementId,文字內容已經由 onChange 寫回文件。
測試刻意把兩條容易互相衝突的路徑放在一起:先在文字框觸發組字與 Delete,確認文字框仍存在;再離開輸入狀態,對已選取物件按 Delete,確認物件真的被刪除。這比只測試「可以輸入中文」更能防止未來新增快捷鍵時誤傷輸入法。
v0.1.0 的 8 個測試檔共定義 32 個案例,其中包含中文文字輸入、組字期間不刪物件、選取後 Delete 刪除物件。後續 Edge 手動驗收也以中文輸入法操作 Backspace 與 Delete,結果通過。
這個規則的重點不是把每個按鍵都特判,而是先確認目前事件屬於「輸入文字」還是「操作物件」。下一篇會把選取後的拖曳拉回實際操作,驗證不同縮放比例下如何仍然跟著游標移動。