Copyright © 2023 - All right reserved by Junpei K.

Photo by Martin Sanchez

    🖌️ Table of Contents

    🖌️ TOC

  1. 実装
    実装

【React】ユーザが入力した文字数の表示(「コメント行」考慮つき)

ReactとMUIのTextFieldで、ユーザが入力した文字数を表示させてみる。

なお、

  • アルファベット、2バイト文字といった文字の種類に関わらず「1字」を正確にカウントする
    • ただし改行文字は1字としてカウントしない
  • JavaScriptのコメント行のように「//」で始まる行は「コメント行」として 文字数カウントの対象外にする
    • VSCodeのように「Ctrl+/」キー(Macなら「Cmd+/」キー)のショートカットで、カーソルが当たっている行をコメント行にする (コメント行になっていたらそれをやめる)

という要件があった場合の実装を(ChatGPTと一緒に)考えてみた。

実装

まず『アルファベット、2バイト文字といった文字の種類に関わらず「1字」を正確にカウントする』という要件を満たすために、を用いている(上記34行目)。

は、引数に文字列を受け取るとUnicodeのコードポイントに基づいて1字ずつを要素とした配列に分解する。これにより、全角文字や2バイト文字も正確に1文字としてカウントできる。

(↑とChatGPTに言われたのでV8の実装を調べてみたのだが本当かどうかはわからなかった、ごめん・・)

次に『「//」で始まる行は「コメント行」として 文字数カウントの対象外にする』の要件は、countCharactersの中で実現している(29〜33行目)。

具体的な処理として、から始まる行を対象外にするためにテキストを行ごとに分解し(29行目)、その各行がで始まるかどうかを確認する(31行目)。その上で、で始まらない行だけを文字数カウントの対象としている(30〜32行目)。

1const lines = str.split("\n");
2const linesWithoutComments = lines.filter(
3  (line) => !line.trim().startsWith("//")
4);
5

最後の『「Ctrl+/」キーのショートカットで、カーソルが当たっている行をコメント行にする』要件はuseEffectを使って実現した(42〜88行目)。

まず45行目で『「Ctrl+/」キー(Macなら「Cmd+/」キー)が押された場合』に絞る。

1if ((event.ctrlKey || event.metaKey) && event.key === "/") {
2  ...
3

その上でブラウザにデフォルトで定義されているキーボードイベントをキャンセルする。これにより、ブラウザのデフォルトのキーボードショートカットが発火しないようにする。

1event.preventDefault();
2

次にイベントのターゲットをHTMLTextAreaElementとして取得する。これにより、テキストエリアの現在の状態にアクセスできる。

1const textarea = event.target as HTMLTextAreaElement;
2

そしてテキストエリア内での現在の選択範囲の開始位置と終了位置を取得する。選択範囲が存在しない場合は0を代入する。

1const selectionStart = textarea.selectionStart !== null ? textarea.selectionStart : 0;
2const selectionEnd = textarea.selectionEnd !== null ? textarea.selectionEnd : 0;
3

テキストエリアの現在のテキスト内容を取得した上でテキストエリアのテキスト内容をカーソル位置で分割し、カーソル前後のテキストをそれぞれ取得したら・・

1const { value } = textarea;
2const beforeCursor = value.substring(0, selectionStart);
3const afterCursor = value.substring(selectionEnd);
4

カーソルが現在位置している行の開始位置と終了位置を計算する。行の終了位置は次の改行文字が出現する位置か、テキストの末尾(次の改行がない場合)である。

1const lineStart = beforeCursor.lastIndexOf('\n') + 1;
2const lineEnd = afterCursor.indexOf('\n');
3const lineEndIndex = lineEnd !== -1 ? lineEnd + selectionEnd : value.length;
4

さらにテキストを現在の行の前の部分、現在の行、現在の行の後の部分に分割する。

1const beforeLine = value.substring(0, lineStart);
2const currentLine = value.substring(lineStart, lineEndIndex);
3const afterLine = value.substring(lineEndIndex);
4

それができたら、現在の行が// で始まるかどうかを確認し、もし始まっていたら削除し、始まっていなければ追加する。また、新しいカーソルの位置を計算し、挿入または削除した文字数を反映させる。

1let newValue;
2let newCursorPosition;
3if (currentLine.startsWith('// ')) {
4  newValue = `${beforeLine}${currentLine.replace('// ', '')}${afterLine}`;
5  newCursorPosition = selectionStart - 2; // account for the removed `//`
6} else {
7  newValue = `${beforeLine}//${currentLine}${afterLine}`;
8  newCursorPosition = selectionStart + 2; // account for the inserted `//`
9}
10

上記の処理で新しく生成した文字列でTextField内のテキスト内容、およびカーソルの位置を更新。カーソルを新しい位置に設定する。

1setText(newValue);
2textarea.value = newValue;
3
4// Reset cursor position
5textarea.setSelectionRange(newCursorPosition, newCursorPosition);
6

以上で要件全てを満たす実装ができた。