0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

isComposing だけでは足りなかった — 日本語IME入力イベントをブラウザ6環境で実測した

0
Posted at

検証環境(2026-06-02 実測)
Chrome 148 (macOS) / Safari 26.5 (macOS) / Firefox 149 (macOS) / Edge 147 (macOS) / Chrome 148 (Windows) / Edge 148 (Windows)
入力はすべて「あい」。IMEは macOS が ATOK 35、Windows が Microsoft IME。自作のイベントロガー(単一HTML)で全イベントをタイムスタンプ付きに記録。
IMEが変わると発火の出方も変わりうるので、ここはあえて環境を明記しておきます。同じChromeでもATOKとGoogle日本語入力で差が出る可能性は否定できません。

この記事でわかること

  • 日本語を確定(Enter)したとき、compositionendinput がどの順で出るか。ブラウザで違う。
  • keydown.key がブラウザ・OSで a だったり Process だったりする。keyCode は全環境で 229
  • Safari だけ確定時に input が2回出る。Firefox だけ compositionend の後にもう1回 input が出る。
  • 変換をやめたとき(Esc・全消し)は、全環境で compositionend.data が空。確定とキャンセルはここで見分けられる。
  • Chrome/Edgeはマウスのクリック確定だと input が一切出ない。input頼みの確定処理は穴が開く。
  • それぞれが実務でどう刺さるか(Enter確定の判定、input回数を数える処理、キャンセル検知)。

この分野は2019〜2023年あたりの良い記事がいくつもあって、自分も何度も助けられました。ただ、Safariのバージョンが上がってEdgeが完全にChromiumになった今、確定まわりの出方が当時とは少しずれています。古いメモのまま書いたら、SafariとFirefoxで普通に取りこぼした。それで全部測り直しました。2026年6月時点の、手元の実測値です。


きっかけ

自作プラグインのチャットUIで、入力欄でEnterを押したら送信、という普通の処理を書いていました。日本語の変換確定のEnterと、送信のEnterを切り分けるあれです。isComposing を見ればいい、というのはもう常識になっていますが、Safariだけ送信が二重に走る現象が出ました。

最初はデバウンスの設定ミストか、自分のステート管理の問題だと思って、そっちを半日いじっていました。でも何度ログを仕込んでも、Safariのときだけ送信処理が2回呼ばれる。やっとイベントそのものを疑って、input の発火回数を数えたら、Safariは確定の瞬間に input を2回出していました。Chromeでは1回。前提にしていた「確定でinputは1回」が、そもそもブラウザで違っていたわけです。

ここで「Safariが変」で片付けてもよかったのですが、だとすると他のブラウザはどうなのか、自分のチャットUIは結局どの並びを前提に書けば安全なのか、が分からないままになる。それで、手持ちの6環境で一度きちんと測り直すことにしました。横着して1ブラウザだけ見て直すと、たいてい別のブラウザで別の穴が開くので。


測り方

入力欄ひとつに、keydown keyup compositionstart compositionupdate compositionend beforeinput input の7つのリスナーを付けただけのページです。発火順に番号・isComposinginputTypekeyCode・経過msを記録します。サーバーは要りません。各ブラウザで開いて、同じ操作をするだけ。コードは記事末に丸ごと置きます。

操作は5パターン。確定(1・2)と、取りやめ(4・5)を中心に見ます。

  1. 「あい」と打って変換し、Enterで確定
  2. 変換せずそのままEnterで確定
  3. 変換中に欄の外をクリックして確定
  4. 変換中にEscでキャンセル
  5. 変換中にBackspaceで全消しして取りやめ

測定の前提と限界

先に、この調査がカバーしていない範囲を白状しておきます。あとで「うちでは違った」となったとき、たいていここが原因なので。

測ったのは <input type="text"> だけです。contenteditable な要素やリッチエディタは、composition周りの挙動がさらに変わることが知られていて、今回は対象外にしました。IMEは、macがATOK 35、WindowsがMicrosoft IME。IMEが変わると未確定文字の出方が変わる場合があるので、たとえばGoogle日本語入力やmacの標準IMだと、細部が今回の表とずれるかもしれません。とくにmacの結果は「ATOKでの値」だと思って読んでください。OS標準IMでの追試はまだしていません。

