モダンブラウザにおけるキー入力のキャンセル

###追記・修正

2020/1/28: ご指摘を受け、Firefoxの綴りを正式なものに修正しました（恥ずかしながら知りませんでした）。

また、旧Edgeという表記をしていますが、現時点ではEdge Legacyというのが正しい表現かもしれません。もっと正確にはEdgeHTMLエンジンのEdgeを指します。新Edge(Chromium)は体感的にChromeと同様の動きをします。

## モチベーション

Markdownエディタを作っていたが、Macの動作がWindowsやLinuxとは微妙に異なり、仕様変更が余儀なくされた。

一方で、Firefoxは異なるOS間でも一貫性をもっており、素晴らしい。

ChromeはIME入力時のKeyDownイベントでevent.keyの値が非IME時と異なってくれれば制御しやすかった。、特に開始時には非IMEでの入力と区別ができないので困った。

新Edge(Chromium)は今回は未調査。おそらくChromeと同様だろう。

## 基本的な方針と解決策

Markdownに対応したリッチなユーザー操作をさせつつ、Undoの履歴を完全に管理するには次の二通りの方針が考えられる。

1. ブラウザによるDOMの操作はさせない。ユーザーによるキー入力をpreventDefaultし、その上で、そのキーに対応したDOM操作を自前で全て実装し、自前のUndo履歴に登録する。
2. ブラウザによるDOM操作を行わせた後、操作された痕跡を調査してその内容をUndo履歴に登録する。

ここで、今回は前者の方針を取った。後者の場合だと、DOM操作前の状態を常にモニタリングしておかない限り、操作の内容を完全に把握することが困難である。特にテキスト入力以外のDOM操作(文字の削除やタグのBRタグの入力・削除など)の把握は大変難しい。またMarkdownに対応したDOMツリー構造の制限を設けるとなると、ブラウザによるDOM操作とは異なる操作をすることになる。であれば、最初から全てのブラウザによるDOM操作をキャンセルしてしまい、全て自前で操作した方が把握できて良い。

前者に従うには、大まかに次のような処理を行う。

- KeydownイベントにおいてpreventDefaultし、ブラウザによるDOM操作をキャンセルする。同時に自前でDOM操作を行う。

しかし、実際には次の問題と回避策をとる。

- IME入力はpreventDefault()でキャンセルできない。その為、IME入力に関しては、後者の方針を取らざるを得ない。IME入力中(compositionstart以後)のKeydownでは自前DOM操作は行わないようにし、compositionendイベントにおいて、ブラウザが操作した内容を推理し、Undo履歴だけを残す。IME操作に関する詳細は以前の記事"[モダンブラウザにおけるIME入力検知](https://qiita.com/pochman/items/5b69ebedf4465f93c2f1)"を参照されたし。
- Safari(Mac)ではKeydownにおけるpreventDefault()での非IME入力のキャンセルが無効である。おそらくbeforeinputとinputがkeydownよりも先に発火する為である。幸い、beforeinputでpreventDefault()することで文字入力をキャンセルできるので、これを利用し、keydownで行うpreventDefaultと自前DOM操作を、beforeinputで行う。
- Chrome(Mac)では、IME入力開始(最初の一文字目の入力)のcompositionstartの直前にKeydown (event.key==アルファベット)が発火するため、非IME入力の場合と区別できない。幸い、beforeinputはcompositionstartの後に発火し、かつ、beforeinputでpreventDefault()することで文字入力をキャンセルできる。よって、Safari(Mac)と同様に、keydownで行うpreventDefaultと自前DOM操作を、beforeinputで行う。
- beforeinputは旧EdgeとFirefoxで発火しない。全ブラウザで共通して発火するのはinputイベントのみ。さらに、旧Edgeではもinputイベントが発火するものの、event.data等は全てundefinedである。よって、旧EdgeとFirefoxのpreventDefaultと自前DOM操作はkeydownで行う。
- 文字入力以外のキー入力イベント(Delete,backspace,Enter,Tabなど)は、どのブラウザでもkeydownでpreventDefault()することでキャンセルできる。あわせて自前DOM操作も行う。

これらの方針をとった理由として、以下に各ブラウザでの動作試験の様子を記載する。

## Macにおける問題

