雑記帳

整理しない情報集

更新情報

テーブル行をドラッグで移動する

公開日:

カテゴリ: Javascript

Drag and Drop APIの練習に、ドラッグでテーブルの行を移動するスクリプトを書いてみました。

今回の最終形

data1data2
test1sample text.
test2sample text.
test3sample text.

HTML

<table>
	<thead>
		<tr><th></th><th>data1</th><th>data2</th></tr>
	</thead>
	<tbody>
		<tr><th data-drag>&#x283f;</th><td>test1</td><td>sample text.</td></tr>
		<tr><th data-drag>&#x283f;</th><td>test2</td><td>sample text.</td></tr>
		<tr><th data-drag>&#x283f;</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>&#x283f;</th><td>test1</td><td>sample text.</td></tr>

ドラッグのトリガーは、今回はdatasetを使うことにしました。HTML要素の属性にdata-dragが指定されている<th><td>または<tr>要素をドラッグすると、行を移動する仕様にします。

⠿(&#x283f;)は、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-width3px以上でなければ見た目は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イベント以外でも内容を拾いたい場合は、別途変数に格納しておく(今回の場合dragAreadragRow)ことになります。

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クラスの削除と、dragoverdragleaveのイベントリスナーを削除しています。

このあたりのイベントリスナーの処理は人によって異なるかもしれませんが、個人的には使っていないイベントリスナーが残り続けるのは気持ち悪いので消しています。

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ですが、使い勝手は正直微妙でした。mousedownmousemoveを使ったほうが自由度は高そうです。dataTransferの制約上、ブラウザ外からテキストやファイルをドラッグしてくる際は必須のAPIとなりそうですが、それ以外で使うのは敷居が高いかもしれません。

脚注

  1. もし読み取れてしまうと、他のアプリでドラッグ時にブラウザ上を通り抜けた際に、ブラウザ側でドラッグした内容を読み取ることができてしまいます

カテゴリ: Javascript