src/jquery.fancytree.dnd.js
/*!
* 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