モダンブラウザにおけるIME入力検知


背景

ブラウザ上で動くリッチテキストエディタを作りたい。

時にはtext以外の要素も使いたいので、独自のDOM操作をしたい。そうなると、入力領域はtextareaではなく、contentEditableを設定したdiv等にしたい。このとき、殆どのブラウザではexecCommandでundoとredoができる。しかし、一度でもJavascriptからDOM操作すると、execCommandによるundo/redoは上手く動かなくなる。結局は独自のUndo/Redo機構を作りたくなる。

この際の鬼門がIME入力の検知。ブラウザごとの数々のバグを、試行錯誤して乗り越えたので、情報共有として公開します。間違いもいっぱいあると思いますが、ご容赦ください。ご指摘も大歓迎。

※この記事は公開しないつもりでしたが、2020年1月にEdgeが生まれということで、一番の鬼門だったEdgeの情報を残したくなり、ここに至ります。

※記載したロジックで実装したエディタはいつかどこかで公開したいと思っています。

検証システム

  • OS: Windows 10, 64bit
    • Edge, ver. 44.18362.449.0 (EdgeHTML 18), Chromiumベースでは無いもの
    • Chrome, ver. 79.0.3945.88
    • Fire Fox, ver 71.0
  • OS: Ubuntu 18.04LTS
    • Fire Fox

一般論

文字入力対応

文字入力をUndo/Redoに対応させることを前提にどこで記憶させるか。基本的にIMEによりcompositionend前であっても、つまりcompositionupdateまでの時点でも、DOMは変更されてしまっている。preventDefault()も効かない。よって、他のキー入力の様にJSで自前でDOM操作できない。よって、compositionendイベント発火のタイミングで、IMEによるDOM操作がどのようなものであったか調べて、Undo/Redo履歴に記録する。IMEによるDOM操作は次のどれか。

  • 既存のTEXT_NODEに文字が追加される。この場合、TEXT_NODEのインスタンス自体は変更されない(少なくともChome, Edge, FireFoxでは)。
  • 新規のTEXT_NODEが作られる。
  • 「変換」キー(Convert)やctrl+backspaceにより既存のTEXT_NODEの一部もしくは全てがIME変換対象となる。

一つ目のケースか、二つ目のケースかを判断するだけなら、TEXT_NODEがfocusNodeになっているので、composition系のイベントハンドラでfocusNode.length===event.data.lengthであるかどうかを調べればよい。trueなら後者である。2番目のケースの場合にはノードが追加されるだけでなく、既存のBRノードが削除される場合がある。これに関しては後述する。

難しいのは三つ目のケースがあることであり、そもそもこれを他の2ケースと区別することが難しい。

イベント取得

基本的にはcompositionstart, compositionupdate, compositionendでイベントを取得できる。

compositionstart: IMEの最初の入力の"直前"にイベントが発生。文字入力前のDOMやカーソルの状態を知るにはこれを使う。

compositionupdate: IMEで文字が打たれる、文字が消されるたびに発生。

compositionend: IMEの入力終了時に発生。入力終了のケースは次の3通りあり、どれでもこのイベントが発火する

  1. Enterキーを押して入力を決定した場合
  2. クリックしても入力が決定されたことになる。
  3. 入力中の文字をbackspace等で全て消して取りやめた場合。

入力中および入力終了時のIMEによる文字列は、イベントハンドラの引数のdataプロパティーで取得できる。

(注)ただしEDGEはdataの内容が間違っている場合がある。たとえば「あいうえお」と入力すると「おえいうあ」という文字が返ってくる場合や、決定前にbackspaceを押して削除しても消えていない場合などがある。よって、EDGEではdataの内容を信じて使うことはできない。

ブラウザごとの動作

ここでブラウザごとの動作を纏める。

キー操作とcomposition系イベントの発火タイミング

まずキー入力に対して発火するイベントは次の表のになる。compositionupdateは文字入力の度に毎回起こる。矢印キーによるフォーカス移動ではcomposition系のイベントは発火しない。しかし、スペースキー等による変換では候補を変える度に発火する。

