HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

tableの行をドラッグ&ドロップで入れ替える

はじめに

ちょっとお仕事で必要が生じて、HTMLテーブルの行をドラッグ&ドロップで入れ替える機能を実装することになったので、その検証メモ。 お仕事では、ドラッグ&ドロップによる行入れ替えのほかに、ドラッグ&ドロップでの行選択の機能も混じるので、動的にdraggable属性の値を切り替える必要があるので、簡単な行選択のロジックも組み込む。 また、入れ替えた行の順番をサーバー側で保存するためのロジックも記述していく。

今回は、Laravelを使って組んでいく。

なお、実行環境は以下だが、構築の詳細は割愛。

下準備

まずはLaravelのプロジェクトを作る。

cd /var/www/html
composer create-project laravel/laravel:11.* sample09
chmod -R a+w  sample09/storage/
chmod a+w  sample09/bootstrap/cache

お仕事がLaravel 11なので、バージョンとしてLaravel 11系を指定しているが、最新のLaravel 12系でも問題なく動くと思う。

ファイルの作成・編集

sample09/resources/views/sample/index.blade.php

<!DOCTYPE html>
<html lang="ja">

<head>
    <base href="/sample09/public/" />
    <meta charset="UTF-8" />
    <title>ドラッグ&ドロップによるテーブル行の順序入れ替え</title>
    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    <link rel="stylesheet" type="text/css" href="sample.css" />
    <script type="text/javascript" src="sample.js"></script>
</head>

<body>
    <table class="sorting-by-drag-table">
        <thead>
            <tr>
                <th>メールアドレス</th>
                <th></th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @foreach ($persons as $person)
            <tr data-id="{{$person->id}}">
                <td>{{$person->mailAddress}}</td>
                <th>{{$person->lastName}}</th>
                <th>{{$person->firstName}}</th>
            </tr>
            @endforeach
        </tbody>
    </table>

    <script type="text/javascript">
        initSortingByDrag('.sorting-by-drag-table');
    </script>
</body>

</html>

sample09/app/Http/Controllers/SampleController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
use stdClass;

class SampleController extends Controller
{
    public function index()
    {
        $persons = Session::get('persons');
        if (!$persons) {
            $persons = [];
            for ($i = 1; $i <= 20; ++$i) {
                $person = new stdClass();
                $person->id = $i + 1000;
                $person->mailAddress = 'メールアドレス' . mb_convert_kana($i, 'N');
                $person->lastName = '姓' . mb_convert_kana($i, 'N');
                $person->firstName = '名' . mb_convert_kana($i, 'N');
                $person->showOrder = $i;
                $persons[$person->id] = $person;
            }

            Session::put('persons', $persons);
        }

        // showOrderでソート
        $viewPersons = [];
        foreach ($persons as $person) {
            $viewPersons[$person->showOrder] = $person;
        }
        ksort($viewPersons);

        return view(
            'sample.index',
            [
                'persons' => $viewPersons,
            ]
        );
    }
    public function insertBefore(Request $request)
    {
        $rowIds = $request->get('row_ids');
        $targetId = $request->get('target_id');

        $this->updateShowOrder($rowIds, $targetId, true);
    }
    public function insertAfter(Request $request)
    {
        $rowIds = $request->get('row_ids');
        $targetId = $request->get('target_id');

        $this->updateShowOrder($rowIds, $targetId, false);
    }