- SafariはkeydownでpreventDefaultが効かない。
- ChromeはIME入力時にもkeydownイベント(event.keyがアルファベット)が発火。windows10では発火しないので、IME入力と、アルファベット入力で処理を分けられた。Macでは発火してしまうために、IME入力開始時の最初のkeydownにおいて、アルファベット入力なのかIMEなのか判断できない。

仕方ないので再調査。代わりにinputイベントを使えないか。

beforeinputは旧EdgeとFirefoxで発火しない。全ブラウザで共通して発火するのはinputイベントのみ。

inputイベントは次のプロパティーを持つ

- event.data: 入力した文字列が格納されている。IME入力中では、updateの度にinputイベントが発火し、dataには変換途中の文字列が入っている。
- event.event.inputType: アルファベット入力、IME入力、delete、backspaceなどが分類されて格納されている。しかし、ここでdelete、backspaceを拾った時には既に文字は消えており、元の文字がなんであったか取得できない。つまり独自Undoを実装するには、やはりkeydownやbeforeinputで拾うしかない。

ここでSafariが圧倒的に気持ち悪いのは、なぜかEnter入力時のイベント発火順序はkeydownからなのに、文字入力時の発火順序はkeydownが最後ということ。そしてEnter入力はkeydownでpreventDefaultが有効。やはり、Safariにおいて文字入力にkeydownでpreventDefaultが効かないのは発火順序の問題なのでは。

## イベント発火順序

### アルファベット1文字入力

| 旧Edge    | Firefox (Win&Mac&Linux) | Chrome (Win&Mac&Linux) | Safari(Mac) |
| --------- | ------------------------ | ---------------------- | ----------- |
| keydown   | keydown                  | keydown                |             |
|           |                          | beforeinput            | beforeinput |
| input(※1) | input                    | input                  | input       |
|           |                          |                        | keydown※2   |

※1：旧Edgeでのinputイベントはdata==undefined, inputType==undefinedとなって必要な情報は何も取れない。

※2：なぜかkeydownが後に来る。その為、keydownでpreventDefaultしても効果が無いのかもしれない。

### Enter入力(非IMEモード)

| 旧Edge    | Firefox (Win&Mac&Linux) | Chrome (Win&Mac&Linux)        | Safari(Mac)                   |
| --------- | ------------------------ | ----------------------------- | ----------------------------- |
| keydown   | keydown                  | keydown                       | keydown                       |
|           |                          | beforeinput (insertParagraph) | beforeinput (insertParagraph) |
| input(※1) | input (insertParagraph)  | input (insertParagraph)       | input (insertParagraph)       |

### IME入力開始（最初の一文字, IME直前のConvertキー相当のkeydownイベントは除く。）

| 旧Edge                   | Firefox (Win&Mac&Linux) | Chrome (Win&Mac&Linux)                                      | Safari(Mac)           |
| ------------------------ | ------------------------ | ----------------------------------------------------------- | --------------------- |
| keydown (Unidentified)※3 | keydown (Process)※3      | keydown (Process: Win, AsciiChar:Mac, Unidentified:Linux)※4 |                       |
| compositionstart         | compositionstart         | compositionstart                                            | compositionstart      |
|                          |                          |                                                             | compositionupdate     |
|                          |                          | beforeinput                                                 | beforeinput           |
|                          | compositionupdate        | compositionupdate                                           |                       |
| input(※1)                | input                    | input                                                       | input                 |
| compositionupdate        |                          |                                                             |                       |
|                          |                          |                                                             | keydown (AsciiChar)※4 |

※3：以後Keydown (hogehoge)と記載した場合は、event.key==="hogehoge"であることを指す。つまり、"Unidentified"や"Process"の場合には本当に押したキーは取得できない。

※4：一方で"AsciiChar"と記載した場合は実際に押したキー(アルファベット文字)がevent.keyで取れることを指す。

### IME入力の変更(文字追加やスペースキーによる変換)

| 旧Edge                 | Firefox (Win&Mac&Linux) | Chrome (Win&Mac&Linux)                                    | Safari(Mac)         |
| ---------------------- | ------------------------ | --------------------------------------------------------- | ------------------- |
| keydown (Unidentified) | keydown (Process)        | keydown (Process: Win, AsciiChar:Mac, Unidentified:Linux) |                     |
|                        |                          |                                                           | compositionupdate   |
|                        |                          | beforeinput                                               | beforeinput         |
|                        | compositionupdate        | compositionupdate                                         |                     |
| input(※1)              | input                    | input                                                     | input               |
| compositionupdate      |                          |                                                           |                     |
|                        |                          |                                                           | keydown (AsciiChar) |