入力 Edge Chrome Fire Fox
1文字目の入力 compositionstart, compositionupdate compositionstart, compositionupdate compositionstart, compositionupdate
2文字目以降の入力 compositionupdate compositionupdate compositionupdate
入力途中の矢印キーによるフォーカス移動 発火しない compositionupdate 発火しない
スペースキー等による変換候補の変更 compositionupdate compositionupdate compositionupdate
deleteキーやbackspaceによる入力領域文字の削除 新規入力ならcompositionupdate, 再変換なら発火しない compositionupdate compositionupdate
エンター等による入力完了 compositionend compositionupdate compositionend
compositionend
別の場所のクリックによる入力完了 compositionend (※A) compositionend compositionend
矢印キーによるフォーカスキーでIME入力範囲を抜けた場合 compositionend (※A,※E) 抜けられない 抜けられない
IME入力後のctrl + backspaceによる再変換開始 compositionstart (※B) 動作しない compositionstart (※B), compositionupdate
変換キーを押す compositionstart(※C) compositionstart(※C), compositionupdate compositionstart(※C), compositionupdate(※D)

※A:おそらくEdgeのバグで、これらの方法でIME入力を完了させた場合には、続けて別の文字入力を行った際に、先のIME入力内容が再びDOMに挿入されてしまう。その時は、compositionstart, compositionupdate, compositionendが一度ずつ発生し、かつ、event.dataには先のIME入力内容の全ての文字列が格納されている。これを利用して、回避動作を実装しなければならない。

※B:前回のIME入力から一度もカーソルを動かさずにctrl + backspaceを押した場合だけ再入力になる。また、クリックや矢印キーで入力範囲を抜けて完了した場合にもカーソルが動いたとみなされて、再入力はできない。

※C:focus位置にある単語がIME変換される

※D:2度発火する。1度目は再変換前のfocus位置が取得でき、2度目は再変換領域の末尾のfocus位置が取得できる。

※E:IME領域を抜けるタイミングだけはkeydownイベントが発火する。ここでpreventDefaultすることで、IME領域から抜けられなくできる。

compositionstartイベントの動作

compositionstartイベントトリガーで取得できる状態、および、可能なプログラマブル操作は次の表の様になる。getSelection()で取得したselectionオブジェクトのcollapseメソッド等を使ってプログラマブルにfocus移動した場合、および、イベントハンドラ内でDOM操作をした場合の動作も記載する。

項目 Edge Chrome Fire Fox
取得できるDOM 変更前のDOM 変更前DOM (※F) 変更前のDOM
getSelection()の返すfocus 入力直前のフォーカス位置 入力直前のフォーカス位置 入力直前のフォーカス位置
event.data 常に空 選択領域のTEXT内容。collapse状態なら空 選択領域のTEXT内容。collapse状態なら空
イベントハンドラ内でfocus移動した時の動作 IME入力がキャンセルされる。(※G) 移動後のフォーカス位置でIME入力が開始される。 移動後のフォーカス位置でIME入力が開始される。
イベントハンドラ内でDOM操作した場合 DOM操作は実施され、IME入力がキャンセルされる。(※G) DOM操作は実施され、IME入力も開始される。 DOM操作は実施され、IME入力も開始される。
イベントハンドラ内でthis.blur()した場合 IMEが正常にキャンセルされる IMEがキャンセルされるが、IMEウィンドウが裏で生きている(※H) IMEが正常にキャンセルされる

※F:focusがTEXTノードのとき、consoleでnode.dataを指定すれば変更前が取れているのがわかるが、単にnodeを指定すると、変更後のテキスト文字列が表示される。おそらくconsoleの実際の表示タイミングではdataの中身が更新された後だからだろう。前者の場合には文字列が渡されていて更新されないのだろうと推測される。

※G:compositionstart発火に続いて、compositionupdateを飛ばしてcompositionendが発火する。この時のevent.dataの値は空であり、DOMも何も変化しない。

※H:次にfocusを合わせると、IME入力が復活してしまう。しかもcompositionstartは発火しない。

compositionupdateイベントの動作

compositionupdateイベントトリガーで取得できる状態は次の表の様になる。ただし、compositionupdateイベントはキー入力があるたびに起こるので、あるキー入力があってから、次のキー入力の時点では、DOMは変更されている。ここで記述した「変更前のDOM」とはあくまで、直近の1回のキー入力である。また、フォーカス位置に関しては、必ずしもIME入力中の文字列の末尾とは限らない。入力途中で矢印キーによってフォーカスが移動している可能性がある。フォーカス位置に関しては全てのブラウザで動作が異なる。

