/**
* TreeBrowser
* A navigation tool for presenting data as tree structures
*
* Copyright 2020, 2021 Rolf Bagge, Janus B. Kristensen, CAVI,
* Center for Advanced Visualization and Interacion, Aarhus University
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
/* global mdc */
/**
* Called when a TreeNode is selected in a TreeBrowser
*
* @event TreeBrowser#EventSystem:"TreeBrowser.Selection"
* @type {CustomEvent}
* @property {TreeNode} selection - The selected TreeNode
*/
/**
* Called when a TreeNode's action is triggered. (Ie. double click it)
*
* @event TreeNode#EventSystem:"TreeBrowser.TreeNode.Action"
* @type {CustomEvent}
* @property {TreeNode} node - The node which had its action triggered
*/
/**
* TreeBrowser can take a TreeNode and construct a browsable tree
*/
class TreeBrowser {
/**
* Create a TreeBrowser with the given TreeNode as root
*
* @param {TreeNode} rootNode
* @param {TreeBrowser~options} options
*/
constructor(rootNode) {
let self = this;
/** @member {TreeNode} - The root node used to build the tree */
this.rootNode = rootNode;
/** @member {Element} - The DOM Element of this Tree */
this.html = WebstrateComponents.Tools.loadTemplate("TreeBrowser_Main");
/** @member {MDCList} - The MDCList object from Material Design */
this.mdcList = new mdc.list.MDCList(this.html);
this.mdcList.singleSelection = true;
this.html.mdcList = this.mdcList;
this.mdcList.listen("MDCList:action", (evt)=>{
let listItem = self.mdcList.listElements[evt.detail.index];
EventSystem.triggerEvent("TreeBrowser.Selection", {
selection: listItem.treeNode
});
});
this.rootNode.insertIntoDom(this.html, null);
this.rootNode.treeBrowser = this;
this.html.treeBrowser = this;
this.html.addEventListener("keyup", (evt)=>{
let selectedItem = self.mdcList.listElements[self.mdcList.selectedIndex];
if(selectedItem == null) {
return;
}
EventSystem.triggerEvent("TreeBrowser.Keyup", {
evt: evt,
treeNode: selectedItem.treeNode
});
if(evt.key === "Enter") {
selectedItem.treeNode.triggerAction();
} else if(evt.key === "ArrowRight") {
selectedItem.treeNode.unfold();
} else if(evt.key === "ArrowLeft") {
selectedItem.treeNode.fold();
} else if(evt.key === "ArrowDown") {
let focusedItem = self.mdcList.listElements[self.mdcList["foundation"].adapter.getFocusedElementIndex()];
if(focusedItem != null) {
self.setSelected(focusedItem.treeNode);
}
} else if(evt.key === "ArrowUp") {
let focusedItem = self.mdcList.listElements[self.mdcList["foundation"].adapter.getFocusedElementIndex()];
if(focusedItem != null) {
self.setSelected(focusedItem.treeNode);
}
}
});
this.html.addEventListener("keydown", (evt)=>{
let selectedItem = self.mdcList.listElements[self.mdcList.selectedIndex];
if(selectedItem == null) {
return;
}
EventSystem.triggerEvent("TreeBrowser.Keydown", {
evt: evt,
treeNode: selectedItem.treeNode
});
});
}
/**
* Sets the given TreeNode as the current selection
* @param {TreeNode} treeNode
*/
setSelected(treeNode) {
let index = -1;
let i = 0;
this.html.mdcList.listElements.forEach((elm)=>{
if(elm === treeNode.html) {
index = i;
}
i++;
});
if(index !== -1) {
this.mdcList.selectedIndex = index;
this.mdcList.foundation.adapter.focusItemAtIndex(index);
EventSystem.triggerEvent("TreeBrowser.Selection", {
selection: treeNode
});
}
}
/**
* Finds all TreeNode's that has the given context
* @param context
* @returns {TreeNode[]}
*/
findTreeNodeForContext(context) {
function lookupContext(node) {
let foundNodes = [];
if(node.context === context) {
foundNodes.push(node);
}
for(let child of node.childNodes) {
foundNodes.push(...lookupContext(child));
}
return foundNodes;
}
return lookupContext(this.rootNode);
}
/**
* Finds all TreeNodes that have the given lookupKey
* @param key
* @returns {TreeNode[]}
*/
findTreeNode(lookupKey) {
function lookupTheLookupKey(node) {
let foundNodes = [];
if(node.lookupKey === lookupKey) {
foundNodes.push(node);
}
for(let child of node.childNodes) {
foundNodes.push(...lookupTheLookupKey(child));
}
return foundNodes;
}
return lookupTheLookupKey(this.rootNode);
}
/**
* Find all TreeBrowsers currently in the DOM
* @returns {TreeBrowser[]}
*/
static findAllTreeBrowsers() {
return Array.from(document.querySelectorAll(".tree-browser-root")).map((dom)=>{
return dom.treeBrowser;
});
}
}
window.TreeBrowser = TreeBrowser;
/**
* @typedef {Object} TreeNode~config
* @property {string} type - The type of this TreeNode, ex. "DomTreeNode", "AssetNode", "AssetRootNode"
* @property {*} context - The context of this TreeNode
* @property {boolean} [alwaysOpen=false] - If this TreeNode should stay unfolded
* @property {*} [lookupKey=context] - The lookupkey to use when TreeGenerator saves this TreeNode for later lookup
* @property {boolean} [startOpen=false] - If this TreeNode should start unfolded
* @property {boolean} [hideSelf=false] - If this TreeNode should be hidden.
*/
/**
* Represents a tree node
*/
class TreeNode {
/**
* Create a new TreeNode with the given configuration
* @param {TreeNode~config} config - The configuration for this TreeNode
*/
constructor(config) {
let self = this;
this.config = config;
/** @memeber {string} - The type of this TreeNode */
this.type = this.getProperty("type");
/** @memeber {*} - The context of this TreeNode */
this.context = this.getProperty("context");
/** @memeber {boolean} - If this TreeNode should always stay unfolded */
this.alwaysOpen = this.getProperty("alwaysOpen", false);
this.startOpen = this.getProperty("startOpen", false);
this.lookupKey = this.getProperty("lookupKey", this.context);
/** @member {TreeNode[]} - The children of this TreeNode */
this.childNodes = [];
/** @member {Element} - The DOM Element of this TreeNode */
this.html = WebstrateComponents.Tools.loadTemplate("TreeBrowser_Leaf");
this.html.treeNode = this;
this.hideSelf = this.getProperty("hideSelf", false);
this.childrenHtml = WebstrateComponents.Tools.loadTemplate("TreeBrowser_Children");
this.childrenUl = this.childrenHtml.querySelector("ul.tree-browser");
/** @member {TreeNode} - The parent node of this TreeNode */
this.parentNode = null;
/** @member {Element[]} - Meta icons of this TreeNode */
this.metaIcons = [];
this.html.addEventListener("mouseup", (evt)=>{
if(evt.button === 2) {
self.select();
}
});
this.onDecoratedCallbacks = new Set();
self.html.querySelector(".tree-browser-navigator").addEventListener("click", ()=>{
if(!self.isLeaf()) {
if(self.alwaysOpen) {
self.unfold();
} else {
self.toggleFold();
}
}
});
if(this.startOpen) {
this.unfold();
console.log("Opening up!");
} else {
this.fold();
}
this.setupContextMenu();
this.setupMetaMenu();
this.setupDragging();
this.render();
this.setupClickListeners();
}
get hideSelf() {
return this.html.classList.contains("tree-browser-node-hidden");
}
set hideSelf(hide) {
if(hide) {
this.html.classList.add("tree-browser-node-hidden");
} else {
this.html.classList.remove("tree-browser-node-hidden");
}
}
/**
* Return the TreeBrowser at the top of the tree this TreeNode is in, if any
* @returns {null|TreeBrowser}
*/
getTreeBrowser() {
if(this.treeBrowser != null) {
return this.treeBrowser;
} else if(this.parentNode != null) {
return this.parentNode.getTreeBrowser();
}
return null;
}
/**
* @private
* @param parent
*/
insertIntoDom(parent) {
parent.appendChild(this.html);
parent.insertBefore(this.childrenHtml, this.html.nextElementSibling);
}
/**
* @private
*/
removeFromDom() {
this.html.remove();
this.childrenHtml.remove();
if(this.nextAnimFrame != null) {
cancelAnimationFrame(this.nextAnimFrame);
this.nextAnimFrame = null;
}
}
/**
* Retrieve the value of the given property
* @param {String} propertyName
* @param {*} [defaultValue] - The default value if the property does not exist
* @returns {*}
*/
getProperty(propertyName, defaultValue = null) {
return WebstrateComponents.Tools.fromConfig(this.config, propertyName, defaultValue);
}
/**
* Sets the value of the given property
* @param {String} propertyName
* @param {*} value
*/
setProperty(propertyName, value) {
this.config[propertyName] = value;
}
/**
* Add a meta icon to this TreeNode
* @param {*} icon
*/
addMetaIcon(icon) {
this.metaIcons.push(icon);
this.render();
}
/**
* Remove a meta icon from this TreeNode
* @param {*} icon
*/
removeMetaIcon(icon) {
this.metaIcons.splice(this.metaIcons.indexOf(icon),1);
this.render();
}
/**
* Add a child node to this node at a given index
* @param {Number} index
* @param {TreeNode} node
*/
addNode(node, index = -1) {
node.parentNode = this;
if(this.isFolded()) {
node.html.classList.remove("mdc-list-item");
} else {
node.html.classList.add("mdc-list-item");
}
if(index === -1) {
this.childNodes.push(node);
} else {
this.childNodes.splice(index, 0, node);
}
this.updateChildren();
}
/**
* Remove a child node from this node
* @param {TreeNode} node
*/
removeNode(node) {
if(this.childNodes.indexOf(node) !== -1) {
this.childNodes.splice(this.childNodes.indexOf(node), 1);
node.removeFromDom();
}
}
/**
* Removes all child nodes from this TreeNode
*/
clearNodes() {
let self = this;
let childNodeClone = this.childNodes.slice();
childNodeClone.forEach((child)=>{
self.removeNode(child);
});
}
/**
* Checks if this TreeNode has any children
* @returns {boolean} True/False depending on if this TreeNode has any children
*/
isLeaf() {
let visibleChildNodes = this.childNodes.filter((child)=>{
return !child.hideSelf;
});
return visibleChildNodes.length === 0;
}
/**
* Unfold this TreeNode
*/
unfold() {
if(!this.isLeaf()) {
this.html.classList.add("tree-browser-unfolded");
this.childNodes.forEach((child)=>{
child.html.classList.add("mdc-list-item");
});
}
}
/**
* Fold this TreeNode
*/
fold() {
if(this.alwaysOpen) {
return;
}
this.html.classList.remove("tree-browser-unfolded");
this.childNodes.forEach((child)=>{
child.html.classList.remove("mdc-list-item");
});
}
/**
* Toggle fold state of this TreeNode
*/
toggleFold() {
if(this.html.classList.contains("tree-browser-unfolded")) {
this.fold();
} else {
this.unfold();
}
}
/**
* Check wether this TreeNode is folded or unfolded
* @returns {boolean} - True of this TreeNode is folded, false if unfolded
*/
isFolded() {
return !this.html.classList.contains("tree-browser-unfolded");
}
/**
* Make this TreeNode the selected node in the tree
*/
select() {
let self = this;
let treeBrowser = this.getTreeBrowser();
if(treeBrowser != null) {
treeBrowser.setSelected(this);
EventSystem.triggerEvent("TreeBrowser.Selection", {
selection: self
});
}
}
/**
* Make this TreeNode and all parent TreeNode's unfold,
*/
reveal() {
this.unfold();
if(this.parentNode != null) {
this.parentNode.reveal();
}
}
/**
* Called when we are removed from the tree
*/
onRemoved() {
this.removeFromDom();
}
/**
* Called when this TreeNode has been decorated
*/
onDecorated() {
let self = this;
this.render();
this.onDecoratedCallbacks.forEach((callback)=>{
callback(self);
});
}
/**
* @private
*/
setupContextMenu() {
let self = this;
if(window.MenuSystem != null) {
this.html.addEventListener("contextmenu", (evt)=>{
evt.preventDefault();
});
let contextMenu = null;
this.html.addEventListener("mouseup", (evt)=>{
if(evt.button !== 2) {
return;
}
self.select();
//Find top component after html
let parent = self.html;
while(parent.parentNode != null && !parent.parentNode.matches("html")) {
parent = parent.parentNode;
}
//Setup context menus
if(contextMenu == null) {
contextMenu = MenuSystem.MenuManager.createMenu("TreeBrowser.TreeNode.ContextMenu", {
context: self,
groupDividers: true
});
contextMenu.registerOnCloseCallback(() => {
if (contextMenu.html.parentNode != null) {
contextMenu.html.parentNode.removeChild(contextMenu.html);
}
});
}
parent.appendChild(contextMenu.html);
contextMenu.open({
x: evt.pageX,
y: evt.pageY
});
evt.stopPropagation();
evt.preventDefault();
});
}
}
/**
* @private
*/
updateChildren() {
let self = this;
this.render();
if(this.updateChildrenAnimFrame != null) {
return;
}
this.updateChildrenAnimFrame = requestAnimationFrame(()=>{
//Reinsert all nodes?
self.childNodes.forEach((child)=>{
child.insertIntoDom(self.childrenUl);
});
self.updateChildrenAnimFrame = null;
});
}
/**
* @private
*/
render() {
let self = this;
if(this.nextAnimFrame != null) {
//We are already scheduled to render
return;
}
this.nextAnimFrame = requestAnimationFrame(()=>{
let content = self.getProperty("content");
let contentContainer = self.html.querySelector(":scope > .tree-browser-content");
while(contentContainer.lastChild) contentContainer.lastChild.remove();
if(content != null) {
if (typeof content === "string") {
contentContainer.textContent = content;
} else if (content instanceof Element) {
contentContainer.appendChild(content);
} else {
contentContainer.textContent = "Unknown 'content' [" + (typeof content) + "]:[" + JSON.stringify(content) + "]";
}
}
// Tooltips
let tooltip = self.getProperty("tooltip");
if (tooltip != null){
self.html.setAttribute("title", tooltip);
}
let icon = self.getProperty("icon");
let iconContainer = self.html.querySelector(":scope > .tree-browser-icons");
let oldIcon = iconContainer.querySelector(".tree-browser-icon");
if(oldIcon != null) {
oldIcon.remove();
}
if(icon != null) {
if(typeof icon === "string") {
//Probabely wrong!
let textSpan = document.createElement("span");
textSpan.textContent = icon;
icon = textSpan;
}
icon.classList.add("tree-browser-icon");
iconContainer.append(icon);
}
["a", "b", "c", "d"].forEach((modifierType)=>{
//Remove old modifier
let oldModifier = iconContainer.querySelector(".tree-browser-modifier-"+modifierType);
if(oldModifier != null) {
oldModifier.remove();
}
let modifier = self.getProperty("modifier-"+modifierType);
if(modifier != null) {
let tpl = WebstrateComponents.Tools.loadTemplate("TreeBrowser_Modifier");
tpl.classList.add("tree-browser-modifier-"+modifierType);
if(typeof modifier === "string") {
let textSpan = document.createElement("span");
textSpan.textContent = modifier;
modifier = textSpan;
}
tpl.appendChild(modifier);
iconContainer.appendChild(tpl);
}
});
let metaIconContainer = self.html.querySelector(":scope > .mdc-list-item__meta");
while(metaIconContainer.lastChild) metaIconContainer.lastChild.remove();
self.metaIcons.forEach((icon)=>{
metaIconContainer.appendChild(icon);
});
if(self.isLeaf()) {
if(!self.html.classList.contains("tree-browser-leaf")) {
self.html.querySelector(".tree-browser-navigator").innerHTML = "";
self.html.classList.add("tree-browser-leaf");
}
this.fold();
} else {
if(self.html.classList.contains("tree-browser-leaf")) {
let icon = (IconRegistry.createIcon("mdc:chevron_right"));
self.html.querySelector(".tree-browser-navigator").appendChild(icon);
self.html.classList.remove("tree-browser-leaf");
}
if(self.alwaysOpen) {
self.unfold();
}
}
self.nextAnimFrame = null;
});
}
/**
* @private
*/
setupDragging() {
let self = this;
let uuid = UUIDGenerator.generateUUID();
this.html.setAttribute("transient-drag-id", uuid);
let hoverStartTime = -1;
new CaviDraggableHTML5(this.html, {
onDragStart: (evt)=>{
evt.dataTransfer.setData("treenode/href", location.href);
evt.dataTransfer.setData("treenode/uuid", uuid);
evt.dataTransfer.setData("treenodedata/uuid|"+uuid, "");
TreeGenerator.decorateDataTransfers(self, evt.dataTransfer);
},
onDragComplete: (evt)=>{
if(evt.dataTransfer.dropEffect !== "none") {
EventSystem.triggerEvent("TreeBrowser.TreeNode.DragEnd", {
draggedNode: self,
dropEffect: evt.dataTransfer.dropEffect,
dragEvent: evt
});
}
}
});
new CaviDroppableHTML5(this.html, {
onDragLeave: (evt)=>{
hoverStartTime = -1;
EventSystem.triggerEvent("TreeBrowser.TreeNode.DragLeave", {
node: self,
dragEvent: evt
});
},
onDragOver: (evt)=>{
evt.dataTransfer.dropEffect = "none";
if(hoverStartTime === -1) {
hoverStartTime = Date.now();
}
let hoverTime = Date.now() - hoverStartTime;
if(hoverTime > 1000) {
self.unfold();
}
EventSystem.triggerEvent("TreeBrowser.TreeNode.DragOver", {
node: self,
dragEvent: evt
});
},
onDrop: (evt, dropEffect)=>{
let otherWebstrate = null;
if(evt.dataTransfer.types.includes("treenode/href")) {
otherWebstrate = evt.dataTransfer.getData("treenode/href");
}
if(evt.dataTransfer.types.includes("treenode/uuid")) {
try {
let dragUUID = evt.dataTransfer.getData("treenode/uuid");
let dragged = document.querySelector("[transient-drag-id='" + dragUUID + "']");
if (dragged != null && dragged.treeNode != null) {
EventSystem.triggerEvent("TreeBrowser.TreeNode.Dropped", {
draggedNode: dragged.treeNode,
droppedNode: self,
dropEffect: dropEffect,
otherWebstrate: otherWebstrate,
dragEvent: evt
});
return;
}
} catch(e) {
console.log("Error accepting drop as treenode/uuid", );
}
}
if(evt.dataTransfer.types.includes("Files")) {
try {
EventSystem.triggerEvent("TreeBrowser.Files.Dropped", {
files: evt.dataTransfer.files,
droppedNode: self,
otherWebstrate: otherWebstrate,
dragEvent: evt
});
return;
} catch(e) {
console.log("Error accepting drop as Files", e);
}
}
if(evt.dataTransfer.types.includes("treenode/asset")) {
try {
let assetUrl = evt.dataTransfer.getData("treenode/asset");
EventSystem.triggerEvent("TreeBrowser.Asset.Dropped", {
assetUrl: assetUrl,
droppedNode: self,
otherWebstrate: otherWebstrate,
dragEvent: evt
});
return;
} catch(e) {
console.log("Error accepting drop as Asset", e);
}
}
if(evt.dataTransfer.types.includes("text/plain")) {
try {
let text = evt.dataTransfer.getData("text/plain");
let tpl = document.createElement("template");
tpl.innerHTML = text;
WPMv2.stripProtection(tpl.content);
EventSystem.triggerEvent("TreeBrowser.DomFragment.Dropped", {
fragment: tpl.content,
droppedNode: self,
otherWebstrate: otherWebstrate,
dragEvent: evt
});
return;
} catch(e) {
console.log("Error accepting drop as text/plain", e);
}
}
console.log("No supported data transfers:", evt.dataTransfer.types.slice());
evt.dataTransfer.types.forEach((type)=>{
console.log(type, evt.dataTransfer.getData(type));
});
}
});
}
/**
* @private
*/
setupClickListeners() {
let self = this;
this.html.addEventListener("dblclick", ()=>{
self.triggerAction();
});
}
/**
* Trigger the action listeners of this TreeNode
*/
triggerAction() {
let preventDefault = EventSystem.triggerEvent("TreeBrowser.TreeNode.Action", {
node: this,
treeBrowser: this.getTreeBrowser()
});
if(!preventDefault && !this.alwaysOpen) {
this.toggleFold();
}
}
registerOnDecoratedCallback(callback) {
this.onDecoratedCallbacks.add(callback);
}
deregisterOnDecoratedCallback(callback) {
this.onDecoratedCallbacks.delete(callback);
}
setupMetaMenu() {
this.metaMenu = MenuSystem.MenuManager.createMenu("TreeBrowser.TreeNode.MetaMenu", {
context: this.context,
keepOpen: true,
layoutDirection: MenuSystem.Menu.LayoutDirection.HORIZONTAL,
layoutWrapping: false,
layoutCompact: true,
defaultFocusState: mdc.menu.DefaultFocusState.NONE
});
this.addMetaIcon(this.metaMenu.html);
}
}
window.TreeNode = TreeNode;