それから、物理キーボードでローマ字入力した場合の話です。フリック入力や音声入力、サジェスト確定では、また別の並びになる可能性があります。ここも測っていません。

つまりこの記事は「6ブラウザ × 2 OS、input要素、ATOK/MS-IME、ローマ字入力」という条件での実測です。条件が動けば結果も動く前提で、気になる組み合わせは末尾のロガーで各自試してもらうのが確実です。


本題:変換してEnterで確定したときの発火順

まず、確定の瞬間に何がどの順で出るか。各ブラウザのログから、確定キー以降だけを抜き出して並べます。同じ「あい」を確定しただけなのに、ここまで違うのかと正直びっくりしました。

Chrome (macOS) / Edge (macOS)

keydown(Enter, isComposing:true, keyCode:229)
compositionupdate(愛)
beforeinput(愛, insertCompositionText, isComposing:true)
input(愛, insertCompositionText, isComposing:true)
compositionend(愛)
keyup(Enter, isComposing:false, keyCode:13)

compositionend が最後(keyupの直前)。input は確定の前に1回だけ。素直な並びです。

Chrome (Windows) / Edge (Windows)

keydown(Process, isComposing:true, keyCode:229)
compositionupdate(愛)
beforeinput(愛, insertCompositionText, isComposing:true)
input(愛, insertCompositionText, isComposing:true)
compositionend(愛)
keyup(Process, isComposing:false, keyCode:229)   ← keyupがProcess/229のまま
keyup(Escape...) 等

順序はmacと同じ。ただし keydown.keykeyup.keyProcess で、確定のkeyupでも keyCode13 ではなく 229 のままでした。Windowsでは「keyupのkeyCodeが13だから確定」という判定が効きません。

Safari (macOS)

beforeinput(-, deleteCompositionText, isComposing:true)   ← いったん未確定文字を削除
input(-, deleteCompositionText, isComposing:true)
beforeinput(愛, insertFromComposition, isComposing:true)  ← 確定文字を挿し直し
input(愛, insertFromComposition, isComposing:true)
compositionend(愛)
keydown(Enter, isComposing:false, keyCode:229)            ← キーイベントが後ろに回る
keyup(Enter, isComposing:false, keyCode:13)

Safariだけ別物でした。確定時に「未確定を消す→確定文字を入れる」の二段で動き、input が2回出ます。inputTypedeleteCompositionTextinsertFromComposition という、他のブラウザでは見ない値が入る。さらに keydown/keyupcompositionend より後ろに回ります。

Firefox (macOS)

keydown(Process, isComposing:true, keyCode:229)
beforeinput(愛, insertCompositionText, isComposing:true)
input(愛, insertCompositionText, isComposing:true)
compositionend(愛)
input(愛, insertCompositionText, isComposing:false)        ← 確定後にもう1回input
keyup(Enter, isComposing:false, keyCode:13)

Firefoxは compositionend の後に、もう1回 input が出ます。しかもこのinputだけ isComposing:false。「composition中はisComposingで弾く」処理だと、この最後のinputだけがすり抜けます。


変換をやめたとき(Escキャンセル・Backspace全消し)

確定の裏返しで、変換を取りやめたときも測りました。ここが実装でいちばん事故ります。「キャンセルされた」をどう検知するかが、ブラウザでばらつくからです。

結論から言うと、6環境すべてで共通していたのは一点だけ。キャンセル時も compositionend は必ず発火し、その event.data が空文字でした。確定したときは compositionenddata に確定文字(「愛」「あい」)が入る。やめたときは空。つまり、確定とキャンセルの最終的な見分けは、キー名でもinputTypeでもなく compositionenddata が空かどうかで付きます。これがいちばん安定した判定でした。

途中の出方はブラウザで割れます。Escキャンセルの場合だけ抜き出すと、こうなりました。