項目 Edge Chrome Fire Fox
取得できるDOM 変更前のDOM 変更前DOM 変更前のDOM
getSelection()の返すfocus 入力直後のフォーカス位置。矢印キーでの移動によって末尾より以前になり得る。 矢印キーでの見た目の移動に関わらず、常に入力直前のIME入力領域の末尾 入力直前のフォーカス位置。矢印キーでの移動によって末尾より以前になり得る。
event.data IME入力内容(※I) IME入力内容 IME入力内容
focusNodeノード(TEXT)のインスタンス 初回に生成された場合のみ、変換候補が変わるたびにインスタンスが変わってしまう(※J)。 稀にインスタンスが変わる(変換による再変換が怪しい) 稀にインスタンスが変わる(変換による再変換が怪しい)

※I:間違った内容が格納されている場合がある。おそらくbug。ただし、検証した限りでは、compositionstartに続く一度目のcompositionupdateの発火では正しい値が入る。前述の※Aに対する処理で、クリック等でIME入力を終了させてた、次の入力でのバグ挿入でも、一度目のcompositionupdateではevent.dataに正しい文字列が入っている。ただし、一文字とは限らない。

※J:IME入力開始の場所が既存のTEXTノードでなかった場合は、新しいTEXTノードが追加されるが、変換候補を変える度にTEXTノードのインスタンスが変わる。IMEの入力開始場所がTEXTノードだった場合は変換候補を変えてもインスタンスは変わらない。

compositionendイベントの動作

compositionendイベントトリガーで取得できる状態は次の表の様になる。取得できるフォーカス位置に関しては、Edgeは入力完了直後のフォーカス位置である。これは、入力途中で矢印キーによってフォーカスが移動していた場合にはその位置が帰るし、入力領域外をクリックして完了した場合にはクリック場所のフォーカス位置が帰る。一方で、ChromeとFire Foxでは入力完了時のフォーカス位置に依らず、IME入力領域の末尾が帰る。

項目 Edge Chrome Fire Fox
取得できるDOM 変更前後で同一のDOM 変更前のDOM 変更前後で同一のDOM
getSelection()の返すfocus 入力完了直後のフォーカス位置。入力領域に限らない(※3) IME入力領域の末尾(※4) IME入力領域の末尾(※4)
event.data IME入力内容(※2) IME入力内容 IME入力内容
イベントハンドラ内でexecCommand('undo')可能か 可。入力直前のDOMへ巻き戻る。 可。入力直前のDOMへ巻き戻る。 不可。入力内容が残り、巻き戻せない。

※K:クリックや矢印キー移動で入力領域外へ出たことによるcompositionend発火の場合は、取得できるフォーカス位置も領域外の場所となる。

※L:IME入力領域内で矢印キーでフォーカスを移動させていようが、IME入力領域外をクリックして入力完了させようが、取得できるカーソル位置はIME入力領域の末尾である。

ctrl+backspaceもしくは「変換」キーで再変換の場合

IME入力直後にctrl+backspaceキーを押すと、再変換モードになる。これはIMEソフト自体のショートカットであるもよう。ただし、Chromeは再変換モードにならない。

また、テキストにカーソルがあったいる場合、もしくは領域選択している場合に、「変換」キーを押すと、IME再変換モードに入る。これはIME入力直後である必要はなく、任意の場所で可能。IMEソフト自体のショートカットである。こちらはChromeでも動作する。

Edgeの場合は以下の表にまとめる。

項目 Edge(ctrl+backspace) Edge(「変換」キー)
再変換開始で発火するイベント compositionstart
compositionstart発火時の状態 以下 以下
取得できるDOM 変更前のDOM
getSelection()の返すfocus 入力直前のフォーカス位置。nodeは必ずTEXT_NODEであり、offsetはIME入力領域の末尾。 入力直前のフォーカス位置。nodeは必ずTEXT_NODEであり、offsetはIME入力領域のどこか。(※M)
event.data
イベントハンドラ内でDOM操作した場合 DOM操作は実施され、IME入力がキャンセルされる(compositionend発火)。 DOM操作は実施され、IME入力がキャンセルされる(compositionend発火)。また、もう一度「変換」キーを押すと、先の選択領域の変換候補が、新規に追加される形でcompositionstartが新たに発火する。
スペースキー等での変換候補変更時の発火イベント compositionupdate
compositionupdate発火時の状態 以下 以下
取得できるDOM 変更後のDOM
getSelection()の返すfocus IME入力領域のどこか。offset位置の再現性不明。
event.data IME入力内容(ただし壊れている可能性大)
Enterキーでの完了時 compositionend
compositionend発火時の状態 以下 以下
取得できるDOM 変更後のDOM
getSelection()の返すfocus IME入力領域の末尾。ただし、クリックや矢印キーで入力領域を抜けた場合は、入力領域外になる。
event.data IME入力内容(ただし壊れている可能性大)

