javascriptjqueryjquery-uijquery-ui-draggablejquery-ui-droppable

Build the matching option for jQuery UI Droppable's Intersect tolerance


I want to drag an element into TWO OR MORE droppable areas, but those droppable areas need to be wholly contained within my draggable object.

The problem is, none of jQuery UI's existing functionality for droppable tolerances meets this need.

Ideally I'd use "intersect", but where the the draggable and droppable object measurements are reversed in the code. (This logic can be found in jquery-ui.js by searching for $.ui.intersect.)

I have tried overriding that function by duck punching with jQuery and tried setting the tolerance to a custom function like this:

tolerance: function(draggable, droppable) {
            if(!droppable.offset) return false;

            return ...logic check here...
        },
        drop: ...continues...

Neither worked.

Here is a JSFiddle to illustrate what I mean: https://jsfiddle.net/myingling/kgaqb0ay/5/

Again, all Items //covered// by a person should be assigned.


Solution

  • Modifying $.ui.intersect seems to be the best approach. You have different options. If you don't need that much flexibility, you can simply add a tolerance type, 'cover' for example. Then you just need to add a case to the switch that checks the tolerance type in intersect, which can be precisely the inverse of 'fit'. Like this:

      case 'fit':
        return (l <= x1 && x2 <= r && t <= y1 && y2 <= b);
        break;
      case 'cover':
        return (l >= x1 && x2 >= r && t >= y1 && y2 >= b);
        break;
    

    See: https://jsfiddle.net/6nyqja4a/4/

    Or if you want more flexibility, you add a case where tolerance is a function. Then you can pass a function in the option, which allows you to have precise tolerance for different droppable. Something like this for example: In interserct function:

     if (toleranceMode instanceof Function) {
    
        return toleranceMode(draggable, droppable, x1, x2, y1, y2, l, r, t, b);
    
      } else {
        switch (toleranceMode) {
          case 'fit':
            return (l <= x1 && x2 <= r && t <= y1 && y2 <= b);
            break;
    ...
    

    And you call it like this:

    $('.droppable').droppable({
      hoverClass: "yellow",
      tolerance: function(drag, drop, x1, x2, y1, y2, l, r, t, b) {
        return (l >= x1 && x2 >= r && t >= y1 && y2 >= b);
      },
      drop: function(event, ui) {
        $("#value_" + $(this).data("id")).val(ui.draggable.data("id"));
        console.log("Item " + $(this).data("id") + " taken by " + ui.draggable.data("id"));
      }
    });
    

    See: https://jsfiddle.net/h4wm3r09/3/

    From jquery 1.12 $.ui.intersect function is scoped so that it cannot be directly modified afterwards. It is called in $.ui.ddmanager as a local variable, so even if you modify $.ui.intersect, it won't be used. Customizing it is a bit more complex. You can do it this way, basically you rescope intersect and then redefine drag and drop method on $.ui.ddmanager so that it calls the modified intersect:

    var intersect = $.ui.intersect = ( function() {
        function isOverAxis( x, reference, size ) {
            return ( x >= reference ) && ( x < ( reference + size ) );
        }
    
        return function( draggable, droppable, toleranceMode, event ) {
    
            if ( !droppable.offset ) {
                return false;
            }
    
            var x1 = ( draggable.positionAbs ||
                    draggable.position.absolute ).left + draggable.margins.left,
                y1 = ( draggable.positionAbs ||
                    draggable.position.absolute ).top + draggable.margins.top,
                x2 = x1 + draggable.helperProportions.width,
                y2 = y1 + draggable.helperProportions.height,
                l = droppable.offset.left,
                t = droppable.offset.top,
                r = l + droppable.proportions().width,
                b = t + droppable.proportions().height;
            if (toleranceMode instanceof Function) {
    
                return toleranceMode(draggable, droppable, x1, x2, y1, y2, l, r, t, b);
    
            } else {
                switch ( toleranceMode ) {
                    case "fit":
                        return ( l <= x1 && x2 <= r && t <= y1 && y2 <= b );
                    case "intersect":
                        return ( l < x1 + ( draggable.helperProportions.width / 2 ) && // Right Half
                    x2 - ( draggable.helperProportions.width / 2 ) < r && // Left Half
                    t < y1 + ( draggable.helperProportions.height / 2 ) && // Bottom Half
                    y2 - ( draggable.helperProportions.height / 2 ) < b ); // Top Half
                    case "pointer":
                        return isOverAxis( event.pageY, t, droppable.proportions().height ) &&
                    isOverAxis( event.pageX, l, droppable.proportions().width );
                    case "touch":
                        return (
                    ( y1 >= t && y1 <= b ) || // Top edge touching
                    ( y2 >= t && y2 <= b ) || // Bottom edge touching
                    ( y1 < t && y2 > b ) // Surrounded vertically
                ) && (
                    ( x1 >= l && x1 <= r ) || // Left edge touching
                    ( x2 >= l && x2 <= r ) || // Right edge touching
                    ( x1 < l && x2 > r ) // Surrounded horizontally
                );
                    default:
                        return false;
                }
            }
        };
    } )();
    

    Then this, where you don't change anything, you just redefine them exactly the same way.

    $.ui.ddmanager.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 );
        }
    
        // 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;
            }
    
            var parentInstance, scope, parent,
                intersects = intersect( draggable, this, this.options.tolerance, event ),
                c = !intersects && this.isover ?
                    "isout" :
                    ( intersects && !this.isover ? "isover" : null );
            if ( !c ) {
                return;
            }
    
            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 );
            }
        } );
    
    }
    
    $.ui.ddmanager.drop = function( draggable, event ) {
    
        var dropped = false;
    
        // 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 &&
                    intersect( draggable, this, this.options.tolerance, event ) ) {
                dropped = this._drop.call( this, event ) || dropped;
            }
    
            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 dropped;
    
    }
    

    https://jsfiddle.net/u6wfj8mj/1/

    Obviously, this one is a bit more prone to errors and there might be a better way to achieve this. Normally you could extend the widgets for example, which would be cleaner. But intersect and ddmanager are used both in draggable and droppable and are not directly in these widgets. So it's harder to extend in a clean way. You could also put the logic directly in drag event and drop event of you draggables and droppables, but since there's a default tolerance, not sure it's much better.