Chrome / Edge(mac・win共通)は、Escを押すと compositionupdate(data空)→ beforeinput(insertCompositionText)→ inputcompositionend(data空)。確定のときと同じ並びで、中身だけ空になります。

Safari は、beforeinput(deleteCompositionText)→ inputcompositionend(data空)。確定のときに出ていた insertFromComposition(確定文字の挿し直し)が、キャンセルでは出ません。delete だけで終わる。つまりSafariは、確定なら delete + insert、キャンセルなら delete のみ、で inputType から区別が付きます。

Firefox は、compositionupdate(data空)→ beforeinputinputcompositionend(data空)→ さらに input(isComposing:false)。確定のときと同じく、compositionend の後にもう一回 input が来ます。キャンセルでもこの追いかけinputは消えませんでした。

Backspaceで一文字ずつ消して全消しした場合も、最後の一文字が消えた瞬間に上と同じ「空のcompositionend」で終わります。違いは、消す途中で compositionupdate が「あ」→空と段階的に出ること(Backspace押下のたびにupdateが発火)。Firefox/WindowsのChrome・Edgeでは、この間の keydown.keyProcess のままでした。

ついでに、変換せずそのままEnterで確定した場合(未変換確定)も触れておくと、並びは変換確定とまったく同じで、compositionenddata が漢字ではなくかな(「あい」)になるだけでした。ここはブラウザ差がありません。


マウスで確定したとき(クリック確定)

ここがいちばんの落とし穴でした。変換中に、Enterではなく入力欄の外をマウスでクリックして確定するパターンです。日本語ユーザーは無意識にこれをやります。

Chrome と Edge(mac・win)は、クリック確定だと compositionend(data:あい)がいきなり1回だけ発火しました。Enter確定のときに前に出ていた compositionupdatebeforeinputinput も、出ません。つまりChrome系では、マウス確定のときだけ input がまったく飛ばない。

これが何を意味するか。確定処理を input に引っかけて書いていると、Chromeでマウス確定したときだけ、確定が一度も検知されません。Enterやキー操作では拾えていたのに、クリックで確定したときだけ値が取れない、という嫌な取りこぼし方をします。自分も最初これに気づかず、「たまにメッセージが空で送られる」という再現しにくいバグを抱えていました。原因はこれでした。

一方 Safari は、クリック確定でもEnter確定と同じく beforeinput(deleteCompositionText)→ inputbeforeinput(insertFromComposition)→ inputcompositionend の二段。Firefox も同じく、beforeinputinputcompositionend → 追いかけ input。SafariとFirefoxは、Enterだろうがクリックだろうが input を出してくれるので、input頼みでも一応動いてしまう。だからChromeで初めて表面化する、という気づきにくさがありました。

結論はキャンセルのときと同じです。確定の合図を input に置くと、Chromeのクリック確定で穴が開く。compositionend に寄せれば、Enter・未変換Enter・クリック・全環境で1回ずつ拾えて揃います。この記事でいちばん伝えたいのは、突き詰めるとこの一点です。


確定パターンの早見表

確定時の input 回数 compositionend と input の順 特記
Chrome (mac) 1回 input → compositionend 素直
Edge (mac) 1回 input → compositionend Chromeと一致
Chrome (win) 1回 input → compositionend keyup.keyCodeが229のまま
Edge (win) 1回 input → compositionend 同上
Safari (mac) 2回 input ×2 → compositionend delete→insertの二段、inputType特殊
Firefox (mac) 2回 compositionend → input 確定後の追加inputがisComposing:false

「inputの回数を数えて処理する」「compositionendを合図に確定処理する」系のコードは、この差で確実にズレます。


keydown.key と keyCode のブラウザ × OS 差

IME入力中(変換が確定する前)の keydown で取れる値です。

