jquery-ui draggable(1.12.1) 重なりを考慮した処理に変更

jquery-ui draggableについて

jquery-uiを使うと簡単にドラッグ&ドロップできて大変便利です。
しかしデフォルトではドロップ領域が重なった場合、
どちらにもドロップイベントが発生します。
重なったドロップ領域が親子関係であれば、
設定値(greedy)やドロップイベントの返り値で処理することも可能ですが、
posotionをabsoluteやrelativeにして重なってしまうと、
親子関係ではないため、重なったどちらにも反応します。

なんとかならないのか

今回、重なった一番上のドロップ領域のみ反応してほしかった。
ばっちりそれな便利なブログ記事があった。

jQuery droppable の複数要素が重なった場合の問題 – binary-lifeの日記

ありがたい限りでございます。ただ、私もプロの端くれ、
本当にそのソースが正しいのか気になる。記事の日付も古いですし。

問題はないが…

実際にjquery-ui draggableのソースを確認したところ大筋では
問題ないものの、記事が古いからかオプション(tolerance)に対応できていなかった。
そこに対応するのもそうだが、そもそもdropイベント以外に、
over、outのイベントも重なりに対応させたいが、それについては記載がなかった。

そういう思いで作成した完成ソースがこちらになります

(function($){

	$.extend($.ui.ddmanager, {

		// ドロップ処理
		drop: function( draggable, event ) {

			var dropped = false;
			var max_z = -1;
			var _that, z, trgt, pos;

			// Create a copy of the droppables in case the list changes during the drop (#9116)
			$.each( ( $.ui.ddmanager.droppables[ draggable.options.scope ] || [] ).slice(), function() {

				if ( !this.options ) {
					return;
				}

				if ( !this.options.disabled && this.visible &&
						$.ui.intersect( draggable, this, this.options.tolerance, event ) ) {	// ドラッグ位置に掛かっているドロップ範囲のもののみ

					// get z-index
					trgt = this.element;
					z = ( ( trgt.css('zIndex') == 'auto' ) ? 0 : trgt.css('zIndex') ) || 0;

					// 最大z-indexのものを処理対象にする
					if (z > max_z) {
						_that = this;
						max_z = z;
					}
				}

				if ( !this.options.disabled && this.visible && this.accept.call( this.element[ 0 ],
						( draggable.currentItem || draggable.element ) ) ) {
					this.isout = true;
					this.isover = false;
					this._deactivate.call( this, event );
				}

			} );
			
			return (_that) ? _that._drop.call(_that, event) : false;

		},
		
		drag: function( draggable, event ) {

			// If you have a highly dynamic page, you might try this option. It renders positions
			// every time you move the mouse.
			if ( draggable.options.refreshPositions ) {
				$.ui.ddmanager.prepareOffsets( draggable, event );
			}
			
			var dropped = false
				,max_z = -1
				,_that, z, trgt, pos, _c
			;

			// Run through all droppables and check their positions based on specific tolerance options
			$.each( $.ui.ddmanager.droppables[ draggable.options.scope ] || [], function() {

				if ( this.options.disabled || this.greedyChild || !this.visible ) {
					return;
				}

				// ドラッグ位置に掛かっているドロップ範囲のものの中からz-indexが最大のものを見つける
				var intersects = $.ui.intersect( draggable, this, this.options.tolerance, event );
				if (intersects) {

					// get z-index
					trgt = this.element;
					z = ( ( trgt.css('zIndex') == 'auto' ) ? 0 : trgt.css('zIndex') ) || 0;

					// 最大z-indexのものを処理対象にする
					if ( z > max_z ){

						var c = !intersects && this.isover ?
							"isout" :
							( intersects && !this.isover ? "isover" : null );

						_that = this;
						max_z = z;
						_c = c;

					}

				}
			} );

			$.each( $.ui.ddmanager.droppables[ draggable.options.scope ] || [], function() {
				if ( this.options.disabled || this.greedyChild || !this.visible ) {
					return;
				}

				var c;
				if ( _that === this ) {				// 手前のものは通常通りover,outを処理
					c = _c;
				} else {							// 手前それ以外はoutのみ処理
					c = this.isover ? "isout" : null;
				}

				if ( !c ) {							// 変わりないものは処理しない
					return;
				}
				
				var parentInstance, scope, parent;

				if ( this.options.greedy ) {

					// find droppable parents with same scope
					scope = this.options.scope;
					parent = this.element.parents( ":data(ui-droppable)" ).filter( function() {
						return $( this ).droppable( "instance" ).options.scope === scope;
					} );

					if ( parent.length ) {
						parentInstance = $( parent[ 0 ] ).droppable( "instance" );
						parentInstance.greedyChild = ( c === "isover" );
					}
				}

				// We just moved into a greedy child
				if ( parentInstance && c === "isover" ) {
					parentInstance.isover = false;
					parentInstance.isout = true;
					parentInstance._out.call( parentInstance, event );
				}

				this[ c ] = true;
				this[ c === "isout" ? "isover" : "isout" ] = false;
				this[ c === "isover" ? "_over" : "_out" ].call( this, event );

				// We just moved out of a greedy child
				if ( parentInstance && c === "isout" ) {
					parentInstance.isout = false;
					parentInstance.isover = true;
					parentInstance._over.call( parentInstance, event );
				}
			} );

		}
	});

})(jQuery);

・基本的な考え方として、重なりはcssのz-indexを比較しそれが最大のものを使用しています。
これは参考にしたブログのまんまです。皆様にはいつもお世話になっております。
・ドロップイベントでの変更点はオプション(tolerance)への対応です。
このオプションはドロップ領域にドラッグしたときにどうなっていれば
重なっていると判定するかを決めます。

“fit”: Draggable overlaps the droppable entirely.
“intersect”: Draggable overlaps the droppable at least 50% in both directions.
“pointer”: Mouse pointer overlaps the droppable.
“touch”: Draggable overlaps the droppable any amount.

Droppable Widget | jQuery UI API Documentation

・ドラッグイベントは新規で作成しました。
 ドラッグイベントでover、outイベントの判定と呼び出しが行われます。
 イベントが呼び出されると以下の流れで処理されていました(ドラッグイベントはちょっと動く度に呼び出されます)。
 
 1.droppableした要素が格納された配列droppablesを周回する
   (disabledオプション等から無効と判断した要素は無視)
 2.ドラッグ判定と重なっていて、今回初めて重なった場合、overイベント呼び出し
   ドラッグ判定と重なっておらず、今回初めて外れた場合、outイベント呼び出し
   (その前後でgreedyなとこを考慮)
 
 色々試行錯誤したのですが、結局、以下の方法で対応しました。
 
 1.droppableした要素が格納された配列droppablesを周回し
   ドラッグ判定が重なる要素の中からz-indexが最大のものを探す

 2.droppableした要素が格納された配列droppablesを周回する
   (disabledオプション等から無効と判断した要素は無視)
 3.[1]で見つけた要素のみ、以下のイベント呼び出し
   ドラッグ判定と重なっていて、今回初めて重なった場合、overイベント呼び出し
   ドラッグ判定と重なっておらず、今回初めて外れた場合、outイベント呼び出し
   (その前後でgreedyなとこを考慮)
 4.[1]以外の要素はoverになっては困るので、
   それまで重なっていた場合、outイベント呼び出し

おしまい

僕としては最終的にシンプルに実装できたので満足しました。誰の役に立つかは知りません。
懸念はdroppablesのループが1回から2回になったので、
性能の低いパソコンだと影響が出るかも知れないというところです。

以上