mar10/fancytree

View on GitHub
src/jquery.fancytree.dnd.js

Summary

Maintainability
F
5 days
Test Coverage
/*!
 * jquery.fancytree.dnd.js
 *
 * Drag-and-drop support (jQuery UI draggable/droppable).
 * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/)
 *
 * Copyright (c) 2008-2023, Martin Wendt (https://wwWendt.de)
 *
 * Released under the MIT license
 * https://github.com/mar10/fancytree/wiki/LicenseInfo
 *
 * @version @VERSION
 * @date @DATE
 */

(function (factory) {
    if (typeof define === "function" && define.amd) {
        // AMD. Register as an anonymous module.
        define([
            "jquery",
            "jquery-ui/ui/widgets/draggable",
            "jquery-ui/ui/widgets/droppable",
            "./jquery.fancytree",
        ], factory);
    } else if (typeof module === "object" && module.exports) {
        // Node/CommonJS
        require("./jquery.fancytree");
        module.exports = factory(require("jquery"));
    } else {
        // Browser globals
        factory(jQuery);
    }
})(function ($) {
    "use strict";

    /******************************************************************************
     * Private functions and variables
     */
    var didRegisterDnd = false,
        classDropAccept = "fancytree-drop-accept",
        classDropAfter = "fancytree-drop-after",
        classDropBefore = "fancytree-drop-before",
        classDropOver = "fancytree-drop-over",
        classDropReject = "fancytree-drop-reject",
        classDropTarget = "fancytree-drop-target";

    /* Convert number to string and prepend +/-; return empty string for 0.*/
    function offsetString(n) {
        // eslint-disable-next-line no-nested-ternary
        return n === 0 ? "" : n > 0 ? "+" + n : "" + n;
    }

    //--- Extend ui.draggable event handling --------------------------------------

    function _registerDnd() {
        if (didRegisterDnd) {
            return;
        }

        // Register proxy-functions for draggable.start/drag/stop

        $.ui.plugin.add("draggable", "connectToFancytree", {
            start: function (event, ui) {
                // 'draggable' was renamed to 'ui-draggable' since jQueryUI 1.10
                var draggable =
                        $(this).data("ui-draggable") ||
                        $(this).data("draggable"),
                    sourceNode = ui.helper.data("ftSourceNode") || null;

                if (sourceNode) {
                    // Adjust helper offset, so cursor is slightly outside top/left corner
                    draggable.offset.click.top = -2;
                    draggable.offset.click.left = +16;
                    // Trigger dragStart event
                    // TODO: when called as connectTo..., the return value is ignored(?)
                    return sourceNode.tree.ext.dnd._onDragEvent(
                        "start",
                        sourceNode,
                        null,
                        event,
                        ui,
                        draggable
                    );
                }
            },
            drag: function (event, ui) {
                var ctx,
                    isHelper,
                    logObject,
                    // 'draggable' was renamed to 'ui-draggable' since jQueryUI 1.10
                    draggable =
                        $(this).data("ui-draggable") ||
                        $(this).data("draggable"),
                    sourceNode = ui.helper.data("ftSourceNode") || null,
                    prevTargetNode = ui.helper.data("ftTargetNode") || null,
                    targetNode = $.ui.fancytree.getNode(event.target),
                    dndOpts = sourceNode && sourceNode.tree.options.dnd;

                // logObject = sourceNode || prevTargetNode || $.ui.fancytree;
                // logObject.debug("Drag event:", event, event.shiftKey);
                if (event.target && !targetNode) {
                    // We got a drag event, but the targetNode could not be found
                    // at the event location. This may happen,
                    // 1. if the mouse jumped over the drag helper,
                    // 2. or if a non-fancytree element is dragged
                    // We ignore it:
                    isHelper =
                        $(event.target).closest(
                            "div.fancytree-drag-helper,#fancytree-drop-marker"
                        ).length > 0;
                    if (isHelper) {
                        logObject =
                            sourceNode || prevTargetNode || $.ui.fancytree;
                        logObject.debug("Drag event over helper: ignored.");
                        return;
                    }
                }
                ui.helper.data("ftTargetNode", targetNode);

                if (dndOpts && dndOpts.updateHelper) {
                    ctx = sourceNode.tree._makeHookContext(sourceNode, event, {
                        otherNode: targetNode,
                        ui: ui,
                        draggable: draggable,
                        dropMarker: $("#fancytree-drop-marker"),
                    });
                    dndOpts.updateHelper.call(sourceNode.tree, sourceNode, ctx);
                }

                // Leaving a tree node
                if (prevTargetNode && prevTargetNode !== targetNode) {
                    prevTargetNode.tree.ext.dnd._onDragEvent(
                        "leave",
                        prevTargetNode,
                        sourceNode,
                        event,
                        ui,
                        draggable
                    );
                }
                if (targetNode) {
                    if (!targetNode.tree.options.dnd.dragDrop) {
                        // not enabled as drop target
                    } else if (targetNode === prevTargetNode) {
                        // Moving over same node
                        targetNode.tree.ext.dnd._onDragEvent(
                            "over",
                            targetNode,
                            sourceNode,
                            event,
                            ui,
                            draggable
                        );
                    } else {
                        // Entering this node first time
                        targetNode.tree.ext.dnd._onDragEvent(
                            "enter",
                            targetNode,
                            sourceNode,
                            event,
                            ui,
                            draggable
                        );
                        targetNode.tree.ext.dnd._onDragEvent(
                            "over",
                            targetNode,
                            sourceNode,
                            event,
                            ui,
                            draggable
                        );
                    }
                }
                // else go ahead with standard event handling
            },
            stop: function (event, ui) {
                var logObject,
                    // 'draggable' was renamed to 'ui-draggable' since jQueryUI 1.10:
                    draggable =
                        $(this).data("ui-draggable") ||
                        $(this).data("draggable"),
                    sourceNode = ui.helper.data("ftSourceNode") || null,
                    targetNode = ui.helper.data("ftTargetNode") || null,
                    dropped = event.type === "mouseup" && event.which === 1;

                if (!dropped) {
                    logObject = sourceNode || targetNode || $.ui.fancytree;
                    logObject.debug("Drag was cancelled");
                }
                if (targetNode) {
                    if (dropped) {
                        targetNode.tree.ext.dnd._onDragEvent(
                            "drop",
                            targetNode,
                            sourceNode,
                            event,
                            ui,
                            draggable
                        );
                    }
                    targetNode.tree.ext.dnd._onDragEvent(
                        "leave",
                        targetNode,
                        sourceNode,
                        event,
                        ui,
                        draggable
                    );
                }
                if (sourceNode) {
                    sourceNode.tree.ext.dnd._onDragEvent(
                        "stop",
                        sourceNode,
                        null,
                        event,
                        ui,
                        draggable
                    );
                }
            },
        });

        didRegisterDnd = true;
    }

    /******************************************************************************
     * Drag and drop support
     */
    function _initDragAndDrop(tree) {
        var dnd = tree.options.dnd || null,
            glyph = tree.options.glyph || null;

        // Register 'connectToFancytree' option with ui.draggable
        if (dnd) {
            _registerDnd();
        }
        // Attach ui.draggable to this Fancytree instance
        if (dnd && dnd.dragStart) {
            tree.widget.element.draggable(
                $.extend(
                    {
                        addClasses: false,
                        // DT issue 244: helper should be child of scrollParent:
                        appendTo: tree.$container,
                        //            appendTo: "body",
                        containment: false,
                        //            containment: "parent",
                        delay: 0,
                        distance: 4,
                        revert: false,
                        scroll: true, // to disable, also set css 'position: inherit' on ul.fancytree-container
                        scrollSpeed: 7,
                        scrollSensitivity: 10,
                        // Delegate draggable.start, drag, and stop events to our handler
                        connectToFancytree: true,
                        // Let source tree create the helper element
                        helper: function (event) {
                            var $helper,
                                $nodeTag,
                                opts,
                                sourceNode = $.ui.fancytree.getNode(
                                    event.target
                                );

                            if (!sourceNode) {
                                // #405, DT issue 211: might happen, if dragging a table *header*
                                return "<div>ERROR?: helper requested but sourceNode not found</div>";
                            }
                            opts = sourceNode.tree.options.dnd;
                            $nodeTag = $(sourceNode.span);
                            // Only event and node argument is available
                            $helper = $(
                                "<div class='fancytree-drag-helper'><span class='fancytree-drag-helper-img' /></div>"
                            )
                                .css({ zIndex: 3, position: "relative" }) // so it appears above ext-wide selection bar
                                .append(
                                    $nodeTag
                                        .find("span.fancytree-title")
                                        .clone()
                                );

                            // Attach node reference to helper object
                            $helper.data("ftSourceNode", sourceNode);

                            // Support glyph symbols instead of icons
                            if (glyph) {
                                $helper
                                    .find(".fancytree-drag-helper-img")
                                    .addClass(
                                        glyph.map._addClass +
                                            " " +
                                            glyph.map.dragHelper
                                    );
                            }
                            // Allow to modify the helper, e.g. to add multi-node-drag feedback
                            if (opts.initHelper) {
                                opts.initHelper.call(
                                    sourceNode.tree,
                                    sourceNode,
                                    {
                                        node: sourceNode,
                                        tree: sourceNode.tree,
                                        originalEvent: event,
                                        ui: { helper: $helper },
                                    }
                                );
                            }
                            // We return an unconnected element, so `draggable` will add this
                            // to the parent specified as `appendTo` option
                            return $helper;
                        },
                        start: function (event, ui) {
                            var sourceNode = ui.helper.data("ftSourceNode");
                            return !!sourceNode; // Abort dragging if no node could be found
                        },
                    },
                    tree.options.dnd.draggable
                )
            );
        }
        // Attach ui.droppable to this Fancytree instance
        if (dnd && dnd.dragDrop) {
            tree.widget.element.droppable(
                $.extend(
                    {
                        addClasses: false,
                        tolerance: "intersect",
                        greedy: false,
                        /*
            activate: function(event, ui) {
                tree.debug("droppable - activate", event, ui, this);
            },
            create: function(event, ui) {
                tree.debug("droppable - create", event, ui);
            },
            deactivate: function(event, ui) {
                tree.debug("droppable - deactivate", event, ui);
            },
            drop: function(event, ui) {
                tree.debug("droppable - drop", event, ui);
            },
            out: function(event, ui) {
                tree.debug("droppable - out", event, ui);
            },
            over: function(event, ui) {
                tree.debug("droppable - over", event, ui);
            }
*/
                    },
                    tree.options.dnd.droppable
                )
            );
        }
    }

    /******************************************************************************
     *
     */

    $.ui.fancytree.registerExtension({
        name: "dnd",
        version: "@VERSION",
        // Default options for this extension.
        options: {
            // Make tree nodes accept draggables
            autoExpandMS: 1000, // Expand nodes after n milliseconds of hovering.
            draggable: null, // Additional options passed to jQuery draggable
            droppable: null, // Additional options passed to jQuery droppable
            focusOnClick: false, // Focus, although draggable cancels mousedown event (#270)
            preventVoidMoves: true, // Prevent dropping nodes 'before self', etc.
            preventRecursiveMoves: true, // Prevent dropping nodes on own descendants
            smartRevert: true, // set draggable.revert = true if drop was rejected
            dropMarkerOffsetX: -24, // absolute position offset for .fancytree-drop-marker relatively to ..fancytree-title (icon/img near a node accepting drop)
            dropMarkerInsertOffsetX: -16, // additional offset for drop-marker with hitMode = "before"/"after"
            // Events (drag support)
            dragStart: null, // Callback(sourceNode, data), return true, to enable dnd
            dragStop: null, // Callback(sourceNode, data)
            initHelper: null, // Callback(sourceNode, data)
            updateHelper: null, // Callback(sourceNode, data)
            // Events (drop support)
            dragEnter: null, // Callback(targetNode, data)
            dragOver: null, // Callback(targetNode, data)
            dragExpand: null, // Callback(targetNode, data), return false to prevent autoExpand
            dragDrop: null, // Callback(targetNode, data)
            dragLeave: null, // Callback(targetNode, data)
        },

        treeInit: function (ctx) {
            var tree = ctx.tree;
            this._superApply(arguments);
            // issue #270: draggable eats mousedown events
            if (tree.options.dnd.dragStart) {
                tree.$container.on("mousedown", function (event) {
                    //                if( !tree.hasFocus() && ctx.options.dnd.focusOnClick ) {
                    if (ctx.options.dnd.focusOnClick) {
                        // #270
                        var node = $.ui.fancytree.getNode(event);
                        if (node) {
                            node.debug(
                                "Re-enable focus that was prevented by jQuery UI draggable."
                            );
                            // node.setFocus();
                            // $(node.span).closest(":tabbable").trigger("focus");
                            // $(event.target).trigger("focus");
                            // $(event.target).closest(":tabbable").trigger("focus");
                        }
                        setTimeout(function () {
                            // #300
                            $(event.target)
                                .closest(":tabbable")
                                .trigger("focus");
                        }, 10);
                    }
                });
            }
            _initDragAndDrop(tree);
        },
        /* Display drop marker according to hitMode ('after', 'before', 'over'). */
        _setDndStatus: function (
            sourceNode,
            targetNode,
            helper,
            hitMode,
            accept
        ) {
            var markerOffsetX,
                pos,
                markerAt = "center",
                instData = this._local,
                dndOpt = this.options.dnd,
                glyphOpt = this.options.glyph,
                $source = sourceNode ? $(sourceNode.span) : null,
                $target = $(targetNode.span),
                $targetTitle = $target.find("span.fancytree-title");

            if (!instData.$dropMarker) {
                instData.$dropMarker = $(
                    "<div id='fancytree-drop-marker'></div>"
                )
                    .hide()
                    .css({ "z-index": 1000 })
                    .prependTo($(this.$div).parent());
                //                .prependTo("body");

                if (glyphOpt) {
                    instData.$dropMarker.addClass(
                        glyphOpt.map._addClass + " " + glyphOpt.map.dropMarker
                    );
                }
            }
            if (
                hitMode === "after" ||
                hitMode === "before" ||
                hitMode === "over"
            ) {
                markerOffsetX = dndOpt.dropMarkerOffsetX || 0;
                switch (hitMode) {
                    case "before":
                        markerAt = "top";
                        markerOffsetX += dndOpt.dropMarkerInsertOffsetX || 0;
                        break;
                    case "after":
                        markerAt = "bottom";
                        markerOffsetX += dndOpt.dropMarkerInsertOffsetX || 0;
                        break;
                }

                pos = {
                    my: "left" + offsetString(markerOffsetX) + " center",
                    at: "left " + markerAt,
                    of: $targetTitle,
                };
                if (this.options.rtl) {
                    pos.my = "right" + offsetString(-markerOffsetX) + " center";
                    pos.at = "right " + markerAt;
                }
                instData.$dropMarker
                    .toggleClass(classDropAfter, hitMode === "after")
                    .toggleClass(classDropOver, hitMode === "over")
                    .toggleClass(classDropBefore, hitMode === "before")
                    .toggleClass("fancytree-rtl", !!this.options.rtl)
                    .show()
                    .position($.ui.fancytree.fixPositionOptions(pos));
            } else {
                instData.$dropMarker.hide();
            }
            if ($source) {
                $source
                    .toggleClass(classDropAccept, accept === true)
                    .toggleClass(classDropReject, accept === false);
            }
            $target
                .toggleClass(
                    classDropTarget,
                    hitMode === "after" ||
                        hitMode === "before" ||
                        hitMode === "over"
                )
                .toggleClass(classDropAfter, hitMode === "after")
                .toggleClass(classDropBefore, hitMode === "before")
                .toggleClass(classDropAccept, accept === true)
                .toggleClass(classDropReject, accept === false);

            helper
                .toggleClass(classDropAccept, accept === true)
                .toggleClass(classDropReject, accept === false);
        },

        /*
         * Handles drag'n'drop functionality.
         *
         * A standard jQuery drag-and-drop process may generate these calls:
         *
         * start:
         *     _onDragEvent("start", sourceNode, null, event, ui, draggable);
         * drag:
         *     _onDragEvent("leave", prevTargetNode, sourceNode, event, ui, draggable);
         *     _onDragEvent("over", targetNode, sourceNode, event, ui, draggable);
         *     _onDragEvent("enter", targetNode, sourceNode, event, ui, draggable);
         * stop:
         *     _onDragEvent("drop", targetNode, sourceNode, event, ui, draggable);
         *     _onDragEvent("leave", targetNode, sourceNode, event, ui, draggable);
         *     _onDragEvent("stop", sourceNode, null, event, ui, draggable);
         */
        _onDragEvent: function (
            eventName,
            node,
            otherNode,
            event,
            ui,
            draggable
        ) {
            // if(eventName !== "over"){
            //     this.debug("tree.ext.dnd._onDragEvent(%s, %o, %o) - %o", eventName, node, otherNode, this);
            // }
            var accept,
                nodeOfs,
                parentRect,
                rect,
                relPos,
                relPos2,
                enterResponse,
                hitMode,
                r,
                opts = this.options,
                dnd = opts.dnd,
                ctx = this._makeHookContext(node, event, {
                    otherNode: otherNode,
                    ui: ui,
                    draggable: draggable,
                }),
                res = null,
                self = this,
                $nodeTag = $(node.span);

            if (dnd.smartRevert) {
                draggable.options.revert = "invalid";
            }

            switch (eventName) {
                case "start":
                    if (node.isStatusNode()) {
                        res = false;
                    } else if (dnd.dragStart) {
                        res = dnd.dragStart(node, ctx);
                    }
                    if (res === false) {
                        this.debug("tree.dragStart() cancelled");
                        //draggable._clear();
                        // NOTE: the return value seems to be ignored (drag is not cancelled, when false is returned)
                        // TODO: call this._cancelDrag()?
                        ui.helper.trigger("mouseup").hide();
                    } else {
                        if (dnd.smartRevert) {
                            // #567, #593: fix revert position
                            // rect = node.li.getBoundingClientRect();
                            rect =
                                node[
                                    ctx.tree.nodeContainerAttrName
                                ].getBoundingClientRect();
                            parentRect = $(
                                draggable.options.appendTo
                            )[0].getBoundingClientRect();
                            draggable.originalPosition.left = Math.max(
                                0,
                                rect.left - parentRect.left
                            );
                            draggable.originalPosition.top = Math.max(
                                0,
                                rect.top - parentRect.top
                            );
                        }
                        $nodeTag.addClass("fancytree-drag-source");
                        // Register global handlers to allow cancel
                        $(document).on(
                            "keydown.fancytree-dnd,mousedown.fancytree-dnd",
                            function (event) {
                                // node.tree.debug("dnd global event", event.type, event.which);
                                if (
                                    event.type === "keydown" &&
                                    event.which === $.ui.keyCode.ESCAPE
                                ) {
                                    self.ext.dnd._cancelDrag();
                                } else if (event.type === "mousedown") {
                                    self.ext.dnd._cancelDrag();
                                }
                            }
                        );
                    }
                    break;

                case "enter":
                    if (
                        dnd.preventRecursiveMoves &&
                        node.isDescendantOf(otherNode)
                    ) {
                        r = false;
                    } else {
                        r = dnd.dragEnter ? dnd.dragEnter(node, ctx) : null;
                    }
                    if (!r) {
                        // convert null, undefined, false to false
                        res = false;
                    } else if (Array.isArray(r)) {
                        // TODO: also accept passing an object of this format directly
                        res = {
                            over: $.inArray("over", r) >= 0,
                            before: $.inArray("before", r) >= 0,
                            after: $.inArray("after", r) >= 0,
                        };
                    } else {
                        res = {
                            over: r === true || r === "over",
                            before: r === true || r === "before",
                            after: r === true || r === "after",
                        };
                    }
                    ui.helper.data("enterResponse", res);
                    // this.debug("helper.enterResponse: %o", res);
                    break;

                case "over":
                    enterResponse = ui.helper.data("enterResponse");
                    hitMode = null;
                    if (enterResponse === false) {
                        // Don't call dragOver if onEnter returned false.
                        //                break;
                    } else if (typeof enterResponse === "string") {
                        // Use hitMode from onEnter if provided.
                        hitMode = enterResponse;
                    } else {
                        // Calculate hitMode from relative cursor position.
                        nodeOfs = $nodeTag.offset();
                        relPos = {
                            x: event.pageX - nodeOfs.left,
                            y: event.pageY - nodeOfs.top,
                        };
                        relPos2 = {
                            x: relPos.x / $nodeTag.width(),
                            y: relPos.y / $nodeTag.height(),
                        };

                        if (enterResponse.after && relPos2.y > 0.75) {
                            hitMode = "after";
                        } else if (
                            !enterResponse.over &&
                            enterResponse.after &&
                            relPos2.y > 0.5
                        ) {
                            hitMode = "after";
                        } else if (enterResponse.before && relPos2.y <= 0.25) {
                            hitMode = "before";
                        } else if (
                            !enterResponse.over &&
                            enterResponse.before &&
                            relPos2.y <= 0.5
                        ) {
                            hitMode = "before";
                        } else if (enterResponse.over) {
                            hitMode = "over";
                        }
                        // Prevent no-ops like 'before source node'
                        // TODO: these are no-ops when moving nodes, but not in copy mode
                        if (dnd.preventVoidMoves) {
                            if (node === otherNode) {
                                this.debug(
                                    "    drop over source node prevented"
                                );
                                hitMode = null;
                            } else if (
                                hitMode === "before" &&
                                otherNode &&
                                node === otherNode.getNextSibling()
                            ) {
                                this.debug(
                                    "    drop after source node prevented"
                                );
                                hitMode = null;
                            } else if (
                                hitMode === "after" &&
                                otherNode &&
                                node === otherNode.getPrevSibling()
                            ) {
                                this.debug(
                                    "    drop before source node prevented"
                                );
                                hitMode = null;
                            } else if (
                                hitMode === "over" &&
                                otherNode &&
                                otherNode.parent === node &&
                                otherNode.isLastSibling()
                            ) {
                                this.debug(
                                    "    drop last child over own parent prevented"
                                );
                                hitMode = null;
                            }
                        }
                        //                this.debug("hitMode: %s - %s - %s", hitMode, (node.parent === otherNode), node.isLastSibling());
                        ui.helper.data("hitMode", hitMode);
                    }
                    // Auto-expand node (only when 'over' the node, not 'before', or 'after')
                    if (
                        hitMode !== "before" &&
                        hitMode !== "after" &&
                        dnd.autoExpandMS &&
                        node.hasChildren() !== false &&
                        !node.expanded &&
                        (!dnd.dragExpand || dnd.dragExpand(node, ctx) !== false)
                    ) {
                        node.scheduleAction("expand", dnd.autoExpandMS);
                    }
                    if (hitMode && dnd.dragOver) {
                        // TODO: http://code.google.com/p/dynatree/source/detail?r=625
                        ctx.hitMode = hitMode;
                        res = dnd.dragOver(node, ctx);
                    }
                    accept = res !== false && hitMode !== null;
                    if (dnd.smartRevert) {
                        draggable.options.revert = !accept;
                    }
                    this._local._setDndStatus(
                        otherNode,
                        node,
                        ui.helper,
                        hitMode,
                        accept
                    );
                    break;

                case "drop":
                    hitMode = ui.helper.data("hitMode");
                    if (hitMode && dnd.dragDrop) {
                        ctx.hitMode = hitMode;
                        dnd.dragDrop(node, ctx);
                    }
                    break;

                case "leave":
                    // Cancel pending expand request
                    node.scheduleAction("cancel");
                    ui.helper.data("enterResponse", null);
                    ui.helper.data("hitMode", null);
                    this._local._setDndStatus(
                        otherNode,
                        node,
                        ui.helper,
                        "out",
                        undefined
                    );
                    if (dnd.dragLeave) {
                        dnd.dragLeave(node, ctx);
                    }
                    break;

                case "stop":
                    $nodeTag.removeClass("fancytree-drag-source");
                    $(document).off(".fancytree-dnd");
                    if (dnd.dragStop) {
                        dnd.dragStop(node, ctx);
                    }
                    break;

                default:
                    $.error("Unsupported drag event: " + eventName);
            }
            return res;
        },

        _cancelDrag: function () {
            var dd = $.ui.ddmanager.current;
            if (dd) {
                dd.cancel();
            }
        },
    });
    // Value returned by `require('jquery.fancytree..')`
    return $.ui.fancytree;
}); // End of closure