【React】textarea で Tabインデントが使えるようにする


フロントエンドエンジニアの茶木です。Markdown 便利ですよね。
前回の記事で、react-markdown というライブラリを使ったリアルタイム Markdown エディターの実装を行いました。

今回は、前回分の実装で不便だと感じた、タブによるインデントの機能を追加します。

前回分

前回分はこちらです。

textarea で Tabキーを受け取れるようにする

修正前のコードを示します。前回のコードの装飾を取り払ったもので、これに対して入力側の textarea の修正を行っていきます。
また、今回は出力側の ReactMarkdown の変更はありません。

const MarkdownEditor = () => {
  const [text, setText] = useState("");
  return (
    <>
      <textarea onChange={(e) => setText(e.target.value)} />
      <ReactMarkdown>{text}</ReactMarkdown>
    </>
  );
};

Tabキーのデフォルト動作をキャンセルする

アプリでは、Tabキーはインデント機能を担うことが多いのですが、ウェブでは次の要素へフォーカスを移動するのがデフォルトの動作です。

このため、まず Tabキーの入力時にはデフォルトの動作をキャンセルする必要があります。

function updateIndent(currentTarget) {
 :
 : 
}

const MarkdownEditor = () => {
  const [text, setText] = useState("");
  const onKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
      if (e.key === "Tab") {
        const newText = updateIndent(e.currentTarget);
        setText(newText);
        e.preventDefault();
      }
    },
    []
  );
  return (
    <>
      <textarea 
        onChange={(e) => setText(e.target.value)} 
        onKeyDown={onKeyDown}
      />
      <ReactMarkdown>{text}</ReactMarkdown>
    </>
  );
};

これは簡単で onKeyDown のイベント発火タイミングで e.key"Tab" であるかどうかでフィルタをします。その後、

  1. インデントを追加した newText で内容を書き換える
  2. preventDefault() でデフォルトの動作をキャンセル

とすればOKです。ここでのデフォルトの動作とはフォーカスの移動です。
インデントの追加の実装は updateIndent にこれから記述していきます。

行頭にインデントを入力する

エディターによりますが、インデントが挿入されるのはカーソルの位置ではなくて、カーソルのある行の行頭に入力されるものだと思います。

function updateIndent(currentTarget) {
  const lines = text.split("\n");
  const cursor = currentTarget.startSelection;
  let current = 0;
  return currentTarget.value.map((line) => {
    // 改行文字を含めた長さ
    const lineLength = line.length + 1;
    const newLine = cursor >= current && cursor < current + lineLength
     ? ` ${line}`
     : line;
    current += lineLength;
    return newLine;
  }).join("\n");
}

そのため、カーソル位置が何行目に存在するかを判定して、その行頭にスペースを2個追加しています。

テキスト全体は、currentTarget.value で、カーソルの位置は currentTarget.startSelection で文字数として取得できます。テキスト全体を "\n" を区切り文字として行ごとに分解し、行頭と行末の文字数をカウントし、カーソルがある行を特定して、その行頭に、スペースを2個追加してから、再度結合をしています。

これで、インデントの追加の基本ができました。

インデントの削除

これもまた、エディター依存かもしれませんが、Shiftキー + Tabキー でインデントの削除もよくある機能だと思います。これを実装します。

const newText = updateIndent(e.currentTarget, e.shiftKey);

updateIndent の引数に Shiftキーの押下状態を渡します。
なお、Shiftキーが押下されているかは、e.shiftKey で簡単に取得できます。

function addIndent(line: string) {
  return ` ${line}`;
}

function removeIndent(line: string) {
  return line.startsWith("  ") ? line.substring(2) : line;
}

function updateIndent(currentTarget, shift) {
  const lines = text.split("\n");
  const cursor = currentTarget.startSelection;
  let current = 0;
  return currentTarget.value.map((line) => {
    // 改行文字を含めた長さ
    const lineLength = line.length + 1;
    if(cursor >= current && cursor < current + lineLength) {
      return shift ? removeIndent(line) : addIndent(line);
    }
    return line;
  }).join("\n");
}

