23
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-17

###追記・修正

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と同様と盲目的に信じているけど、果たして、、、

23
19
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?