Laravel + React でインデント機能をもった Textarea をつくる

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、以前から私は(ほぼ趣味なので遅々として進みませんが😂)個人で開発しているサービスを持っています。

そして、その中で開発したとある機能が意外に気に入ったので、ぜひこのブログでも紹介することにしました。

その機能とは、

インデント機能をもった Textarea

です。

つまり、以下のように行の先頭に空白を入れる(and 消す)ことができる機能のことですね。

通常だとこの機能はそれほど重要ではないかもしれませんが、ソースコードを入力する場合は、インデント機能がないと不便だったので敢えてつくることにしました。

ということで、今回は「Laravel + React」でインデントできるテキストエリアをコンポーネントとしてつくってみたいと思います。

ぜひ何かの参考になりましたら嬉しいです。😄✨

「バルサンを…
焚いて外出…
スマホどこ?😫」

開発環境: Laravel 9.x、React、Vite、Inertia.js、TailwindCSS

やりたいこと

冒頭でも書きましたが、今回やりたいことは以下のとおりです。

  • テキストエリアの文字にインデントを追加できる
  • 逆にインデントの削除もできる
  • 利用するのは Tab キー
  • 逆インデントは Shift + Tab。
  • 1行だけでなく、複数行のインデントにも対応する

では楽しんでやっていきましょう❗

ルート&ビューをつくる

今回は構造としてはとてもシンプルなのでルート&ビューを一気につくってしまいます。

routes/web.php

// 省略

Route::get('indent_textarea', fn() => Inertia::render('IndentTextarea/Index'));

resources/js/Pages/IndentTextarea/Index.jsx

import IndentTextarea from '@/Components/IndentTextarea';
import {useState} from "react";

export default function Index() {

    const [value, setValue] = useState("テスト1\nテスト2\nテスト3");

    return (
        <div className="p-5">
            <h1>インデント機能付きテキストエリア</h1>
            <IndentTextarea value={value} onChange={value => setValue(value)} />
        </div>
    );

}

このコードの中にある「IndentTextarea」が次の項目でつくるコンポーネントです。

コンポーネント「IndentTextarea」をつくる

では、今回メインになるインデント機能つきのテキストエリアです。

resources/js/Components/IndentTextarea.jsx

import {useEffect, useRef, useState} from "react";

export default function SourceCodeTextarea(props) {

    // データ
    const [value, setValue] = useState(props.value || '');
    const indentCount = props.indentCount || 4; // インデントするスペース数

    // テキストエリア
    const textareaRef = useRef(null);
    const handleChange = e => {

        setValue(e.target.value);

    };
    useEffect(() => {

        if(typeof props.onChange === 'function') {

            props.onChange(value);

        }

    }, [value]);

    // インデント
    const indentValue = () => {

        const texts = textareaRef.current.value.split("\n");
        let newTexts = [];

        texts.forEach((text,index) => {

            if(indentIndexes.current.includes(index)) { // インデントすべき行かチェック

                if(isPressingShift.current === true) { // 逆インデント

                    for(let i = indentCount; i > 0; i--) {

                        const pattern = new RegExp(`^\\s{${i}}`);

                        if(pattern.test(text)) {

                            text = text.replace(pattern, '');
                            break;

                        }

                    }

                } else { // 通常のインデント

                    text = ' '.repeat(indentCount) + text; // repeat() は、指定回数の文字列を繰り返す

                }

            }

            newTexts.push(text);

        });

        setValue(newTexts.join("\n"));

    };

    // キーボード・イベント
    const isPressingShift = useRef(false);  // Shiftキーが押されているか
    const isIndenting = useRef(false);      // インデント中か
    const handleKeyDown = e => {

        if(e.key === 'Shift') { // Shiftキーが押された

            isPressingShift.current = true;

        }

        if(e.key === 'Tab') { // Tabキーが押された

            isIndenting.current = true;
            indentValue();
            e.preventDefault();

        }

    };
    const handleKeyUp = e => {

        if(e.key === 'Shift') { // Shiftキーが離された

            isPressingShift.current = false;

        }

        if(e.key === 'Tab') { // Tabキーが離された

            isIndenting.current = false;

        }

    };

    // セレクション(カーソル)
    const indentIndexes = useRef([]);
    const handleSelect = e => {

        indentIndexes.current = [];
        const selectionStart = e.target.selectionStart;
        const selectionEnd = e.target.selectionEnd;
        const texts = textareaRef.current.value.split("\n");
        let currentPosition = 0;
        let shouldIndent = false;

        texts.forEach((text,index) => {

            const textPositionStart = currentPosition;
            const textPositionEnd = textPositionStart + text.length;

            if(selectionStart >= textPositionStart && selectionStart <= textPositionEnd) {

                shouldIndent = true;

            } else if(selectionEnd < textPositionStart) {

                shouldIndent = false;

            }

            if(shouldIndent === true) {

                indentIndexes.current.push(index);

            }

            currentPosition = textPositionEnd + 1;

        });

    };
    useEffect(() => {

        if(isIndenting.current === true) { // インデント中の場合

            const texts = textareaRef.current.value.split("\n");
            const minIndentIndex = indentIndexes.current[0];
            const maxIndentIndex = indentIndexes.current.at(-1);
            let currentPosition = 0;
            let selectionStart = -1;
            let selectionEnd = -1;

            texts.forEach((text,index) => {

                const textPositionStart = currentPosition;
                const textPositionEnd = textPositionStart + text.length;

                if(minIndentIndex === index) {

                    selectionStart = textPositionStart;

                }

                if(maxIndentIndex === index) {

                    selectionEnd = textPositionEnd;

                }

                currentPosition = textPositionEnd + 1;

            })

            textareaRef.current.setSelectionRange(selectionStart, selectionEnd);

        }

    }, [value]);

    return (
        <textarea
            ref={textareaRef}
            className="w-full h-64 bg-gray-900 rounded-lg"
            style={{color:'#f8f8f2'}}
            value={value}
            onChange={handleChange}
            onSelect={handleSelect}
            onKeyDown={handleKeyDown}
            onKeyUp={handleKeyUp} />
    );

}