※M:領域選択されていなかった場合、focus位置より前にある文字列だけを再変換することはなく、focus位置直後の文字列か、focus位置を含む文字列の変換となる。よって、取得できるoffsetは絶対に末尾ではない。

ChromeとFire Foxの場合を以下にまとめる。

項目 Chrome(「変換」キー) Fire Fox(ctrl+backspace、「変換」キー)
再変換開始で発火するイベント compositionstart, compositionupdate(2回) compositionstart, compositionupdate
compositionstart発火時の状態 以下 以下
取得できるDOM 変更前のDOM。ただし壊れている場合がある(※N) 変更前のDOM。IME入力直後の場合は、それ以前へ巻き戻る。
getSelection()の返すfocus 入力直前のフォーカス位置。nodeは必ずTEXT_NODEであり、offsetはIME入力領域の末尾。 再入力領域のが選択領域として認識され、anchorが末尾、focusが先頭になる。TEXT_NODEのfocusのoffsetが0の場合は親nodeが返る場合がある。
event.data IME入力内容
イベントハンドラ内でDOM操作した場合 DOM操作は実施され、その上でIME入力が起こる。ただし、変換中となる文字列は選択領域とは変わっている。 DOM操作は実施され、その上でIME入力が起こる。ただし、変換中となる文字列は選択領域とは変わっている。
スペースキー等での変換候補変更時の発火イベント compositionupdate compositionupdate
compositionupdate発火時の状態 以下 以下
取得できるDOM 変更前のDOM。ただし壊れている場合がある(※N) 変更前のDOM(直前の変換候補がDOM内に書き込まれている)
getSelection()の返すfocus 1度目の発火は再変換直前のfocus位置、2度目以降の発火は常にIME入力領域の末尾 直前のfocus(IME入力内)
event.data IME入力内容 IME入力内容
Enterキーでの完了時 compositionupdate, compositionend compositionend
compositionend発火時の状態 以下 以下
取得できるDOM 変更後のDOM 変更後のDOM
getSelection()の返すfocus IME入力領域の末尾(※L) IME入力領域の末尾(※L)
event.data IME入力内容 IME入力内容
その他 再変換となった文字が複製されてしまうバグがある。

※N:「変換」キーで再変換を開始した際に、compositionstartで取得できるDOMが、それよりもさらに前の状態に巻き戻ることがある。どうやら一つ前の動作がIME入力だった場合に、その直前まで巻き戻るように振る舞う。

Edgeの課題と解決策

課題

  • compositionendの時点でfocusがIME入力領域以外を返すことがあり得るので、イベントハンドラ内で取得できるfocusから、実際に変換されたTEXT_NODEを取得することができない。よって、compositionupdateの度に更新中のTEXT_NODEのインスタンスを保持するようにし、最後に起こったcompositionupdateでのTEXT_NODEインスタンスを利用する。
  • 複数のノードを選択している状態で「変換」キーを押した場合の挙動が難しい。
  • 一度の「変換」キーにより、compositionupdateが複数回発生し、イベントの度にfocusの指すノードが変わるので、入力文字列を特定できない。IMEを無効化するか?しかし「変換」キー以外の通常のIME入力なら上手く拾えそうなので、悩ましい。

解決策

領域選択されていない状態(collapse==true)でのIME入力の場合

通常の新規入力の場合と、ctrl+backspaceや「変換」キーによる再変換の場合で処理が異なる。新規入力の場合はIMEによるDOM操作は文字列の追加のみであり、それ以外に既存のDOM内容変更はない。よって、入力された文字列を取得して、compositionendイベントハンドラに置いて、英数字と同様の文字列のInsert処理をしたものとしてUndo/Redoの履歴に登録してやればよい。一方で再変換の場合は、既存の文字列の一部が置き換わることとなる。ここで、置き換えの対象となる既存の文字列(の範囲)をEdgeでは取得できそうにない。よって、入力focusを持っているTEXTノードの全内容を置き置換したものとしてUndo/Redoの履歴に登録する。compositionstartイベントハンドラで入力開始時のTEXTノードの全内容を保管しておいて、compositionendイベントハンドラで入力終了時のTEXTノードの全内容を取得、これらが丸々置換されたものとする。必要なメモリが大きくなりがちで残念。