    private function updateShowOrder($rowIds, $targetId, $before)
    {
        /*
         * target のデータを取得する
         * target の showOrder を変数 targetShowOrder に格納する
         * selectedRows のデータをすべて取得する
         * selectedRows の showOrder の min, max を求めて変数に格納する
         * targetShowOrder と min/max の間のデータをすべて取得する
         * 取得したデータを selected と notSelected に分ける
         * after の場合は逆順に処理するので、順番を入れ替えておく
         * targetShowOrder を別の変数 currentShowOrder に格納する
         * selected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する
         * notSelected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する
         */
        $persons = Session::get('persons');
        // target のデータを取得する
        if (!isset($persons[$targetId])) {
            echo json_encode(['status', false, 'errorMessage' => 'target_id:指定されたIDのデータが見つかりません。']);
            return;
        }
        $target = $persons[$targetId];
        // target の showOrder を変数 targetShowOrder に格納する
        $targetShowOrder = $target->showOrder;
        // selectedRows のデータをすべて取得する
        $selectedRows = []; // SQLなら、IN句で一発で取得できる
        foreach ($rowIds as $rowId) {
            if (!isset($persons[$rowId])) {
                echo json_encode(['status', false, 'errorMessage' => 'row_id:指定されたIDのデータが見つかりません。']);
                return;
            }
            $selectedRows[] = $persons[$rowId];
        }
        // selectedRows の showOrder の min, max を求めて変数に格納する
        $min = 0x7fffffffffffffff;
        $max = -1;
        foreach ($selectedRows as $row) {
            $min = min($min, $row->showOrder);
            $max = max($max, $row->showOrder);
        }
        // targetShowOrder と min/max の間のデータをすべて取得する
        //   ※サンプルではループを回して全件チェックする必要があるが、実際にはDBにrangeクエリーを出せばよい
        $editRows = [];
        foreach ($persons as $person) {
            if ($before) {
                if ($targetShowOrder <= $person->showOrder && $person->showOrder <= $max) {
                    $editRows[] = $person;
                }
            } else {
                if ($min <= $person->showOrder && $person->showOrder <= $targetShowOrder) {
                    $editRows[] = $person;
                }
            }
        }
        // 取得したデータを selected と notSelected に分ける
        $selected = [];
        $notSelected = [];
        foreach ($editRows as $editRow) {
            if (in_array($editRow->id, $rowIds)) {
                $selected[] = $editRow;
            } else {
                $notSelected[] = $editRow;
            }
        }
        // after の場合は逆順に処理するので、順番を入れ替えておく
        if (!$before) {
            $selected = array_reverse($selected);
            $notSelected = array_reverse($notSelected);
        }
        // targetShowOrder を別の変数 currentShowOrder に格納する
        $currentShowOrder = $targetShowOrder;
        // selected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する
        foreach ($selected as $row) {
            if ($before) {
                $row->showOrder = $currentShowOrder++;
            } else {
                $row->showOrder = $currentShowOrder--;
            }
        }
        // notSelected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する
        foreach ($notSelected as $row) {
            if ($before) {
                $row->showOrder = $currentShowOrder++;
            } else {
                $row->showOrder = $currentShowOrder--;
            }
        }
        // オーバーフロー/アンダーフローチェック
        if ($before) {
            if ($currentShowOrder > $max + 1) {
                echo json_encode(['status' => false, 'errorMessage' => 'ShowOrderがオーバーフローしました。']);
                return;
            }
        } else {
            if ($currentShowOrder < $min - 1) {
                echo json_encode(['status' => false, 'errorMessage' => 'ShowOrderがアンダーフローしました。']);
                return;
            }
        }
        // showOrderで並べ替えてからセッションに書き込み
        //   ※DBを使っている場合はORDER BY句を指定して取得すればいいので不要な処理
        $tmpPersons = [];
        foreach ($persons as $person) {
            $tmpPersons[$person->showOrder] = $person;
        }
        ksort($tmpPersons);
        $persons = [];
        foreach ($tmpPersons as $tmpPerson) {
            $persons[$tmpPerson->id] = $tmpPerson;
        }
        Session::put('persons', $persons);

        echo json_encode(['status' => true]);
    }
}

sample09/public/sample.css

table {
    border-collapse: collapse;
    outline: none;
}
th,
td {
    border: 1px solid #ccc;
}

.dragging {
    opacity: 0.5; /* ドラッグ中の行の透明度を調整 */
}

.drop-placeholder {
    border-top: 2px dashed #000; /* ドロップ位置の目印 */
}

.selected {
    background-color: #d0d8ff; /* 選択時の背景色 */
    border-left: 3px solid #007bff; /* 選択時の左ボーダー */
}

sample09/public/sample.js