環境 keydown.key keydown.keyCode
Chrome (mac) 実際のキー(a i Enter 229
Edge (mac) 実際のキー 229
Safari (mac) 実際のキー(ただし発火が後ろ) 229
Firefox (mac) Process 229
Chrome (win) Process 229
Edge (win) Process 229

同じChromeでも、macは生のキー、Windowsは Process を返す。エンジン(Chromium)ではなくOSで割れる、というのが実測のいちばんの驚きでした。

keyCode はIME入力中、全6環境で一貫して 229 でした。if (e.keyCode === 229) での判定は今も全環境で動くものの、keyCode 自体が非推奨です。新しく書くなら isComposing を使うのが筋です。

なぜ 229 で、なぜ Process なのか

この 229Process は、出どころが同じです。OSがキー入力をIMEに渡して処理させている最中、ブラウザはそのキーが何の文字になるかをまだ確定できません。そこで「いまIMEが処理中」を表す番兵の値を返します。それが keyCode では 229KeyboardEvent.key では "Process" です。古い記事で見かける keyCode === 229 判定は、この番兵を拾ってIME入力中かどうかを当てる手法でした。

ただ keyCode は仕様上すでに deprecated で、いずれ消える前提の値です。代わりに用意されたのが KeyboardEvent.isComposing で、2016年あたりのモダンブラウザから入りました。今回の実測でも、IME入力中の keydown は6環境すべてで isComposing:true を返したので、判定はこちらに寄せて問題ありません。番兵を覚えておく必要はもうない、ということです。

keyProcess になるか実際のキー名になるかは、上で見たとおりOSで割れました。仕様としては「IMEが処理しているなら Process」が正で、macのChrome・Edge・Safariが生のキー名を返すほうが、むしろ仕様から外れている挙動です。なので「macは親切に実キーをくれる」とあてにせず、key の値はIME入力中は信用しない、と決めておくのが安全でした。


isComposing はどこで true / false になるか

compositionstart から compositionend までが true、というのが基本仕様で、6環境ともそこは守られていました。ただし境界に癖があります。

確定キーの keydown の時点では、6環境とも isComposing:true。これは助かります。「keydownのisComposingがtrueなら変換確定のEnterなので送信しない」が、全環境で正しく効きます。Enter送信を切り分けたいなら、ここがいちばん安定した判定点でした。

注意は確定後です。Firefoxの確定後の追加 inputisComposing:false、Safariの確定の input(insertFromComposition)は isComposing:true。inputイベント側で isComposing を見る場合、ブラウザで真偽が割れるので、確定の検知はinputではなくkeydownかcompositionendに寄せるのが安全でした。


実務でハマる3点(自分が踏んだ順)

ひとつ目、Safariの二重input。確定時にinputが2回出るので、「inputのたびに送信候補を組み立てる」処理がSafariだけ2回走りました。最初の送信二重化はこれが原因でした。確定の合図はinputではなく compositionend(か、keydownのisComposing判定)に寄せたら直りました。

ふたつ目、Firefoxの確定後input。compositionend で確定処理を済ませた後に isComposing:false のinputがもう1回来るので、ここで値を読み直す処理を入れていると、Firefoxだけ一回多く走ります。compositionend の中で完結させて、その直後のinputは無視する、で揃いました。

みっつ目、Enter送信の切り分け。keydownisComposing を見る、が結論として全環境で一番安定でした。Windowsで keyCode が229のままだったり、keyProcess だったりするので、key名やkeyCodeでの分岐はやめて、isComposing 一本に倒すのが事故りませんでした。

field.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.isComposing) {
    // 変換確定ではないEnter。ここで送信
    send();
  }
});

e.key === 'Enter' の判定は、Windows/Firefoxで keydown.keyProcess になる間は通らず、確定しきって isComposing:false になった本物のEnterだけ通ります。結果的に、これがいちばん素直に効きました。

そして、入力が確定したのか取りやめられたのかまで知りたいなら、compositionenddata を見ます。さっきの「やめたときは空」がそのまま効きます。全環境で共通だったので、ここはブラウザ分岐が要りません。

// 確定とキャンセルを分けて拾う。compositionend.data が空ならキャンセル。
field.addEventListener('compositionend', (e) => {
  if (e.data) {
    // e.data が確定文字(「愛」「あい」など)。ここで確定処理を1回だけ行う
    onCommit(e.data);
  } else {
    // 空=Escや全消しで取りやめた。確定処理は走らせない
    onCancel();
  }
});

