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


追記・修正

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入力検知"を参照されたし。
  • 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と同様と盲目的に信じているけど、果たして、、、