【React】textarea で 複数行対応の Tabインデントを実装する


フロントエンドエンジニアの茶木です。
ユーザーの自由記述を可能にしたうえで、装飾と構造化も行いたいからといってユーザーに HTML を書かせるのは少々オーバーだしセキュリティの問題もある。そんなときの Markdown は素晴らしくちょうどいいですね!
前々回の記事で、react-markdown というライブラリを使ったリアルタイム Markdown エディターの実装を行いました。さらに前回の記事では、タブによるインデントの機能を追加しました。

今回はさらに、複数行を選択しているときに、まとめてタブのインデントを行う機能を追加します。さらにインデント変更後の選択範囲の調整も行います。

選択範囲から選択中の行を特定する

前回はカーソルのある行のインデントを行うため、該当行の特定を行いました。

今回は選択範囲の始まりと終わりに対して該当行の特定を行います。
selectionStart が選択範囲の開始位置、selectionEnd は選択範囲の終了位置となります。

type Indented = {
  value: string;
  selectionStart: number;
  selectionEnd: number;
};

function isInLine(target: number, lineHead: number, lineLength: number) {
  return target >= lineHead && target < lineHead + lineLength;
}

function getSelectedLineRange(input: Indented): {
  startLine: number;
  endLine: number;
} {
  const { value, selectionStart, selectionEnd } = input;

  let lineHead = 0;
  const result = value.split("\n").reduce(
    (acc, line, index) => {
      const lineLength = line.length + 1; // '\n'(改行記号) 分の1をプラスする

      if ( acc.startLine === -1 && isInLine(selectionStart, lineHead, lineLength)) {
        acc.startLine = index;
      }
      if ( acc.endLine === -1 && isInLine(selectionEnd, lineHead, lineLength)) {
        acc.endLine = index;
      }

      lineHead += lineLength;
      return acc;
    },
    { startLine: -1, endLine: -1 }
  );

  return result;
}

開始位置と終了位置は、前回の単行を扱う場合と同じく、先頭からの文字数で与えられるため、行ごとの文字数をカウントしていくことで特定できます。

選択中の行のインデントを行う

行が特定できたら、行のインデントを行います。

function updateIndent(input: Indented, remove: boolean): Indented {
  const { startLine, endLine } = getSelectedLineRange(input);
  const value = input.value
    .split("\n")
    .map((line, index) => {
      if (index < startLine || index > endLine) {
        return line;
      }
      if (remove) {
        if (line.startsWith("  ")) {
          return line.substring(2);
        } else if (line.startsWith(" ")) {
          return line.substring(1);
        }
        return line;
      } else {
        const match = line.match(/^\s+/);
        if (match && match[0].length % 2 !== 0) {
          return ` ${line}`;
        } else {
          return `  ${line}`;
        }
      }
    })
    .join("\n");

  return {
    value,
  };
}

複数行がインデントの対象になっていますが、これも前回の単行のインデントと変わらず、空白が2文字に満たない不完全インデントとインデントの削除の対応も入っています。

インデントを調整したあとの選択範囲の調整

ここが今記事の目玉です。
実行してみるとインデントは問題なく行われるものの、インデントを調整した後に、選択範囲が追加や削除した空白分だけズレます。実は前回の単数行でも起きていたのですが単数行に対するインデントでは、大きな問題にはなりにくいものでした。

複数行のインデントでは行数分ズレが累積するため、インデント後に選択範囲にある行が変わるケースが発生しえます。そして、インデントの調整は選択を解除せず複数回行うケースがあるため、インデントの調整前と調整後で選択範囲が変わってしまうと、使い勝手を悪くしてしまいます。

踏まえて、インデント前後で選択範囲が変わらないように対応を行います。

選択範囲を return するようにする

function updateIndent(input: Indented, remove: boolean): Indented {
  let { selectionStart, selectionEnd } = input;
  :
  処理
  :
  return {
    value,
    selectionStart,
    selectionEnd,
  };
}

後で位置調整を行うため、 インデント前の selectionStartselectionEnd を取得して、計算処理を行って return をします。

選択範囲の移動の処理

if (remove) {
  if (line.startsWith("  ")) {
    if (index === startLine) selectionStart -= 2;
    selectionEnd -= 2;
    return line.substring(2);
  } else if (line.startsWith(" ")) {
    if (index === startLine) selectionStart -= 1;
    selectionEnd -= 1;
    return line.substring(1);
  }
  return line;
} else {
  const match = line.match(/^\s+/);
  if (match && match[0].length % 2 !== 0) {
    if (index === startLine) selectionStart += 1;
    selectionEnd += 1;
    return ` ${line}`;
  } else {
    if (index === startLine) selectionStart += 2;
    selectionEnd += 2;
    return `  ${line}`;
  }
}

選択範囲の調整の処理は単純です。

  • 選択終了位置は、空白文字の追加・削除分だけ移動する
  • 選択開始位置は、インデント対象の先頭の行であれば、空白文字の追加・削除分だけ移動する

先程のインデント対応とあわせて処理を書けます。

選択範囲を更新する

const onKeyDown = useCallback(
  (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key !== "Tab") return;
    e.preventDefault();
    const newIndent = updateIndent(e.currentTarget, e.shiftKey);
    setText(newIndent.value);
    setTimeout(() => {
      if (ref.current) {
        ref.current.selectionStart = newIndent.selectionStart;
        ref.current.selectionEnd = newIndent.selectionEnd;
      }
    }, 0);
  },
  []
);

少し意外なのですが、対象の textareaselectionStartselectionEnd の値を保持しており、それを新しい値で書き換えるだけで選択範囲が変更できます。

なお、選択範囲の変更は、インデントが行われた新しいテキストに対して行う必要があるため、setTimeout で非同期の処理にしています。

おわりに

前々回、前回に続くリアルタイム Markdown エディター開発シリーズ、今回は複数行の Tab インデント機能の実装でした。

インデント機能で意外にも重要だったのが、操作後の選択範囲を適切に維持することでした。よく見かける機能でも、使用前後も含めてのユーザー体験を向上するために、実に細やかな技術の積み重ねがあります。観察と技術でより良いプロダクトづくりを目指していこうと思います。

前回分・前々回分

前回分はこちらです。

前々回分はこちらです。

Gaji-Labo は Next.js, React, TypeScript 開発の実績と知見があります

フロントエンド開発の専門家である私たちが御社の開発チームに入ることで、バックエンドも含めた全体の開発効率が上がります。

「既存のサイトを Next.js に移行したい」
「人手が足りず信頼できるエンジニアを探している」
「自分たちで手を付けてみたがいまいち上手くいかない」

フロントエンド開発に関わるお困りごとがあれば、まずは一度お気軽に Gaji-Labo にご相談ください。

オンラインでのヒアリングとフルリモートでのプロセス支援にも対応しています。

Next.js, React, TypeScript の相談をする!

Gaji-Labo Culture Deck

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

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

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

求人応募してみる!

タグ


投稿者 Chaki Hironori

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