インデントの削除のメソッドとして、 removeIndent を定義しました。(あわせて addIndent も定義しています)

インデントが存在しない場合は無視するため、削除は2つのステップからなります。

  1. インデントの存在確認 line.startsWith("  ")
  2. インデントの削除 line.substring(2)

これで、インデントの削除もできました。

スペースが奇数の場合の対応

行頭から続くスペース2個をひとつのインデントとみなすため、奇数個のスペースは不完全なインデントです。インデントを追加・削除するときに、偶数個のスペースになるように修正を行います。

function addIndent(line: string) {
  const match = line.match(/^\s+/);
  return match && match[0].length % 2 !== 0
    ? ` ${line}`
    : `  ${line}`;
}

追加の場合、行頭のスペースの数を正規表現で取得し、奇数個か偶数個かの判定を行い、奇数個であれば、ひとつだけスペースを追加します。

function removeIndent(line: string) {
  const match = line.match(/^\s+/);
  if (!match) return line;
  return match && match[0].length % 2 !== 0
    ? line.substring(1)
    : line.substring(2);
}

削除の場合も同様です。奇数個のときはひとつだけスペースを削除します。ただしマッチしなかったときのスキップ処理を残しておく必要があります。

おわりに

これで、よくある textarea でのインデント機能を実装できました。
以下がコードの全体像です。

function addIndent(line: string) {
  const match = line.match(/^\s+/);
  return match && match[0].length % 2 !== 0
    ? ` ${line}`
    : `  ${line}`;
}

function removeIndent(line: string) {
  const match = line.match(/^\s+/);
  if (!match) return line;
  return match && match[0].length % 2 !== 0
    ? line.substring(1)
    : line.substring(2);
}

function updateIndent(currentTarget, shift) {
  const lines = text.split("\n");
  const cursor = currentTarget.startSelection;
  let current = 0;
  return currentTarget.value.map((line) => {
    // 改行文字を含めた長さ
    const lineLength = line.length + 1;
    if(cursor >= current && cursor < current + lineLength) {
      return shift ? removeIndent(line) : addIndent(line);
    }
    return line;
  }).join("\n");
}

const MarkdownEditor = () => {
  const [text, setText] = useState("");
  const onKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
      if (e.key === "Tab") {
        const newText = updateIndent(e.currentTarget, e.shiftKey);
        setText(newText);
        e.preventDefault();
      }
    },
    []
  );
  return (
    <>
      <textarea 
        onChange={(e) => setText(e.target.value)} 
        onKeyDown={onKeyDown}
      />
      <ReactMarkdown>{text}</ReactMarkdown>
    </>
  );
};

今回の実装

  • textarea での Tabキーのデフォルト動作の無効化
  • カーソルのある行の行頭のインデントの追加と削除の機能
  • スペースが奇数個だったときの不完全なインデントの修正機能

さらなる拡張と改良

もうひとつ、実装したかった機能があります。
今回は、カーソルの存在する行に対してのインデントでしたが、選択範囲内の複数行をターゲットにインデントの追加・削除がまとめてできたら便利ですよね。コードエディターのほとんどに実装されている機能だと思います。

これらは次回以降のテーマとしておきます。
Gaji-Labo は支援しているスタートアップに対して、ただの作業者ではなく頼れるパートナーとして支援できる仕事のやり方を大切にしています。便利なライブラリであったとしても、実装に合わせて調整すべき点は多いものです。プラスワンの価値を考えていけるようなエンジニアでありたいと思います。

Gaji-Laboでは、 Next.js 経験が豊富なフロントエンドエンジニアを募集しています

弊社では Next.js の知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違う Gaji-Labo を味わいに来ませんか?

Next.js の設計・実装を得意とするフロントエンドエンジニア募集要項

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

タグ


投稿者 Chaki Hironori

webライターもやってるフロントエンドエンジニアです。Reactは自信があります。またデザイン畑の出身で、気持ちのいいアニメーションやインタラクティブな表現は丁寧に手掛けます。好きなものは中南米の遺跡で、スペイン語が少しできます。