jquery.fancytree
Version:
JavaScript tree view / tree grid plugin with support for keyboard, inline editing, filtering, checkboxes, drag'n'drop, and lazy loading
1,786 lines (1,717 loc) • 219 kB
JavaScript
/*!
* jquery.fancytree.js
* Tree view control with support for lazy loading and much more.
* 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 2.38.5
* @date 2025-04-05T06:40:00Z
*/
/** Core Fancytree module.
*/
// UMD wrapper for the Fancytree core module
(function (factory) {
if (typeof define === "function" && define.amd) {
// AMD. Register as an anonymous module.
define(["jquery", "./jquery.fancytree.ui-deps"], factory);
} else if (typeof module === "object" && module.exports) {
// Node/CommonJS
require("./jquery.fancytree.ui-deps");
module.exports = factory(require("jquery"));
} else {
// Browser globals
factory(jQuery);
}
})(function ($) {
"use strict";
// prevent duplicate loading
if ($.ui && $.ui.fancytree) {
$.ui.fancytree.warn("Fancytree: ignored duplicate include");
return;
}
/******************************************************************************
* Private functions and variables
*/
var i,
attr,
FT = null, // initialized below
TEST_IMG = new RegExp(/\.|\//), // strings are considered image urls if they contain '.' or '/'
REX_HTML = /[&<>"'/]/g, // Escape those characters
REX_TOOLTIP = /[<>"'/]/g, // Don't escape `&` in tooltips
RECURSIVE_REQUEST_ERROR = "$recursive_request",
INVALID_REQUEST_TARGET_ERROR = "$request_target_invalid",
ENTITY_MAP = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
"/": "/",
},
IGNORE_KEYCODES = { 16: true, 17: true, 18: true },
SPECIAL_KEYCODES = {
8: "backspace",
9: "tab",
10: "return",
13: "return",
// 16: null, 17: null, 18: null, // ignore shift, ctrl, alt
19: "pause",
20: "capslock",
27: "esc",
32: "space",
33: "pageup",
34: "pagedown",
35: "end",
36: "home",
37: "left",
38: "up",
39: "right",
40: "down",
45: "insert",
46: "del",
59: ";",
61: "=",
// 91: null, 93: null, // ignore left and right meta
96: "0",
97: "1",
98: "2",
99: "3",
100: "4",
101: "5",
102: "6",
103: "7",
104: "8",
105: "9",
106: "*",
107: "+",
109: "-",
110: ".",
111: "/",
112: "f1",
113: "f2",
114: "f3",
115: "f4",
116: "f5",
117: "f6",
118: "f7",
119: "f8",
120: "f9",
121: "f10",
122: "f11",
123: "f12",
144: "numlock",
145: "scroll",
173: "-",
186: ";",
187: "=",
188: ",",
189: "-",
190: ".",
191: "/",
192: "`",
219: "[",
220: "\\",
221: "]",
222: "'",
},
MODIFIERS = {
16: "shift",
17: "ctrl",
18: "alt",
91: "meta",
93: "meta",
},
MOUSE_BUTTONS = { 0: "", 1: "left", 2: "middle", 3: "right" },
// Boolean attributes that can be set with equivalent class names in the LI tags
// Note: v2.23: checkbox and hideCheckbox are *not* in this list
CLASS_ATTRS =
"active expanded focus folder lazy radiogroup selected unselectable unselectableIgnore".split(
" "
),
CLASS_ATTR_MAP = {},
// Top-level Fancytree attributes, that can be set by dict
TREE_ATTRS = "columns types".split(" "),
// TREE_ATTR_MAP = {},
// Top-level FancytreeNode attributes, that can be set by dict
NODE_ATTRS =
"checkbox expanded extraClasses folder icon iconTooltip key lazy partsel radiogroup refKey selected statusNodeType title tooltip type unselectable unselectableIgnore unselectableStatus".split(
" "
),
NODE_ATTR_MAP = {},
// Mapping of lowercase -> real name (because HTML5 data-... attribute only supports lowercase)
NODE_ATTR_LOWERCASE_MAP = {},
// Attribute names that should NOT be added to node.data
NONE_NODE_DATA_MAP = {
active: true,
children: true,
data: true,
focus: true,
};
for (i = 0; i < CLASS_ATTRS.length; i++) {
CLASS_ATTR_MAP[CLASS_ATTRS[i]] = true;
}
for (i = 0; i < NODE_ATTRS.length; i++) {
attr = NODE_ATTRS[i];
NODE_ATTR_MAP[attr] = true;
if (attr !== attr.toLowerCase()) {
NODE_ATTR_LOWERCASE_MAP[attr.toLowerCase()] = attr;
}
}
// for(i=0; i<TREE_ATTRS.length; i++) {
// TREE_ATTR_MAP[TREE_ATTRS[i]] = true;
// }
function _assert(cond, msg) {
// TODO: see qunit.js extractStacktrace()
if (!cond) {
msg = msg ? ": " + msg : "";
msg = "Fancytree assertion failed" + msg;
// consoleApply("assert", [!!cond, msg]);
// #1041: Raised exceptions may not be visible in the browser
// console if inside promise chains, so we also print directly:
if ($.ui && $.ui.fancytree) {
$.ui.fancytree.error(msg);
}
// Throw exception:
$.error(msg);
}
}
function _hasProp(object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
}
/* Replacement for the deprecated `jQuery.isFunction()`. */
function _isFunction(obj) {
return typeof obj === "function";
}
/* Replacement for the deprecated `jQuery.trim()`. */
function _trim(text) {
return text == null ? "" : text.trim();
}
/* Replacement for the deprecated `jQuery.isArray()`. */
var _isArray = Array.isArray;
_assert($.ui, "Fancytree requires jQuery UI (http://jqueryui.com)");
function consoleApply(method, args) {
var i,
s,
fn = window.console ? window.console[method] : null;
if (fn) {
try {
fn.apply(window.console, args);
} catch (e) {
// IE 8?
s = "";
for (i = 0; i < args.length; i++) {
s += args[i];
}
fn(s);
}
}
}
/* support: IE8 Polyfil for Date.now() */
if (!Date.now) {
Date.now = function now() {
return new Date().getTime();
};
}
/*Return true if x is a FancytreeNode.*/
function _isNode(x) {
return !!(x.tree && x.statusNodeType !== undefined);
}
/** Return true if dotted version string is equal or higher than requested version.
*
* See http://jsfiddle.net/mar10/FjSAN/
*/
function isVersionAtLeast(dottedVersion, major, minor, patch) {
var i,
v,
t,
verParts = $.map(_trim(dottedVersion).split("."), function (e) {
return parseInt(e, 10);
}),
testParts = $.map(
Array.prototype.slice.call(arguments, 1),
function (e) {
return parseInt(e, 10);
}
);
for (i = 0; i < testParts.length; i++) {
v = verParts[i] || 0;
t = testParts[i] || 0;
if (v !== t) {
return v > t;
}
}
return true;
}
/**
* Deep-merge a list of objects (but replace array-type options).
*
* jQuery's $.extend(true, ...) method does a deep merge, that also merges Arrays.
* This variant is used to merge extension defaults with user options, and should
* merge objects, but override arrays (for example the `triggerStart: [...]` option
* of ext-edit). Also `null` values are copied over and not skipped.
*
* See issue #876
*
* Example:
* _simpleDeepMerge({}, o1, o2);
*/
function _simpleDeepMerge() {
var options,
name,
src,
copy,
clone,
target = arguments[0] || {},
i = 1,
length = arguments.length;
// Handle case when target is a string or something (possible in deep copy)
if (typeof target !== "object" && !_isFunction(target)) {
target = {};
}
if (i === length) {
throw Error("need at least two args");
}
for (; i < length; i++) {
// Only deal with non-null/undefined values
if ((options = arguments[i]) != null) {
// Extend the base object
for (name in options) {
if (_hasProp(options, name)) {
src = target[name];
copy = options[name];
// Prevent never-ending loop
if (target === copy) {
continue;
}
// Recurse if we're merging plain objects
// (NOTE: unlike $.extend, we don't merge arrays, but replace them)
if (copy && $.isPlainObject(copy)) {
clone = src && $.isPlainObject(src) ? src : {};
// Never move original objects, clone them
target[name] = _simpleDeepMerge(clone, copy);
// Don't bring in undefined values
} else if (copy !== undefined) {
target[name] = copy;
}
}
}
}
}
// Return the modified object
return target;
}
/** Return a wrapper that calls sub.methodName() and exposes
* this : tree
* this._local : tree.ext.EXTNAME
* this._super : base.methodName.call()
* this._superApply : base.methodName.apply()
*/
function _makeVirtualFunction(methodName, tree, base, extension, extName) {
// $.ui.fancytree.debug("_makeVirtualFunction", methodName, tree, base, extension, extName);
// if(rexTestSuper && !rexTestSuper.test(func)){
// // extension.methodName() doesn't call _super(), so no wrapper required
// return func;
// }
// Use an immediate function as closure
var proxy = (function () {
var prevFunc = tree[methodName], // org. tree method or prev. proxy
baseFunc = extension[methodName], //
_local = tree.ext[extName],
_super = function () {
return prevFunc.apply(tree, arguments);
},
_superApply = function (args) {
return prevFunc.apply(tree, args);
};
// Return the wrapper function
return function () {
var prevLocal = tree._local,
prevSuper = tree._super,
prevSuperApply = tree._superApply;
try {
tree._local = _local;
tree._super = _super;
tree._superApply = _superApply;
return baseFunc.apply(tree, arguments);
} finally {
tree._local = prevLocal;
tree._super = prevSuper;
tree._superApply = prevSuperApply;
}
};
})(); // end of Immediate Function
return proxy;
}
/**
* Subclass `base` by creating proxy functions
*/
function _subclassObject(tree, base, extension, extName) {
// $.ui.fancytree.debug("_subclassObject", tree, base, extension, extName);
for (var attrName in extension) {
if (typeof extension[attrName] === "function") {
if (typeof tree[attrName] === "function") {
// override existing method
tree[attrName] = _makeVirtualFunction(
attrName,
tree,
base,
extension,
extName
);
} else if (attrName.charAt(0) === "_") {
// Create private methods in tree.ext.EXTENSION namespace
tree.ext[extName][attrName] = _makeVirtualFunction(
attrName,
tree,
base,
extension,
extName
);
} else {
$.error(
"Could not override tree." +
attrName +
". Use prefix '_' to create tree." +
extName +
"._" +
attrName
);
}
} else {
// Create member variables in tree.ext.EXTENSION namespace
if (attrName !== "options") {
tree.ext[extName][attrName] = extension[attrName];
}
}
}
}
function _getResolvedPromise(context, argArray) {
if (context === undefined) {
return $.Deferred(function () {
this.resolve();
}).promise();
}
return $.Deferred(function () {
this.resolveWith(context, argArray);
}).promise();
}
function _getRejectedPromise(context, argArray) {
if (context === undefined) {
return $.Deferred(function () {
this.reject();
}).promise();
}
return $.Deferred(function () {
this.rejectWith(context, argArray);
}).promise();
}
function _makeResolveFunc(deferred, context) {
return function () {
deferred.resolveWith(context);
};
}
function _getElementDataAsDict($el) {
// Evaluate 'data-NAME' attributes with special treatment for 'data-json'.
var d = $.extend({}, $el.data()),
json = d.json;
delete d.fancytree; // added to container by widget factory (old jQuery UI)
delete d.uiFancytree; // added to container by widget factory
if (json) {
delete d.json;
// <li data-json='...'> is already returned as object (http://api.jquery.com/data/#data-html5)
d = $.extend(d, json);
}
return d;
}
function _escapeTooltip(s) {
return ("" + s).replace(REX_TOOLTIP, function (s) {
return ENTITY_MAP[s];
});
}
// TODO: use currying
function _makeNodeTitleMatcher(s) {
s = s.toLowerCase();
return function (node) {
return node.title.toLowerCase().indexOf(s) >= 0;
};
}
function _makeNodeTitleStartMatcher(s) {
var reMatch = new RegExp("^" + s, "i");
return function (node) {
return reMatch.test(node.title);
};
}
/******************************************************************************
* FancytreeNode
*/
/**
* Creates a new FancytreeNode
*
* @class FancytreeNode
* @classdesc A FancytreeNode represents the hierarchical data model and operations.
*
* @param {FancytreeNode} parent
* @param {NodeData} obj
*
* @property {Fancytree} tree The tree instance
* @property {FancytreeNode} parent The parent node
* @property {string} key Node id (must be unique inside the tree)
* @property {string} title Display name (may contain HTML)
* @property {object} data Contains all extra data that was passed on node creation
* @property {FancytreeNode[] | null | undefined} children Array of child nodes.<br>
* For lazy nodes, null or undefined means 'not yet loaded'. Use an empty array
* to define a node that has no children.
* @property {boolean} expanded Use isExpanded(), setExpanded() to access this property.
* @property {string} extraClasses Additional CSS classes, added to the node's `<span>`.<br>
* Note: use `node.add/remove/toggleClass()` to modify.
* @property {boolean} folder Folder nodes have different default icons and click behavior.<br>
* Note: Also non-folders may have children.
* @property {string} statusNodeType null for standard nodes. Otherwise type of special system node: 'error', 'loading', 'nodata', or 'paging'.
* @property {boolean} lazy True if this node is loaded on demand, i.e. on first expansion.
* @property {boolean} selected Use isSelected(), setSelected() to access this property.
* @property {string} tooltip Alternative description used as hover popup
* @property {string} iconTooltip Description used as hover popup for icon. @since 2.27
* @property {string} type Node type, used with tree.types map. @since 2.27
*/
function FancytreeNode(parent, obj) {
var i, l, name, cl;
this.parent = parent;
this.tree = parent.tree;
this.ul = null;
this.li = null; // <li id='key' ftnode=this> tag
this.statusNodeType = null; // if this is a temp. node to display the status of its parent
this._isLoading = false; // if this node itself is loading
this._error = null; // {message: '...'} if a load error occurred
this.data = {};
// TODO: merge this code with node.toDict()
// copy attributes from obj object
for (i = 0, l = NODE_ATTRS.length; i < l; i++) {
name = NODE_ATTRS[i];
this[name] = obj[name];
}
// unselectableIgnore and unselectableStatus imply unselectable
if (
this.unselectableIgnore != null ||
this.unselectableStatus != null
) {
this.unselectable = true;
}
if (obj.hideCheckbox) {
$.error(
"'hideCheckbox' node option was removed in v2.23.0: use 'checkbox: false'"
);
}
// node.data += obj.data
if (obj.data) {
$.extend(this.data, obj.data);
}
// Copy all other attributes to this.data.NAME
for (name in obj) {
if (
!NODE_ATTR_MAP[name] &&
(this.tree.options.copyFunctionsToData ||
!_isFunction(obj[name])) &&
!NONE_NODE_DATA_MAP[name]
) {
// node.data.NAME = obj.NAME
this.data[name] = obj[name];
}
}
// Fix missing key
if (this.key == null) {
// test for null OR undefined
if (this.tree.options.defaultKey) {
this.key = "" + this.tree.options.defaultKey(this);
_assert(this.key, "defaultKey() must return a unique key");
} else {
this.key = "_" + FT._nextNodeKey++;
}
} else {
this.key = "" + this.key; // Convert to string (#217)
}
// Fix tree.activeNode
// TODO: not elegant: we use obj.active as marker to set tree.activeNode
// when loading from a dictionary.
if (obj.active) {
_assert(
this.tree.activeNode === null,
"only one active node allowed"
);
this.tree.activeNode = this;
}
if (obj.selected) {
// #186
this.tree.lastSelectedNode = this;
}
// TODO: handle obj.focus = true
// Create child nodes
cl = obj.children;
if (cl) {
if (cl.length) {
this._setChildren(cl);
} else {
// if an empty array was passed for a lazy node, keep it, in order to mark it 'loaded'
this.children = this.lazy ? [] : null;
}
} else {
this.children = null;
}
// Add to key/ref map (except for root node)
// if( parent ) {
this.tree._callHook("treeRegisterNode", this.tree, true, this);
// }
}
FancytreeNode.prototype = /** @lends FancytreeNode# */ {
/* Return the direct child FancytreeNode with a given key, index. */
_findDirectChild: function (ptr) {
var i,
l,
cl = this.children;
if (cl) {
if (typeof ptr === "string") {
for (i = 0, l = cl.length; i < l; i++) {
if (cl[i].key === ptr) {
return cl[i];
}
}
} else if (typeof ptr === "number") {
return this.children[ptr];
} else if (ptr.parent === this) {
return ptr;
}
}
return null;
},
// TODO: activate()
// TODO: activateSilently()
/* Internal helper called in recursive addChildren sequence.*/
_setChildren: function (children) {
_assert(
children && (!this.children || this.children.length === 0),
"only init supported"
);
this.children = [];
for (var i = 0, l = children.length; i < l; i++) {
this.children.push(new FancytreeNode(this, children[i]));
}
this.tree._callHook(
"treeStructureChanged",
this.tree,
"setChildren"
);
},
/**
* Append (or insert) a list of child nodes.
*
* @param {NodeData[]} children array of child node definitions (also single child accepted)
* @param {FancytreeNode | string | Integer} [insertBefore] child node (or key or index of such).
* If omitted, the new children are appended.
* @returns {FancytreeNode} first child added
*
* @see FancytreeNode#applyPatch
*/
addChildren: function (children, insertBefore) {
var i,
l,
pos,
origFirstChild = this.getFirstChild(),
origLastChild = this.getLastChild(),
firstNode = null,
nodeList = [];
if ($.isPlainObject(children)) {
children = [children];
}
if (!this.children) {
this.children = [];
}
for (i = 0, l = children.length; i < l; i++) {
nodeList.push(new FancytreeNode(this, children[i]));
}
firstNode = nodeList[0];
if (insertBefore == null) {
this.children = this.children.concat(nodeList);
} else {
// Returns null if insertBefore is not a direct child:
insertBefore = this._findDirectChild(insertBefore);
pos = $.inArray(insertBefore, this.children);
_assert(pos >= 0, "insertBefore must be an existing child");
// insert nodeList after children[pos]
this.children.splice.apply(
this.children,
[pos, 0].concat(nodeList)
);
}
if (origFirstChild && !insertBefore) {
// #708: Fast path -- don't render every child of root, just the new ones!
// #723, #729: but only if it's appended to an existing child list
for (i = 0, l = nodeList.length; i < l; i++) {
nodeList[i].render(); // New nodes were never rendered before
}
// Adjust classes where status may have changed
// Has a first child
if (origFirstChild !== this.getFirstChild()) {
// Different first child -- recompute classes
origFirstChild.renderStatus();
}
if (origLastChild !== this.getLastChild()) {
// Different last child -- recompute classes
origLastChild.renderStatus();
}
} else if (!this.parent || this.parent.ul || this.tr) {
// render if the parent was rendered (or this is a root node)
this.render();
}
if (this.tree.options.selectMode === 3) {
this.fixSelection3FromEndNodes();
}
this.triggerModifyChild(
"add",
nodeList.length === 1 ? nodeList[0] : null
);
return firstNode;
},
/**
* Add class to node's span tag and to .extraClasses.
*
* @param {string} className class name
*
* @since 2.17
*/
addClass: function (className) {
return this.toggleClass(className, true);
},
/**
* Append or prepend a node, or append a child node.
*
* This a convenience function that calls addChildren()
*
* @param {NodeData} node node definition
* @param {string} [mode=child] 'before', 'after', 'firstChild', or 'child' ('over' is a synonym for 'child')
* @returns {FancytreeNode} new node
*/
addNode: function (node, mode) {
if (mode === undefined || mode === "over") {
mode = "child";
}
switch (mode) {
case "after":
return this.getParent().addChildren(
node,
this.getNextSibling()
);
case "before":
return this.getParent().addChildren(node, this);
case "firstChild":
// Insert before the first child if any
var insertBefore = this.children ? this.children[0] : null;
return this.addChildren(node, insertBefore);
case "child":
case "over":
return this.addChildren(node);
}
_assert(false, "Invalid mode: " + mode);
},
/**Add child status nodes that indicate 'More...', etc.
*
* This also maintains the node's `partload` property.
* @param {boolean|object} node optional node definition. Pass `false` to remove all paging nodes.
* @param {string} [mode='child'] 'child'|firstChild'
* @since 2.15
*/
addPagingNode: function (node, mode) {
var i, n;
mode = mode || "child";
if (node === false) {
for (i = this.children.length - 1; i >= 0; i--) {
n = this.children[i];
if (n.statusNodeType === "paging") {
this.removeChild(n);
}
}
this.partload = false;
return;
}
node = $.extend(
{
title: this.tree.options.strings.moreData,
statusNodeType: "paging",
icon: false,
},
node
);
this.partload = true;
return this.addNode(node, mode);
},
/**
* Append new node after this.
*
* This a convenience function that calls addNode(node, 'after')
*
* @param {NodeData} node node definition
* @returns {FancytreeNode} new node
*/
appendSibling: function (node) {
return this.addNode(node, "after");
},
/**
* (experimental) Apply a modification (or navigation) operation.
*
* @param {string} cmd
* @param {object} [opts]
* @see Fancytree#applyCommand
* @since 2.32
*/
applyCommand: function (cmd, opts) {
return this.tree.applyCommand(cmd, this, opts);
},
/**
* Modify existing child nodes.
*
* @param {NodePatch} patch
* @returns {$.Promise}
* @see FancytreeNode#addChildren
*/
applyPatch: function (patch) {
// patch [key, null] means 'remove'
if (patch === null) {
this.remove();
return _getResolvedPromise(this);
}
// TODO: make sure that root node is not collapsed or modified
// copy (most) attributes to node.ATTR or node.data.ATTR
var name,
promise,
v,
IGNORE_MAP = { children: true, expanded: true, parent: true }; // TODO: should be global
for (name in patch) {
if (_hasProp(patch, name)) {
v = patch[name];
if (!IGNORE_MAP[name] && !_isFunction(v)) {
if (NODE_ATTR_MAP[name]) {
this[name] = v;
} else {
this.data[name] = v;
}
}
}
}
// Remove and/or create children
if (_hasProp(patch, "children")) {
this.removeChildren();
if (patch.children) {
// only if not null and not empty list
// TODO: addChildren instead?
this._setChildren(patch.children);
}
// TODO: how can we APPEND or INSERT child nodes?
}
if (this.isVisible()) {
this.renderTitle();
this.renderStatus();
}
// Expand collapse (final step, since this may be async)
if (_hasProp(patch, "expanded")) {
promise = this.setExpanded(patch.expanded);
} else {
promise = _getResolvedPromise(this);
}
return promise;
},
/** Collapse all sibling nodes.
* @returns {$.Promise}
*/
collapseSiblings: function () {
return this.tree._callHook("nodeCollapseSiblings", this);
},
/** Copy this node as sibling or child of `node`.
*
* @param {FancytreeNode} node source node
* @param {string} [mode=child] 'before' | 'after' | 'child'
* @param {Function} [map] callback function(NodeData, FancytreeNode) that could modify the new node
* @returns {FancytreeNode} new
*/
copyTo: function (node, mode, map) {
return node.addNode(this.toDict(true, map), mode);
},
/** Count direct and indirect children.
*
* @param {boolean} [deep=true] pass 'false' to only count direct children
* @returns {int} number of child nodes
*/
countChildren: function (deep) {
var cl = this.children,
i,
l,
n;
if (!cl) {
return 0;
}
n = cl.length;
if (deep !== false) {
for (i = 0, l = n; i < l; i++) {
n += cl[i].countChildren();
}
}
return n;
},
// TODO: deactivate()
/** Write to browser console if debugLevel >= 4 (prepending node info)
*
* @param {*} msg string or object or array of such
*/
debug: function (msg) {
if (this.tree.options.debugLevel >= 4) {
Array.prototype.unshift.call(arguments, this.toString());
consoleApply("log", arguments);
}
},
/** Deprecated.
* @deprecated since 2014-02-16. Use resetLazy() instead.
*/
discard: function () {
this.warn(
"FancytreeNode.discard() is deprecated since 2014-02-16. Use .resetLazy() instead."
);
return this.resetLazy();
},
/** Remove DOM elements for all descendents. May be called on .collapse event
* to keep the DOM small.
* @param {boolean} [includeSelf=false]
*/
discardMarkup: function (includeSelf) {
var fn = includeSelf ? "nodeRemoveMarkup" : "nodeRemoveChildMarkup";
this.tree._callHook(fn, this);
},
/** Write error to browser console if debugLevel >= 1 (prepending tree info)
*
* @param {*} msg string or object or array of such
*/
error: function (msg) {
if (this.tree.options.debugLevel >= 1) {
Array.prototype.unshift.call(arguments, this.toString());
consoleApply("error", arguments);
}
},
/**Find all nodes that match condition (excluding self).
*
* @param {string | function(node)} match title string to search for, or a
* callback function that returns `true` if a node is matched.
* @returns {FancytreeNode[]} array of nodes (may be empty)
*/
findAll: function (match) {
match = _isFunction(match) ? match : _makeNodeTitleMatcher(match);
var res = [];
this.visit(function (n) {
if (match(n)) {
res.push(n);
}
});
return res;
},
/**Find first node that matches condition (excluding self).
*
* @param {string | function(node)} match title string to search for, or a
* callback function that returns `true` if a node is matched.
* @returns {FancytreeNode} matching node or null
* @see FancytreeNode#findAll
*/
findFirst: function (match) {
match = _isFunction(match) ? match : _makeNodeTitleMatcher(match);
var res = null;
this.visit(function (n) {
if (match(n)) {
res = n;
return false;
}
});
return res;
},
/** Find a node relative to self.
*
* @param {number|string} where The keyCode that would normally trigger this move,
* or a keyword ('down', 'first', 'last', 'left', 'parent', 'right', 'up').
* @returns {FancytreeNode}
* @since v2.31
*/
findRelatedNode: function (where, includeHidden) {
return this.tree.findRelatedNode(this, where, includeHidden);
},
/* Apply selection state (internal use only) */
_changeSelectStatusAttrs: function (state) {
var changed = false,
opts = this.tree.options,
unselectable = FT.evalOption(
"unselectable",
this,
this,
opts,
false
),
unselectableStatus = FT.evalOption(
"unselectableStatus",
this,
this,
opts,
undefined
);
if (unselectable && unselectableStatus != null) {
state = unselectableStatus;
}
switch (state) {
case false:
changed = this.selected || this.partsel;
this.selected = false;
this.partsel = false;
break;
case true:
changed = !this.selected || !this.partsel;
this.selected = true;
this.partsel = true;
break;
case undefined:
changed = this.selected || !this.partsel;
this.selected = false;
this.partsel = true;
break;
default:
_assert(false, "invalid state: " + state);
}
// this.debug("fixSelection3AfterLoad() _changeSelectStatusAttrs()", state, changed);
if (changed) {
this.renderStatus();
}
return changed;
},
/**
* Fix selection status, after this node was (de)selected in multi-hier mode.
* This includes (de)selecting all children.
*/
fixSelection3AfterClick: function (callOpts) {
var flag = this.isSelected();
// this.debug("fixSelection3AfterClick()");
this.visit(function (node) {
node._changeSelectStatusAttrs(flag);
if (node.radiogroup) {
// #931: don't (de)select this branch
return "skip";
}
});
this.fixSelection3FromEndNodes(callOpts);
},
/**
* Fix selection status for multi-hier mode.
* Only end-nodes are considered to update the descendants branch and parents.
* Should be called after this node has loaded new children or after
* children have been modified using the API.
*/
fixSelection3FromEndNodes: function (callOpts) {
var opts = this.tree.options;
// this.debug("fixSelection3FromEndNodes()");
_assert(opts.selectMode === 3, "expected selectMode 3");
// Visit all end nodes and adjust their parent's `selected` and `partsel`
// attributes. Return selection state true, false, or undefined.
function _walk(node) {
var i,
l,
child,
s,
state,
allSelected,
someSelected,
unselIgnore,
unselState,
children = node.children;
if (children && children.length) {
// check all children recursively
allSelected = true;
someSelected = false;
for (i = 0, l = children.length; i < l; i++) {
child = children[i];
// the selection state of a node is not relevant; we need the end-nodes
s = _walk(child);
// if( !child.unselectableIgnore ) {
unselIgnore = FT.evalOption(
"unselectableIgnore",
child,
child,
opts,
false
);
if (!unselIgnore) {
if (s !== false) {
someSelected = true;
}
if (s !== true) {
allSelected = false;
}
}
}
// eslint-disable-next-line no-nested-ternary
state = allSelected
? true
: someSelected
? undefined
: false;
} else {
// This is an end-node: simply report the status
unselState = FT.evalOption(
"unselectableStatus",
node,
node,
opts,
undefined
);
state = unselState == null ? !!node.selected : !!unselState;
}
// #939: Keep a `partsel` flag that was explicitly set on a lazy node
if (
node.partsel &&
!node.selected &&
node.lazy &&
node.children == null
) {
state = undefined;
}
node._changeSelectStatusAttrs(state);
return state;
}
_walk(this);
// Update parent's state
this.visitParents(function (node) {
var i,
l,
child,
state,
unselIgnore,
unselState,
children = node.children,
allSelected = true,
someSelected = false;
for (i = 0, l = children.length; i < l; i++) {
child = children[i];
unselIgnore = FT.evalOption(
"unselectableIgnore",
child,
child,
opts,
false
);
if (!unselIgnore) {
unselState = FT.evalOption(
"unselectableStatus",
child,
child,
opts,
undefined
);
state =
unselState == null
? !!child.selected
: !!unselState;
// When fixing the parents, we trust the sibling status (i.e.
// we don't recurse)
if (state || child.partsel) {
someSelected = true;
}
if (!state) {
allSelected = false;
}
}
}
// eslint-disable-next-line no-nested-ternary
state = allSelected ? true : someSelected ? undefined : false;
node._changeSelectStatusAttrs(state);
});
},
// TODO: focus()
/**
* Update node data. If dict contains 'children', then also replace
* the hole sub tree.
* @param {NodeData} dict
*
* @see FancytreeNode#addChildren
* @see FancytreeNode#applyPatch
*/
fromDict: function (dict) {
// copy all other attributes to this.data.xxx
for (var name in dict) {
if (NODE_ATTR_MAP[name]) {
// node.NAME = dict.NAME
this[name] = dict[name];
} else if (name === "data") {
// node.data += dict.data
$.extend(this.data, dict.data);
} else if (
!_isFunction(dict[name]) &&
!NONE_NODE_DATA_MAP[name]
) {
// node.data.NAME = dict.NAME
this.data[name] = dict[name];
}
}
if (dict.children) {
// recursively set children and render
this.removeChildren();
this.addChildren(dict.children);
}
this.renderTitle();
/*
var children = dict.children;
if(children === undefined){
this.data = $.extend(this.data, dict);
this.render();
return;
}
dict = $.extend({}, dict);
dict.children = undefined;
this.data = $.extend(this.data, dict);
this.removeChildren();
this.addChild(children);
*/
},
/** Return the list of child nodes (undefined for unexpanded lazy nodes).
* @returns {FancytreeNode[] | undefined}
*/
getChildren: function () {
if (this.hasChildren() === undefined) {
// TODO: only required for lazy nodes?
return undefined; // Lazy node: unloaded, currently loading, or load error
}
return this.children;
},
/** Return the first child node or null.
* @returns {FancytreeNode | null}
*/
getFirstChild: function () {
return this.children ? this.children[0] : null;
},
/** Return the 0-based child index.
* @returns {int}
*/
getIndex: function () {
// return this.parent.children.indexOf(this);
return $.inArray(this, this.parent.children); // indexOf doesn't work in IE7
},
/** Return the hierarchical child index (1-based, e.g. '3.2.4').
* @param {string} [separator="."]
* @param {int} [digits=1]
* @returns {string}
*/
getIndexHier: function (separator, digits) {
separator = separator || ".";
var s,
res = [];
$.each(this.getParentList(false, true), function (i, o) {
s = "" + (o.getIndex() + 1);
if (digits) {
// prepend leading zeroes
s = ("0000000" + s).substr(-digits);
}
res.push(s);
});
return res.join(separator);
},
/** Return the parent keys separated by options.keyPathSeparator, e.g. "/id_1/id_17/id_32".
*
* (Unlike `node.getPath()`, this method prepends a "/" and inverts the first argument.)
*
* @see FancytreeNode#getPath
* @param {boolean} [excludeSelf=false]
* @returns {string}
*/
getKeyPath: function (excludeSelf) {
var sep = this.tree.options.keyPathSeparator;
return sep + this.getPath(!excludeSelf, "key", sep);
},
/** Return the last child of this node or null.
* @returns {FancytreeNode | null}
*/
getLastChild: function () {
return this.children
? this.children[this.children.length - 1]
: null;
},
/** Return node depth. 0: System root node, 1: visible top-level node, 2: first sub-level, ... .
* @returns {int}
*/
getLevel: function () {
var level = 0,
dtn = this.parent;
while (dtn) {
level++;
dtn = dtn.parent;
}
return level;
},
/** Return the successor node (under the same parent) or null.
* @returns {FancytreeNode | null}
*/
getNextSibling: function () {
// TODO: use indexOf, if available: (not in IE6)
if (this.parent) {
var i,
l,
ac = this.parent.children;
for (i = 0, l = ac.length - 1; i < l; i++) {
// up to length-2, so next(last) = null
if (ac[i] === this) {
return ac[i + 1];
}
}
}
return null;
},
/** Return the parent node (null for the system root node).
* @returns {FancytreeNode | null}
*/
getParent: function () {
// TODO: return null for top-level nodes?
return this.parent;
},
/** Return an array of all parent nodes (top-down).
* @param {boolean} [includeRoot=false] Include the invisible system root node.
* @param {boolean} [includeSelf=false] Include the node itself.
* @returns {FancytreeNode[]}
*/
getParentList: function (includeRoot, includeSelf) {
var l = [],
dtn = includeSelf ? this : this.parent;
while (dtn) {
if (includeRoot || dtn.parent) {
l.unshift(dtn);
}
dtn = dtn.parent;
}
return l;
},
/** Return a string representing the hierachical node path, e.g. "a/b/c".
* @param {boolean} [includeSelf=true]
* @param {string | function} [part="title"] node property name or callback
* @param {string} [separator="/"]
* @returns {string}
* @since v2.31
*/
getPath: function (includeSelf, part, separator) {
includeSelf = includeSelf !== false;
part = part || "title";
separator = separator || "/";
var val,
path = [],
isFunc = _isFunction(part);
this.visitParents(function (n) {
if (n.parent) {
val = isFunc ? part(n) : n[part];
path.unshift(val);
}
}, includeSelf);
return path.join(separator);
},
/** Return the predecessor node (under the same parent) or null.
* @returns {FancytreeNode | null}
*/
getPrevSibling: function () {
if (this.parent) {
var i,
l,
ac = this.parent.children;
for (i = 1, l = ac.length; i < l; i++) {
// start with 1, so prev(first) = null
if (ac[i] === this) {
return ac[i - 1];
}
}
}
return null;
},
/**
* Return an array of selected descendant nodes.
* @param {boolean} [stopOnParents=false] only return the topmost selected
* node (useful with selectMode 3)
* @returns {FancytreeNode[]}
*/
getSelectedNodes: function (stopOnParents) {
var nodeList = [];
this.visit(function (node) {
if (node.selected) {
nodeList.push(node);
if (stopOnParents === true) {
return "skip"; // stop processing this branch
}
}
});
return nodeList;
},
/** Return true if node has children. Return undefined if not sure, i.e. the node is lazy and not yet loaded).
* @returns {boolean | undefined}
*/
hasChildren: function () {
if (this.lazy) {
if (this.children == null) {
// null or undefined: Not yet loaded
return undefined;
} else if (this.children.length === 0) {
// Loaded, but response was empty
return false;
} else if (
this.children.length === 1 &&
this.children[0].isStatusNode()
) {
// Currently loading or load error
return undefined;
}
return true;
}
return !!(this.children && this.children.length);
},
/**
* Return true if node has `className` defined in .extraClasses.
*
* @param {string} className class name (separate multiple classes by space)
* @returns {boolean}
*
* @since 2.32
*/
hasClass: function (className) {
return (
(" " + (this.extraClasses || "") + " ").indexOf(
" " + className + " "
) >= 0
);
},
/** Return true if node has keyboard focus.
* @returns {boolean}
*/
hasFocus: function () {
return this.tree.hasFocus() && this.tree.focusNode === this;
},
/** Write to browser console if debugLevel >= 3 (prepending node info)
*
* @param {*} msg string or object or array of such
*/
info: function (msg) {
if (this.tree.options.debugLevel >= 3) {
Array.prototype.unshift.call(arguments, this.toString());
consoleApply("info", arguments);
}
},
/** Return true if node is active (see also FancytreeNode#isSelected).
* @returns {boolean}
*/
isActive: function () {
return this.tree.activeNode === this;
},
/** Return true if node is vertically below `otherNode`, i.e. rendered in a subsequent row.
* @param {FancytreeNode} otherNode
* @returns {boolean}
* @since 2.28
*/
isBelowOf: function (otherNode) {
return this.getIndexHier(".", 5) > otherNode.getIndexHier(".", 5);
},
/** Return true if node is a direct child of otherNode.
* @param {FancytreeNode} otherNode
* @returns {boolean}
*/
isChildOf: function (otherNode) {
return this.parent && this.parent === otherNode;
},
/** Return true, if node is a direct or indirect sub node of otherNode.
* @param {FancytreeNode} otherNode
* @returns {boolean}
*/
isDescendantOf: function (otherNode) {
if (!otherNode || otherNode.tree !== this.tree) {
return false;
}
var p = this.parent;
while (p) {
if (p === otherNode) {
return true;
}
if (p === p.parent) {
$.error("Recursive parent link: " + p);
}
p = p.parent;
}
return false;
},
/** Return true if node is expanded.
* @returns {boolean}
*/
isExpanded: function () {
return !!this.expanded;
},
/** Return true if node is the first node of its parent's children.
* @returns {boolean}
*/
isFirstSibling: function () {
var p = this.parent;
return !p || p.children[0] === this;
},
/** Return true if node is a folder, i.e. has the node.folder attribute set.
* @returns {boolean}
*/
isFolder: function () {
return !!this.folder;
},
/** Return true if node is the last node of its parent's children.
* @returns {boolean}
*/
isLastSibling: function () {
var p = this.parent;
return !p || p.children[p.children.length - 1] === this;
},
/** Return true if node is lazy (even if data was already loaded)
* @returns {boolean}
*/
isLazy: function () {
return !!this.lazy;
},
/** Return true if node is lazy and loaded. For non-lazy nodes always return true.
* @returns {boolean}
*/
isLoaded: function () {
return !this.lazy || this.hasChildren() !== undefined; // Also checks if the only child is a status node
},
/** Return true if children are currently beeing loaded, i.e. a Ajax request is pending.
* @returns {boolean}
*/
isLoading: function () {
return !!this._isLoading;
},
/*
* @deprecated since v2.4.0: Use isRootNode() instead
*/
isRoot: function () {
return this.isRootNode();
},
/** Return true if node is partially selected (tri-state).
* @returns {boolean}
* @since 2.23
*/
isPartsel: function () {
return !this.selected && !!this.partsel;
},
/** (experimental) Return true if this is partially loaded.
* @returns {boolean}
* @since 2.15
*/
isPartload: function () {
return !!this.partload;
},
/** Return true if this is the (invisible) system root node.
* @returns {boolean}
* @since 2.4
*/
isRootNode: function () {
return this.tree.rootNode === this;
},
/** Return true if node is selected, i.e. has a checkmark set (see also FancytreeNode#isActive).
* @returns {boolean}
*/
isSelected: function () {
return !!this.selected;
},
/** Return true if this node is a temporarily generated system node like
* 'loading', 'paging', or 'error' (node.statusNodeType contains the type).
* @returns {boolean}
*/
isStatusNode: function () {
return !!this.statusNodeType;
},
/** Return true if this node is a status node of type 'paging'.
* @returns {boolean}
* @since 2.15
*/
isPagingNode: function () {
return this.statusNodeType === "paging";
},
/** Return true if this a top level node, i.e. a direct child of the (invisible) system root node.
* @returns {boolean}
* @since 2.4
*/
isTopLevel: function () {
return this.tree.rootNode === this.parent;
},
/** Return true if node is lazy and not yet loaded. For non-lazy nodes always return false.
* @returns {boolean}
*/
isUndefined: function () {
return this.hasChildren() === undefined; // also checks if the only child is a status node
},
/** Return true if all parent nodes are expanded. Note: this does not check
* whether the node is scrolled into the visible part of the screen.
* @returns {boolean}
*/
isVisible: function () {
var i,
l,
n,
hasFilter = this.tree.enableFilter,
parents = this.getParentList(false, false);
// TODO: check $(n.span).is(":visible")
// i.e. return false for nodes (but not parents) that are hidden
// by a filter
if (hasFilter && !this.match && !this.subMatchCount) {
// this.debug( "isVisible: HIDDEN (" + hasFilter + ", " + this.match + ", " + this.match + ")" );
return false;
}
for (i = 0, l = parents.length; i < l; i++) {
n = parents[i];
if (!n.expanded) {
// this.debug("isVisible: HIDDEN (parent collapsed)");
return false;
}
// if (hasFilter && !n.match && !n.subMatchCount) {
// this.debug("isVisible: HIDDEN (" + hasFilter + ", " + this.match + ", " + this.match + ")");
// return false;
// }
}
// this.debug("isVisible: VISIBLE");
return true;
},
/** Deprecated.
* @deprecated since 2014-02-16: use load() instead.
*/
lazyLoad: function (discard) {
$.error(
"FancytreeNode.lazyLoad() is deprecated since 2014-02-16. Use .load() instead."
);
},
/**
* Load all children of a lazy node if neccessary. The <i>expanded</i> state is maintained.
* @param {boolean} [forceReload=false] Pass true to discard any existing nodes before. Otherwise this method does nothing if the node was already loaded.
* @returns {$.Promise}
*/
load: function (forceReload) {
var res,
source,
self = this,
wasExpanded = this.isExpanded();
_assert(this.isLazy(), "load() requires a lazy node");
// _assert( forceReload || this.isUndefined(), "Pass forceReload=true to re-load a lazy node" );
if (!forceReload && !this.isUndefined()) {
return _getResolvedPromise(this);
}
if (this.isLoaded()) {
this.resetLazy(); // also collapses
}
// This method is also called by setExpanded() and loadKeyPath(), so we
// have to avoid recursion.
source = this.tree._triggerNodeEvent("lazyLoad", this);
if (source === false) {
// #69
return _getResolvedPromise(this);
}
_assert(
typeof source !== "boolean",
"lazyLoad event must return source in data.result"
);
res = this.tree._callHook("nodeLoadChildren", this, source);
if (wasExpanded) {
this.expanded = true;
res.always(function () {
self.render();
});
} else {
res.always(function () {
self.renderStatus(); // fix expander icon to 'loaded'
});
}
return res;
},
/** Expand all parents and optionally scroll into visible area as neccessary.
* Promise is resolved, when lazy loading and animations are done.
* @param {object} [opts] passed to `setExpanded()`.
* Defaults to {noAnimation: false, noEvents: false, scrollIntoView: true}
* @returns {$.Promise}
*/
makeVisible: function (opts) {
var i,
self = this,
deferreds = [],
dfd = new $.Deferred(),
parents = this.getParentList(false, false),
len = parents.length,
effects = !(opts && opts.noAnimation === true),
scroll = !(opts && opts.scrollIntoView === false);
// Expand bottom-up, so only the top node is animated
for (i = len - 1; i >= 0; i--) {
// self.debug("pushexpand" + parents[i]);
deferreds.push(parents[i].setExpanded(true, opts));
}
$.when.apply($, deferreds).done(function () {
// All expands have finished
// self.debug("expand DONE", scroll);
if (scroll) {
self.scrollIntoView(effects).done(function () {
// self.debug("scroll DONE");
dfd.resolve();
});
} else {
dfd.resolve();
}
});
return dfd.promise();
},
/** Move this node to targetNode.
* @param {FancytreeNode} targetNode
* @param {string} mode <pre>
* 'child': append this node as last child of targetNode.
* This is the default. To be compatble with the D'n'd
* hitMode, we also accept 'over'.
* 'firstChild': add this node as first child of targetNode.
* 'before': add this node as sibling before targetNode.
* 'after': add this node as sibling after targetNode.</pre>
* @param {function} [map] optional callback(FancytreeNode) to allow modifcations
*/
moveTo: function (targetNode, mode, map) {
if (mode === undefined || mode === "over") {
mode = "child";
} else if (mode === "firstChild") {
if (targetNode.children && targetNode.children.length) {
mode = "before";
targetNode = targetNode.children[0];
} else {
mode = "child";
}
}
var pos,
tree = this.tree,
prevParent = this.parent,
targetParent =
mode === "child" ? targetNode : targetNode.parent;
if (this === targetNode) {
return;
} else if (!this.parent) {
$.error("Cannot move system root");
} else if (targetParent.isDescendantOf(this)) {
$.error("Cannot move a node to its own descendant");
}
if (targetParent !== prevParent) {
prevParent.triggerModifyChild("remove", this);
}
// Unlink this node from current parent
if (this.parent.children.length === 1) {
if (this.parent === targetParent) {
return; // #258
}
this.parent.children = this.parent.lazy ? [] : null;
this.parent.expanded = false;
} else {
pos = $.inArray(this, this.parent.children);
_assert(pos >= 0, "invalid source parent");
this.parent.children.splice(pos, 1);
}
// Remove from source DOM parent
// if(this.parent.ul){
// this.parent.ul.removeChild(this.li);
// }
// Insert this node to target parent's child list
this.parent = targetParent;
if (targetParent.hasChildren()) {
switch (mode) {
case "