大事なのは、確定処理を input ではなく compositionend に置くことです。Safariは確定時に input が2回、Firefoxは compositionend の後にもう1回 input が来るので、inputを数える設計だとその差をまともに食らいます。さらにChrome/Edgeはマウスのクリック確定で input を一切出さないので、input頼みだとその確定を丸ごと取りこぼします。compositionend は確定でもキャンセルでも、Enterでもクリックでも、各操作につき1回だけ。ここに寄せると、6環境で回数が揃いました。最初に踏んだSafariの二重発火も、Chromeのクリック確定の取りこぼしも、これで両方消えました。

Reactなどで onChange(中身は input)を使っているときも考え方は同じです。isComposing 中の input は値の確定とみなさず状態だけ更新し、確定の確定は compositionend で取る。これで未確定文字が確定値として漏れるのを防げます。

やりたいこと別・どのイベントを見るか

今回の6環境ぶんを踏まえて、目的ごとに「これを見ておけば全環境で揃う」というのを表にしました。自分が次にチャットUIを書くときの早見表でもあります。

やりたいこと 見るもの なぜ
Enterでの送信と変換確定を分ける keydownisComposing 確定キーのkeydownは全環境でtrue。key名やkeyCodeはOSで割れる
入力が確定した値を取る compositionenddata 各操作で1回だけ。inputの回数はブラウザで割れる
確定とキャンセルを見分ける compositionend.data が空かどうか 空ならEsc/全消し。全環境共通
変換中の途中文字を表示する compositionupdatedata キー入力ごとに最新の未確定文字が入る
IME入力中かを常時持っておく compositionstart/compositionend で自前フラグ isComposing の境界が確定後にぶれる環境があるため、フラグ管理が確実

最後の行だけ補足します。isComposing はだいたい信用できるのですが、確定直後のinput(Firefoxの追いかけinputや、Safariのinsert)でtrue/falseがブラウザでぶれます。なので「いまIME中か」を厳密に持ちたいなら、compositionstart で自分のフラグを立て、compositionend で倒す、という昔ながらのやり方が、結局いちばん事故りませんでした。イベントの isComposing を毎回読むより、自前フラグのほうが安定する場面がまだ残っています。

let composing = false;
field.addEventListener('compositionstart', () => { composing = true; });
field.addEventListener('compositionend',   () => { composing = false; });
// どこからでも composing で「IME中か」を判定できる

Chromium系(Vivaldi / Brave)と Firefox(Windows) について

手元のVivaldi・Braveはまだ測り切れていません。中身がChromiumなので、おそらくOSに従ってmacはChrome(mac)、WindowsはChrome(win)と同じ並びになるはずですが、「はず」で書くのは趣旨に反するので、測れたら追記します。Firefox(Windows)も同様に未確認です。ここは自信のない箇所として残しておきます。


検証に使ったロガー