問題は、新規入力と再変換をどのように見分けるか。少なくともEdgeにおいてはcompositionstartイベントだけで、判別することはできなかった。しかし、compositionupdateと組みあわせて判別することはできた。ここでのcompositionupdateは、compositionstartの発火後から一回目の発火のcompositionupdateイベントとする。この時、判別に必要なプロパティーは以下の表で分かる通り、IME入力開始時点と、最初の更新直後のIME入力領域の文字列長さを比べればよいことがわかる。(c)==1となっていれば新規入力である。

新規入力 再入力(cotrl+backspace) 再入力(「変換」キー)
(a)compositionstart時のIME入力領域の長さ(Edgeでは直接取得できない) 0 1以上 1以上
(b)一回目のcompositionupdate時のIME入力領域の長さ(event.data.lengthで取得) 1 1以上 1以上
(c) = {(b)==1}の場合の{(b) - (a)} 1 0以下の整数 0以下の整数

ここで、(b)はcmpositionupdateの初回のevent.dataは壊れていない(経験則)ことから取得できるが、(a)を取得するすべはない。しかし、

(d) = (compositionupdateにおけるTEXTノードの長さ) - (compositionstartにおけるTEXTノードの長さ)

とすると(c)==(d)であり、(d)ならばgetSelection().focusNode.lengthの差として取得できる。よって、(b)==1かつ(d)==1が満たされていれば新規入力と判断してよい。

領域選択された状態(collapse==false)でのIME入力の場合

  • compositionstartイベントハンドラで、this.blur()によってフォーカスを外せばIMEは強制キャンセル可能。
  • compositionstart発火時に複数ノードに跨いで選択されていた場合、それら全てが連続していて、かつ、TEXTノードならば、compositionstartイベントハンドラに置いてそれらを1つのTEXTノードにまとめてしまっても、選択文字列が一緒ならばIME変換は滞りなく進む。具体的には、選択されているうち先頭のTEXTノードのdataに、2番目以降のTEXTノードのdataを足してしまい、同時に2番目以降のノードはDOMからremove()で削除する。その上で、selectionでfocusをanchorのnodeは生き残った先頭TEXTノードに資、offsetはその中で同じ文字列を指す場所にする。
  • 一方で、ompositionstart発火時に複数ノードに跨いで選択されており、その中にTEXTノード以外が含まれている場合、Edgeでは先のthis.blur()によってIME変換を強制キャンセルさせることにした。

その他

話は変わって、compositionstart発火時点ではTEXTノードでありながら、compositionend発火時にはそのTEXTノードは既にDOMの一部ではなくなっている場合がある。さらに、その間にcompositionupdateは一度も起きない場合がある。これは、「変換」キーで再変換に入った場合に、TEXTノードの全ての文字列が変換対象となり、かつ、変換候補の変更をせずに、backspaceキー等でIME入力領域の文字を全て消してしまった場合である。この場合の処理をcompostionendで捕まえて、TEXTノードの削除と、場合によりBRノードの追加をUndo/Redo管理に登録しておく必要がある。

Chromeの課題と解決策

課題

  • 問題1:IME入力のキャンセルができない。Edgeではうまく動いたthis.blur()だけではキャンセルできない。
  • 問題2:「変換」キーで再変換となった文字が複製されてしまうバグがある。
  • 問題3:「変換」キーで再変換を開始した際に、compositionstartで取得できるDOMが、それよりもさらに前の状態に巻き戻ることがある。どうやら一つ前の動作がIME入力だった場合に、その直前まで巻き戻るように振る舞う。
  • 問題4:「変換」キーで再変換になった文字列が、あるpタグ等の全ての文字列だった場合に、文字列が書き換わるだけでなく、TEXTノードのインスタンスが変わる。元のTEXTノードが消えて、新しいTEXTノードが現れる。さらに厄介なことに、compositionstartの時には元のTEXTノードが消えているものの、新しいTEXTノードが作られていない。とにかく、元のTEXTノードが消えているため、再変換前の状態を取得することができず、Undo/Redo管理できない。
  • 問題5:全角スペースを最初に打つと、IME入力モードにならない。
  • 問題6:keydownやbeforeinputよりも先にcompositionupdateが発火することがある。不定期に起こるバグで、決まったルールが見つからない。

