###追記・修正
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の履歴を完全に管理するには次の二通りの方針が考えられる。
- ブラウザによるDOMの操作はさせない。ユーザーによるキー入力をpreventDefaultし、その上で、そのキーに対応したDOM操作を自前で全て実装し、自前のUndo履歴に登録する。
- ブラウザによるDOM操作を行わせた後、操作された痕跡を調査してその内容をUndo履歴に登録する。
ここで、今回は前者の方針を取った。後者の場合だと、DOM操作前の状態を常にモニタリングしておかない限り、操作の内容を完全に把握することが困難である。特にテキスト入力以外のDOM操作(文字の削除やタグのBRタグの入力・削除など)の把握は大変難しい。またMarkdownに対応したDOMツリー構造の制限を設けるとなると、ブラウザによるDOM操作とは異なる操作をすることになる。であれば、最初から全てのブラウザによるDOM操作をキャンセルしてしまい、全て自前で操作した方が把握できて良い。
前者に従うには、大まかに次のような処理を行う。
- KeydownイベントにおいてpreventDefaultし、ブラウザによるDOM操作をキャンセルする。同時に自前でDOM操作を行う。
しかし、実際には次の問題と回避策をとる。
- IME入力はpreventDefault()でキャンセルできない。その為、IME入力に関しては、後者の方針を取らざるを得ない。IME入力中(compositionstart以後)のKeydownでは自前DOM操作は行わないようにし、compositionendイベントにおいて、ブラウザが操作した内容を推理し、Undo履歴だけを残す。IME操作に関する詳細は以前の記事"モダンブラウザにおけるIME入力検知"を参照されたし。
- 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を発行する。
//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やそのタイミングでは回避できないものとしての事例に遭遇した。
- 半角スペースを入力すると、
に置き換わる場合がある。しかし、テキストノードのメソッドであるinsertData()を利用して半角スペースを挿入した場合にも置き換わってしまう。この点は諦めて、markdown出力時に、
と半角スペースの置換を行ってユーザーには認識させないくらいしか手がなさそう。 - IME入力によるBRタグの生成や消滅。非IME入力と違ってprventDefault()できないので、compositionstart時にBRタグの存在を記録しておいて、compositionend時に削除されていたりインスタンスが異なっていれば、BRタグの消滅や再生成が行われたものとしてUndo履歴に記録する。
最後に
この記事がどこかのだれかの役に立つといいのですが。
ElectronやNWJSでラップした場合の動作はChromeと同様と盲目的に信じているけど、果たして、、、