単一HTMLです。保存してブラウザで開き、入力欄に「あい」と打って各パターンを試すと、発火順のログが出ます。「結果をコピー」でタブ区切りテキストになります。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IME入力イベント検証ロガー</title>
<style>
  :root {
    --bg: #14161a;
    --panel: #1c1f26;
    --line: #2a2f3a;
    --ink: #e6e9ef;
    --muted: #9aa3b2;
    --accent: #ffd166;
    --true: #6ee7a0;
    --false: #6b7280;
    --kbd: #7cc4ff;
    --comp: #ffd166;
    --inp: #f4a8d0;
    --beforeinp: #c4a8f4;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0; background: var(--bg); color: var(--ink);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Courier New", monospace;
    line-height: 1.5; padding: 20px;
  }
  h1 { font-size: 18px; margin: 0 0 4px; }
  .sub { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
  .env {
    background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
    padding: 12px 14px; margin-bottom: 16px; font-size: 13px; color: var(--muted);
    word-break: break-all;
  }
  .env b { color: var(--ink); }
  .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
  label.field { font-size: 13px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; }
  input[type=text] {
    background: #0e1014; border: 1px solid var(--line); color: var(--ink);
    border-radius: 6px; padding: 10px 12px; font: inherit; font-size: 15px; min-width: 280px;
  }
  input[type=text]:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
  button {
    background: #262b34; color: var(--ink); border: 1px solid var(--line);
    border-radius: 6px; padding: 9px 14px; font: inherit; font-size: 13px; cursor: pointer;
  }
  button:hover { border-color: var(--accent); }
  button.primary { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 700; }
  .hint { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; font-size: 13px; }
  .hint ol { margin: 6px 0 0; padding-left: 20px; }
  .hint li { margin: 3px 0; }
  table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
  th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; }
  th { color: var(--muted); font-weight: 600; position: sticky; top: 0; background: var(--bg); }
  .logwrap { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: auto; max-height: 60vh; }
  .ev { font-weight: 700; }
  .ev-keydown, .ev-keyup { color: var(--kbd); }
  .ev-compositionstart, .ev-compositionupdate, .ev-compositionend { color: var(--comp); }
  .ev-input { color: var(--inp); }
  .ev-beforeinput { color: var(--beforeinp); }
  .t { color: var(--true); }
  .f { color: var(--false); }
  .n { color: var(--muted); }
  .seq { color: var(--muted); }
  .marker td { background: #0e1014; color: var(--accent); text-align: center; font-style: italic; }
  textarea {
    width: 100%; height: 140px; margin-top: 12px; background: #0e1014; color: var(--ink);
    border: 1px solid var(--line); border-radius: 8px; padding: 12px; font: inherit; font-size: 12px;
  }
</style>
</head>
<body>
  <h1>IME入力イベント検証ロガー</h1>
  <p class="sub">日本語を入力・変換・確定して、発火するイベントとプロパティを発火順に記録します。サーバー不要。各ブラウザで同じ操作をして結果を見比べてください。</p>

  <div class="env" id="env">User Agent: 読み込み中…</div>

  <div class="hint">
    <b>計測の手順(5パターンを順番に。各パターンの前に「区切りを挿入」を押すとログが見やすくなります)</b>
    <ol>
      <li>「あい」と打って <b>変換 → Enterで確定</b></li>
      <li>「あい」と打って <b>変換せずそのままEnterで確定</b></li>
      <li>「あい」と打って <b>変換中にマウスで欄の外をクリックして確定</b></li>
      <li>「あい」と打って <b>変換中にEscでキャンセル</b></li>
      <li>「あい」と打って <b>Backspaceで全部消して取りやめ</b></li>
    </ol>
  </div>

  <div class="controls">
    <label class="field">入力欄(ここに日本語を打つ)
      <input type="text" id="field" autocomplete="off" placeholder="ここで日本語を入力・変換・確定" />
    </label>
    <button id="marker">区切りを挿入</button>
    <button id="clear">ログをクリア</button>
    <button class="primary" id="copy">結果をコピー</button>
  </div>

  <div class="logwrap">
    <table>
      <thead>
        <tr>
          <th>#</th><th>event</th><th>key / data</th>
          <th>isComposing</th><th>inputType</th><th>keyCode</th><th>+ms</th>
        </tr>
      </thead>
      <tbody id="log"></tbody>
    </table>
  </div>

  <textarea id="out" readonly placeholder="「結果をコピー」を押すと、ここに貼り付け用のテキストが入ります(記事の表のもとになります)"></textarea>

<script>
(function () {
  var field = document.getElementById('field');
  var logBody = document.getElementById('log');
  var out = document.getElementById('out');
  var seq = 0;
  var last = null;
  var rows = []; // 保存用

  document.getElementById('env').innerHTML =
    '<b>User Agent:</b> ' + navigator.userAgent +
    '<br><b>記録開始:</b> ' + new Date().toLocaleString();

  function val(v) {
    if (v === true) return '<span class="t">true</span>';
    if (v === false) return '<span class="f">false</span>';
    if (v === undefined || v === null || v === '') return '<span class="n">—</span>';
    return v;
  }

  function logEvent(e) {
    seq++;
    var now = performance.now();
    var delta = last === null ? 0 : Math.round(now - last);
    last = now;

    var isComp =
      (typeof e.isComposing === 'boolean') ? e.isComposing : undefined; // KeyboardEvent / InputEvent
    var inputType = (e.inputType !== undefined) ? e.inputType : undefined;
    var keyCode = (e.keyCode !== undefined && (e.type === 'keydown' || e.type === 'keyup')) ? e.keyCode : undefined;

    // data: composition系は e.data、key系は e.key
    var dataField;
    if (e.type.indexOf('composition') === 0) dataField = e.data;
    else if (e.type === 'input' || e.type === 'beforeinput') dataField = e.data;
    else dataField = e.key;

    rows.push({
      seq: seq, type: e.type, data: dataField,
      isComposing: isComp, inputType: inputType, keyCode: keyCode, delta: delta
    });

    var tr = document.createElement('tr');
    tr.innerHTML =
      '<td class="seq">' + seq + '</td>' +
      '<td class="ev ev-' + e.type + '">' + e.type + '</td>' +
      '<td>' + val(dataField) + '</td>' +
      '<td>' + val(isComp) + '</td>' +
      '<td>' + val(inputType) + '</td>' +
      '<td>' + val(keyCode) + '</td>' +
      '<td class="n">' + delta + '</td>';
    logBody.appendChild(tr);
    tr.scrollIntoView({ block: 'nearest' });
  }

  // beforeinput は input の前に発生。順序確認のため両方取る
  ['keydown', 'keyup', 'compositionstart', 'compositionupdate',
   'compositionend', 'beforeinput', 'input'].forEach(function (type) {
    field.addEventListener(type, logEvent);
  });

  document.getElementById('marker').addEventListener('click', function () {
    rows.push({ marker: true });
    var tr = document.createElement('tr');
    tr.className = 'marker';
    tr.innerHTML = '<td colspan="7">— 区切り —</td>';
    logBody.appendChild(tr);
    last = null;
  });

  document.getElementById('clear').addEventListener('click', function () {
    rows = []; seq = 0; last = null; logBody.innerHTML = ''; out.value = '';
  });

  document.getElementById('copy').addEventListener('click', function () {
    var lines = [];
    lines.push('UA: ' + navigator.userAgent);
    lines.push('time: ' + new Date().toISOString());
    lines.push('seq\tevent\tkey/data\tisComposing\tinputType\tkeyCode\t+ms');
    rows.forEach(function (r) {
      if (r.marker) { lines.push('--- 区切り ---'); return; }
      function s(v) { return (v === undefined || v === null || v === '') ? '-' : v; }
      lines.push([r.seq, r.type, s(r.data), s(r.isComposing), s(r.inputType), s(r.keyCode), r.delta].join('\t'));
    });
    out.value = lines.join('\n');
    out.select();
    try { document.execCommand('copy'); } catch (err) {}
  });
})();
</script>
</body>
</html>

次の自分に渡すメモ

日本語入力まわりは、「isComposingを見れば大丈夫」で片付けてきたけれど、確定の瞬間のinputの回数と順序は、ブラウザでまだこれだけ割れている。次に同じ実装をするときは、確定の合図をinputに置かない。keydownのisComposingか、compositionendに寄せる。これだけ守れば、たぶんまた半日溶かさずに済む。

数字は2026年6月時点の、手元の実測です。しかもmacはATOK、Windowsは Microsoft IME、input要素、ローマ字入力、という限られた条件での値です。ブラウザのバージョンが上がっても、IMEが違っても、たぶん細部は変わります。だから鵜呑みにせず、気になったら同じロガーで自分の環境を測り直してください。ロガーごと置いておくので、別のIMEやcontenteditableで試した結果をコメントで教えてもらえると、この表がもっと正確になります。むしろ、それを集めるためにロガーを同梱したようなものです。

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?