解決策

  • IME入力開始のタイミングで、compositionstartが発火する前に、keydownが発火する。そのkeydownイベントハンドラにて、event.key=="Process"かつevent.composed==trueとなっている。この時、event.codeにも(信頼できそうな)キーが入っている。例えば新規入力開始した場合、「あ」をAキーを押して入力したとすると、event.code=="KeyA"となっている。一方で、「変換」キーを押した場合は、event.code=="Convert"となっている。

  • 全角スペースの問題に関しては、keydownイベントハンドラでevent.key=="Process"かつevent.composed==trueかつevent.code=="Space"をキャッチして自前で全角スペース入力。

  • 解決(問題1)IME入力のキャンセル方法:this.blur()に加えて、preventDefault()が必要。これで一旦キャンセルできる。しかし、次にクリック等でもう一度カーソルを合わせると、そこでIME入力のサブウィンドが出てきてIME入力を再開してしまう。しかもcompositionstartが発火しない。これを防ぐには、alert()で何らかのメッセージを出す。するとなぜかIME入力が再開しなくなる。結局、IMEのキャンセルはChromeでは次のようなコードになる。

  function OnCompositionstart(event){
     /* ... do something ... */

     //for cancel here//
     if(I_want_to_cancel_IME){
           event.preventDefault();
         event.stopImmediatePropagation();
         this.blur();
         selection.removeAllRanges();
         alert("IME is cancelled to avoid bug"); 
         //!!!!This alert is very important to vanish IME subwindow!!!!//
     }   
  }

(こんな裏技を使わなければいけないweb開発って、すごい世界ね。早くネイティブC++に戻りたい。。。)

  • 解決(問題4):TEXTノードがcompositionstart発火以前に消える場合の対策。幸いにもこの場合にはcompositionstartより先にkeydown(”Convert")が先に発火してくれる(今のところ例外なく)。これを利用してkeydown(”Convert")で先に状態を取得しておく。つまり、compositionstartとkeydown("Convert")の先に発生した方を、IME入力の直前状態とみなして必要な情報を記録しておく。これにより問題6も同時に解決。だ、これも裏技レベルよね。。。beforeinputの方がいいかもしれないが、これも信用できるのかな?

Fire Foxの課題と解決策

今のところ、仕様の違いはあれ、特に目立ったバグが無い。おそらく開発経緯によってマヒしているところがあって、Edge→Chrome→Fire Foxという順にIME入力の対策コードを作ってきたので、最初の二つで実装した対応策の流用で何とかなっている。

課題

  • 「変換」キーで入力した際に、再入力領域のが選択領域として認識され、anchorが末尾、focusが先頭になる。TEXT_NODEのfocusのoffsetが0の場合は親nodeが返る場合がある。
  • リストが子リストを持つ際に、子リスト直前のテキストノードを「変換」キーで再変換モードにし、IME入力領域をbackspaceで削除する。そうすると、再変換となったテキストノードがDOMから削除されるが、BRタグは挿入されずに、子リストの最初要素のTEXTノードにfocusがあってしまい、compositionendでそれが返る。

解決策

  • 一つ目の課題のfocusが親ノードにあってしまう問題は、次の処理で回避できる
  function RefineFocusToText(focus_node, focus_offset){
      const ch = focus_node.childNodes.item(focus_offset);
      if(ch){
          if(ch.nodeType===Node.TEXT_NODE){
              focus_node = ch;
              focus_offset = 0;
          }
      }else{
          const back = focus_node.childNodes.item(focus_offset-1);
          if(back){
              if(back.nodeType===Node.TEXT_NODE){
                  focus_node = back;
                  focus_offset = focus_node.length;
              }
          }
      }

      return [focus_node, focus_offset];
  }

  if(focus_node.nodeType!==Node.TEXT_NODE){
    [focus_node, focus_offset] = RefineFocusToText(focus_node, focus_offset);
  }
  • 二つ目の課題については、変換対象となったTEXTノードの親ノードと、親ノードから見たoffsetを、compositionstartイベントで記録しておき、compositionendにて取得したfocusが親から見て同じ位置のものかを判定する。違う位置のノードならば再変換となったTEXTノードは削除されたと判断する。例としては次のような処理。
  let FF_IME_parent = null;
  let FF_IME_offset_in_parent = 0;
  function OnCompositionstart(event){
      //... something to do ... //

      const focus_node = document.getSelection().focusNode;
      FF_IME_parent = focus_node.parentNode;
      FF_IME_offset_in_parent = GetIndex(FF_IME_parent, focus_node); //obtain offset//

      //... something to do ... //
  }

  function OnCompositionend(event){
      //... something to do ... //

      const focus_node = document.getSelection().focusNode;
      if((focus_node.nodeType !== Node.TEXT_NODE) || 
        (focus_node!==FF_IME_parent.childNodes.item(FF_IME_offset_in_parent))){

          console.log("all text restarted by Henkan key is deteled.");

          //... something to do ... //
      }
      //... something to do ... //
  }

