【決定版？！】モダンブラウザでIME入力検知：MutationObserver


## モチベーション

**MutationObserver とMutationRecord でDOMの変更を監視。変更前の状態も取得できるので、IMEの入力制御に使えそう！**

さて、モダンブラウザとElectronで動くMarkdownエディタを作っています。ユーザーのキー入力を監視して、自前でDOM操作したいわけですが、IMEだけはどうしても苦労しています。これまで、CompositionEventの監視による試行錯誤の結果を何度か投稿しましたが、今回は別の手段としてMutationObserver を試したのでまた情報共有の投稿です。

- 過去記事1: [モダンブラウザにおけるIME入力検知](https://qiita.com/pochman/items/5b69ebedf4465f93c2f1)
- 過去記事2: [モダンブラウザにおけるキー入力のキャンセル](https://qiita.com/pochman/items/b99c835bf598810d3c18)

## MutationObserverのメリット

そのMutationObserverですが、CompositionEventと比べて以下の点が素晴らしいです。

- Chrome, Firefox, Edge(Legacy), Safariで動作確認。CompositionEventと比べるとばらつきが少ない
- 変更前のDOMの状態も取得出来る！（CompositionEventはこれが取得できない）
- 文字列以外のDOM変更も取得できる！

はたしてIME入力制御・感知の決定打になるか？！

IMEでの入力は、原則preventDefaultもできないので、ブラウザのデフォルト動作に任せるしかありません。しかし、文字を単に入力するだけでなく、BRノードを勝手に消したり、テキストノードを勝手に接続したりと、まあやりたい放題。実際にどんなDOM操作が行われたか、経験則として調べ上げていたわけですが、どこまでやっても漏れが心配でした。

MutationObserverの通知を信じるならば、この文字入力以外のDOM操作も全て知ることができる。これはありがたい。

## MutationOvserverの使用法

MutationOvserverのcallback関数が発火する度に、記録していけばよい。ただし、`oldValue`は初回のまま残し、`target`だけ上書きしてゆく。そして、`compositionend`が発火したタイミングで、初回の`oldValue`から最後の`target`へ変わったと理解すればよい(以下にChromeの例外を記載)。

まあ、結局はIME部分だけを監視するならば、CompositionEventと合わせ技にならざるを得ませんが。

以下に挙動を記載します。

## キー入力時の発火順序（通常入力→Enter決定）

「あいう」と三文字入力した場合の発火順序

### Windows10

| 入力|Edge(Legacy)|Chrome|Firefox|
|---|---|---|---|
|「あ」|compositionstart|compositionstart|compositionstart|
||compositionupdate|compositionupdate|compositionupdate|
||MutationOvserver|MutationOvserver|MutationOvserver|
|「い」|compositionupdate|compositionupdate|compositionupdate|
||MutationOvserver|MutationOvserver|MutationOvserver|
|「う」|compositionupdate|compositionupdate|compositionupdate|
||MutationOvserver|MutationOvserver|MutationOvserver|
|Enterキー||**compositionupdate**|MutationOvserver|
|-|compositionend|compositionend|compositionend|

### MacOS

| |Safari|Chrome|Firefox|
|---|---|---|---|
|「あ」|compositionstart|compositionstart|compositionstart|
||compositionupdate|compositionupdate|compositionupdate|
||MutationOvserver|MutationOvserver|MutationOvserver|
|「い」|compositionupdate|compositionupdate|compositionupdate-|
||MutationOvserver×2回|MutationOvserver|MutationOvserver|
|「う」|compositionupdate|compositionupdate|compositionupdate|
||MutationOvserver×2回|MutationOvserver|MutationOvserver|
|Enterキー||compositionupdate||
||MutationOvserver×2回||MutationOvserver|
|compositionend|compositionend|compositionend||
|keydown(Enter)||||

なんと素晴らしい統一感でしょう！

しかし、MacのSafariだけはcompositionend後にEnterのkeydownが発火してしまいます。自前でDOM操作する場合はこのEnterは無視するように加工が必要です。

## キー入力時の発火順序（通常入力→Backspace消去）

「あいう」と三文字入力し、Enterを押さずに、Backspaceで三文字とも消去した場合の発火順序

### Windows10

| 入力      | Edge(Legacy), Firefox | Chrome                | Firefox           |
| --------- | ----------------- | --------------------- | ----------------- |
| 「あ」    | compositionstart  | compositionstart      | compositionstart  |
|| compositionupdate | compositionupdate     | compositionupdate |
|| MutationOvserver  | MutationOvserver      | MutationOvserver  |
| 「い」    | compositionupdate | compositionupdate     | compositionupdate |
|| MutationOvserver  | MutationOvserver      | MutationOvserver  |
| 「う」    | compositionupdate | compositionupdate     | compositionupdate |
|| MutationOvserver  | MutationOvserver      | MutationOvserver  |
| Backspaceキーで「う」を消す                            | compositionupdate | compositionupdate                       | compositionupdate |
|                                                         | MutationOvserver | MutationOvserver     | MutationOvserver |
| Backspaceキーで「い」を消す                            | compositionupdate | compositionupdate         | compositionupdate |
|| MutationOvserver | MutationOvserver     | MutationOvserver |
| Backspaceキーで「あ」を消す                                    | compositionupdate | compositionupdate | compositionupdate |
|| MutationOvserver |  | MutationOvserver |
|| compositionend | compositionend | compositionend |
| （ここまでで文字は消える）  |                       | MutationOvserver  |                   |

### MacOS

|           | Safari                                 | Chrome            | Firefox            |
| --------- | -------------------------------------- | ----------------- | ------------------ |
| 「あ」    | compositionstart                       | compositionstart  | compositionstart   |
| | compositionupdate                      | compositionupdate | compositionupdate  |
| | MutationOvserver                       | MutationOvserver  | MutationOvserver   |
| 「い」    | compositionupdate                      | compositionupdate | compositionupdate- |
| | MutationOvserver×2回                  | MutationOvserver  | MutationOvserver   |
| 「う」    | compositionupdate                      | compositionupdate | compositionupdate  |
| | MutationOvserver×2回    | MutationOvserver  | MutationOvserver   |
| Backspaceキーで「う」を消す                            | compositionupdate | compositionupdate                       | compositionupdate |
|| MutationOvserver×2回 | MutationOvserver     | MutationOvserver |
| Backspaceキーで「い」を消す                            | compositionupdate | compositionupdate         | compositionupdate |
|| MutationOvserver×2回 | MutationOvserver     | MutationOvserver |
| Backspaceキーで「あ」を消す                                    |  | compositionupdate | compositionupdate |
|| MutationOvserver |  | MutationOvserver |
|| compositionend | compositionend | compositionend |
| （ここまでで文字は消える）  |                       | MutationOvserver  |                   |

Chromeだけはcompositionendの後に最後のMutationOvserverが発火します。厄介な問題で、後述します。

また、やはり、MacのSafariだけはcompositionend後にbackspaceのkeydownが発火してしまいます。自前でDOM操作する場合はこのEnterは無視するように対応が必要です。

Firefoxは基本的にWindowsとMacで同じ挙動です。素晴らしい。

## Safariの問題１：compositionend後のkeydown発火

Safari特有の問題ですが、compositionendの切っ掛けになるEnterキーやBackspaceキー(おそらくDeleteキーも)の入力に対して、compositionend後にkeydownが発火します。これはIME入力中以外のこれらのキー入力と区別しなければ、独自のDOM操作の邪魔になります。compositionend直後のkeydownがこれらのキーだった場合には無視するようにすれば対処可能です。

実際に私が使っている方法はcompositionendではなく、inputイベントを監視して、`event.inputType=="insertFromComposition"`の場合（Enterで終了)と、`event.inputType==="deleteCompositionText"`の場合（Backspaceで終了）にフラグを立てています。


```javascript
let SAFARI_is_just_after_compositionend = false;
function SAFARI_OnInputMarkingEnter(event){
    //monitor the Enter/Backspace/Delete keydown just after IME end//
    if((event.inputType==="insertFromComposition")||
       (event.inputType==="deleteCompositionText")){
        SAFARI_is_just_after_compositionend = true;
        return false;
    }
}

function SAFARI_OnKeydown(event){
    //cancel the Enter/Backspace/Delete keydown just after IME end//
    if(SAFARI_is_just_after_compositionend){
        SAFARI_is_just_after_compositionend = false;
          if((event.key==="Enter")||
             (event.key==="Delete")||
             (event.key==="Backspace")){
            event.preventDefault();
            console.log("cancel the", event.key, "just after IME end");
            return;
        }
    }
    
    OnKeydownCommon(event);//他のブラウザと共通の処理//
    return;
}

target.addEventListener("input", SAFARI_OnInputMarkingEnter, false);
target.addEventListener("input", SAFARI_OnKeydown, false);
```

## Safariの問題２：MutationOvserverが2回ずつ発火

また、MutationOvserverが二度ずつ発火しますが、一度目の発火では`oldValue`に直前の文字列が入っており、二度目の発火ではcompositionstart発火時の値が入っています。これは区別の出来るのであまり問題ではありません。

## Chromeの問題１：変換対象の消去による挙動

IME変換対象の文字をBackspace等で消した場合、**最後の一文字の消去に関してはMutationOvserverの発火がcompositionendより後**になる。そして、これが必ず起こる挙動かどうかわかりません。

仕方ないので、必ず起こる挙動と信じれば、BackspaceやDeleteキーで変換対象をすべて削除してIME入力を終了した時には、compositionendで受け取る変数が`event.data.length==0`になります。Enterで決定して終了した場合には`event.data.length>0`です。これを利用して、`event.data.length==0`の場合に直後の一回分のMutationOvserverもIME入力によるものと解釈するようにします。

## Chromeの問題２：「変換」キーによる挙動（未解決）

こちらはもっと厄介で解決策が見つかりませんない。いっそのこと「変換」キーをpreventDefaultしたくなるがどうにもならないです。

1. 「変換」キーが押されたタイミングで**Chromeが勝手に判断した「ある範囲」**を変換対象にしてしまう。仮にを跨いでいても変換対象にしてしまう。よって自前でDOM操作していたものが勝手に崩される。
1. 「変換」キーが押された際、**keydownイベントが発火したりしなかったりする**。発火のタイミングも**compositionstartの前になるときもあれば、後になるときもある**。よって、keydownと連携した動きは不可能。
  - おそらくChromiumエンジン部分のマルチスレッド処理が絡んでいて、タイミングを制御することは困難。
  - もう一つ考えられる原因として、変換候補となった文字列の「第一の変換候補」が現在の文字列と同じか異なるかによって挙動が異なるように見える。同じ場合には、compositionstartにおけるevent.dataが空文字で、異なる場合にはevent.dataに値が入っているように見える。

### 参考：「変換」キーとBRタグの生成消滅のコンボ


入力済みの`<p>いろは</p>`に対して、その「は」の直後にキャレットを合わせた状態で「変換」キーを押して再変換に入った場合。全ての文字が消えた時点でBRが自動生成されます。



|入力| Chrome(Win) |
| ------------------------------------------------------- | --------------------------------------- |
| 「変換」                                                | compositionstart                        |
| -                                                       | MutationOvserver(update "いろは" to "") | 
| -                                                       | MutationOvserver(remove "")             |
| -                                                       | MutationOvserver(add `<BR/>`)           |
| -                                                       | compositionupdate                       |
| -                                                       | MutationOvserver(add "")                |
| -                                                       | MutationOvserver(remove `<BR/>`)        |
| （ここまでで「色は」に変換される）                      | MutationOvserver(update "" to "色は")   |      |
| Backspaceキーで「は」を消す                             | compositionupdate                       |
|                                                         | MutationOvserver(update "" to "色")     |
| Backspaceキーで「色」を消す                             | compositionend                          |
|                                                         | MutationOvserver(update "色" to "")     |
| -                                                       | MutationOvserver(remove "")             |
| （ここまでで文字は消えてPタグの中にBRタグが生成される） | MutationOvserver(add `<BR/>`)           |

## 感想

さて、CompositionEventだけでIMEの入力検知していた時は、Edge(Legacy)ではCompositionEventがくれる`event.data`がバグだらけでどうにもならなかったわけですが、MutationOvserverの場合は今のところバグもなくしっかり動く印象です。逆にChromeは嫌だなという印象です。特に「変換」キーはもう諦めモードです。

やっぱりEdge(Legacy)はEHTMLエンジンとしてでも生き残って欲しかったです。多様性も大切なのでは？