テーブル行をドラッグで移動する
公開日:
カテゴリ: Javascript
Drag and Drop APIの練習に、ドラッグでテーブルの行を移動するスクリプトを書いてみました。
今回の最終形
data1 | data2 | |
---|---|---|
⠿ | test1 | sample text. |
⠿ | test2 | sample text. |
⠿ | test3 | sample text. |
HTML
<table>
<thead>
<tr><th></th><th>data1</th><th>data2</th></tr>
</thead>
<tbody>
<tr><th data-drag>⠿</th><td>test1</td><td>sample text.</td></tr>
<tr><th data-drag>⠿</th><td>test2</td><td>sample text.</td></tr>
<tr><th data-drag>⠿</th><td>test3</td><td>sample text.</td></tr>
</tbody>
</table>
CSS
tr.js-border-top {
border-top: 1px double red;
}
tr.js-border-bottom {
border-bottom: 1px double red;
}
/* 以下は見栄えのためだけの設定で、無くても動作に影響はありません */
[data-drag] {
user-select: none;
opacity: 50%;
cursor: grab;
}
[data-drag]:active {
cursor: grabbing;
}
Javascript
const getRow = (tableArea, child) => {
let node = child;
while (node && node.parentNode) {
if (tableArea === node.parentNode) return node;
node = node.parentNode;
}
};
[...document.querySelectorAll("[data-drag]")].forEach(element => {
element.draggable = true;
element.addEventListener("dragstart", e => {
const dragRow = e.currentTarget.tagName === "TR" ? e.currentTarget : e.currentTarget.parentNode;
const dragArea = dragRow.parentNode;
e.dataTransfer.setDragImage(dragRow, e.offsetX, e.offsetY);
const dragoverHandler = ev => {
ev.preventDefault();
const row = getRow(dragArea, ev.target);
row.classList.remove("js-border-top", "js-border-bottom");
if (ev.offsetY < ev.target.offsetHeight / 2) {
row.classList.add("js-border-top");
} else {
row.classList.add("js-border-bottom");
}
};
const dragleaveHandler = ev => {
ev.currentTarget.classList.remove("js-border-top", "js-border-bottom");
};
const dropHandler = ev => {
const pos = ev.offsetY < ev.target.offsetHeight / 2 ? "beforebegin" : "afterend";
getRow(dragArea, ev.target).insertAdjacentElement(pos, dragRow);
};
const dragendHandler = ev => {
dragArea.removeEventListener("dragover", dragoverHandler);
[...dragArea.children].forEach(item => {
item.removeEventListener("dragleave", dragleaveHandler);
item.classList.remove("js-border-top", "js-border-bottom");
});
};
[...dragArea.childNodes].forEach(item => {
item.addEventListener("dragleave", dragleaveHandler);
});
dragArea.addEventListener("dragover", dragoverHandler);
dragArea.addEventListener("drop", dropHandler, {once: true});
dragArea.addEventListener("dragend", dragendHandler, {once: true});
});
});
解説
HTML部分
テーブルは一般的な構成の <table>
> <thead><tbody>
> <tr>
> <th><td>
を想定します。
<tr><th data-drag>⠿</th><td>test1</td><td>sample text.</td></tr>
ドラッグのトリガーは、今回はdataset
を使うことにしました。HTML要素の属性にdata-drag
が指定されている<th><td>
または<tr>
要素をドラッグすると、行を移動する仕様にします。
⠿(⠿
)は、Unicodeの点字です。ドラッグできる場所を表すアイコンにちょうど良さそうだったので使っています。今回はサンプルなので気にしませんが、本来の意味とは違う記号の使い方をしているので、あまり真似しないほうがいいかもしれません。
CSS部分
tr.js-border-top {
border-top: 1px double red;
}
tr.js-border-bottom {
border-bottom: 1px double red;
}
操作中の行の移動先を視覚化する際に指定するスタイルです。今回はborder-collapse: collapse;
が指定されている表に対して実行するので、移動先の視覚化を優先するためにborder-style: double;
を指定しています。枠線の優先度について一つ前の記事をご覧ください。
もちろんborder-width
を太くして優先させてもOKですが、元の線幅と異なってしまう場合は、ドラッグ開始時と終了時に氷の高さが変化することになります。
ちなみにborder-style: double;
は、border-width
が3px
以上でなければ見た目はsolid
と同じになります。
[data-drag] {
user-select: none;
opacity: 50%;
cursor: grab
}
[data-drag]:active {
cursor: grabbing;
}
残りの部分はデザイン上のおまけです。カーソルについて、ドラッグ中はCSSで指定したものは使用されない仕様なので、あまり指定する意味合いは薄いです。:active
擬似クラスは、マウスボタンを押下していてドラッグ開始前の間だけ適用されます。
上述の点字文字を都合よく使っていることもあり、ドラッグ可能な要素を選択できてしまうのは誰も幸せにならないので、user-select: none;
を指定しています。ドラッグ可能な要素は、その要素を起点にドラッグを開始した場合は選択できませんが、他の要素からのドラッグやCtrl + A
などでは選択できます。
Javascript: getRow()
const getRow = (tableArea, child) => {
let node = child;
while (node && node.parentNode) {
if (tableArea === node.parentNode) return node;
node = node.parentNode;
}
};
行を取得する関数です。第1引数の子要素であることを確認します。
Javascript: ドラッグ要素の初期処理
[...document.querySelectorAll("[data-drag]")].forEach(element => {
element.draggable = true;
element.addEventListener("dragstart", e => {
// 処理
});
});
ドラッグ対象の要素を取得してきて、ドラッグ開始のイベントリスナを登録します。今回は特に想定していませんが、もし跡から行を追加する場合、追加した[data-drag]
にこのイベントハンドラを登録する必要があります。
今回はHTML側でdraggable
属性を指定していないので、Javascript側の初期処理で設定します。
Javascript: ドラッグ開始時の初期処理
const dragRow = e.currentTarget.tagName === "TR" ? e.currentTarget : e.currentTarget.parentNode;
const dragArea = dragRow.parentNode;
e.dataTransfer.setDragImage(dragRow, e.offsetX, e.offsetY);
ドラッグ中の行要素、ドラッグ中の表エリア、ドラッグ中のプレビューイメージを設定します。
ドラッグ中の行要素は<tr>
タグに[data-drag]
が指定されていても動くようにしています。
ドラッグ中のプレビューイメージは、画像データだけでなくHTML要素も指定可能です。HTML要素を入れた場合、そのHTML要素のプレビューが表示されます。プレビューイメージの表示座標はドラッグ開始位置のマウスカーソルの座標を指定していますがお好みで。
dataTransfer
にはドラッグデータをセットすることができますが、セキュリティの関係上drop
イベント以外では中身を読み取ることができません1。今回のような、Webページ内で完結するものかつdrop
イベント以外でも内容を拾いたい場合は、別途変数に格納しておく(今回の場合dragArea
やdragRow
)ことになります。
Javascript: dragover
const dragoverHandler = ev => {
ev.preventDefault();
const row = getRow(dragArea, ev.target);
row.classList.remove("js-border-top", "js-border-bottom");
if (ev.offsetY < ev.target.offsetHeight / 2) {
row.classList.add("js-border-top");
} else {
row.classList.add("js-border-bottom");
}
};
ドラッグ時の主要イベント2つのうちの1つ、ドラッグ中に実行され続けるイベントのハンドラです。
デフォルトのイベントはドロップ不可となるのでpreventDefault()
でキャンセルします。
ドラッグ中のマウスカーソルの座標で、ドロップ先の場所が変わるので、ドロップ先を視覚的に表す処理を入れています。今回は、カーソルが乗っている行の半分より上の場合は上の行へ、下の場合は下の行へ移動させる仕様にしたため、それに合わせた表示になるようにCSSクラスを追加しています。
Javascript: dropleave
const dragleaveHandler = ev => {
ev.currentTarget.classList.remove("js-border-top", "js-border-bottom");
};
ドラッグ中に要素からマウスカーソルが離れた際に発生するイベントです。
今回は上述のdragover
イベントでのドロップ先を視覚的に表す処理でCSSクラスを追加しているので、離れた際に追加したCSSクラスを削除します。
Javascript: drop
const dropHandler = ev => {
const pos = ev.offsetY < ev.target.offsetHeight / 2 ? "beforebegin" : "afterend";
getRow(dragArea, ev.target).insertAdjacentElement(pos, dragRow);
};
主要イベントの2つ目、ドロップ時のイベントです。
ドロップした行を取得し、行の上半分にドロップした場合はbeforebegin
で対象要素の兄要素として、行の下半分にドロップした場合はafterend
で対象要素の弟要素として挿入します。
HTMLでは同一要素オブジェクトはDOMツリー内に複数存在できないので、既にDOMツリー内にいる要素の挿入操作を行うと要素が移動します。
drop
イベントのあとにdragend
イベントも発生するので、dragover
で追加したCSSクラスは、後述のdragend
で削除します。
Javascript: dragend
const dragendHandler = ev => {
dragArea.removeEventListener("dragover", dragoverHandler);
dragArea.removeEventListener("drop", dropHandler);
[...dragArea.children].forEach(item => {
item.removeEventListener("dragleave", dragleaveHandler);
item.classList.remove("js-border-top", "js-border-bottom");
});
};
ドロップの有無にかかわらず、ドラッグ操作が終了した際のイベントです。
dragover
で追加したCSSクラスの削除と、dragover
、dragleave
のイベントリスナーを削除しています。
このあたりのイベントリスナーの処理は人によって異なるかもしれませんが、個人的には使っていないイベントリスナーが残り続けるのは気持ち悪いので消しています。
dragend
イベントは1回しか実行されないので、ドラッグ開始時のイベント側でonce: true
を指定して自動削除されるようにしています。
Javascript: イベントリスナの登録
[...dragArea.childNodes].forEach(item => {
item.addEventListener("dragleave", dragleaveHandler);
});
dragArea.addEventListener("dragover", dragoverHandler);
dragArea.addEventListener("drop", dropHandler);
dragArea.addEventListener("dragend", dragendHandler, {once: true});
最後に、ここまで定義してきたイベントリスナーをdragstart
イベント発生時に登録します。dragend
は1回のドラッグイベントで1回しか発生しないため、once: true
を指定しておくとdragend
時に消さずに済みます。
補足
今回は考慮していませんが、table-collapse: collapse
ではない場合、行と行の間の隙間はdrop
イベントが発生しないためドロップできません。対策として行の親要素(今回の場合<tbody>
)にもdrop
イベントの処理を作成する必要があります。
おわり
おもしろいAPIですが、使い勝手は正直微妙でした。mousedown
やmousemove
を使ったほうが自由度は高そうです。dataTransfer
の制約上、ブラウザ外からテキストやファイルをドラッグしてくる際は必須のAPIとなりそうですが、それ以外で使うのは敷居が高いかもしれません。
脚注
もし読み取れてしまうと、他のアプリでドラッグ時にブラウザ上を通り抜けた際に、ブラウザ側でドラッグした内容を読み取ることができてしまいます ↩
カテゴリ: Javascript