Source: jquery.fancytree.grid.js

/*!
 * jquery.fancytree.grid.js
 *
 * Render tree as table (aka 'tree grid', 'table tree').
 * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/)
 *
 * Copyright (c) 2008-2023, Martin Wendt (http://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.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 FT = $.ui.fancytree,
		_assert = FT.assert,
		SCROLL_MODE = "wheel"; // 'wheel' | 'scroll'
	// EPS = 1.0;

	/*
	 * [ext-grid] ...
	 *
	 * @alias Fancytree#_addScrollbar
	 * @requires jquery.fancytree.grid.js
	 */
	function _addScrollbar(table) {
		var sbWidth = 10,
			$table = $(table),
			position = $table.position(),
			// top = $table.find("tbody").position().top,

			$sb = $("<div>", {
				class: "fancytree-scrollbar",
				css: {
					border: "1px solid gray",
					position: "absolute",
					top: position.top,
					left: position.left + $table.width(),
					width: sbWidth,
					height: $table.find("tbody").height(),
				},
			});

		$table
			.css({
				"margin-right": sbWidth,
			})
			.after($sb);

		return $sb;
	}

	/*
	 * [ext-grid] Invalidate renumber status, i.e. trigger renumber next time.
	 *
	 * @alias Fancytree#_renumberReset
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype._renumberReset = function () {
		// this.debug("_renumberReset()");
		this.visibleNodeList = null;
	};

	/*
	 * [ext-grid] Adjust the start value if the content would be outside otherwise.
	 *
	 * @alias Fancytree#_fixStart
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype._fixStart = function (
		start,
		apply
	) {
		var vp = this.viewport,
			nodeList = this.visibleNodeList;

		start = start == null ? vp.start : start;
		// this.debug("_fixStart(" + start + ", " + !!apply + ")");
		var orgStart = start;
		// Don't scroll down below bottom node
		if (nodeList) {
			start = Math.min(start, this.visibleNodeList.length - vp.count);
			start = Math.max(start, 0, start);
			if (start !== orgStart) {
				this.debug("Adjust start " + orgStart + " => " + start);
				if (apply) {
					vp.start = start;
				}
			}
		}
		return start;
	};

	/*
	 * [ext-grid] ...
	 *
	 * @alias Fancytree#_shiftViewport
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype._shiftViewport = function (
		mode,
		ofs
	) {
		this.debug("_shiftViewport", mode, ofs);
		switch (mode) {
			case "vscroll":
				if (ofs) {
					this.setViewport({
						start: this.viewport.start + (ofs > 0 ? 1 : -1),
					});
				}
				break;

			default:
				throw Error("Invalid  mode: " + mode);
		}
	};

	/**
	 * [ext-grid] Return true if viewport cannot be scrolled down any further.
	 *
	 * @alias Fancytree#isViewportBottom
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype.isViewportBottom = function () {
		return (
			this.viewport.start + this.viewport.count >=
			this.visibleNodeList.length
		);
	};

	/**
	 * [ext-grid] Define a subset of rows/columns to display and redraw.
	 *
	 * @param {object | boolean} options viewport boundaries and status.
	 *
	 * @alias Fancytree#setViewport
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype.setViewport = function (opts) {
		if (typeof opts === "boolean") {
			this.debug("setViewport( " + opts + ")");
			return this.setViewport({ enabled: opts });
		}
		opts = opts || {};
		var i,
			count,
			start,
			newRow,
			redrawReason = "",
			vp = this.viewport,
			diffVp = { start: 0, count: 0, enabled: null, force: null },
			newVp = $.extend({}, vp),
			trList = this.tbody.children,
			trCount = trList.length;

		// Sanitize viewport settings and check if we need to redraw
		this.debug("setViewport(" + opts.start + ", +" + opts.count + ")");
		if (opts.force) {
			redrawReason += "force";
			diffVp.force = true;
		}

		opts.enabled = opts.enabled !== false; // default to true
		if (vp.enabled !== opts.enabled) {
			redrawReason += "enable";
			newVp.enabled = diffVp.enabled = opts.enabled;
		}

		start = opts.start == null ? vp.start : Math.max(0, +opts.start);
		// Adjust start value to assure the current content is inside vp
		start = this._fixStart(start, false);

		if (vp.start !== +start) {
			redrawReason += "start";
			newVp.start = start;
			diffVp.start = start - vp.start;
		}

		count = opts.count == null ? vp.count : Math.max(1, +opts.count);
		if (vp.count !== +count) {
			redrawReason += "count";
			newVp.count = count;
			diffVp.count = count - vp.count;
		}
		// if (vp.left !== +opts.left) {
		// 	diffVp.left = left - vp.left;
		// 	newVp.left = opts.left;
		// 	redrawReason += "left";
		// }
		// if (vp.right !== +opts.right) {
		// 	diffVp.right = right - vp.right;
		// 	newVp.right = opts.right;
		// 	redrawReason += "right";
		// }

		if (!redrawReason) {
			return false;
		}
		// Let user cancel or modify the update
		var info = {
			next: newVp,
			diff: diffVp,
			reason: redrawReason,
			scrollOnly: redrawReason === "start",
		};
		if (
			!opts.noEvents &&
			this._triggerTreeEvent("beforeUpdateViewport", null, info) === false
		) {
			return false;
		}
		info.prev = $.extend({}, vp);
		delete info.next;
		// vp.enabled = newVp.enabled;
		vp.start = newVp.start;
		vp.count = newVp.count;

		// Make sure we have the correct count of TRs
		var prevPhase = this.isVpUpdating;

		if (trCount > count) {
			for (i = 0; i < trCount - count; i++) {
				delete this.tbody.lastChild.ftnode;
				this.tbody.removeChild(this.tbody.lastChild);
			}
		} else if (trCount < count) {
			for (i = 0; i < count - trCount; i++) {
				newRow = this.rowFragment.firstChild.cloneNode(true);
				this.tbody.appendChild(newRow);
			}
		}
		trCount = trList.length;

		// Update visible node cache if needed
		var force = opts.force;
		this.redrawViewport(force);

		if (!opts.noEvents) {
			this._triggerTreeEvent("updateViewport", null, info);
		}

		this.isVpUpdating = prevPhase;
		return true;
	};

	/**
	 * [ext-grid] Calculate the viewport count from current scroll wrapper height.
	 *
	 * @alias Fancytree#adjustViewportSize
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype.adjustViewportSize = function () {
		_assert(
			this.scrollWrapper,
			"No parent div.fancytree-grid-container found."
		);
		if (this.isVpUpdating) {
			this.debug("Ignoring adjustViewportSize() during VP update.");
			return;
		}
		// Calculate how many rows fit into current container height
		var $table = this.$container,
			wrapper = this.scrollWrapper,
			trHeight = $table.find(">tbody>tr").first().height() || 0,
			tableHeight = $table.height(),
			headHeight = tableHeight - this.viewport.count * trHeight,
			wrapperHeight = wrapper.offsetHeight,
			free = wrapperHeight - headHeight,
			newCount = trHeight ? Math.floor(free / trHeight) : 0;

		// console.info(
		// 	"set container height",
		// 	$(this)
		// 		.parent(".fancytree-grid-container")
		// 		.height()
		// );

		this.setViewport({ count: newCount });
		// if (SCROLL_MODE === "scroll") {
		// 	// Add bottom margin to the table, to make sure the wrapper becomes
		// 	// scrollable
		// 	var mb = wrapperHeight - $table.height() - 2.0 * EPS;
		// 	this.debug("margin-bottom=" + mb);
		// 	$table.css("margin-bottom", mb);
		// }
	};

	/*
	 * [ext-grid] Calculate the scroll container dimension from the current tree table.
	 *
	 * @alias Fancytree#initViewportWrapper
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype._initViewportWrapper =
		function () {
			var // wrapper = this.scrollWrapper,
				// $wrapper = $(wrapper),
				tree = this;

			// if (SCROLL_MODE === "scroll") {
			// 	$wrapper.on("scroll", function(e) {
			// 		var viewport = tree.viewport,
			// 			curTop = wrapper.scrollTop,
			// 			homeTop = viewport.start === 0 ? 0 : EPS,
			// 			dy = viewport.start === 0 ? 1 : curTop - EPS; //homeTop;

			// 		tree.debug(
			// 			"Got 'scroll' event: scrollTop=" +
			// 				curTop +
			// 				", homeTop=" +
			// 				homeTop +
			// 				", start=" +
			// 				viewport.start +
			// 				", dy=" +
			// 				dy
			// 		);
			// 		if (tree.isVpUpdating) {
			// 			tree.debug("Ignoring scroll during VP update.");
			// 			return;
			// 		} else if (curTop === homeTop) {
			// 			tree.debug("Ignoring scroll to neutral " + homeTop + ".");
			// 			return;
			// 		}
			// 		tree._shiftViewport("vscroll", dy);
			// 		homeTop = viewport.start === 0 ? 0 : EPS;
			// 		setTimeout(function() {
			// 			tree.debug(
			// 				"scrollTop(" +
			// 					wrapper.scrollTop +
			// 					" -> " +
			// 					homeTop +
			// 					")..."
			// 			);
			// 			wrapper.scrollTop = homeTop;
			// 		}, 0);
			// 	});
			// }
			if (SCROLL_MODE === "wheel") {
				this.$container.on("wheel", function (e) {
					var orgEvent = e.originalEvent,
						viewport = tree.viewport,
						dy = orgEvent.deltaY; // * orgEvent.wheelDeltaY;

					if (
						!dy ||
						e.altKey ||
						e.ctrlKey ||
						e.metaKey ||
						e.shiftKey
					) {
						return true;
					}
					if (dy < 0 && viewport.start === 0) {
						return true;
					}
					if (dy > 0 && tree.isViewportBottom()) {
						return true;
					}
					tree.debug(
						"Got 'wheel' event: dy=" +
							dy +
							", mode=" +
							orgEvent.deltaMode
					);
					tree._shiftViewport("vscroll", dy);
					return false;
				});
			}
		};

	/*
	 * [ext-grid] Renumber and collect all visible rows.
	 *
	 * @param {bool} [force=false]
	 * @param {FancytreeNode | int} [startIdx=0]
	 * @alias Fancytree#_renumberVisibleNodes
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype._renumberVisibleNodes = function (
		force,
		startIdx
	) {
		if (
			(!this.options.viewport.enabled || this.visibleNodeList != null) &&
			force !== true
		) {
			// this.debug("_renumberVisibleNodes() ignored.");
			return false;
		}
		this.debugTime("_renumberVisibleNodes()");
		var i = 0,
			prevLength = this.visibleNodeList ? this.visibleNodeList.length : 0,
			visibleNodeList = (this.visibleNodeList = []);

		// Reset previous data
		this.visit(function (node) {
			node._rowIdx = null;
			// node.span = null;
			// if (node.tr) {
			// 	delete node.tr.ftnode;
			// 	node.tr = null;
			// }
		});
		// Iterate over all *visible* nodes
		this.visitRows(function (node) {
			node._rowIdx = i++;
			visibleNodeList.push(node);
		});
		this.debugTimeEnd("_renumberVisibleNodes()");
		if (i !== prevLength) {
			this._triggerTreeEvent("updateViewport", null, {
				reason: "renumber",
				diff: { start: 0, count: 0, enabled: null, force: null },
				next: $.extend({}, this.viewport),
				// visibleCount: prevLength,
				// cur: i,
			});
		}
	};

	/**
	 * [ext-grid] Render all visible nodes into the viweport.
	 *
	 * @param {bool} [force=false]
	 * @alias Fancytree#redrawViewport
	 * @requires jquery.fancytree.grid.js
	 */
	$.ui.fancytree._FancytreeClass.prototype.redrawViewport = function (force) {
		if (this._enableUpdate === false) {
			// tree.debug("no render", tree._enableUpdate);
			return;
		}
		this.debugTime("redrawViewport()");
		this._renumberVisibleNodes(force);
		// Adjust vp.start value to assure the current content is inside:
		this._fixStart(null, true);

		var i = 0,
			vp = this.viewport,
			visibleNodeList = this.visibleNodeList,
			start = vp.start,
			bottom = start + vp.count,
			tr,
			_renderCount = 0,
			trIdx = 0,
			trList = this.tbody.children,
			prevPhase = this.isVpUpdating;

		// Reset previous data
		this.visit(function (node) {
			// node.debug("redrawViewport(): _rowIdx=" + node._rowIdx);
			node.span = null;
			if (node.tr) {
				delete node.tr.ftnode;
				node.tr = null;
			}
		});

		// Redraw the whole tree, erasing all node markup before and after
		// the viewport

		for (i = start; i < bottom; i++) {
			var node = visibleNodeList[i];

			tr = trList[trIdx];

			if (!node) {
				// TODO: make trailing empty rows configurable (custom template or remove TRs)
				var newRow = this.rowFragment.firstChild.cloneNode(true);
				this.tbody.replaceChild(newRow, tr);
				trIdx++;
				continue;
			}
			if (tr !== node.tr) {
				node.tr = tr;
				node.render();
				_renderCount++;

				// TODO:
				// Implement scrolling by re-using existing markup
				// e.g. shifting TRs or TR child elements instead of
				// re-creating all the time
			}
			trIdx++;
		}
		this.isVpUpdating = prevPhase;
		this.debugTimeEnd("redrawViewport()");
	};

	$.ui.fancytree.registerExtension({
		name: "grid",
		version: "@VERSION",
		// Default options for this extension.
		options: {
			checkboxColumnIdx: null, // render the checkboxes into the this column index (default: nodeColumnIdx)
			indentation: 16, // indent every node level by 16px
			mergeStatusColumns: true, // display 'nodata', 'loading', 'error' centered in a single, merged TR
			nodeColumnIdx: 0, // render node expander, icon, and title to this column (default: #0)
		},
		// Overide virtual methods for this extension.
		// `this`       : is this extension object
		// `this._super`: the virtual function that was overriden (member of prev. extension or Fancytree)
		treeInit: function (ctx) {
			var i,
				columnCount,
				n,
				$row,
				$tbody,
				tree = ctx.tree,
				opts = ctx.options,
				tableOpts = opts.table,
				$table = tree.widget.element,
				$scrollWrapper = $table.parent(".fancytree-grid-container");

			if ($.inArray("table", opts.extensions) >= 0) {
				$.error("ext-grid and ext-table are mutually exclusive.");
			}
			if (opts.renderStatusColumns === true) {
				opts.renderStatusColumns = opts.renderColumns;
			}
			// Note: we also re-use CSS rules from ext-table
			$table.addClass(
				"fancytree-container fancytree-ext-grid fancytree-ext-table"
			);
			$tbody = $table.find(">tbody");
			if (!$tbody.length) {
				// TODO: not sure if we can rely on browsers to insert missing <tbody> before <tr>s:
				if ($table.find(">tr").length) {
					$.error(
						"Expected table > tbody > tr. If you see this, please open an issue."
					);
				}
				$tbody = $("<tbody>").appendTo($table);
			}

			tree.tbody = $tbody[0];

			// Prepare row templates:
			// Determine column count from table header if any
			columnCount = $("thead >tr", $table).last().find(">th").length;
			// Read TR templates from tbody if any
			$row = $tbody.children("tr").first();
			if ($row.length) {
				n = $row.children("td").length;
				if (columnCount && n !== columnCount) {
					tree.warn(
						"Column count mismatch between thead (" +
							columnCount +
							") and tbody (" +
							n +
							"): using tbody."
					);
					columnCount = n;
				}
				$row = $row.clone();
			} else {
				// Only thead is defined: create default row markup
				_assert(
					columnCount >= 1,
					"Need either <thead> or <tbody> with <td> elements to determine column count."
				);
				$row = $("<tr />");
				for (i = 0; i < columnCount; i++) {
					$row.append("<td />");
				}
			}
			$row.find(">td")
				.eq(tableOpts.nodeColumnIdx)
				.html("<span class='fancytree-node' />");
			if (opts.aria) {
				$row.attr("role", "row");
				$row.find("td").attr("role", "gridcell");
			}
			tree.rowFragment = document.createDocumentFragment();
			tree.rowFragment.appendChild($row.get(0));

			$tbody.empty();

			// Make sure that status classes are set on the node's <tr> elements
			tree.statusClassPropName = "tr";
			tree.ariaPropName = "tr";
			this.nodeContainerAttrName = "tr";

			// #489: make sure $container is set to <table>, even if ext-dnd is listed before ext-grid
			tree.$container = $table;
			if ($scrollWrapper.length) {
				tree.scrollWrapper = $scrollWrapper[0];
				this._initViewportWrapper();
			} else {
				tree.scrollWrapper = null;
			}

			// Scrolling is implemented completely differently here
			$.ui.fancytree.overrideMethod(
				$.ui.fancytree._FancytreeNodeClass.prototype,
				"scrollIntoView",
				function (effects, options) {
					var node = this,
						tree = node.tree,
						topNode = options && options.topNode,
						vp = tree.viewport,
						start = vp ? vp.start : null;

					if (!tree.viewport) {
						return node._super.apply(this, arguments);
					}
					if (node._rowIdx < vp.start) {
						start = node._rowIdx;
					} else if (node._rowIdx >= vp.start + vp.count) {
						start = node._rowIdx - vp.count + 1;
					}
					if (topNode && topNode._rowIdx < start) {
						start = topNode._rowIdx;
					}
					tree.setViewport({ start: start });
					// Return a resolved promise
					return $.Deferred(function () {
						this.resolveWith(node);
					}).promise();
				}
			);

			tree.visibleNodeList = null; // Set by _renumberVisibleNodes()
			tree.viewport = {
				enabled: true,
				start: 0,
				count: 10,
				left: 0,
				right: 0,
			};
			this.setViewport(
				$.extend(
					{
						// enabled: true,
						autoSize: true,
						start: 0,
						count: 10,
						left: 0,
						right: 0,
						keepEmptyRows: true,
						noEvents: true,
					},
					opts.viewport
				)
			);
			// tree.$scrollbar = _addScrollbar($table);

			this._superApply(arguments);

			// standard Fancytree created a root UL
			$(tree.rootNode.ul).remove();
			tree.rootNode.ul = null;

			// Add container to the TAB chain
			// #577: Allow to set tabindex to "0", "-1" and ""
			this.$container.attr("tabindex", opts.tabindex);
			// this.$container.attr("tabindex", opts.tabbable ? "0" : "-1");
			if (opts.aria) {
				tree.$container
					.attr("role", "treegrid")
					.attr("aria-readonly", true);
			}
		},
		nodeKeydown: function (ctx) {
			var nextNode = null,
				nextIdx = null,
				tree = ctx.tree,
				node = ctx.node,
				nodeList = tree.visibleNodeList,
				// treeOpts = ctx.options,
				viewport = tree.viewport,
				event = ctx.originalEvent,
				eventString = FT.eventToString(event);

			tree.debug("nodeKeydown(" + eventString + ")");

			switch (eventString) {
				case "home":
				case "meta+up":
					nextIdx = 0;
					break;
				case "end":
				case "meta+down":
					nextIdx = nodeList.length - 1;
					break;
				case "pageup":
					nextIdx = node._rowIdx - viewport.count;
					break;
				case "pagedown":
					nextIdx = node._rowIdx + viewport.count;
					break;
			}
			if (nextIdx != null) {
				nextIdx = Math.min(Math.max(0, nextIdx), nodeList.length - 1);
				nextNode = nodeList[nextIdx];
				nextNode.makeVisible();
				nextNode.setActive();
				return false;
			}
			return this._superApply(arguments);
		},
		nodeRemoveChildMarkup: function (ctx) {
			var node = ctx.node;

			node.visit(function (n) {
				if (n.tr) {
					delete n.tr.ftnode;
					n.tr = null;
					n.span = null;
				}
			});
		},
		nodeRemoveMarkup: function (ctx) {
			var node = ctx.node;

			if (node.tr) {
				delete node.tr.ftnode;
				node.tr = null;
				node.span = null;
			}
			this.nodeRemoveChildMarkup(ctx);
		},
		/* Override standard render. */
		nodeRender: function (ctx, force, deep, collapsed, _recursive) {
			var children,
				i,
				l,
				outsideViewport,
				subCtx,
				tree = ctx.tree,
				node = ctx.node;

			if (tree._enableUpdate === false) {
				node.debug("nodeRender(): _enableUpdate: false");
				return;
			}
			var opts = ctx.options,
				viewport = tree.viewport.enabled ? tree.viewport : null,
				start = viewport && viewport.start > 0 ? +viewport.start : 0,
				bottom = viewport ? start + viewport.count - 1 : 0,
				isRootNode = !node.parent;

			_assert(viewport);

			// node.debug("nodeRender(): " + node + ", isRoot=" + isRootNode, "tr=" + node.tr, "hcp=" + ctx.hasCollapsedParents, "parent.tr=" + (node.parent && node.parent.tr));
			if (!_recursive) {
				// node.debug("nodeRender(): start top node");
				if (isRootNode && viewport) {
					node.debug("nodeRender(): redrawViewport() instead");
					return ctx.tree.redrawViewport();
				}
				ctx.hasCollapsedParents = node.parent && !node.parent.expanded;
				// Make sure visible row indices are up-to-date
				if (viewport) {
					tree._renumberVisibleNodes();
				}
			}

			if (!isRootNode) {
				outsideViewport =
					viewport &&
					(node._rowIdx < start ||
						node._rowIdx >= start + viewport.count);

				// node.debug(
				// 	"nodeRender(): idx=" +
				// 		node._rowIdx +
				// 		", outside=" +
				// 		outsideViewport +
				// 		", TR count=" +
				// 		tree.tbody.rows.length
				// );
				if (outsideViewport) {
					// node.debug("nodeRender(): outsideViewport: ignored");
					return;
				}
				if (!node.tr) {
					if (node._rowIdx == null) {
						// node.warn("nodeRender(): ignoring hidden");
						return;
					}
					node.debug("nodeRender(): creating new TR.");
					node.tr = tree.tbody.rows[node._rowIdx - start];
				}
				// _assert(
				// 	node.tr,
				// 	"nodeRender() called for node.tr == null: " + node
				// );
				node.tr.ftnode = node;

				if (node.key && opts.generateIds) {
					node.tr.id = opts.idPrefix + node.key;
				}
				node.span = $("span.fancytree-node", node.tr).get(0);

				// Set icon, link, and title (normally this is only required on initial render)
				// var ctx = this._makeHookContext(node);
				this.nodeRenderTitle(ctx); // triggers renderColumns()

				// Allow tweaking, binding, after node was created for the first time
				if (opts.createNode) {
					opts.createNode.call(this, { type: "createNode" }, ctx);
				}
			}
			// Allow tweaking after node state was rendered
			if (opts.renderNode) {
				opts.renderNode.call(tree, { type: "renderNode" }, ctx);
			}
			// Visit child nodes
			// Add child markup
			children = node.children;
			_assert(!deep, "deep is not supported");

			if (children && (isRootNode || deep || node.expanded)) {
				for (i = 0, l = children.length; i < l; i++) {
					var child = children[i];

					if (viewport && child._rowIdx > bottom) {
						children[i].debug("BREAK render children loop");
						return false;
					}
					subCtx = $.extend({}, ctx, { node: child });
					subCtx.hasCollapsedParents =
						subCtx.hasCollapsedParents || !node.expanded;
					this.nodeRender(subCtx, force, deep, collapsed, true);
				}
			}
		},
		nodeRenderTitle: function (ctx, title) {
			var $cb,
				res,
				tree = ctx.tree,
				node = ctx.node,
				opts = ctx.options,
				isStatusNode = node.isStatusNode();

			res = this._super(ctx, title);

			if (node.isRootNode()) {
				return res;
			}
			// Move checkbox to custom column
			if (
				opts.checkbox &&
				!isStatusNode &&
				opts.table.checkboxColumnIdx != null
			) {
				$cb = $("span.fancytree-checkbox", node.span); //.detach();
				$(node.tr)
					.find("td")
					.eq(+opts.table.checkboxColumnIdx)
					.html($cb);
			}
			// Update element classes according to node state
			this.nodeRenderStatus(ctx);

			if (isStatusNode) {
				if (opts.renderStatusColumns) {
					// Let user code write column content
					opts.renderStatusColumns.call(
						tree,
						{ type: "renderStatusColumns" },
						ctx
					);
				} else if (opts.grid.mergeStatusColumns && node.isTopLevel()) {
					node.warn("mergeStatusColumns is not yet implemented.");
					// This approach would not work, since the roe may be re-used:
					// $(node.tr)
					// 	.find(">td")
					// 	.eq(0)
					// 	.prop("colspan", tree.columnCount)
					// 	.text(node.title)
					// 	.addClass("fancytree-status-merged")
					// 	.nextAll()
					// 	.remove();
				} // else: default rendering for status node: leave other cells empty
			} else if (opts.renderColumns) {
				opts.renderColumns.call(tree, { type: "renderColumns" }, ctx);
			}
			return res;
		},
		nodeRenderStatus: function (ctx) {
			var indent,
				node = ctx.node,
				opts = ctx.options;

			this._super(ctx);

			$(node.tr).removeClass("fancytree-node");
			// indent
			indent = (node.getLevel() - 1) * opts.table.indentation;
			if (opts.rtl) {
				$(node.span).css({ paddingRight: indent + "px" });
			} else {
				$(node.span).css({ paddingLeft: indent + "px" });
			}
		},
		/* Expand node, return Deferred.promise. */
		nodeSetExpanded: function (ctx, flag, callOpts) {
			var node = ctx.node,
				tree = ctx.tree;

			// flag defaults to true
			flag = flag !== false;

			if ((node.expanded && flag) || (!node.expanded && !flag)) {
				// Expanded state isn't changed - just call base implementation
				return this._superApply(arguments);
			}

			var dfd = new $.Deferred(),
				subOpts = $.extend({}, callOpts, {
					noEvents: true,
					noAnimation: true,
				});

			callOpts = callOpts || {};

			function _afterExpand(ok) {
				tree.redrawViewport(true);

				if (ok) {
					if (
						flag &&
						ctx.options.autoScroll &&
						!callOpts.noAnimation &&
						node.hasChildren()
					) {
						// Scroll down to last child, but keep current node visible
						node.getLastChild()
							.scrollIntoView(true, { topNode: node })
							.always(function () {
								if (!callOpts.noEvents) {
									tree._triggerNodeEvent(
										flag ? "expand" : "collapse",
										ctx
									);
								}
								dfd.resolveWith(node);
							});
					} else {
						if (!callOpts.noEvents) {
							tree._triggerNodeEvent(
								flag ? "expand" : "collapse",
								ctx
							);
						}
						dfd.resolveWith(node);
					}
				} else {
					if (!callOpts.noEvents) {
						tree._triggerNodeEvent(
							flag ? "expand" : "collapse",
							ctx
						);
					}
					dfd.rejectWith(node);
				}
			}
			// Call base-expand with disabled events and animation
			this._super(ctx, flag, subOpts)
				.done(function () {
					_afterExpand(true);
				})
				.fail(function () {
					_afterExpand(false);
				});
			return dfd.promise();
		},
		treeClear: function (ctx) {
			// this.nodeRemoveChildMarkup(this._makeHookContext(this.rootNode));
			// this._renumberReset(); // Invalidate visible row cache
			return this._superApply(arguments);
		},
		treeDestroy: function (ctx) {
			this.$container.find("tbody").empty();
			this.$container.off("wheel");
			if (this.$source) {
				this.$source.removeClass("fancytree-helper-hidden");
			}
			this._renumberReset(); // Invalidate visible row cache
			return this._superApply(arguments);
		},
		treeStructureChanged: function (ctx, type) {
			// debugger;
			if (type !== "addNode" || ctx.tree.visibleNodeList) {
				// this.debug("treeStructureChanged(" + type + ")");
				this._renumberReset(); // Invalidate visible row cache
			}
		},
	});
	// Value returned by `require('jquery.fancytree..')`
	return $.ui.fancytree;
}); // End of closure

Fork me on GitHub