まず実装する流れは次のとおりです。

  1. テキストエリアのセレクション(カーソル位置)からどの行をインデントすべきか取得する
  2. もし Tab もしくは Shift + Tab が押された場合はインデント機能を実行
  3. テキストエリアの内容を変更
  4. 連続インデント変更できるようにセレクションを修正

では、コードの中身をブロックごとに見ていきましょう❗

データ

ここはテキストエリアの値とインデントするスペースの数(初期値は4つ)を設定しています。

つまり、以下のようにすることでインデントを2つに変更することができます。

<IndentTextarea indentCount="2" />

テキストエリア

テキストエリア部分です。

ここでは入力が変更された場合にuseStateの更新をし、さらに以下のようにコンポーネントにonChangeイベントがセットされている場合、親側にもその変更を伝えるようにしています。

使い方はこんなカンジです。

<IndentTextarea onChange={...} />

このあたりはReactの基本的な使い方ですね👍

インデント

ここでは、今回メインのインデントの「追加 or 削除」をしています。
流れとしては次のとおりです。

  1. 入力した内容を改行コードで分割し、配列にする
  2. 1行ずつインデントすべきかチェックする
  3. もしインデントすべきとしたら、それは追加なのか削除なのかをチェック
  4. それぞれ各行に変更を加える
  5. 最後は各行のテキストを合体させて元のテキストエリアにセットする

キーボード・イベント

ここでは、キーボードのイベントを管理していますが、今回対象になっているのは以下2つだけです。

  • Tab キー
  • Shift キー

そして、これらのキーが押されたとき、離れたときにその状態をuseRef()にセットしています。

※ ちなみになぜuseRefなのかというと、即効性が必要だからです。ざっくり言うと、useStateだとワンテンポ遅れるのでうまくいかないんですね。

セレクション(カーソル)

この部分で、「どの行をインデントすべきかどうか🤔」を取得しています。

タイミングとしては、マウスでテキストエリアにカーソルが移動したときや、ドラッグ・アンド・ドロップでテキストを反転させたときです。

そして、この変化をキャッチするのがonSelectイベントです。

このイベントが実行されたら、「インデントすべきインデックス値」をindentIndexesにセットするようになっています。(インデックス値なのでゼロから始まる数字になってます)

なお、ここが少しつまったところなのですが、現在反転されているテキストが3行以上の場合、セレクションデータからは検知できない行もインデントの対象になるため(つまり、3行選択した場合2行目はセレクションのデータからインデント対象になることを知ることができない)、ループの中で「インデントすべきフラグ」を開始&終了することで実装しています。

また、最後にここが一番難儀だったのですが、<textarea></textarea>は、中身が変更されるとそれまでのセレクション(カーソルや反転情報)が失われてしまいます

つまり、このせいで連続でインデント処理をしようとしても2回目以降は別の場所が対象になってしまう(正確には最終行だけが対象になる)ため、手動で再度セレクションを設定しなおさないといけませんでした。

しかも、valueuseState()で管理している関係条、その変化はuseEffect()でキャッチしないといけません。

そのため、「インデント中かどうか」をメソッド間を渡ることができるようにisIndentinguseRefで管理するようにしています。

(この辺りは、Reactのクセ的な話なのかもしれません😂)

とにかく、これで作業は終了です❗
お疲れ様でした👍

テストしてみる

では、実際にテストしてみましょう❗

まずViteを起動してからブラウザで「https://******/indent_textarea」へアクセスします。

以下のような表示になります。

では、まず「テスト2」の行にカーソルを当ててみます。

では、この状態でTabキーを押してみましょう。

どうなるでしょうか・・・・・・

はい❗
テスト2の先頭にスペースが追加されました。

記事中でも書きましたが、セレクションを手動で設定しているので連続インデントにも対応できています。

では、今度はShift + Tabで「逆インデント」を実行してみましょう。

うまくいくでしょうか・・・・・・

はい❗
先ほどとは逆にスペースが削除されて元に戻りました。

成功です😄✨

では、最後に「複数行のインデント」もチェックしておきましょう。

以下のようにマウスで複数行を選択肢して・・・・・・

Tabキーを押してみます。

すると・・・・・・

はい❗
ちょっとわかりにくいかもしれませんが、選択されて全ての行にインデントが追加されました。

全て成功です✨😄👍

デモページを用意しました

せっかくなので、でもページを用意しました。
ぜひ実際に触ってみてください。

📝デモページ

企業様へのご提案

今回のように通常のウェブページでは実装されていない機能であっても、使いやすく御社の独自入力に対応できる場合があります。

例えば、複雑なコード番号の入力などではお力になれるかもしれません。

もし、そういったご希望がございましたら、ぜひお問い合わせからご相談ください。お待ちしております。😄✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回は「Laravel + React」でインデント機能があるテキストエリアをつくってみました。

しかし、そう考えると我々が開発しているコードエディタってホントに便利ですよね。

インデントどころか定義された場所まで一瞬で移動できたり。
過去の偉人たちの成果が積み上がった結果ですね。

私もその積み上げにいつか加われると嬉しいです。

ではでは〜❗

「ジークンドーの
滑るようなステップを
練習中❗(勝手に)」

このエントリーをはてなブックマークに追加       follow us in feedly