IME入力時のBRタグの自動削除対応

どの様な時に自動削除されるか

IMEで入力を行うと、既存のBRノードがDOMから勝手に削除される場合がある。次の場合に起こる。

まず、pタグやliタグなどの中身が空の場合、ブラウザは一般的にbrタグを挿入して、段落やリストが確実に存在するようにする。これはレイアウト上の問題だけでなく、カーソルがそこに合わせられるかどうかという問題にも関係している。具体的には、空のpタグは次のようになっている

<p>空ではない段落</p>
<p><br></p>   <!--空の段落-->

ここで、空の段落にカーソルを合わせてIMEであいうえおと入力すると次のようにbrタグが自動で消える。

<p>空ではない段落</p>
<p>あいうえお</p>

Undo/Redo対応するには、compositionendイベント発火時に、BRタグの自動削除も検知して、Undo/Redoの記録として残しておく必要がある。

自動削除が起こるUndo履歴の破壊と対処

IMEによってBRタグが自動削除されてしまうと、compositionend時にそれをUndo/Redo履歴に残すだけでは済まない問題が起こる。具体的には、削除されてしまったBRが、それ以前のDOM操作でノードとして追加されたものだった場合である。次のような例を挙げる。

まず、ノードを追加する関数をAddNode

以下の手順でDOM操作したとする

  1. 空の段落を追加した
    1. Pノード追加
    2. Pノードの子にBRノードを追加
  2. 空の段落にIMEであいうえおを入力
    1. あいうえおという新TEXTノードが追加されたと履歴だけ記録
    2. BRノードが消えたと履歴だけ記録
  3. Undoで戻す
    1. TEXTノードあいうえおを消す
    2. BRノードを追加
  4. Undoで戻す
    1. BRノードを消す
    2. Pノードを消す。

さて、ここで最初に起こり得る問題は、2-2の操作でBRノードが消えたことを履歴に残す部分である。消えたことを履歴に残す関数として次のようなものを想定する。

Register(UR_TYPE.REMOVE_NODE, node, parent, offset, 1);

ここで、nodeは消えたBRノードのインスタンス、parentはBRノードの親ノードのインスタンスで、offsetはBRノードの親ノード中での位置である。基本的にはだいたいこのような関数になるだろう。インスタンスを記録するのは、もし消したいノードが子ノードを持っているときに、その子ノードのインスタンスをUndo/Redo履歴の中で保持して、GCによるメモリ開放を防ぐ目的がある。

問題は、消えたことを記録したいのに、BRノードは既にIMEによってDOMから消えてしまっており、インスタンスを取得することができない点である。幸い、BRノードは子ノードを持たないので、このインスタンスが消去されることがないが、そういうケースも起こるならば怖い。

暫定的な処理として、ここでは、仕方ないので新規にBRノードを作成し、node引数に渡しておくことにしてみよう。

// 2-2 におけるBR削除履歴を残す操作 (暫定版)
const br = document.createElement("BR");
Register(UR_TYPE.REMOVE_NODE, br, parent, offset, 1);

parentoffsetはIME入力直後のフォーカス位置から適当に取得しておく。

すると、次の問題が起こる。それは4-1の操作で、BRノードを削除する場面である。

そもそも最初にBRノードは1-2の操作で追加されており、そこではUndo/Redo履歴に次のように記録されているだろう。

Register(UR_TYPE.ADD_NODE, node, parent, offset, 1);

ここでnodeには1-2で作成したBRノードのインスタンスが渡されている。これを基に、4-1の操作では例えば次のように、履歴を取得し、元に戻すDOM操作をするのではないだろうか。

//ノード追加に対するUndo処理
const history = GetHistory(time);
const parent = history.prent;  // p node //
const node = history.node;     // br node //
parent.removeChild(node);    // or, node.remove() //

追加した、BRノードのインスタンスをnodeとして覚えているのだから、それをDOM上から削除(removeChild)すればよいわけだ。しかし、今回はこれで問題が起こる。なぜなら、2-2の操作で、記録されたBRノードの削除履歴では、1-2で追加されたBRノードとは別の、暫定的に新規に作ったBRノードのインスタンスが記録される。それが、3-2の操作によって復元され、DOM上に追加されることになる。よって、4-2の操作の時点では、DOM上に存在するのは2-2で作ったBRノードであり、1-2で作ったBRノードではなくなっているのである。よって上記のremoveChildは失敗する。