function initSortingByDrag(selector) {
    const table = document.querySelector(selector);
    const rows = table.querySelectorAll("tbody > tr");

    let draggingRows = null; // ドラッグ中の行を保持する
    let min;
    let max;

    // 各行にイベントリスナーを設定
    rows.forEach((row) => {
        row.addEventListener("dragstart", handleDragStart);
        row.addEventListener("dragenter", handleDragEnter);
        row.addEventListener("dragover", handleDragOver);
        row.addEventListener("dragleave", handleDragLeave);
        row.addEventListener("drop", handleDrop);
        row.addEventListener("dragend", handleDragEnd);

        row.addEventListener("click", handleClick);
    });

    // ドラッグ開始時の処理
    function handleDragStart(ev) {
        draggingRows = table.querySelectorAll(".selected");
        ev.dataTransfer.effectAllowed = "move";
        ev.dataTransfer.setData("text/html", this.innerHTML);

        // ドラッグ中の行の間にドロップ先の行が含まれないことを確認
        min = 9223372036854775807;
        max = -1;
        draggingRows.forEach((draggingRow) => {
            if (min > draggingRow.rowIndex) {
                min = draggingRow.rowIndex;
            }
            if (max < draggingRow.rowIndex) {
                max = draggingRow.rowIndex;
            }
        });

        // ドラッグ中の行にCSSクラスを追加してスタイルを適用
        setTimeout(() => {
            draggingRows.forEach((row) => {
                row.classList.add("dragging");
            });
        }, 0);
    }

    // ドラッグ中の要素が他の要素の上に侵入した時の処理
    function handleDragEnter(ev) {
        ev.preventDefault();
        this.classList.add("drop-placeholder");
    }

    // ドラッグ中の要素が他の要素の上にある時の処理
    function handleDragOver(ev) {
        // ドロップ先の行を取得
        let targetRow = this;

        if (targetRow.rowIndex < min || max < targetRow.rowIndex) {
            ev.preventDefault(); // デフォルトの動作をキャンセルして、ドロップを許可する
            ev.dataTransfer.dropEffect = "move";
        }
    }

    // ドラッグ中の要素が他の要素から離れた時の処理
    function handleDragLeave(ev) {
        this.classList.remove("drop-placeholder");
    }

    // ドロップ時の処理
    function handleDrop(ev) {
        ev.preventDefault();

        // ドロップ先の行を取得
        let targetRow = this;

        // ドラッグ中の行の間にドロップ先の行が含まれないことを確認
        if (targetRow.rowIndex < min || max < targetRow.rowIndex) {
            const parent = targetRow.parentNode;

            const rowIds = []; // ドラッグされている行のデータID
            const targetId = targetRow.dataset.id;

            // ドロップ位置を判定し、要素を入れ替える
            if (targetRow.rowIndex < min) {
                // ドラッグしている行よりも上の行にドロップした場合
                draggingRows.forEach((draggingRow) => {
                    parent.insertBefore(draggingRow, targetRow);
                    rowIds.push(draggingRow.dataset.id);
                });

                console.log(rowIds + " is/are inserted before " + targetId);
                save("sample/insert-before", rowIds, targetId);
            } else {
                // ドラッグしている行よりも下の行にドロップした場合
                draggingRows.forEach((draggingRow) => {
                    parent.insertBefore(draggingRow, targetRow.nextSibling);
                    // 順序を保持するために、挿入先を変える
                    targetRow = draggingRow;
                    rowIds.push(draggingRow.dataset.id);
                });

                console.log(rowIds + " is/are inserted after " + targetId);
                save("sample/insert-after", rowIds, targetId);
            }
        }
    }

    // ドラッグ終了時の処理
    function handleDragEnd(ev) {
        draggingRows.forEach((draggingRow) => {
            draggingRow.classList.remove("dragging");
        });
        table.querySelectorAll("tbody > tr").forEach((row) => {
            row.classList.remove("drop-placeholder");
        });
        draggingRows = null;
    }

    // 簡易的な複数行選択機能
    function handleClick(ev) {
        const row = ev.target.closest("tr");

        if (row.classList.contains("selected")) {
            row.classList.remove("selected");
            row.draggable = false;
        } else {
            row.classList.add("selected");
            row.draggable = true;
        }
    }

    // 順序をサーバー側に保存する
    function save(url, rowIds, targetId) {
        $.ajax({
            type: "get",
            url: url,
            dataType: "json",
            data: {
                row_ids: rowIds,
                target_id: targetId,
            },
        })
            .done((result) => {
                if (!result.status) {
                    alert(result.errorMessage);
                }
            })
            .fail((jqXHR, textStatus, errorThrown) => {
                alert("Ajax通信に失敗しました。");
                console.log("jqXHR          : " + jqXHR.status); // HTTPステータスを表示
                console.log("textStatus     : " + textStatus); // タイムアウト、パースエラーなどのエラー情報を表示
                console.log("errorThrown    : " + errorThrown.message); // 例外情報を表示
            });
    }
}

sample09/routes/web.php

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/sample', [\App\Http\Controllers\SampleController::class, 'index']);
Route::get('/sample/insert-before', [\App\Http\Controllers\SampleController::class, 'insertBefore']);
Route::get('/sample/insert-after', [\App\Http\Controllers\SampleController::class, 'insertAfter']);

動作確認

以下のURLにアクセスして動作確認。

http://<your-ip-address>/sample09/public/sample

  • 行をクリックすると、選択状態がトグルされる
  • 選択されていない行をドラッグしても何も起きない(テキストが選択されるだけ)
  • 選択されている行をドラッグ&ドロップすると、ドラッグ元との位置関係によって、ドロップ先の行の上か下にドラッグした行が挿入される
  • ページをリロードしても順番が保持される

辺りが確認できれば完成。

参考

Geminiと会話して得られたコードを元にカスタマイズした。

share.google