用 Composition Event 改進 CodeMirror 對輸入法的支援

CodeMirror 是一套相當熱門的編輯器套件,除了 WebKit 跟 Chromium 裡面的 Inspector 採用以外,Logdown 的編輯器也是用 CodeMirror 做成的。恰如其名,CodeMirror 用來處理程式碼片段非常適合;但是用來處理「文字」倒是有個缺點:沒辦法好好的處理「輸入法」。

對於要編寫非英語文章的使用者來說,多半需要使用輸入法 (Input Method Editor, IME) 來輸入不同語言的文字。大部分的輸入法(例如注音、倉頡、日文拼音輸入法)在打字的時候,會進入一個「組字模式」,把你鍵盤上的輸入,經過對應後轉換為非 ASCII 的文字。

以注音輸入法來說,就是你會看到打出來的字,最後面一小段下面會有底線,然後你可以把游標移動到有底線的文字上、按下「↓」或是空白鍵之類的來改變選字,好讓你可以把「 放棄 」改成「 放氣 」。

但是如果你試過在 CodeMirror(現在是 v3.18)的編輯器裡面使用輸入法打字,你會發現:

  1. 看不到標示為「組字模式」的組字區底線。
  2. 在鍵盤按左右,卻看不到 CodeMirror 的游標真的移動。

所以你會不知道哪邊的文字還可以改、而且也不知道現在游標的真正位置,不知道等一下改下去會改到哪個字。

CodeMirror 的問題

CodeMirror 會有這樣的問題,是因為你所看到的編輯介面都不是瀏覽器/系統的原生 UI。你看到編輯區裡面的每個文字,都是你輸入在 CodeMirror 一個隱藏起來的 <textarea> 裡面、再被他讀進去的 <span>。你看到的那個在閃爍的游標,也是一個使用 JS 不斷控制他顯示/隱藏的 <div>

當使用輸入法在打字時,即便在組字模式狀態,輸入框的 value 其實也是會跟著改變。改變之後,就會被 CodeMirror 抓到而更新他編輯器的畫面。所以你看起來好像字都打進去了,可是你卻看不到組字模式應該有的底線。

同樣的,當你用鍵盤左右移動想要選字的時候,你實際上是在那個隱藏的 <textarea> 裡面做到這件事情,但是 CodeMirror 不會知道你正在移動游標要做選字──因為在組字模式下,不管你鍵盤按什麼按鍵,JS 的 Keyboard Event 都只拿得到 keyCode = 229──他根本不知道你在幹嘛。《黑暗執行緒》的這篇有詳細的解釋

Composition Event to Rescue

Composition Event 是一組可以探查輸入法組字狀態的 API。他提供三個 event 可以聽,命名上其實非常直覺:

  • compositionstart: 表示開始進入組字模式了。
  • compositionupdate: 表示組字區裡面被更新了,可能是有新的輸入、或是可能因為使用者選字而更改了內容。
  • compositionend: 表示組字模式結束,有結果要脫離組字區出來了。

三個 event 都帶有 event.data,不過代表的意義不太一樣:

  • compositionstart 的 data: 表示進入組字模式時,被選擇的文字(所以組字完應該會被取代掉)。
  • compositionupdate 的 data: 表示組字區裡面的內容。
  • compositionend 的 data: 表示將會脫離組字區的文字。

其中以 compositionend 的 data 比較特別一點,因為「脫離組字區」的文字不見得就是在組字區裡面所有的文字。這個後面會做簡單說明。

Composition Event 的週期

雖然這三個 event 本身其實就還蠻一目了然的,不過實際觀察下來其實有些特別的小地方。輸入法的組字區並不是無限大,讓你要打多少就打多少,所以其實你一邊輸入,一邊會有一部分的文字脫離組字區,先正式進入系統的輸入框。以 Yahoo! 輸入法(注音)來說,通常你輸入到這樣的長度:

中文輸入測試一二三四五

打到「五」出來的時候,「中文」兩個字就會脫離組字區了。在這個瞬間,會因為有文字「結束組字」而立即觸發一個 compositionend event。之後緊接著再觸發 compositionstartcompositionupdate event,來繼續後面還在組字的部分。

所以在這邊的 compositionend 裡面,event.data 就會拿到剛脫離組字區的「中文」兩個字;而後續第一個 compositionupdate 則會重新拿到剩下還在組字區的內容。

整個打字起來的 event 週期大概像是這樣:

  1. composition start: 進入組字模式(例如打了「」)
  2. composition update: 在每次打字或選字的時候持續觸發
  3. input event
  4. composition end: 有文字脫離組字區(如上面的「中文」)
  5. input event
  6. composition start: 重新進入組字模式
  7. composition update: 把先前未完成的組字區放回來繼續
  8. input event
  9. composition update: 使用者繼續輸入
  10. input event
  11. composition end: 終於全部完成組字輸入(例如按下 Enter
  12. input event

這邊的 input event 是當使用者有「輸入」的時候就會被觸發,後面會用到。

試著使用 Composition Event 來 “Hack” CodeMirror

我的想法大概是這樣:

When compositionstart

判斷使用者進入組字模式,記錄目前游標位置,然後把 CodeMirror 隱藏的輸入框用覆蓋 CSS 強制顯示出來。只有在系統原生的輸入框,使用者才有辦法看得到那個組字區的底線、跟看到你的游標在組字區裡面移動。

另外因為 CodeMirror 的設計,會讓那個輸入框自動被放到游標所在的位置,所以我就不用管如何放置這個輸入框的問題了。

When compositionupdate

因為組字區內容更新了,所以把 CodeMirror 上相對應的文字,用取代的方式一併強制更新。同時因為游標位置會隨著文字輸入而移動,連帶導致 CodeMirror 的輸入框跟著移動,所以用 CSS Transform 的 translateX 強制把它移回來,蓋在正確的位置上,看起來才比較像是正常的輸入框。

其實 CodeMirror 本來會自動隨著輸入框的內容做更新,所以理論上組字區的內容他也一樣會更新到他的編輯器裡面;但是不知道為什麼,他就是吃不到 Mac 內建倉頡輸入法的組字內容。所以只好採用自己抓組字區內容,強制 replace 上去的方法。

When compositionend

總而言之,一定有個什麼文字要脫離組字區出來了,所以把 event.data 拿到的文字一樣 replace 回 CodeMirror 編輯器上面。然後因為離開組字模式了,所以把該還原的什麼都還原一下,好讓之後不管有沒有要立即再進入組字模式都 ok。

在隨後的第一個 input event 要把 CodeMirror 輸入框的內容給清空,以避免離開組字模式的時候,因為我用 event.data 插入了一次組字內容、CodeMirror 又自動從輸入框裡面讀取文字更新上去,會導致文字重複的問題。

結論

整體的想法差不多就是這樣。第一次做的時候還做了很多奇怪的 dirty hack,不過後來慢慢理清頭緒之後,光用 CodeMirror 自己的 API、大概就可以做得差不多了。效果應該還可以啦,雖然還不完美,不過我覺得目前還算可用。除了在接近換行邊緣那邊,沒辦法完美模擬自動換行以外(沒辦法,CodeMirror 的編輯器就不是原生 UI。)

如果想要試試看 Demo 的話,可以:

  1. 上 CodeMirror 官網 試試看在 CodeMirror 原本的編輯器上面,用輸入法打字。
  2. 然後 上 Logdown Demo 試試看在套用了 Composition Mod 之後的編輯器裡面,用輸入法打字。

Source Code 我放在 Github 上面:

https://github.com/zhusee2/codemirror-composition-mod

希望如果有興趣的大大可以多多指教!

comments powered by Disqus