これを回避するにはノード追加に対するUndo処理を次のように修正する。

//ノード追加に対するUndo処理
const history = GetHistory(time);
const parent = history.prent;  // p node //
const node = history.node;     // br node //
const offset = history.offset; //
const target = parent.childNodes.item(offset);
if(node !== target){
    console.log("warning: node is already removed");
    if(node.nodeName !== target.nodeName){
        alert("ERROR: target is different");
        return;
    }
    console.log("but, target is compatible");    
}
parent.removeChild(target);    // or, target.remove() //

エラーメッセージはさておき、offsetを使って取得したtargetは2-2で暫定的に追加したBRノードであり、実際にDOM上にの存在するものである。これをremoveChildすればよい。

ただし、この回避策はIMEによる自動削除の対象があくまでBRノードだったから有効なだけである。もし、子要素を持つようなノードが削除された場合にはこの手は使えない。何とかして削除前のノードのインスタンスを知りたい。

自動削除前のBRノードのインスタンスの保護

IMEによって自動削除されるまえに、BRノードのインスタンスを取得して、何らかの変数に保持しておきたい。しかし、compositionendやcompositionupdateイベントの発火時には既にDOM上からBRノードは消えて決まっている。一方で、compositionstartイベントの発火時には、DOM上にBRノードが残っている(少なくともEDGE, Chrome,Fire Foxでは)。そこで、compositionstartイベントのイベントハンドラで次のような処理を行う。

//variable to keep original br instanse
let original_br_node_before_composition = null;

function OnCompositionstart(event){
  //br is automatically removed by IME input.
  //then original br is kept before input IME//
  const selection = document.getSelection();
  const focus_node = selection.focusNode;
  const focus_offset = selection.focusOffset;

  if(focus_node==="BR"){
    original_br_node_before_composition = focus_node;
  }else if(focus_node.hasChildNodes()){
    const n=focus_node.childNodes.item(focus_offset);
    if(n.nodeName==="BR"){
      original_br_node_before_composition = n;
    }
  }    
}

つまり、IME入力の直前のカーソル位置にBRノードがあれば、それを保護用の変数に格納しておいてインスタンスがGCされないように保持しておくのである。

そして、先の操作手順の2-2で、BRノードの削除の履歴を残す際、先程までは暫定処理として新規のBRノードを行うのではなく、ここで保護したオリジナルのBRノードを引数に渡す。つまり、次のようになる。

// 2-2 におけるBR削除履歴を残す操作 (修正版)
const br = original_br_node_before_composition;
Register(UR_TYPE.REMOVE_NODE, br, parent, offset, 1);

この様にすれば、一貫してオリジナルのBRノードのインスタンスが履歴に残り、手順4-2においてtargetとなるBRノードもオリジナルのままであるので問題が起こらない。

今のところChrome,EDGE,Fire Foxではこの方法で上手く動いている。特にIMEによってBRノードが自動削除される点は苦労したが、compositionstartイベントでインスタンスを保護することで問題は回避できた。

しかし、この方法が有効であったのは、あくまで開発者側が「IME入力によって、ある条件でBRノードが自動削除される」ことを知った為である。IME入力では、まだこちらの認識していない自動DOM操作が行われている可能性があり、その場合にはまた別の打開策が必要となる。

まとめ

さて、contentEditableで独自のブラウザのexecCommandに頼らず、独自にUndo/Redo履歴を残す場合を想定して、IME入力の扱い方について纏めた。

どのブラウザも英語圏で作られているためか、IME入力に関してはバグが多い。仕様が決まっていないことも問題だろうが、おそらく仕様が決まったところで実装モチ開発者のモチベーションが上がらないだろうなというのは理解できる。動作チェックできる環境も少ないだろうし、なにより本人がIME入力の方法に慣れていないだろうか。

結局はブラウザごとにIME部分のコードを作り分けることとなった。本当にきつかった。

将来、今回の様な回避策が取れない場合には、そもそもUndo/Redo履歴にノードのインスタンスを保持させるという方法自体を変える必要がでてくるかもしれない。たとえば、ユーザー入力の度にDOMをクローンしたりして、ノードおよび子要素ツリーを残しておけば(場合によってはテキスト形式ででも)、そもそもインスタンスが残らない。ただ、インスタンスを残せれば===演算子による比較が使えて楽だったり、利便性は高いので、可能ならインスタンスを残した今の形式を続けたい。

そして、どこかのタイミングで、開発したエディタ「NowType」を公開したいなぁ。。。