SmartMemoの開発中、Markdownエディタのプレビュー更新処理を戻している途中で、日本語入力時のIMEプレビューに問題があることに気づきました。
今回は、その問題を調査して修正した過程を記録しておきます。
今回の環境
SmartMemoでは、Markdownの入力にCodeMirrorを使用しています。
入力したMarkdownはリアルタイムでプレビューへ反映するようにしています。
今回問題になったのは、
- CodeMirror
- JavaScript
- 日本語IME
- Markdownプレビュー
の組み合わせです。
1. まず lastStableText is not defined が発生
IME入力の状態を管理するために、compositionstart、compositionupdate、compositionend を使っていました。
ところが、コンソールに次のエラーが出ました。
Uncaught ReferenceError: lastStableText is not defined
原因は単純で、lastStableTextを宣言していませんでした。
そこで、IMEの状態を管理する変数として以下を追加しました。
let isComposing = false;
let lastStableText = "";
これで、IME開始時の確定済み本文を保存できるようにしました。
2. IME入力中の本文を保存する
compositionstartでは、IME入力が始まった時点の本文を保存します。
editor.getInputField().addEventListener('compositionstart', () => {
isComposing = true;
lastStableText = editor.getValue();
console.log(
'IME開始時の確定済みの本文:',
JSON.stringify(lastStableText)
);
});
これで、IME入力中にCodeMirrorの値が変化しても、IME開始時点の本文を基準にプレビューを作れるようにしました。
3. 最初は末尾への入力なら問題なく動いた
compositionupdateでは、IME入力中の文字を使ってプレビューを更新していました。
最初は、
const previewText = lastStableText + event.data;
という形で試しました。
例えば、
日本語変換
の末尾で日本語を入力すると、IME入力中の文字がプレビューに表示されます。
この時点では、
「これで直ったのでは?」
と思いました。
しかし、途中にカーソルを移動して入力すると問題が発生しました。
4. 文章の途中に入力するとプレビューがずれる
例えば、本文が、
日本語交換だったとします。
カーソルを、日本語|交換の位置に移動して、IMEから文字を入力します。
実際のCodeMirrorの本文は、日本語 交換となりました。
ところが、IME入力中のプレビューでは、
日本語交換 のように、入力した文字が末尾に追加されてしまいました。
ここで、
「実際の入力位置とプレビューの入力位置が違う」ということに気づきました。
5. 原因は lastStableText + event.data
原因を考えてみると、かなり単純でした。
const previewText = lastStableText + event.data;
この処理では、必ず event.data を文字列の最後に追加しています。
つまり、カーソルが本文の途中にあっても、
本文 + IME入力
となってしまいます。
実際のエディタでは、
日本語|交換
の位置に入力しているので、
日本語 + IME入力 + 交換
という形にする必要があります。
6. カーソル位置を取得する
そこで、CodeMirrorから現在のカーソル位置を取得しました。
const cursor = editor.getCursor();
ただし、getCursor()で取得できるのは行と列の情報です。
そこで、文字列上のインデックスに変換します。
const cursorIndex = editor.indexFromPos(cursor);
これで、現在のカーソル位置を文字列の位置として扱えるようになりました。
7. 本文をカーソル位置の前後に分ける
次に、lastStableTextをカーソル位置で分割します。
const beforeCursor = lastStableText.slice(0, cursorIndex);
const afterCursor = lastStableText.slice(cursorIndex);
例えば、
日本語交換
の、
日本語|交換という位置なら、
beforeCursorは、日本語となります。
そして、
afterCursorは、交換となります。
8. IME入力をカーソル位置に挿入する
あとは、この3つを組み合わせます。
const previewText = beforeCursor + event.data + afterCursor;
つまり、
カーソルより前
+
IME入力中の文字
+
カーソルより後
という形です。
最終的なcompositionupdateは、このようになりました。
editor.getInputField().addEventListener('compositionupdate',(event) =>{
console.log(
'確認済み本文:',
JSON.stringify(lastStableText)
);
console.log(
'IME交換中',
JSON.stringify(event.data)
);
//現在のカーソル位置を取得
const cursor = editor.getCursor();
//カーソル位置を文字列上のインデックスに変換
const cursorIndex = editor.indexFromPos(cursor);
//カーソル位置の前後に分割
const beforeCursor = lastStableText.slice(0,cursorIndex);
const afterCursor = lastStableText.slice(cursorIndex);
//IME入力中の文字をカーソル位置に挿入
const previewText = beforeCursor + event.data + afterCursor;
console.log(
'プレビュー用:',
JSON.stringify(previewText)
);
updatePreview(previewText);
});
9. 最終確認
もう一度、
日本語|交換
の位置にカーソルを置いてIME入力を行いました。
コンソールには、
確認済み本文: "日本語交換"
IME交換中 " "
プレビュー用: "日本語 交換"
と表示されました。
これで、文章の途中にカーソルを置いた場合でも、IME入力中の文字が正しい位置に表示されるようになりました。
- compositionendでは確定後の本文を保存
IME入力が確定した後は、CodeMirrorから現在の本文を取得してlastStableTextを更新します。
editor.getInputField().addEventListener('compositionend', () => {
isComposing = false;
lastStableText = editor.getValue();
console.log(
'IME確定後の本文:',
JSON.stringify(lastStableText)
);
});
これで、次のIME入力に備えて最新の確定済み本文を保持できます。
今回分かったこと
今回のデバッグで一番大きかったのは、
「実際のエディタへの入力」と「IME入力中に表示しているプレビュー」は別物として考える必要がある
ということでした。
CodeMirror自体への入力は、実は正しく動いていました。
問題だったのは、IME入力中に一時的に作っているプレビューの文字列でした。
最初は、
lastStableText + event.data
としていたため、入力位置に関係なく文字が末尾に追加されていました。
そこで、
lastStableText
↓
カーソル位置で分割
↓
beforeCursor + event.data + afterCursor
という形に変更することで解決できました。
まとめ
今回の流れをまとめると、
- Markdownプレビュー更新を戻す
- 日本語IME入力で問題を発見
- lastStableText is not defined が発生
- lastStableTextを追加
- 末尾でのIME入力が正常に動くことを確認
- 文章の途中で入力するとプレビューがずれることを発見
- lastStableText + event.data が原因だと判明
- CodeMirrorからカーソル位置を取得
- 本文をbeforeCursorとafterCursorに分割
- beforeCursor + event.data + afterCursorに変更
- 文章の途中でも正しい位置にIME入力が表示されることを確認
という流れでした。
今回のデバッグはかなり時間がかかりましたが、ログを一つずつ確認していくことで、
「入力そのものは正しい。プレビュー側の一時的な文字列生成だけが間違っている」
というところまで切り分けることができました。
今回のデバッグでは、AIにも相談しながら原因を切り分けました。
エラーメッセージの確認や、IMEイベントの動き、CodeMirrorのカーソル位置の扱いについてAIとやり取りしながら、実際のコンソールログを確認して少しずつ修正していきました。
特に、
lastStableText + event.data
ではIME入力が常に末尾に追加されてしまうことに気づき、
beforeCursor + event.data + afterCursor
という形に変更することで、カーソル位置への入力をプレビューに反映できました。
今回のようなデバッグでは、AIから提案された内容をそのまま使うのではなく、実際にコードを変更して動作を確認しながら進めました。AIに相談しながらログを見て原因を一つずつ切り分けていく方法がかなり勉強になりました。