|
| 1 | +--- |
| 2 | +title: "なぜ日本語入力と相性の悪いWebサイトができてしまうのか" |
| 3 | +date: 2026/01/20 00:00:00 |
| 4 | +postid: a |
| 5 | +tag: |
| 6 | + - IME |
| 7 | + - HTML |
| 8 | + - Web |
| 9 | +category: |
| 10 | + - Programming |
| 11 | +thumbnail: /images/2026/20260120a/thumbnail.png |
| 12 | +author: 澁川喜規 |
| 13 | +lede: "海外製のチャットサービスとかでIMEを確定しようとしたら勝手に送信されてしまって困った!という経験をしたかは多いでしょう。日本語入力はIMEを通じて行われますが、イベントのハンドリングを間違うとこのような挙動になってしまいます。ウェブフロントエンドのIMEにまつわる2つのトピックを紹介します。* Enterで勝手に送信しちゃう* IMEの最初の文字が取れない" |
| 14 | +--- |
| 15 | +海外製のチャットサービスとかでIMEを確定しようとしたら勝手に送信されてしまって困った! という経験をしたかは多いでしょう。日本語入力はIME(Input Method Editor)を通じて行われますが、イベントのハンドリングを間違うとこのような挙動になってしまいます。ウェブフロントエンドのIMEにまつわる2つのトピックを紹介します。 |
| 16 | + |
| 17 | +* Enterで勝手に送信しちゃう |
| 18 | +* IMEの最初の文字が取れない |
| 19 | + |
| 20 | +# Enterで勝手に送信されちゃう |
| 21 | + |
| 22 | +`<input>`でテキスト入力を作り、チャットを作りたいとします。わざわざボタンをクリックしないと送信できないのは不便なのでEnterで送信できるようにしようとします。changeイベントやblurではフォーカスを一度外さないと確定にならないため、keydownイベントでEnterを拾って送信したいとします。こんな感じでしょうか?もう令和8年なのでReact Compilerを想定してシンプルに書いています。 |
| 23 | + |
| 24 | +```tsx |
| 25 | +const [text, setText] = useState(""); |
| 26 | + |
| 27 | +function checkEnter(e) { |
| 28 | + if (e.key === "Enter") { |
| 29 | + sendMessage(text); // 送信! |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +<input |
| 34 | + type="text" |
| 35 | + value={text} |
| 36 | + onChange={(e) => setText(e.target.value)} |
| 37 | + onKeyDown={checkEnter} |
| 38 | +/> |
| 39 | +``` |
| 40 | + |
| 41 | +これを実際に動かしてみると、日本語確定のEnterで送信されてしまいました。 |
| 42 | + |
| 43 | +「はい(変換確定)」と入力し、送信用にEnterを押すと<input>には次のようなイベントが流れてきます。 |
| 44 | + |
| 45 | +1. `[h]`押下 |
| 46 | + * input: value=h |
| 47 | + * keydown: key=h |
| 48 | +2. `[a]`押下 |
| 49 | + * input: value=は |
| 50 | + * keydown: key=a |
| 51 | +3. `[i]`押下 |
| 52 | + * input: value=はい |
| 53 | + * keydown: key=i |
| 54 | +4. `[Enter]`押下で確定 |
| 55 | + * input: value=(empty) |
| 56 | + * input: value=はい |
| 57 | + * keydown: key=Enter →ここで送信されちゃう! |
| 58 | +5. `[Enter]`押下で送信したかった |
| 59 | + * keydown: key=Enter |
| 60 | + * change: value=はい |
| 61 | + |
| 62 | +これを見れば、keydownイベントでとるのではなく、changeイベントで送信すれば一見良さそうです。しかしそれではうまくいきません。Slackとかもそうですが、だいたい「送信ボタン」がありつつも、ショートカットとして「Enter」送信を許容しています。 |
| 63 | + |
| 64 | +<img src="/images/2026/20260120a/スクリーンショット_2026-01-14_16.05.15.png" alt="スクリーンショット_2026-01-14_16.05.15.png" width="492" height="135" loading="lazy"> |
| 65 | + |
| 66 | +送信確定前にちょっと別のチャンネルを見て確認しておこうと別の要素にフォーカスしたりすると次のようなイベント発生されます。 |
| 67 | + |
| 68 | +6. `[Enter]`を押さずに別の要素をフォーカス |
| 69 | + * change: value=はい |
| 70 | + * blur: value=Enter |
| 71 | + |
| 72 | +このchangeではまだ送りたくはないですが、送信するかどうかはこのあとにblurが起きるかどうかで判定が必要です。が・・・なんて後の時系列で起きるイベントの判定ロジックなんて実装したくはないですよね。 |
| 73 | + |
| 74 | +IMEに関するイベントや情報がHTMLにはあります。IMEを使った変換の開始と確定後にはcompositionstartとcompositionendイベントが発火します。また、inputイベントやkeydownイベントには`isComnposing`という属性が付きます。Reactであれば、``e.nativeEvent.isComposing``といった感じで情報が取れます。この2つも足すと以下のようなイベントが流れてきます。 |
| 75 | + |
| 76 | +1. `[h]`押下 |
| 77 | + * compositionstart |
| 78 | + * input: value=h isComposing=true |
| 79 | + * keydown: key=h isComposing=true |
| 80 | +2. `[a]`押下 |
| 81 | + * input: value=は isComposing=true |
| 82 | + * keydown: key=a isComposing=true |
| 83 | +3. `[i]`押下 |
| 84 | + * input: value=はい isComposing=true |
| 85 | + * keydown: key=i isComposing=true |
| 86 | +4. `[Enter]`押下で確定 |
| 87 | + * input: value=(empty) isComposing=true |
| 88 | + * input: value=はい isComposing=true |
| 89 | + * compositionend: value=はい |
| 90 | + * keydown: key=Enter isComposing=false // ここに注目 |
| 91 | +5. `[Enter]`押下で送信 |
| 92 | + * keydown: key=Enter isComposing=false |
| 93 | + * change: value=はい |
| 94 | + |
| 95 | +残念ながら、最後の確定のEnterではisComposing=falseなので、ステートレスに判定はできません。しかし、compositionendからすぐ後にkeydownが発生するので、最後のcompositionendイベントでタイムスタンプを取得し、そこから短時間(20mSとか)以内のEnterは除外するというロジックにすればOKです。 |
| 96 | + |
| 97 | +`isComposing`属性関係ないじゃん! と思われるかもしれませんが、グリッドコントロールとかで確定済み状態でのカーソルやタブでアクティブなセルを移動したい、みたいなケースはあるかと思います。Enter以外のキーであれば、isComposing=trueの時のこれらのキーは無視する、というロジックにすればOKです。 |
| 98 | + |
| 99 | +# キー入力でラベルの変更するが変換前の文字が入ってしまう |
| 100 | + |
| 101 | +PowerPointのオブジェクト編集機能のようなものを作りたいとします。マウスクリックなどでフォーカスした要素に対し、キーボード入力をするとそのラベルの変更ができ、確定するとそのラベルが設定されます。よくある機能なのでイメージしやすいですよね?前項の確定部分の話はちょっとここでは除外してonChangeで確定としておきます。こんな感じでしょうか? |
| 102 | + |
| 103 | +```tsx |
| 104 | +const divRef = useRef(null); |
| 105 | +const [editing, setEditing] = useState(false); |
| 106 | +const [label, setLabel] = useState("ラベル"); |
| 107 | + |
| 108 | +function focus() { |
| 109 | + difRef.current?.focus(); |
| 110 | + e.preventDefault(); |
| 111 | +} |
| 112 | + |
| 113 | +function keydown(e) { |
| 114 | + if (!editing) { |
| 115 | + setLabel(e.key); |
| 116 | + setEditing(true); |
| 117 | + } |
| 118 | + e.preventDefault(); |
| 119 | +} |
| 120 | + |
| 121 | +function fix(e) { |
| 122 | + setLabel(e.value); |
| 123 | + setEditing(false); |
| 124 | +} |
| 125 | + |
| 126 | +<div ref={divRef} |
| 127 | + onClick={focus} |
| 128 | + onKeyDown={keydown} |
| 129 | +> |
| 130 | + {editing ? <input value={label} onChange={fix} /> : label} |
| 131 | +</div> |
| 132 | +``` |
| 133 | + |
| 134 | +これ、そのまま動かしてみると「あいうえお」と入力すると「aいうえお」となっちゃうんですよね。keydownでは変換前の文字が入ってしまうので。inputイベントにすると、あ行だけはうまくいきますが、子音を入力すると同じ結果になります。変換途中の情報はフォーカスと一緒で、controlled componentとして外から状態を与えられないので、1文字目が変換中、という状態を外から作り出すことはできません。 |
| 135 | + |
| 136 | +あと、ここでは毎回リセットになっていますが、カーソル移動で末尾だけ編集したいみたいなものを実現するのは大変です。 |
| 137 | + |
| 138 | +代わりに、選択時に非表示のinputタグを作って、全選択状態でフォーカスしておきます。そして何かしらのイベントが発生したらそのinputタグを表示するというやり方にします。IMEが関連するテキスト入力は1つのinputタグが責任を持って最初の文字から全て受ける必要があります。クリックやカーソルでの適切な位置のカーソル移動もinputタグ任せにできるため、最小工数で自然な入力が実現できるでしょう。 |
| 139 | + |
| 140 | +```tsx |
| 141 | +const inputRef = useRef(null); |
| 142 | +const [select, setSelect] = useState(false); |
| 143 | +const [editing, setEditing] = useState(false); |
| 144 | +const [label, setLabel] = useState("ラベル"); |
| 145 | + |
| 146 | +function focus() { |
| 147 | + inputRef.current?.focus(); |
| 148 | + inputRef.current?.setSelectionRange(label.length, label.length); |
| 149 | + e.preventDefault(); |
| 150 | +} |
| 151 | + |
| 152 | +function show(e) { |
| 153 | + if (!editing) { |
| 154 | + setEditing(true); |
| 155 | + } |
| 156 | + e.preventDefault(); |
| 157 | +} |
| 158 | + |
| 159 | +function fix(e) { |
| 160 | + setLabel(e.value); |
| 161 | + setSelect(false); |
| 162 | + setEditing(false); |
| 163 | +} |
| 164 | + |
| 165 | +let inputStyle = ""; |
| 166 | +if (editing) { |
| 167 | + inputStyle = ""; |
| 168 | +} else if (select) { |
| 169 | + inputStyle = "position: absolute; left: -9999px;" |
| 170 | +} else { |
| 171 | + inputStyle = "display: hidden;" |
| 172 | +} |
| 173 | + |
| 174 | +<div ref={divRef} |
| 175 | + onClick={focus} |
| 176 | + onKeyDown={keydown} |
| 177 | +> |
| 178 | + <input ref={inputRef} style={inputStyle} value={label} onChange={fix} onKeyDown={show} onClick={show} /> |
| 179 | + {isEditing ? undefined : label} |
| 180 | +</div> |
| 181 | + |
| 182 | +``` |
| 183 | + |
| 184 | +# まとめ: キーボード入力は使い勝手に直結する |
| 185 | + |
| 186 | +どちらも明確な「送信ボタン」「編集ボタン」を用意すれば用意して、操作する人に対して1アクション余計にしてもらえば良いだけの話ですが、世の中に便利なものがあるなら、それと近づけてほしいと思うのが人情というものです。 |
| 187 | + |
| 188 | +試験的なプログラムでその1アクションを減らすのに、生成AIに雑に指示を投げてもなかなか問題を解決してくれなくてちょっと苦戦したのでメモとしてブログにしておきます。 |
| 189 | + |
| 190 | +# 生成AIに指示するなら? |
| 191 | + |
| 192 | +シンプルな機能であればWhatを指示することで賢いモデルであればきちんと実装してくれるのですが、過去の経験上、このIME周りのハンドリングは必ずしもうまくいかないですね。Howで実装方法を指定してあげる必要があります。 |
| 193 | + |
| 194 | +前者のEnterのハンドリングを指示する場合は... |
| 195 | + |
| 196 | +> `compositionend`のタイムスタンプを取得し、keydownでのEnterの判定で変換直後の物は除外してください |
| 197 | +
|
| 198 | +...といった感じで指示すればうまくいくでしょう。 |
| 199 | + |
| 200 | +後者の方はやや難しいですが... |
| 201 | + |
| 202 | +> オブジェクトの状態には選択モード、編集モードがあります。選択モードでは見えないinputタグを作り、これまでのラベルのテキストを入れた上で全選択状態としてください。その後何かしらのキー入力で編集モードになります。編集モードではinputタグが見えるようになります |
| 203 | +
|
| 204 | +...といった感じの指示の必要があるでしょう。前者の確定のEnter除外を組み合わせたい場合は別途追加で指示が必要です。ESCでrevertとかいろいろ機能を入れようとするとそれなりに複雑化しますが、最初のIMEハンドリングがきちんとできていればなんとかなるはずです。 |
0 commit comments