import { ROOT_NODE } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' export class N2Tree extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` }// }}} constructor() {// {{{ super() this.id = 'tree-nodes' this.tabIndex = 0 this.treeNodeComponents = {} this.treeTrunk = [] this.expandedNodes = {} // keyed on UUID this.selectedNode = null this.rendered = false this.addEventListener('keydown', event => this.keyHandler(event)) this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search')) this.elSync.addEventListener('click', () => _sync.run()) this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false)) this.populateFirstLevel() }// }}} render() {// {{{ if (this.rendered) alert('Tree should only be rendered once.') for (const node of this.treeTrunk) { const treenode = new N2TreeNode(this, node) this.treeNodeComponents[node.UUID] = treenode this.appendChild(treenode.render()) } this.rendered = true return this }// }}} populateFirstLevel() {//{{{ nodeStore.get(ROOT_NODE) .then(node => node.fetchChildren()) .then(children => { this.treeNodeComponents = {} this.treeTrunk = [] for (const node of children) { // The root node isn't supposed to be shown in the tree. if (node.UUID === ROOT_NODE) continue if (node.ParentUUID === ROOT_NODE) this.treeTrunk.push(node) } _mbus.dispatch('TREE_TRUNK_FETCHED') }) .catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) }) }//}}} getNodeExpanded(UUID) {//{{{ if (this.expandedNodes[UUID] === undefined) this.expandedNodes[UUID] = false return this.expandedNodes[UUID] }//}}} setNodeExpanded(node, value) {//{{{ let expanded = this.expandedNodes[node.UUID] if (expanded === undefined) { this.expandedNodes[node.UUID] = false expanded = false } if (expanded === value) return this.expandedNodes[node.UUID] = value _mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value) }//}}} setSelected(node, dontExpand) {//{{{ if (node === undefined) return // The previously selected node, if any, needs to be rerendered // to not retain its 'selected' class. const prevUUID = this.selectedNode?.UUID this.selectedNode = node if (prevUUID) this.treeNodeComponents[prevUUID]?.render(true) // And now the newly selected node is rerendered. this.treeNodeComponents[node.UUID]?.render(true) if (!dontExpand) this.setNodeExpanded(node, true) }//}}} isSelected(node) {//{{{ return this.selectedNode?.UUID === node.UUID }//}}} async keyHandler(event) {//{{{ let handled = true const n = this.selectedNode const Space = ' ' // This handler would otherwise react to stuff like Ctrl+L. if (event.ctrlKey || event.altKey) return switch (event.key) { // Space and enter is toggling expansion. // Holding shift down does it recursively. case Space: case 'Enter': const expanded = this.getNodeExpanded(n.UUID) if (event.shiftKey) { this.recursiveExpand(n, !expanded) } else { this.setNodeExpanded(n, !expanded) } break case 'g': case 'Home': this.navigateTop() break case 'G': case 'End': this.navigateBottom() break case 'j': case 'ArrowDown': await this.navigateDown(this.selectedNode) break case 'k': case 'ArrowUp': await this.navigateUp(this.selectedNode) break case 'h': case 'ArrowLeft': await this.navigateLeft(this.selectedNode) break case 'l': case 'ArrowRight': await this.navigateRight(this.selectedNode) break default: // nonsole.log(event.key) handled = false } if (handled) { event.preventDefault() event.stopPropagation() } }//}}} async navigateLeft(n) {//{{{ if (n === null || n === undefined) return const expanded = this.getNodeExpanded(n.UUID) if (expanded && n.hasChildren()) { this.setNodeExpanded(n, false) return } if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) { _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: true, dontExpand: true }) return } const siblingBefore = n.getSiblingBefore() const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID) if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { const siblingAbove = this.getLastExpandedNode(siblingBefore) _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: true, dontExpand: true }) return } _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: true, dontExpand: true }) }//}}} async navigateRight(n) {//{{{ if (n === null || n === undefined) return const siblingAfter = n.getSiblingAfter() const expanded = this.getNodeExpanded(n.UUID) if (!expanded && n.hasChildren()) { this.setNodeExpanded(n, true) return } if (expanded && n.hasChildren()) { _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: true, dontExpand: true }) return } if (n.isLastSibling()) { const nextNode = this.getParentWithNextSibling(n) _mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: true, dontExpand: true }) return } _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: true, dontExpand: true }) }//}}} async navigateUp(n) {//{{{ if (n === null || n === undefined) return let parent = null const siblingBefore = n.getSiblingBefore() let siblingExpanded = false if (siblingBefore !== null) siblingExpanded = this.getNodeExpanded(siblingBefore.UUID) if (n.isFirstSibling()) { parent = n.getParent() if (parent?.UUID === ROOT_NODE) return _mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: true, dontExpand: true }) return } if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: true, dontExpand: true }) return } if (siblingBefore) { _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: true, dontExpand: true }) return } }//}}} async navigateDown(n) {//{{{ if (n === null || n === undefined) return const nodeExpanded = this.getNodeExpanded(n.UUID) // Last node, not expanded, so it matters not whether it has children or not. // Traverse upward to nearest parent with next sibling. if (!nodeExpanded && n.isLastSibling()) { const wantedNode = this.getParentWithNextSibling(n) _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: true, dontExpand: true }) return } if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) { const wantedNode = this.getParentWithNextSibling(n) _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: true, dontExpand: true }) return } // Node not expanded. Go to this node's next sibling. // GoToNode will abort if given null. if (!nodeExpanded || !n.hasChildren()) { _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: true, dontExpand: true }) return } // Node is expanded. // Children will be visually beneath this node, if any. if (nodeExpanded && n.hasChildren()) { _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: true, dontExpand: true }) return } }//}}} async navigateTop() {//{{{ const root = await nodeStore.get(ROOT_NODE) if (root.Children.length === 0) return _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: true, dontExpand: true }) }//}}} async navigateBottom() {//{{{ const root = await nodeStore.get(ROOT_NODE) if (root.Children.length === 0) return const toplevel = root.Children[root.Children.length - 1] const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID) if (toplevelExpanded) { const lastnode = this.getLastExpandedNode(toplevel) _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: true, dontExpand: true }) } else _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: true, dontExpand: true }) }//}}} getParentWithNextSibling(node) {//{{{ let currNode = node while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) { currNode = currNode.getParent() } return currNode?.getSiblingAfter() }//}}} getLastExpandedNode(node) {//{{{ let currNode = node while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) { currNode = currNode.Children[currNode.Children.length - 1] } return currNode }//}}} async recursiveExpand(node, state) {//{{{ if (state) await this.setNodeExpanded(node, true) for (const child of node.Children) await this.recursiveExpand(child, state) if (!state) await this.setNodeExpanded(node, false) }//}}} async makeVisible(node) {// {{{ const treenode = this.treeNodeComponents[node.UUID] const ancestors = await nodeStore.getNodeAncestry(node) for (const ancestor of ancestors.reverse()) { this.setNodeExpanded(ancestor, true) } // The ROOT_NODE for example hasn't got a treenode. treenode?.scrollIntoView({ block: 'nearest' }) }// }}} } customElements.define('n2-tree', N2Tree) export class N2TreeNode extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` }// }}} constructor(tree, node, parent) {//{{{ super() this.classList.add('node') this.tree = tree this.node = node this.parent = parent this.children_populated = false this.rendered = false this.elExpandToggle.addEventListener('click', () => this.tree.setNodeExpanded(this.node, !this.tree.getNodeExpanded(this.node.UUID))) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) _mbus.subscribe(`NODE_CHILDREN_FETCHED_${node.UUID}`, () => { this.render(true) }) _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, state => { this.render(true) }) if (this.node.Level === 0 || this.tree.getNodeExpanded(this.node.UUID)) this.fetchChildren() }// }}} async fetchChildren() {//{{{ await this.node.fetchChildren() this.children_populated = true }//}}} render(force_update) {//{{{ if (this.rendered && force_update !== true) return this // Fetch the next level of children if the parent tree node is expanded and our children thus will be visible. const expanded = this.node.Children.length > 0 && this.tree.getNodeExpanded(this.node.UUID) if (!this.children_populated && this.tree.getNodeExpanded(this.parent?.node.UUID)) { this.node.fetchChildren().then(() => this.children_populated = true) } // Update the name and selected status this.elName.innerText = this.node.get('Name') if (this.tree.isSelected(this.node)) this.elName.classList.add('selected') else this.elName.classList.remove('selected') // Update expansion state if (expanded) { this.elChildren.classList.add('expanded') this.elChildren.classList.remove('collapsed') } else { this.elChildren.classList.remove('expanded') this.elChildren.classList.add('collapsed') } // The expand icon is only changed to not get a flickering when re-rendering. if (this.node.Children.length === 0) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`) else if (this.tree.getNodeExpanded(this.node.UUID)) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/expanded.svg`) else this.setImgSrc(this.elExpand, `/images/${window._VERSION}/collapsed.svg`) // Should children be rendered? this.elChildren.innerHTML = '' let children = [] if (expanded) children = this.node.Children.map(node => { let treenode = this.tree.treeNodeComponents[node.UUID] if (treenode === undefined) { treenode = new N2TreeNode(this.tree, node, this) this.tree.treeNodeComponents[node.UUID] = treenode } return treenode }) for (const c of children) this.elChildren.appendChild(c.render()) this.rendered = true return this }//}}} setImgSrc(img, newSrc) {// {{{ if (img.getAttribute('src') === newSrc) return img.setAttribute('src', newSrc) }// }}} } customElements.define('n2-treenode', N2TreeNode) // vim: foldmethod=marker