### IME入力の終了(Enterによる決定)

| 旧Edge         | Firefox (Win&Mac&Linux) | Chrome (Win&Mac&Linux)                               | Safari(Mac)                            |
| -------------- | ------------------------ | ---------------------------------------------------- | -------------------------------------- |
|                | keydown (Process)        | keydown (Process:Win, Enter:Mac, Unidentified:Linux) |                                        |
|                |                          |                                                      | beforeinput (deleteCompositionText) ※8 |
|                |                          |                                                      | input (deleteCompositionText) ※8       |
|                |                          | beforeinput                                          | beforeinput (insertFromComposition) ※8 |
|                |                          | compositionupdate                                    |                                        |
| input          |                          | input                                                | input (insertFromComposition) ※8       |
| compositionend | compositionend           | compositionend                                       | compositionend                         |
|                | input(※5)                |                                                      |                                        |
|                |                          | keydown (Unidentified, Linuxのみ)※6                  | keydown(Enter)※7                       |

※5：event.isComposing==falseとなっているが、inputType==="insertCompositionText"となっているため、非IMEのアルファベット入力(inputType==="insertText")とは区別可能。

※6：Linuxの場合のみ、ここで二度目のKeydownが発生する。

※7：このkeydownがIME決定であるという情報を取得する術がない。単純に"IME入力後のcompositionendの直後のkeydown(Enter)"という条件にしてしまうと、マウスクリックでIMEを終了させた場合にこの条件がNGとなる。ありそうなのは"input (insertFromComposition)の直後の後のkeydown(Enter)"ということならIME入力決定のEnterとして判別可能かもしれない。

### 別の場所をマウスクリックしてカーソルを移動させることによりIME入力終了させた場合

| 旧Edge                                  | Firefox (Win&Mac&Linux)        | Chrome (Win&Mac&Linux)          | Safari(Mac)                     |
| --------------------------------------- | ------------------------------- | ------------------------------- | ------------------------------- |
| compositionend (カーソル移動後のノード) | compositionend (入力textノード) | compositionend (入力textノード) | compositionend (入力textノード) |
|                                         | input (入力textノード)          |                                 |                                 |

Enterキーによる決定と比べて、beforeinputやinputが発火しない。

カッコ内はそのイベントハンドラにおいてdocument.getSelection()で取得できるfocus位置。旧Edgeだけが問題を持っている。

## その他の注意

addEventListennerで同じイベントに二つのハンドラを登録した場合、前者でstopPropagationを行っても後者のイベントが発生する。防ぐ場合はstopImmediatePropagationを発行する。

```js
//example
my_div.addEventListener("keydown", OnKeydown1, false);
my_div.addEventListener("keydown", OnKeydown2, false);
my_div.addEventListener("keydown", OnKeydown3, false);

function OnKeydown1(event){
    event.stopPropagation();      //cannot cancel next event
    event.preventDefault();
}

function OnKeydown2(event){
    //fired
    event.stopImmediatePropagation();  //cancel next event//
    event.preventDefault();
}

function OnKeydown3(event){
    //not fired
}
```

## ブラウザによる勝手なDOM改変

また、ブラウザによる勝手なDOM操作として、preventDefaultやそのタイミングでは回避できないものとしての事例に遭遇した。

- 半角スペースを入力すると、`&nbsp;`に置き換わる場合がある。しかし、テキストノードのメソッドであるinsertData()を利用して半角スペースを挿入した場合にも置き換わってしまう。この点は諦めて、markdown出力時に、`&nbsp;`と半角スペースの置換を行ってユーザーには認識させないくらいしか手がなさそう。
- IME入力によるBRタグの生成や消滅。非IME入力と違ってprventDefault()できないので、compositionstart時にBRタグの存在を記録しておいて、compositionend時に削除されていたりインスタンスが異なっていれば、BRタグの消滅や再生成が行われたものとしてUndo履歴に記録する。

## 最後に

この記事がどこかのだれかの役に立つといいのですが。
ElectronやNWJSでラップした場合の動作はChromeと同様と盲目的に信じているけど、果たして、、、