xl-infinite-tree
Version:
A browser-ready tree library that can efficiently display a large amount of data using infinite scrolling.
1,321 lines (1,131 loc) • 66.5 kB
JavaScript
/* eslint no-continue: 0 */
/* eslint operator-assignment: 0 */
import events from 'events';
import classNames from 'classnames';
import elementClass from 'element-class';
import isDOM from 'is-dom';
import { flatten, Node } from 'flattree';
import Clusterize from './clusterize';
import ensureArray from './ensure-array';
import extend from './extend';
import { get } from './utilities';
import LookupTable from './lookup-table';
import { defaultRowRenderer } from './renderer';
import {
preventDefault,
addEventListener,
removeEventListener
} from './dom';
const noop = () => {};
const error = (format, ...args) => {
let argIndex = 0;
const message = 'Error: ' + format.replace(/%s/g, () => {
return args[argIndex++];
});
if (console && console.error) {
console.error(message);
}
try {
// This error was thrown as a convenience so that you can use this stack
// to find the callsite that caused this error to fire.
throw new Error(message);
} catch (e) {
// Ignore
}
};
const ensureNodeInstance = (node) => {
if (!node) { // undefined or null
return false;
}
if (!(node instanceof Node)) {
error('The node must be a Node object.');
return false;
}
return true;
};
const createRootNode = (rootNode) => {
return extend(rootNode || new Node(), {
parent: null,
children: [],
state: {
depth: -1,
open: true, // always open
path: '',
prefixMask: '',
total: 0
}
});
};
class InfiniteTree extends events.EventEmitter {
options = {
autoOpen: false,
blocksInCluster: 4,
droppable: false,
shouldLoadNodes: null,
loadNodes: null,
rowRenderer: defaultRowRenderer,
selectable: true,
shouldSelectNode: null,
// When el is not specified, the tree will run in the stealth mode
el: null,
// The following options will have no effect in the stealth mode
layout: 'div',
noDataClass: 'infinite-tree-no-data',
noDataText: 'No data',
nodeIdAttr: 'data-id',
togglerClass: 'infinite-tree-toggler'
};
state = {
openNodes: [],
rootNode: createRootNode(),
selectedNode: null
};
clusterize = null;
nodeTable = new LookupTable();
nodes = [];
rows = [];
filtered = false;
// The following elements will have no effect in the stealth mode
scrollElement = null;
contentElement = null;
draggableTarget = null;
droppableTarget = null;
contentListener = {
'click': (event) => {
event = event || window.event;
// Wrap stopPropagation that allows click event handler to stop execution
// by setting the cancelBubble property
const stopPropagation = event.stopPropagation;
event.stopPropagation = function() {
// Setting the cancelBubble property in browsers that don't support it doesn't hurt.
// Of course it doesn't actually cancel the bubbling, but the assignment itself is safe.
event.cancelBubble = true;
if (stopPropagation) {
stopPropagation.call(event);
}
};
// Call setTimeout(fn, 0) to re-queues the execution of subsequent calls, it allows the
// click event to bubble up to higher level event handlers before handling tree events.
setTimeout(() => {
// Stop execution if the cancelBubble property is set to true by higher level event handlers
if (event.cancelBubble === true) {
return;
}
// Emit a "click" event
this.emit('click', event);
// Stop execution if the cancelBubble property is set to true after emitting the click event
if (event.cancelBubble === true) {
return;
}
let itemTarget = null;
let clickToggler = false;
if (event.target) {
itemTarget = (event.target !== event.currentTarget) ? event.target : null;
} else if (event.srcElement) { // IE8
itemTarget = event.srcElement;
}
while (itemTarget && itemTarget.parentElement !== this.contentElement) {
if (elementClass(itemTarget).has(this.options.togglerClass)) {
clickToggler = true;
}
itemTarget = itemTarget.parentElement;
}
if (!itemTarget || itemTarget.hasAttribute('disabled')) {
return;
}
const id = itemTarget.getAttribute(this.options.nodeIdAttr);
const node = this.getNodeById(id);
if (!node) {
return;
}
// Click on the toggler to open/close a tree node
if (clickToggler) {
this.toggleNode(node, { async: true });
return;
}
this.selectNode(node); // selectNode will re-render the tree
}, 0);
},
'dblclick': (event) => {
// Emit a "doubleClick" event
this.emit('doubleClick', event);
},
'keydown': (event) => {
// Emit a "keyDown" event
this.emit('keyDown', event);
},
'keyup': (event) => {
// Emit a "keyUp" event
this.emit('keyUp', event);
},
// https://developer.mozilla.org/en-US/docs/Web/Events/dragstart
// The dragstart event is fired when the user starts dragging an element or text selection.
'dragstart': (event) => {
event = event || window.event;
this.draggableTarget = event.target || event.srcElement;
},
// https://developer.mozilla.org/en-US/docs/Web/Events/dragend
// The dragend event is fired when a drag operation is being ended (by releasing a mouse button or hitting the escape key).
'dragend': (event) => {
event = event || window.event;
const { hoverClass = '' } = this.options.droppable;
// Draggable
this.draggableTarget = null;
// Droppable
if (this.droppableTarget) {
elementClass(this.droppableTarget).remove(hoverClass);
this.droppableTarget = null;
}
},
// https://developer.mozilla.org/en-US/docs/Web/Events/dragenter
// The dragenter event is fired when a dragged element or text selection enters a valid drop target.
'dragenter': (event) => {
event = event || window.event;
let itemTarget = null;
if (event.target) {
itemTarget = (event.target !== event.currentTarget) ? event.target : null;
} else if (event.srcElement) { // IE8
itemTarget = event.srcElement;
}
while (itemTarget && itemTarget.parentElement !== this.contentElement) {
itemTarget = itemTarget.parentElement;
}
if (!itemTarget) {
return;
}
if (this.droppableTarget === itemTarget) {
return;
}
const { accept, hoverClass = '' } = this.options.droppable;
elementClass(this.droppableTarget).remove(hoverClass);
this.droppableTarget = null;
let canDrop = true; // Defaults to true
if (typeof accept === 'function') {
const id = itemTarget.getAttribute(this.options.nodeIdAttr);
const node = this.getNodeById(id);
canDrop = !!accept.call(this, event, {
type: 'dragenter',
draggableTarget: this.draggableTarget,
droppableTarget: itemTarget,
node: node
});
}
if (canDrop) {
elementClass(itemTarget).add(hoverClass);
this.droppableTarget = itemTarget;
}
},
// https://developer.mozilla.org/en-US/docs/Web/Events/dragover
// The dragover event is fired when an element or text selection is being dragged over a valid drop target (every few hundred milliseconds).
'dragover': (event) => {
event = event || window.event;
preventDefault(event);
},
// https://developer.mozilla.org/en-US/docs/Web/Events/drop
// The drop event is fired when an element or text selection is dropped on a valid drop target.
'drop': (event) => {
event = event || window.event;
// prevent default action (open as link for some elements)
preventDefault(event);
if (!(this.draggableTarget && this.droppableTarget)) {
return;
}
const { accept, drop, hoverClass = '' } = this.options.droppable;
const id = this.droppableTarget.getAttribute(this.options.nodeIdAttr);
const node = this.getNodeById(id);
let canDrop = true; // Defaults to true
if (typeof accept === 'function') {
canDrop = !!accept.call(this, event, {
type: 'drop',
draggableTarget: this.draggableTarget,
droppableTarget: this.droppableTarget,
node: node
});
}
if (canDrop && typeof drop === 'function') {
drop.call(this, event, {
draggableTarget: this.draggableTarget,
droppableTarget: this.droppableTarget,
node: node
});
}
elementClass(this.droppableTarget).remove(hoverClass);
this.droppableTarget = null;
}
};
// Creates new InfiniteTree object.
constructor(el, options) {
super();
if (isDOM(el)) {
options = { ...options, el };
} else if (el && typeof el === 'object') {
options = el;
}
// Assign options
this.options = {
...this.options,
...options
};
this.create();
// Load tree data if it's provided
if (this.options.data) {
this.loadData(this.options.data);
}
}
create() {
if (this.options.el) {
let tag = null;
this.scrollElement = document.createElement('div');
if (this.options.layout === 'table') {
const tableElement = document.createElement('table');
tableElement.className = classNames(
'infinite-tree',
'infinite-tree-table'
);
const contentElement = document.createElement('tbody');
tableElement.appendChild(contentElement);
this.scrollElement.appendChild(tableElement);
this.contentElement = contentElement;
// The tag name for supporting elements
tag = 'tr';
} else {
const contentElement = document.createElement('div');
this.scrollElement.appendChild(contentElement);
this.contentElement = contentElement;
// The tag name for supporting elements
tag = 'div';
}
this.scrollElement.className = classNames(
'infinite-tree',
'infinite-tree-scroll'
);
this.contentElement.className = classNames(
'infinite-tree',
'infinite-tree-content'
);
this.options.el.appendChild(this.scrollElement);
this.clusterize = new Clusterize({
tag: tag,
rows: [],
scrollElement: this.scrollElement,
contentElement: this.contentElement,
emptyText: this.options.noDataText,
emptyClass: this.options.noDataClass,
blocksInCluster: this.options.blocksInCluster
});
this.clusterize.on('clusterWillChange', () => {
this.emit('clusterWillChange');
});
this.clusterize.on('clusterDidChange', () => {
this.emit('clusterDidChange');
});
addEventListener(this.contentElement, 'click', this.contentListener.click);
addEventListener(this.contentElement, 'dblclick', this.contentListener.dblclick);
addEventListener(this.contentElement, 'keydown', this.contentListener.keydown);
addEventListener(this.contentElement, 'keyup', this.contentListener.keyup);
if (this.options.droppable) {
addEventListener(document, 'dragstart', this.contentListener.dragstart);
addEventListener(document, 'dragend', this.contentListener.dragend);
addEventListener(this.contentElement, 'dragenter', this.contentListener.dragenter);
addEventListener(this.contentElement, 'dragleave', this.contentListener.dragleave);
addEventListener(this.contentElement, 'dragover', this.contentListener.dragover);
addEventListener(this.contentElement, 'drop', this.contentListener.drop);
}
}
}
destroy() {
this.clear();
if (this.options.el) {
removeEventListener(this.contentElement, 'click', this.contentListener.click);
removeEventListener(this.contentElement, 'dblclick', this.contentListener.dblclick);
removeEventListener(this.contentElement, 'keydown', this.contentListener.keydown);
removeEventListener(this.contentElement, 'keyup', this.contentListener.keyup);
if (this.options.droppable) {
removeEventListener(document, 'dragstart', this.contentListener.dragstart);
removeEventListener(document, 'dragend', this.contentListener.dragend);
removeEventListener(this.contentElement, 'dragenter', this.contentListener.dragenter);
removeEventListener(this.contentElement, 'dragleave', this.contentListener.dragleave);
removeEventListener(this.contentElement, 'dragover', this.contentListener.dragover);
removeEventListener(this.contentElement, 'drop', this.contentListener.drop);
}
if (this.clusterize) {
this.clusterize.destroy(true); // True to remove all data from the list
this.clusterize = null;
}
// Remove all child nodes
while (this.contentElement.firstChild) {
this.contentElement.removeChild(this.contentElement.firstChild);
}
while (this.scrollElement.firstChild) {
this.scrollElement.removeChild(this.scrollElement.firstChild);
}
const containerElement = this.options.el;
while (containerElement.firstChild) {
containerElement.removeChild(containerElement.firstChild);
}
this.contentElement = null;
this.scrollElement = null;
}
}
// Adds an array of new child nodes to a parent node at the specified index.
// * If the parent is null or undefined, inserts new childs at the specified index in the top-level.
// * If the parent has children, the method adds the new child to it at the specified index.
// * If the parent does not have children, the method adds the new child to the parent.
// * If the index value is greater than or equal to the number of children in the parent, the method adds the child at the end of the children.
// @param {Array} newNodes An array of new child nodes.
// @param {number} [index] The 0-based index of where to insert the child node.
// @param {Node} parentNode The Node object that defines the parent node.
// @return {boolean} Returns true on success, false otherwise.
addChildNodes(newNodes, index, parentNode) {
newNodes = [].concat(newNodes || []); // Ensure array
if (newNodes.length === 0) {
return false;
}
parentNode = parentNode || this.state.rootNode; // Defaults to rootNode if not specified
if (index === undefined) {
index = parentNode.children.length;
}
if (!ensureNodeInstance(parentNode)) {
return false;
}
// Assign parent
newNodes.forEach((newNode) => {
newNode.parent = parentNode;
});
// Insert new child node at the specified index
parentNode.children.splice.apply(parentNode.children, [index, 0].concat(newNodes));
// Get the index of the first new node within the array of child nodes
index = parentNode.children.indexOf(newNodes[0]);
const deleteCount = parentNode.state.total;
const nodes = flatten(parentNode.children, { openNodes: this.state.openNodes });
const rows = [];
// Update rows
rows.length = nodes.length;
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[i];
rows[i] = this.options.rowRenderer(node, this.options);
}
if (parentNode === this.state.rootNode) {
this.nodes = nodes;
this.rows = rows;
} else {
const parentOffset = this.nodes.indexOf(parentNode);
if (parentOffset >= 0) {
if (parentNode.state.open === true) {
// Update nodes & rows
this.nodes.splice.apply(this.nodes, [parentOffset + 1, deleteCount].concat(nodes));
this.rows.splice.apply(this.rows, [parentOffset + 1, deleteCount].concat(rows));
}
// Update the row corresponding to the parent node
this.rows[parentOffset] = this.options.rowRenderer(parentNode, this.options);
}
}
// Update the lookup table with newly added nodes
parentNode.children.slice(index).forEach((childNode) => {
this.flattenNode(childNode).forEach((node) => {
if (node.id !== undefined) {
this.nodeTable.set(node.id, node);
}
});
});
// Update list
this.update();
return true;
}
// Adds a new child node to the end of the list of children of a specified parent node.
// * If the parent is null or undefined, inserts the child at the specified index in the top-level.
// * If the parent has children, the method adds the child as the last child.
// * If the parent does not have children, the method adds the child to the parent.
// @param {object} newNode The new child node.
// @param {Node} parentNode The Node object that defines the parent node.
// @return {boolean} Returns true on success, false otherwise.
appendChildNode(newNode, parentNode) {
// Defaults to rootNode if the parentNode is not specified
parentNode = parentNode || this.state.rootNode;
if (!ensureNodeInstance(parentNode)) {
return false;
}
const index = parentNode.children.length;
const newNodes = [].concat(newNode || []); // Ensure array
return this.addChildNodes(newNodes, index, parentNode);
}
// Checks or unchecks a node.
// @param {Node} node The Node object.
// @param {boolean} [checked] Whether to check or uncheck the node. If not specified, it will toggle between checked and unchecked state.
// @return {boolean} Returns true on success, false otherwise.
// @example
//
// tree.checkNode(node); // toggle checked and unchecked state
// tree.checkNode(node, true); // checked=true, indeterminate=false
// tree.checkNode(node, false); // checked=false, indeterminate=false
//
// @doc
//
// state.checked | state.indeterminate | description
// ------------- | ------------------- | -----------
// false | false | The node and all of its children are unchecked.
// true | false | The node and all of its children are checked.
// true | true | The node will appear as indeterminate when the node is checked and some (but not all) of its children are checked.
checkNode(node, checked) {
if (!ensureNodeInstance(node)) {
return false;
}
this.emit('willCheckNode', node);
// Retrieve node index
const nodeIndex = this.nodes.indexOf(node);
if (nodeIndex < 0) {
error('Invalid node index');
return false;
}
if (checked === true) {
node.state.checked = true;
node.state.indeterminate = false;
} else if (checked === false) {
node.state.checked = false;
node.state.indeterminate = false;
} else {
node.state.checked = !!node.state.checked;
node.state.indeterminate = !!node.state.indeterminate;
node.state.checked = (node.state.checked && node.state.indeterminate) || (!node.state.checked);
node.state.indeterminate = false;
}
let topmostNode = node;
const updateChildNodes = (parentNode) => {
let childNode = parentNode.getFirstChild(); // Ignore parent node
while (childNode) {
// Update checked and indeterminate state
childNode.state.checked = parentNode.state.checked;
childNode.state.indeterminate = false;
if (childNode.hasChildren()) {
childNode = childNode.getFirstChild();
} else {
// Find the parent level
while (childNode !== null && childNode.getNextSibling() === null && childNode.parent !== parentNode) {
// Use child-parent link to get to the parent level
childNode = childNode.getParent();
}
// Get next sibling
if (childNode !== null) {
childNode = childNode.getNextSibling();
}
}
}
};
const updateParentNodes = (childNode) => {
let parentNode = childNode.parent;
while (parentNode && parentNode.state.depth >= 0) {
topmostNode = parentNode;
let checkedCount = 0;
let indeterminate = false;
const len = parentNode.children ? parentNode.children.length : 0;
for (let i = 0; i < len; ++i) {
const childNode = parentNode.children[i];
indeterminate = indeterminate || (!!childNode.state.indeterminate);
if (childNode.state.checked) {
checkedCount++;
}
}
if (checkedCount === 0) {
parentNode.state.indeterminate = false;
parentNode.state.checked = false;
} else if ((checkedCount > 0 && checkedCount < len) || indeterminate) {
parentNode.state.indeterminate = true;
parentNode.state.checked = true;
} else {
parentNode.state.indeterminate = false;
parentNode.state.checked = true;
}
parentNode = parentNode.parent;
}
};
updateChildNodes(node);
updateParentNodes(node);
this.updateNode(topmostNode);
// Emit a "checkNode" event
this.emit('checkNode', node);
return true;
}
// Clears the tree.
clear() {
if (this.clusterize) {
this.clusterize.clear();
}
this.nodeTable.clear();
this.nodes = [];
this.rows = [];
this.state.openNodes = [];
this.state.rootNode = createRootNode(this.state.rootNode);
this.state.selectedNode = null;
}
// Closes a node to hide its children.
// @param {Node} node The Node object.
// @param {object} [options] The options object.
// @param {boolean} [options.silent] Pass true to prevent "closeNode" and "selectNode" events from being triggered.
// @return {boolean} Returns true on success, false otherwise.
closeNode(node, options) {
const {
async = false,
asyncCallback = noop,
silent = false
} = { ...options };
if (!ensureNodeInstance(node)) {
return false;
}
this.emit('willCloseNode', node);
// Cannot close the root node
if (node === this.state.rootNode) {
error('Cannot close the root node');
return false;
}
// Retrieve node index
if (this.nodes.indexOf(node) < 0) {
error('Invalid node index');
return false;
}
// Check if the closeNode action can be performed
if (this.state.openNodes.indexOf(node) < 0) {
return false;
}
// Toggle the collapsing state
node.state.collapsing = true;
// Update the row corresponding to the node
this.rows[this.nodes.indexOf(node)] = this.options.rowRenderer(node, this.options);
// Update list
this.update();
const fn = () => {
// Keep selected node unchanged if "node" is equal to "this.state.selectedNode"
if (this.state.selectedNode && (this.state.selectedNode !== node)) {
// row #0 - node.0 => parent node (total=4)
// row #1 - node.0.0 => close this node; next selected node (total=2)
// row #2 node.0.0.0 => selected node (total=0)
// row #3 node.0.0.1
// row #4 node.0.1
const selectedIndex = this.nodes.indexOf(this.state.selectedNode);
const total = node.state.total;
const rangeFrom = this.nodes.indexOf(node) + 1;
const rangeTo = this.nodes.indexOf(node) + total;
if ((rangeFrom <= selectedIndex) && (selectedIndex <= rangeTo)) {
this.selectNode(node, options);
}
}
node.state.open = false; // Set the open state to false
const openNodes = this.state.openNodes.filter((node) => node.state.open);
this.state.openNodes = openNodes;
// Subtract total from ancestor nodes
const total = node.state.total;
for (let p = node; p !== null; p = p.parent) {
p.state.total = p.state.total - total;
}
// Update nodes & rows
this.nodes.splice(this.nodes.indexOf(node) + 1, total);
this.rows.splice(this.nodes.indexOf(node) + 1, total);
// Toggle the collapsing state
node.state.collapsing = false;
// Update the row corresponding to the node
this.rows[this.nodes.indexOf(node)] = this.options.rowRenderer(node, this.options);
// Update list
this.update();
if (!silent) {
// Emit a "closeNode" event
this.emit('closeNode', node);
}
if (typeof asyncCallback === 'function') {
asyncCallback();
}
};
if (async) {
setTimeout(fn, 0);
} else {
fn();
}
return true;
}
// Filters nodes. Use a string or a function to test each node of the tree. Otherwise, it will render nothing after filtering (e.g. tree.filter(), tree.filter(null), tree.flter(0), tree.filter({}), etc.).
// @param {string|function} predicate A keyword string, or a function to test each node of the tree. If the predicate is an empty string, all nodes will be filtered. If the predicate is a function, returns true to keep the node, false otherwise.
// @param {object} [options] The options object.
// @param {boolean} [options.caseSensitive] Case sensitive string comparison. Defaults to false. This option is only available for string comparison.
// @param {boolean} [options.exactMatch] Exact string matching. Defaults to false. This option is only available for string comparison.
// @param {string} [options.filterPath] Gets the value at path of Node object. Defaults to 'name'. This option is only available for string comparison.
// @param {boolean} [options.includeAncestors] Whether to include ancestor nodes. Defaults to true.
// @param {boolean} [options.includeDescendants] Whether to include descendant nodes. Defaults to true.
// @example
//
// const filterOptions = {
// caseSensitive: false,
// exactMatch: false,
// filterPath: 'props.some.other.key',
// includeAncestors: true,
// includeDescendants: true
// };
// tree.filter('keyword', filterOptions);
//
// @example
//
// const filterOptions = {
// includeAncestors: true,
// includeDescendants: true
// };
// tree.filter(function(node) {
// const keyword = 'keyword';
// const filterText = node.name || '';
// return filterText.toLowerCase().indexOf(keyword) >= 0;
// }, filterOptions);
filter(predicate, options) {
options = {
caseSensitive: false,
exactMatch: false,
filterPath: 'name',
includeAncestors: true,
includeDescendants: true,
...options
};
this.filtered = true;
const rootNode = this.state.rootNode;
const traverse = (node, filterNode = false) => {
if (!node || !node.children) {
return false;
}
if (node === rootNode) {
node.state.filtered = false;
} else if (filterNode) {
node.state.filtered = true;
} else if (typeof predicate === 'string') {
// string
let filterText = get(node, options.filterPath, '');
if (Number.isFinite(filterText)) {
filterText = String(filterText);
}
if (typeof filterText !== 'string') {
filterText = '';
}
let keyword = predicate;
if (!options.caseSensitive) {
filterText = filterText.toLowerCase();
keyword = keyword.toLowerCase();
}
node.state.filtered = options.exactMatch
? (filterText === keyword)
: (filterText.indexOf(keyword) >= 0);
} else if (typeof predicate === 'function') {
// function
const callback = predicate;
node.state.filtered = !!callback(node);
} else {
node.state.filtered = false;
}
if (options.includeDescendants) {
filterNode = filterNode || node.state.filtered;
}
let filtered = false;
for (let i = 0; i < node.children.length; ++i) {
const childNode = node.children[i];
if (!childNode) {
continue;
}
if (traverse(childNode, filterNode)) {
filtered = true;
}
}
if (options.includeAncestors && filtered) {
node.state.filtered = true;
}
return node.state.filtered;
};
traverse(rootNode);
// Update rows
this.rows.length = this.nodes.length;
for (let i = 0; i < this.nodes.length; ++i) {
const node = this.nodes[i];
this.rows[i] = this.options.rowRenderer(node, this.options);
}
this.update();
}
// Flattens all child nodes of a parent node by performing full tree traversal using child-parent link.
// No recursion or stack is involved.
// @param {Node} parentNode The Node object that defines the parent node.
// @return {array} Returns an array of Node objects containing all the child nodes of the parent node.
flattenChildNodes(parentNode) {
// Defaults to rootNode if the parentNode is not specified
parentNode = parentNode || this.state.rootNode;
if (!ensureNodeInstance(parentNode)) {
return [];
}
let list = [];
let node = parentNode.getFirstChild(); // Ignore parent node
while (node) {
list.push(node);
if (node.hasChildren()) {
node = node.getFirstChild();
} else {
// Find the parent level
while (node !== null && node.getNextSibling() === null && node.parent !== parentNode) {
// Use child-parent link to get to the parent level
node = node.getParent();
}
// Get next sibling
if (node !== null) {
node = node.getNextSibling();
}
}
}
return list;
}
// Flattens a node by performing full tree traversal using child-parent link.
// No recursion or stack is involved.
// @param {Node} node The Node object.
// @return {array} Returns a flattened list of Node objects.
flattenNode(node) {
if (!ensureNodeInstance(node)) {
return [];
}
return [node].concat(this.flattenChildNodes(node));
}
// Gets a list of child nodes.
// @param {Node} [parentNode] The Node object that defines the parent node. If null or undefined, returns a list of top level nodes.
// @return {array} Returns an array of Node objects containing all the child nodes of the parent node.
getChildNodes(parentNode) {
// Defaults to rootNode if the parentNode is not specified
parentNode = parentNode || this.state.rootNode;
if (!ensureNodeInstance(parentNode)) {
return [];
}
return parentNode.children;
}
// Gets a node by its unique id. This assumes that you have given the nodes in the data a unique id.
// @param {string|number} id An unique node id. A null value will be returned if the id doesn't match.
// @return {Node} Returns a node the matches the id, null otherwise.
getNodeById(id) {
let node = this.nodeTable.get(id);
if (!node) {
// Find the first node that matches the id
node = this.nodes.filter(node => node.id === id)[0];
if (!node) {
return null;
}
this.nodeTable.set(node.id, node);
}
return node;
}
// Returns the node at the specified point. If the specified point is outside the visible bounds or either coordinate is negative, the result is null.
// @param {number} x A horizontal position within the current viewport.
// @param {number} y A vertical position within the current viewport.
// @return {Node} The Node object under the given point.
getNodeFromPoint(x, y) {
let el = document.elementFromPoint(x, y);
while (el && el.parentElement !== this.contentElement) {
el = el.parentElement;
}
if (!el) {
return null;
}
const id = el.getAttribute(this.options.nodeIdAttr);
const node = this.getNodeById(id);
return node;
}
// Gets an array of open nodes.
// @return {array} Returns an array of Node objects containing open nodes.
getOpenNodes() {
// returns a shallow copy of an array into a new array object.
return this.state.openNodes.slice();
}
// Gets the root node.
// @return {Node} Returns the root node, or null if empty.
getRootNode() {
return this.state.rootNode;
}
// Gets the selected node.
// @return {Node} Returns the selected node, or null if not selected.
getSelectedNode() {
return this.state.selectedNode;
}
// Gets the index of the selected node.
// @return {number} Returns the index of the selected node, or -1 if not selected.
getSelectedIndex() {
return this.nodes.indexOf(this.state.selectedNode);
}
// Inserts the specified node after the reference node.
// @param {object} newNode The new sibling node.
// @param {Node} referenceNode The Node object that defines the reference node.
// @return {boolean} Returns true on success, false otherwise.
insertNodeAfter(newNode, referenceNode) {
if (!ensureNodeInstance(referenceNode)) {
return false;
}
const parentNode = referenceNode.getParent();
const index = parentNode.children.indexOf(referenceNode) + 1;
const newNodes = [].concat(newNode || []); // Ensure array
return this.addChildNodes(newNodes, index, parentNode);
}
// Inserts the specified node before the reference node.
// @param {object} newNode The new sibling node.
// @param {Node} referenceNode The Node object that defines the reference node.
// @return {boolean} Returns true on success, false otherwise.
insertNodeBefore(newNode, referenceNode) {
if (!ensureNodeInstance(referenceNode)) {
return false;
}
const parentNode = referenceNode.getParent();
const index = parentNode.children.indexOf(referenceNode);
const newNodes = [].concat(newNode || []); // Ensure array
return this.addChildNodes(newNodes, index, parentNode);
}
// Loads data in the tree.
// @param {object|array} data The data is an object or array of objects that defines the node.
loadData(data = []) {
this.nodes = flatten(data, { openAllNodes: this.options.autoOpen });
// Clear lookup table
this.nodeTable.clear();
this.state.openNodes = this.nodes.filter((node) => node.state.open);
this.state.selectedNode = null;
const rootNode = ((node = null) => {
// Finding the root node
while (node && node.parent !== null) {
node = node.parent;
}
return node;
})((this.nodes.length > 0) ? this.nodes[0] : null);
this.state.rootNode = rootNode || createRootNode(this.state.rootNode); // Create a new root node if rootNode is null
// Update the lookup table with newly added nodes
this.flattenChildNodes(this.state.rootNode).forEach((node) => {
if (node.id !== undefined) {
this.nodeTable.set(node.id, node);
}
});
// Update rows
this.rows.length = this.nodes.length;
for (let i = 0; i < this.nodes.length; ++i) {
const node = this.nodes[i];
this.rows[i] = this.options.rowRenderer(node, this.options);
}
// Update list
this.update();
}
// Moves a node from its current position to the new position.
// @param {Node} node The Node object.
// @param {Node} parentNode The Node object that defines the parent node.
// @param {number} [index] The 0-based index of where to insert the child node.
// @return {boolean} Returns true on success, false otherwise.
moveNodeTo(node, parentNode, index) {
if (!ensureNodeInstance(node) || !ensureNodeInstance(parentNode)) {
return false;
}
for (let p = parentNode; p !== null; p = p.parent) {
if (p === node) {
error(`Cannot move an ancestor node (id=${node.id}) to the specified parent node (id=${parentNode.id}).`);
return false;
}
}
return this.removeNode(node) && this.addChildNodes(node, index, parentNode);
}
// Opens a node to display its children.
// @param {Node} node The Node object.
// @param {object} [options] The options object.
// @param {boolean} [options.silent] Pass true to prevent "openNode" event from being triggered.
// @return {boolean} Returns true on success, false otherwise.
openNode(node, options) {
const {
async = false,
asyncCallback = noop,
silent = false
} = { ...options };
if (!ensureNodeInstance(node)) {
return false;
}
if (!this.nodeTable.has(node.id)) {
error('Cannot open node with the given node id:', node.id);
return false;
}
// Check if the openNode action can be performed
if (this.state.openNodes.indexOf(node) >= 0) {
return false;
}
this.emit('willOpenNode', node);
// Retrieve node index
const fn = () => {
node.state.open = true;
if (this.state.openNodes.indexOf(node) < 0) {
// the most recently used items first
this.state.openNodes = [node].concat(this.state.openNodes);
}
const nodes = flatten(node.children, { openNodes: this.state.openNodes });
// Add all child nodes to the lookup table if the first child does not exist in the lookup table
if ((nodes.length > 0) && !(this.nodeTable.get(nodes[0]))) {
nodes.forEach((node) => {
if (node.id !== undefined) {
this.nodeTable.set(node.id, node);
}
});
}
// Toggle the expanding state
node.state.expanding = false;
if (this.nodes.indexOf(node) >= 0) {
const rows = [];
// Update rows
rows.length = nodes.length;
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[i];
rows[i] = this.options.rowRenderer(node, this.options);
}
// Update nodes & rows
this.nodes.splice.apply(this.nodes, [this.nodes.indexOf(node) + 1, 0].concat(nodes));
this.rows.splice.apply(this.rows, [this.nodes.indexOf(node) + 1, 0].concat(rows));
// Update the row corresponding to the node
this.rows[this.nodes.indexOf(node)] = this.options.rowRenderer(node, this.options);
// Update list
this.update();
}
if (!silent) {
// Emit a "openNode" event
this.emit('openNode', node);
}
if (typeof asyncCallback === 'function') {
asyncCallback();
}
};
if (this.nodes.indexOf(node) < 0) {
// Toggle the expanding state
node.state.expanding = true;
if (async) {
setTimeout(fn, 0);
} else {
fn();
}
return true;
}
const shouldLoadNodes = (typeof this.options.shouldLoadNodes === 'function')
? !!(this.options.shouldLoadNodes(node))
: !node.hasChildren() && node.loadOnDemand;
if (shouldLoadNodes) {
if (typeof this.options.loadNodes !== 'function') {
return false;
}
// Reentrancy not allowed
if (node.state.loading === true) {
return false;
}
// Toggle the loading state
node.state.loading = true;
// Update the row corresponding to the node
this.rows[this.nodes.indexOf(node)] = this.options.rowRenderer(node, this.options);
// Update list
this.update();
// Do a setTimeout to prevent the CPU intensive task
setTimeout(() => {
this.options.loadNodes(node, (err, nodes, done = noop) => {
nodes = ensureArray(nodes);
if (nodes.length === 0 && this.nodes.indexOf(node) >= 0) {
node.state.open = true;
if (this.state.openNodes.indexOf(node) < 0) {
// the most recently used items first
this.state.openNodes = [node].concat(this.state.openNodes);
}
}
if (err || nodes.length === 0) {
// Toggle the loading state
node.state.loading = false;
// Update the row corresponding to the node
this.rows[this.nodes.indexOf(node)] = this.options.rowRenderer(node, this.options);
// Update list
this.update();
if (typeof done === 'function') {
done();
}
return;
}
this.addChildNodes(nodes, undefined, node);
// Ensure the node has children to prevent infinite loop
if (node.hasChildren()) {
// Call openNode again
this.openNode(node, {
...options,
async: true,
asyncCallback: () => {
// Toggle the loading state
node.state.loading = false;
// Update the row corresponding to the node
this.rows[this.nodes.indexOf(node)] = this.options.rowRenderer(node, this.options);
// Update list
this.update();
if (typeof done === 'function') {
done();
}
}
});
} else {
// Toggle the loading state
node.state.loading = false;
// Update the row corresponding to the node
this.rows[this.nodes.indexOf(node)] = this.options.rowRenderer(node, this.options);
// Update list
this.update();
if (typeof done === 'function') {
done();
}
}
});
}, 0);
return true;
}
// Toggle the expanding state
node.state.expanding = true;
// Update the row corresponding to the node
this.rows[this.nodes.indexOf(node)] = this.options.rowRenderer(node, this.options);
// Update list
this.update();
if (async) {
setTimeout(fn, 0);
} else {
fn();
}
return true;
}
// Removes all child nodes from a parent node.
// @param {Node} parentNode The Node object that defines the parent node.
// @param {object} [options] The options object.
// @param {boolean} [options.silent] Pass true to prevent "selectNode" event from being triggered.
// @return {boolean} Returns true on success, false otherwise.
removeChildNodes(parentNode, options) {
if (!ensureNodeInstance(parentNode)) {
return false;
}
if (parentNode.children.length === 0) {
return false;
}
if (parentNode === this.state.rootNode) {
this.clear();
return true;
}
const parentNodeIndex = this.nodes.indexOf(parentNode);
// Update selected node
if ((parentNodeIndex >= 0) && this.state.selectedNode) {
// row #0 - node.0 => parent node (total=4)
// row #1 - node.0.0
// row #2 node.0.0.0 => current selected node
// row #3 node.0.0.1
// row #4 node.0.1
const selectedIndex = this.nodes.indexOf(this.state.selectedNode);
const rangeFrom = parentNodeIndex + 1;
const rangeTo = parentNodeIndex + parentNode.state.total;
if ((rangeFrom <= selectedIndex) && (selectedIndex <= rangeTo)) {
if (parentNode === this.state.rootNode) {
this.selectNode(null, options);
} else {
this.selectNode(parentNode, options);
}
}
}
// Get the nodes being removed
const removedNodes = this.flattenChildNodes(parentNode);
// Get the number of nodes to be removed
const deleteCount = parentNode.state.total;
// Subtract the deleteCount for all ancestors (parent, grandparent, etc.) of the current node
for (let p = parentNode; p !== null; p = p.parent) {
p.state.total = p.state.total - deleteCount;
}
// Update parent node
parentNode.children = [];
if (parentNode !== this.state.rootNode) {
parentNode.state.open = parentNode.state.open && (parentNode.children.length > 0);
}
if (parentNodeIndex >= 0) {
// Update nodes & rows