diff --git a/static/css/notes2.css b/static/css/notes2.css index b6b0963..74b6392 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -17,6 +17,11 @@ html { display: none; } } +/* +#tree-native { + grid-area: tree; +} +*/ #tree { grid-area: tree; padding: 16px 32px; diff --git a/static/js/node.mjs b/static/js/node.mjs index 92c8879..bd4dadd 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -408,7 +408,8 @@ export class Node { } // Notify the tree that all children are fetched and ready to process. - _notes2.current.tree.fetchChildrenOn(this.UUID) + //_notes2.current.tree.fetchChildrenOn(this.UUID) + _mbus.dispatch(`NODE_CHILDREN_FETCHED_${this.UUID}`) return this.Children }//}}} diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index d73ef53..22820f0 100644 --- a/static/js/notes2.mjs +++ b/static/js/notes2.mjs @@ -3,6 +3,7 @@ import { signal } from 'preact/signals' import htm from 'htm' import { Node, NodeUI } from 'node' import { ROOT_NODE } from 'node_store' +import { TreeNative, TreeNodeNative } from 'tree' const html = htm.bind(h) export class Notes2 extends Component { @@ -14,6 +15,7 @@ export class Notes2 extends Component { startNode: null, } this.op = signal('') + this.treeNative = new TreeNative() window._sync = new Sync() window._sync.run() @@ -76,6 +78,7 @@ export class Notes2 extends Component { this.nodeUI.current.setNode(node) this.nodeUI.current.setCrumbs(ancestors) this.tree.setSelected(node, dontExpand) + this.treeNative.setSelected(node, dontExpand) }//}}} logout() {//{{{ localStorage.removeItem('session.UUID') @@ -107,6 +110,7 @@ class Tree extends Component { this.treeNodeComponents[node.UUID] = createRef() return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />` }) + return html`
@@ -118,7 +122,7 @@ class Tree extends Component {
` }//}}} componentDidMount() {//{{{ - this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event)) + //this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event)) // This will show and select the treenode that is selected in the node UI. const node = _notes2.current?.nodeUI.current?.node.value diff --git a/static/js/tree.mjs b/static/js/tree.mjs new file mode 100644 index 0000000..3c0a1ff --- /dev/null +++ b/static/js/tree.mjs @@ -0,0 +1,397 @@ +import { ROOT_NODE } from 'node_store' + +export class TreeNative { + constructor() {// {{{ + this.treeNodeComponents = {} + this.treeTrunk = [] + this.expandedNodes = {} // keyed on UUID + this.selectedNode = null + this.rendered = false + + window._mbus.subscribe('TREE_TRUNK_FETCHED', ()=>{ + document.getElementById('tree').append(this.render()) + }) + + this.populateFirstLevel() + }// }}} + render() {// {{{ + if (this.rendered) + alert('Tree should only be rendered once.') + + const tmpl = document.createElement('template') + tmpl.innerHTML = '
' + const treeEl = tmpl.content.getElementById('tree-nodes') + treeEl.addEventListener('keydown', event=>this.keyHandler(event)) + + for (const node of this.treeTrunk) { + const treenode = new TreeNodeNative(this, node) + this.treeNodeComponents[node.UUID] = treenode + treeEl.appendChild(treenode.render()) + + //return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />` + } + + this.rendered = true + return tmpl.content + }// }}} + 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) {//{{{ + // Creating a default value if it doesn't exist already. + this.getNodeExpanded(node.UUID) + this.expandedNodes[node.UUID] = value + _mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value) + }//}}} + setSelected(node, dontExpand) {//{{{ + // 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) + return + + const expanded = this.getNodeExpanded(n.UUID) + if (expanded && n.hasChildren()) { + this.setNodeExpanded(n, false) + return + } + + if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) { + await _notes2.current.goToNode(n.getParent()?.UUID, true, true) + return + } + + const siblingBefore = n.getSiblingBefore() + const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID) + if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { + const siblingAbove = this.getLastExpandedNode(siblingBefore) + await _notes2.current.goToNode(siblingAbove?.UUID, true, true) + return + } + + await _notes2.current.goToNode(n.getSiblingBefore()?.UUID, true, true) + }//}}} + async navigateRight(n) {//{{{ + if (n === null) + return + + const siblingAfter = n.getSiblingAfter() + const expanded = this.getNodeExpanded(n.UUID) + + if (!expanded && n.hasChildren()) { + this.setNodeExpanded(n, true) + return + } + + if (expanded && n.hasChildren()) { + await _notes2.current.goToNode(n.Children[0]?.UUID, true, true) + return + } + + if (n.isLastSibling()) { + const nextNode = this.getParentWithNextSibling(n) + await _notes2.current.goToNode(nextNode?.UUID, true, true) + return + } + + await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true) + }//}}} + async navigateUp(n) {//{{{ + if (n === null) + 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 + await _notes2.current.goToNode(parent?.UUID, true, true) + return + } + + if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { + await _notes2.current.goToNode(siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, true, true) + return + } + + if (siblingBefore) { + await _notes2.current.goToNode(siblingBefore.UUID, true, true) + return + } + }//}}} + async navigateDown(n) {//{{{ + if (n === null) + 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) + await _notes2.current.goToNode(wantedNode?.UUID, true, true) + return + } + + if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) { + const wantedNode = this.getParentWithNextSibling(n) + await _notes2.current.goToNode(wantedNode?.UUID, true, true) + return + } + + // Node not expanded. Go to this node's next sibling. + // GoToNode will abort if given null. + if (!nodeExpanded || !n.hasChildren()) { + await _notes2.current.goToNode(n.getSiblingAfter()?.UUID, true, true) + return + } + + // Node is expanded. + // Children will be visually beneath this node, if any. + if (nodeExpanded && n.hasChildren()) { + await _notes2.current.goToNode(n.Children[0].UUID, true, true) + return + } + }//}}} + async navigateTop() {//{{{ + const root = await nodeStore.get(ROOT_NODE) + if (root.Children.length === 0) + return + await _notes2.current.goToNode(root.Children[0]?.UUID, true, 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) + await _notes2.current.goToNode(lastnode?.UUID, true, true) + } else + await _notes2.current.goToNode(root.Children[root.Children.length - 1]?.UUID, true, 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) + }//}}} +} + +export class TreeNodeNative { + constructor(tree, node, parent) {//{{{ + this.tree = tree + this.node = node + this.parent = parent + + this.element = document.createElement('div') + this.element.classList.add('node') + this.icon_expand = document.createElement('img') + + this.children_populated = false + + this.createElements() + + _mbus.subscribe(`NODE_CHILDREN_FETCHED_${node.UUID}`, ()=>{ + this.render(true) + }) + + _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, state=>{ + this.render(true) + }) + + this.rendered = false + }//}}} + createElements() {// {{{ + this.element.innerHTML = ` +
+
+
+ ` + + this.element.children[0].addEventListener('click', ()=>this.tree.setNodeExpanded(this.node, !this.tree.getNodeExpanded(this.node.UUID))) + this.element.children[0].appendChild(this.icon_expand) + this.element.children[1].addEventListener('click', ()=>window._notes2.current.goToNode(this.node.UUID)) + }// }}} + render(force_update) {//{{{ + if (this.rendered && force_update !== true) + return this.element + + // 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) + const selected = this.tree.isSelected(this.node) ? 'selected' : '' + + 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.element.children[1].innerText = this.node.get('Name') + this.element.children[1].className = `name ${selected}` + + // Update expansion state + this.element.children[2].className = `children ${expanded ? 'expanded' : 'collapsed'}` + + // The expand icon is cached to not get a flickering when re-rendering. + if (this.icon_expand === null) + this.icon_expand = document.createElement('img') + + if (this.node.Children.length === 0) + this.setImgSrc(this.icon_expand, `/images/${window._VERSION}/leaf.svg`) + else if (this.tree.getNodeExpanded(this.node.UUID)) + this.setImgSrc(this.icon_expand, `/images/${window._VERSION}/expanded.svg`) + else + this.setImgSrc(this.icon_expand, `/images/${window._VERSION}/collapsed.svg`) + + // Should children be rendered? + this.element.children[2].innerHTML = '' + let children = [] + if (expanded) + children = this.node.Children.map(node => { + let treenode = this.tree.treeNodeComponents[node.UUID] + if (treenode === undefined) { + treenode = new TreeNodeNative(this.tree, node, this) + this.tree.treeNodeComponents[node.UUID] = treenode + } + return treenode + //return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${tree} node=${node} parent=${this} ref=${tree.treeNodeComponents[node.UUID]} selected=${node.UUID === tree.props.app.startNode?.UUID} />` + }) + + for(const c of children) + this.element.children[2].appendChild(c.render()) + + this.rendered = true + return this.element + }//}}} + + setImgSrc(img, newSrc) {// {{{ + if (img.getAttribute('src') === newSrc) + return + img.setAttribute('src', newSrc) + }// }}} +} + +// vim: foldmethod=marker diff --git a/static/less/notes2.less b/static/less/notes2.less index a1ae783..a5778ed 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -46,6 +46,12 @@ html { } } +/* +#tree-native { + grid-area: tree; +} +*/ + #tree { grid-area: tree; padding: 16px 32px; diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl index 123fc68..470cfe5 100644 --- a/views/layouts/main.gotmpl +++ b/views/layouts/main.gotmpl @@ -4,16 +4,6 @@ - - + + diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index f633692..4a828cd 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,4 +1,10 @@ {{ define "page" }} +
+
+
+
+
+